928 lines
33 KiB
TypeScript
928 lines
33 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
import {MarkupContent, TextEdit} from "vscode-languageserver-types";
|
|
import {complete} from "./complete.js";
|
|
import {registerLogger} from "./log.js";
|
|
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
|
|
import {TestLogger} from "./test-utils/logger.js";
|
|
import {clearCache} from "./utils/workflow-cache.js";
|
|
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
|
import {FeatureFlags} from "@actions/expressions/features";
|
|
|
|
registerLogger(new TestLogger());
|
|
|
|
beforeEach(() => {
|
|
clearCache();
|
|
});
|
|
|
|
describe("completion", () => {
|
|
it("runs-on", async () => {
|
|
const input = "on: push\njobs:\n build:\n runs-on: |";
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
expect(result).not.toBeUndefined();
|
|
// 12 runner labels + 2 escape hatches (switch to list, switch to full syntax)
|
|
expect(result.length).toEqual(14);
|
|
const labels = result.map(x => x.label);
|
|
expect(labels).toContain("macos-latest");
|
|
expect(labels).toContain("(switch to list)");
|
|
expect(labels).toContain("(switch to mapping)");
|
|
});
|
|
|
|
it("needs", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
build2:
|
|
runs-on: ubuntu-latest
|
|
needs: bu|`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
expect(result).not.toBeUndefined();
|
|
expect(result.length).toEqual(1);
|
|
expect(result[0].label).toEqual("build");
|
|
});
|
|
|
|
it("empty workflow", async () => {
|
|
const input = "|";
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
expect(result).not.toBeUndefined();
|
|
expect(result.length).toEqual(13);
|
|
expect(result[0].label).toEqual("concurrency");
|
|
});
|
|
|
|
it("completion within a sequence", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-on: [ubuntu-latest, u|]`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
expect(result).not.toBeUndefined();
|
|
expect(result.length).toEqual(11);
|
|
|
|
const labels = result.map(x => x.label);
|
|
expect(labels).toContain("macos-latest");
|
|
expect(labels).not.toContain("ubuntu-latest");
|
|
});
|
|
|
|
it("one-of definition completion", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
|`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
expect(result).not.toBeUndefined();
|
|
expect(result.length).toEqual(30);
|
|
});
|
|
|
|
it("string definition completion in sequence", async () => {
|
|
const input = `on:
|
|
release:
|
|
types:
|
|
- |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
expect(result.map(x => x.label)).toEqual([
|
|
"created",
|
|
"deleted",
|
|
"edited",
|
|
"prereleased",
|
|
"published",
|
|
"released",
|
|
"unpublished"
|
|
]);
|
|
});
|
|
|
|
it("string definition completion", async () => {
|
|
const input = `on:
|
|
release:
|
|
types: |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
// Expect string values plus escape hatch to switch to list form
|
|
expect(result.map(x => x.label)).toEqual([
|
|
"created",
|
|
"deleted",
|
|
"edited",
|
|
"prereleased",
|
|
"published",
|
|
"released",
|
|
"unpublished",
|
|
"(switch to list)"
|
|
]);
|
|
});
|
|
|
|
it("map keys filter out existing values", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
|`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
expect(result).not.toBeUndefined();
|
|
expect(result.map(x => x.label)).not.toContain("runs-on");
|
|
});
|
|
|
|
it("one-of narrows down to a specific type", async () => {
|
|
// A job could be a job-factory or a workflow-job (callable workflow with uses)
|
|
// If we have `runs-on`, we should be able to identify that we're in a job-factory
|
|
const jobFactory = `on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
|`;
|
|
const jobFactoryResult = await complete(...getPositionFromCursor(jobFactory));
|
|
expect(jobFactoryResult).not.toBeUndefined();
|
|
expect(jobFactoryResult.map(x => x.label)).not.toContain("uses");
|
|
|
|
const workflowJob = `on: push
|
|
jobs:
|
|
build:
|
|
uses: octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89
|
|
|`;
|
|
const workflowJobResult = await complete(...getPositionFromCursor(workflowJob));
|
|
expect(workflowJobResult).not.toBeUndefined();
|
|
expect(workflowJobResult.map(x => x.label)).not.toContain("runs-on");
|
|
});
|
|
|
|
it("completes boolean values", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
continue-on-error: t|`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
expect(result).not.toBeUndefined();
|
|
expect(result.length).toEqual(2);
|
|
|
|
expect(result.map(x => x.label).sort()).toEqual(["false", "true"]);
|
|
});
|
|
|
|
it("completes for empty map values", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
continue-on-error: |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
expect(result).not.toBeUndefined();
|
|
expect(result.length).toEqual(2);
|
|
|
|
expect(result.map(x => x.label).sort()).toEqual(["false", "true"]);
|
|
});
|
|
|
|
it("does not complete empty map values when cursor is immediately after the position", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
continue-on-error:|`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
expect(result).not.toBeUndefined();
|
|
expect(result.length).toEqual(0);
|
|
});
|
|
|
|
it("custom value providers override defaults", async () => {
|
|
const input = "on: push\njobs:\n build:\n runs-on: |";
|
|
|
|
const config: ValueProviderConfig = {
|
|
"runs-on": {
|
|
kind: ValueProviderKind.SuggestedValues,
|
|
get: () => {
|
|
return Promise.resolve([{label: "my-custom-label"}]);
|
|
}
|
|
}
|
|
};
|
|
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
|
|
|
|
expect(result).not.toBeUndefined();
|
|
// Custom value plus escape hatches for list and full syntax
|
|
expect(result.length).toEqual(3);
|
|
expect(result[0].label).toEqual("my-custom-label");
|
|
expect(result.map(x => x.label)).toContain("(switch to list)");
|
|
expect(result.map(x => x.label)).toContain("(switch to mapping)");
|
|
});
|
|
|
|
it("custom value providers for sequences", async () => {
|
|
const input = "on: push\njobs:\n build:\n runs-on: [m|]";
|
|
|
|
const config: ValueProviderConfig = {
|
|
"runs-on": {
|
|
kind: ValueProviderKind.SuggestedValues,
|
|
get: () => {
|
|
return Promise.resolve([{label: "my-custom-label"}]);
|
|
}
|
|
}
|
|
};
|
|
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
|
|
|
|
expect(result).not.toBeUndefined();
|
|
expect(result.length).toEqual(1);
|
|
expect(result[0].label).toEqual("my-custom-label");
|
|
});
|
|
|
|
it("does not show mapping keys or parent sibling keys in Key mode", async () => {
|
|
// At `container: |`, the scalar form is a string with no constants.
|
|
// Mapping keys should NOT be shown inline - but escape hatch to full syntax IS shown.
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
container: |
|
|
runs-on: ubuntu-latest`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
expect(result).not.toBeUndefined();
|
|
// Only escape hatch to full syntax (container has mapping form but no sequence)
|
|
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
|
|
});
|
|
|
|
it("does not show mapping keys in Key mode when structure is uncommitted", async () => {
|
|
// At `concurrency: |`, user is in Key mode but hasn't committed to a structure.
|
|
// The scalar form is a string with no constants, so no scalar completions.
|
|
// But escape hatch to full syntax IS shown as a way out.
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
concurrency: |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
expect(result).not.toBeUndefined();
|
|
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
|
|
});
|
|
|
|
it("job key", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-|`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
expect(result).not.toBeUndefined();
|
|
expect(result).toHaveLength(30);
|
|
});
|
|
|
|
it("job key with comment afterwards", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-|
|
|
#`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
expect(result).not.toBeUndefined();
|
|
expect(result).toHaveLength(30);
|
|
});
|
|
|
|
it("job key with other values afterwards", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-|
|
|
|
|
concurrency: 'group-name'`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
expect(result).not.toBeUndefined();
|
|
// Verify we get job-level completions, but concurrency is already present so excluded
|
|
expect(result.length).toBeGreaterThan(20);
|
|
expect(result.some(x => x.label === "runs-on")).toBe(true);
|
|
expect(result.some(x => x.label === "concurrency")).toBe(false);
|
|
});
|
|
|
|
it("step key without space after colon", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- env:|
|
|
run: echo`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("empty step", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo
|
|
- |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
expect(result).toHaveLength(11);
|
|
expect(result.map(x => x.label)).toEqual([
|
|
"continue-on-error",
|
|
"env",
|
|
"id",
|
|
"if",
|
|
"name",
|
|
"run",
|
|
"shell",
|
|
"timeout-minutes",
|
|
"uses",
|
|
"with",
|
|
"working-directory"
|
|
]);
|
|
|
|
// Includes detail when available. Using continue-on-error as a sample here.
|
|
expect(result.map(x => (x.documentation as MarkupContent)?.value)).toContain(
|
|
"Prevents a job from failing when a step fails. Set to `true` to allow a job to pass when this step fails."
|
|
);
|
|
});
|
|
|
|
it("loose mapping keys have no completion suggestions", async () => {
|
|
const input = `
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
name:
|
|
type: string
|
|
description: "hello"
|
|
|
|
|
`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("null strings still give suggestions", async () => {
|
|
const input = `
|
|
on: push
|
|
jobs:
|
|
one:
|
|
runs-on: ubuntu-latest
|
|
|:
|
|
- uses: actions/checkout@v2
|
|
`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
// Verify we get job-level completions including runs-on variants
|
|
expect(result.length).toBeGreaterThan(20);
|
|
expect(result.some(x => x.label === "steps")).toBe(true);
|
|
});
|
|
|
|
it("complete from behind a colon will replace it", async () => {
|
|
const input = `
|
|
on: push
|
|
jobs:
|
|
one:
|
|
runs-on: ubuntu-latest
|
|
|:
|
|
- uses: actions/checkout@v2
|
|
`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
// Verify we get job-level completions
|
|
expect(result.length).toBeGreaterThan(20);
|
|
const textEdit = result[0].textEdit as TextEdit;
|
|
expect(textEdit.range).toEqual({
|
|
start: {line: 5, character: 4},
|
|
end: {line: 5, character: 5}
|
|
});
|
|
});
|
|
|
|
it("well known mapping keys have descriptions", async () => {
|
|
const input = `
|
|
o|
|
|
`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
const onResult = result.find(x => x.label === "on");
|
|
expect(onResult).not.toBeUndefined();
|
|
expect((onResult!.documentation as MarkupContent).value).toContain("The GitHub event that triggers the workflow.");
|
|
});
|
|
|
|
it("event list includes descriptions when available ", async () => {
|
|
const input = `
|
|
on: [check_run, |]`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
const dispatchResult = result.find(x => x.label === "workflow_dispatch");
|
|
expect(dispatchResult).not.toBeUndefined();
|
|
expect((dispatchResult!.documentation as MarkupContent).value).toContain(
|
|
"The `workflow_dispatch` event allows you to manually trigger a workflow run."
|
|
);
|
|
});
|
|
|
|
it("sets range when completing token", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
pre-build:
|
|
runs-on: ubuntu-latest
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
needs: pre-bu|`;
|
|
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
expect(result).not.toBeUndefined();
|
|
expect(result.length).toEqual(1);
|
|
|
|
const textEdit = result[0].textEdit as TextEdit;
|
|
expect(textEdit.newText).toEqual("pre-build");
|
|
expect(textEdit.range).toEqual({
|
|
start: {line: 6, character: 11},
|
|
end: {line: 6, character: 17}
|
|
});
|
|
});
|
|
|
|
it("sets a range for token key", async () => {
|
|
const input = "on: push\njobs:\n build:\n runs-o|";
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
expect(result).not.toBeUndefined();
|
|
expect(result.map(e => e.label)).toContain("runs-on");
|
|
|
|
const textEdit = result.filter(e => e.label === "runs-on")[0].textEdit as TextEdit;
|
|
expect(textEdit.newText).toEqual("runs-on: ");
|
|
expect(textEdit.range).toEqual({
|
|
start: {line: 3, character: 4},
|
|
end: {line: 3, character: 10}
|
|
});
|
|
});
|
|
|
|
it("sets a 0-length range while no initial token key", async () => {
|
|
const input = "on: push\njobs:\n build:\n |";
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
expect(result).not.toBeUndefined();
|
|
expect(result.map(e => e.label)).toContain("runs-on");
|
|
|
|
const textEdit = result.filter(e => e.label === "runs-on")[0].textEdit as TextEdit;
|
|
expect(textEdit.newText).toEqual("runs-on: ");
|
|
expect(textEdit.range).toEqual({
|
|
start: {line: 3, character: 4},
|
|
end: {line: 3, character: 4}
|
|
});
|
|
});
|
|
|
|
describe("completes with indentation", () => {
|
|
it("default indentation", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
step|`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// Sequence
|
|
expect(result.filter(x => x.label === "steps").map(x => x.textEdit?.newText)).toEqual(["steps:\n - "]);
|
|
|
|
// Mapping
|
|
expect(result.filter(x => x.label === "env").map(x => x.textEdit?.newText)).toEqual(["env:\n "]);
|
|
|
|
// Value
|
|
expect(result.filter(x => x.label === "timeout-minutes").map(x => x.textEdit?.newText)).toEqual([
|
|
"timeout-minutes: "
|
|
]);
|
|
|
|
// One-of (scalar variant)
|
|
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined);
|
|
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
|
|
});
|
|
|
|
it("custom indentation", async () => {
|
|
// Use 3 spaces to indent
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
step|`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// Sequence
|
|
expect(result.filter(x => x.label === "steps").map(x => x.textEdit?.newText)).toEqual(["steps:\n - "]);
|
|
|
|
// Mapping
|
|
expect(result.filter(x => x.label === "env").map(x => x.textEdit?.newText)).toEqual(["env:\n "]);
|
|
|
|
// Value
|
|
expect(result.filter(x => x.label === "timeout-minutes").map(x => x.textEdit?.newText)).toEqual([
|
|
"timeout-minutes: "
|
|
]);
|
|
|
|
// One-of (scalar variant)
|
|
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined);
|
|
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
|
|
});
|
|
});
|
|
|
|
it("does not show mapping keys in Key mode for one-of with mapping variant", async () => {
|
|
// At `concurrency: |`, mapping keys should NOT be shown.
|
|
// Users who want the mapping form should use `concurrency (full syntax)` at parent level.
|
|
const input = "concurrency: |";
|
|
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
expect(result.filter(x => x.label === "cancel-in-progress")).toEqual([]);
|
|
expect(result.filter(x => x.label === "group")).toEqual([]);
|
|
});
|
|
|
|
it("does not add new line if no key in line", async () => {
|
|
const input = "run-n|";
|
|
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
expect(result.filter(x => x.label === "run-name").map(x => x.textEdit?.newText)).toEqual(["run-name: "]);
|
|
});
|
|
|
|
it("does not show mapping keys when user has started typing a scalar value", async () => {
|
|
// User typed `workflow_dispatch: in` - they've committed to a scalar value
|
|
// Should not show mapping keys like `inputs`
|
|
const input = "on:\n workflow_dispatch: in|";
|
|
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// No mapping keys should be shown since user started typing a scalar
|
|
expect(result.filter(x => x.label === "inputs")).toEqual([]);
|
|
});
|
|
|
|
it("adds : for one-of", async () => {
|
|
const input = "on:\n check_run:\n ty|";
|
|
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// Scalar variant inserts "types: "
|
|
const scalarVariant = result.find(x => x.label === "types" && x.labelDetails === undefined);
|
|
expect(scalarVariant?.textEdit?.newText).toEqual("types: ");
|
|
});
|
|
|
|
it("does not show mapping keys for one-of when user has typed a scalar value", async () => {
|
|
// User typed `check_run: ty` - they've committed to scalar form
|
|
// The only valid value for check_run scalar is null, so no completions
|
|
const input = "on:\n check_run: ty|";
|
|
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// check_run's scalar form only accepts null, so typing anything should show no completions
|
|
// (we don't show mapping keys like `types` anymore - user should use check_run with detail "full syntax" instead)
|
|
expect(result.filter(x => x.label === "types")).toEqual([]);
|
|
});
|
|
|
|
it("shows only scalar options for one-of in Key mode when user hasn't committed to a type", async () => {
|
|
// At `permissions: |` user hasn't typed anything yet - show only scalar options
|
|
// Mapping keys are NOT shown because they would require a newline
|
|
// Users who want the mapping form can use `permissions (full syntax)` at the parent level
|
|
const input = "on: push\npermissions: |";
|
|
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// String values (read-all, write-all) should be available
|
|
expect(result.filter(x => x.label === "read-all").map(x => x.textEdit?.newText)).toEqual(["read-all"]);
|
|
expect(result.filter(x => x.label === "write-all").map(x => x.textEdit?.newText)).toEqual(["write-all"]);
|
|
|
|
// Mapping keys should NOT be shown - they require a newline which is confusing inline
|
|
expect(result.filter(x => x.label === "actions")).toEqual([]);
|
|
expect(result.filter(x => x.label === "contents")).toEqual([]);
|
|
});
|
|
|
|
it("filters to scalar options when user has started typing a scalar", async () => {
|
|
// User typed `permissions: r` - they've committed to scalar form
|
|
const input = "on: push\npermissions: r|";
|
|
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// Only scalar values should be shown (filtering on 'r')
|
|
expect(result.some(x => x.label === "read-all")).toBe(true);
|
|
// Mapping keys should NOT be shown
|
|
expect(result.filter(x => x.label === "actions")).toEqual([]);
|
|
expect(result.filter(x => x.label === "contents")).toEqual([]);
|
|
});
|
|
|
|
it("shows both simple and full syntax for null+mapping one-of", async () => {
|
|
// check_run is a one-of: [null, mapping]. Show both:
|
|
// - check_run (simple, just the key with colon)
|
|
// - check_run with detail "full syntax" (ready to add mapping keys)
|
|
const input = "on:\n |";
|
|
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// Should have both check_run (scalar) and check_run with detail "full syntax"
|
|
const checkRunVariants = result.filter(x => x.label === "check_run");
|
|
expect(checkRunVariants.some(x => x.labelDetails === undefined)).toBe(true);
|
|
expect(checkRunVariants.some(x => x.labelDetails?.description === "full syntax")).toBe(true);
|
|
});
|
|
|
|
it("shows all three variants for scalar+sequence+mapping one-of", async () => {
|
|
// runs-on is a one-of: [string, sequence, mapping]
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
|`;
|
|
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// Should have runs-on (scalar), runs-on with detail "list", and runs-on with detail "full syntax"
|
|
const runsOnVariants = result.filter(x => x.label === "runs-on");
|
|
expect(runsOnVariants.length).toBe(3);
|
|
expect(runsOnVariants.some(x => x.labelDetails === undefined)).toBe(true);
|
|
expect(runsOnVariants.some(x => x.labelDetails?.description === "list")).toBe(true);
|
|
expect(runsOnVariants.some(x => x.labelDetails?.description === "full syntax")).toBe(true);
|
|
});
|
|
|
|
it("generates correct insertText for one-of variants in parent mode", async () => {
|
|
// runs-on is a one-of: [string, sequence, mapping]
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
|`;
|
|
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
const runsOnVariants = result.filter(x => x.label === "runs-on");
|
|
|
|
// Scalar: just key with colon and space
|
|
expect(runsOnVariants.find(x => x.labelDetails === undefined)?.textEdit?.newText).toEqual("runs-on: ");
|
|
|
|
// Sequence: key with colon, newline, and list item
|
|
expect(runsOnVariants.find(x => x.labelDetails?.description === "list")?.textEdit?.newText).toEqual(
|
|
"runs-on:\n - "
|
|
);
|
|
|
|
// Mapping: key with colon, newline, and indentation for nested keys
|
|
expect(runsOnVariants.find(x => x.labelDetails?.description === "full syntax")?.textEdit?.newText).toEqual(
|
|
"runs-on:\n "
|
|
);
|
|
});
|
|
|
|
it("generates correct insertText for one-of variants in parent mode", async () => {
|
|
// concurrency is a one-of: [string, mapping] - testing parent mode (inside mapping)
|
|
// At `concurrency:\n |`, user HAS committed to mapping structure, so mapping keys are shown
|
|
const input = "concurrency:\n |";
|
|
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// In parent mode: just key + colon + space (no leading newline)
|
|
expect(result.find(x => x.label === "group")?.textEdit?.newText).toEqual("group: ");
|
|
|
|
// Boolean in parent mode (cancel-in-progress): key + colon + space
|
|
expect(result.find(x => x.label === "cancel-in-progress")?.textEdit?.newText).toEqual("cancel-in-progress: ");
|
|
});
|
|
|
|
it("uses sortText for ordering qualified one-of variants", async () => {
|
|
// runs-on has multiple structural types, so variants need sorting
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
|`;
|
|
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
const runsOnVariants = result.filter(x => x.label === "runs-on");
|
|
|
|
// Scalar: no sortText needed (sorts naturally first)
|
|
expect(runsOnVariants.find(x => x.labelDetails === undefined)?.sortText).toBeUndefined();
|
|
|
|
// Sequence and mapping: sortText controls ordering
|
|
expect(runsOnVariants.find(x => x.labelDetails?.description === "list")?.sortText).toEqual("runs-on 1");
|
|
expect(runsOnVariants.find(x => x.labelDetails?.description === "full syntax")?.sortText).toEqual("runs-on 2");
|
|
});
|
|
|
|
it("scalar event completion inserts inline without newline", async () => {
|
|
// At `on: |` user is completing the value for 'on' key
|
|
// Scalar events like `push`, `check_run` should insert inline
|
|
const input = "on: |";
|
|
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// Scalar forms should NOT have newline - they insert inline
|
|
const push = result.find(x => x.label === "push");
|
|
expect(push?.textEdit?.newText).toEqual("push");
|
|
|
|
const checkRun = result.find(x => x.label === "check_run" && x.labelDetails === undefined);
|
|
expect(checkRun?.textEdit?.newText).toEqual("check_run");
|
|
|
|
// Full syntax form should NOT be shown in Key mode - it requires a newline
|
|
// which is confusing when typing inline. Users who want the mapping form
|
|
// can use `on (full syntax)` at the parent level.
|
|
expect(result.find(x => x.label === "check_run" && x.labelDetails?.description === "full syntax")).toBeUndefined();
|
|
});
|
|
|
|
it("filters to sequence options when user has started a sequence", async () => {
|
|
// User started a sequence with `- ` syntax - they've committed to sequence form
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-on:
|
|
- |`;
|
|
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// Should show runner labels (sequence item values)
|
|
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
|
|
expect(result.some(x => x.label === "macos-latest")).toBe(true);
|
|
|
|
// Should NOT show mapping keys like `group` or `labels` (those are for full syntax)
|
|
expect(result.filter(x => x.label === "group")).toEqual([]);
|
|
expect(result.filter(x => x.label === "labels")).toEqual([]);
|
|
});
|
|
|
|
describe("escape hatch completions", () => {
|
|
it("runs-on shows switch to list and full syntax", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-on: |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// Should have escape hatches at the end
|
|
const switchToList = result.find(x => x.label === "(switch to list)");
|
|
const switchToFull = result.find(x => x.label === "(switch to mapping)");
|
|
|
|
expect(switchToList).toBeDefined();
|
|
expect(switchToFull).toBeDefined();
|
|
|
|
// Escape hatches should sort last
|
|
expect(switchToList!.sortText).toEqual("zzz_switch_1");
|
|
expect(switchToFull!.sortText).toEqual("zzz_switch_2");
|
|
|
|
// Escape hatches should have textEdit at cursor position (for VS Code filtering compatibility)
|
|
const listEdit = switchToList!.textEdit as TextEdit;
|
|
const fullEdit = switchToFull!.textEdit as TextEdit;
|
|
|
|
// Main textEdit inserts newline and indented content at cursor position
|
|
expect(listEdit.newText).toEqual("\n - ");
|
|
expect(fullEdit.newText).toEqual("\n ");
|
|
|
|
// TextEdit range should be at cursor position (empty range)
|
|
expect(listEdit.range.start).toEqual({line: 3, character: 13});
|
|
expect(listEdit.range.end).toEqual({line: 3, character: 13});
|
|
expect(fullEdit.range.start).toEqual({line: 3, character: 13});
|
|
expect(fullEdit.range.end).toEqual({line: 3, character: 13});
|
|
|
|
// additionalTextEdits should clean up the key portion
|
|
expect(switchToList!.additionalTextEdits).toHaveLength(1);
|
|
expect(switchToList!.additionalTextEdits![0].range.start).toEqual({line: 3, character: 4});
|
|
expect(switchToList!.additionalTextEdits![0].range.end).toEqual({line: 3, character: 13});
|
|
expect(switchToList!.additionalTextEdits![0].newText).toEqual("runs-on:");
|
|
|
|
expect(switchToFull!.additionalTextEdits).toHaveLength(1);
|
|
expect(switchToFull!.additionalTextEdits![0].newText).toEqual("runs-on:");
|
|
});
|
|
|
|
it("permissions shows only switch to full syntax (no sequence form)", async () => {
|
|
const input = `on: push
|
|
permissions: |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// Should have full syntax escape hatch but NOT list (permissions has no sequence form)
|
|
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
|
|
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
|
});
|
|
|
|
it("escape hatches are not shown when value is non-empty", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-|`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// User has started typing a scalar value, no escape hatches
|
|
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
|
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
|
|
});
|
|
|
|
it("escape hatches are not shown when inside a sequence", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-on:
|
|
- |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// User is already in sequence form, no escape hatches
|
|
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
|
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
|
|
});
|
|
|
|
it("escape hatches are not shown when inside a mapping", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-on:
|
|
group: |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// User is in mapping form completing a value, no escape hatches for the parent
|
|
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
|
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
|
|
});
|
|
|
|
it("escape hatches ARE shown even when no scalar completions exist", async () => {
|
|
// concurrency: | has no scalar constants, but escape hatch provides a way out
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
concurrency: |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// Escape hatch to mapping should be available even with no scalar completions
|
|
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
|
|
});
|
|
|
|
it("pure mapping type (strategy) shows switch to mapping", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
strategy: |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
|
|
});
|
|
|
|
it("pure sequence type (steps) shows switch to list", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
steps: |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
expect(result.some(x => x.label === "(switch to list)")).toBe(true);
|
|
});
|
|
|
|
it("selecting switch to list restructures YAML", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-on: |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
const switchToList = result.find(x => x.label === "(switch to list)");
|
|
const textEdit = switchToList!.textEdit as TextEdit;
|
|
const additionalEdits = switchToList!.additionalTextEdits!;
|
|
|
|
// Main textEdit inserts newline content at cursor
|
|
expect(textEdit.newText).toEqual("\n - ");
|
|
|
|
// additionalTextEdits replaces "runs-on: " with "runs-on:"
|
|
expect(additionalEdits).toHaveLength(1);
|
|
expect(additionalEdits[0].newText).toEqual("runs-on:");
|
|
|
|
// Combined result when applied: "runs-on:\n - "
|
|
});
|
|
});
|
|
|
|
describe("runs-on mapping syntax", () => {
|
|
it("provides label completions for labels as scalar", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-on:
|
|
labels: |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// Should show runner labels
|
|
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
|
|
expect(result.some(x => x.label === "macos-latest")).toBe(true);
|
|
expect(result.some(x => x.label === "self-hosted")).toBe(true);
|
|
});
|
|
|
|
it("provides label completions for labels as sequence item", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-on:
|
|
labels:
|
|
- |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// Should show runner labels
|
|
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
|
|
expect(result.some(x => x.label === "macos-latest")).toBe(true);
|
|
expect(result.some(x => x.label === "self-hosted")).toBe(true);
|
|
});
|
|
|
|
it("excludes already used labels in sequence", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-on:
|
|
labels:
|
|
- ubuntu-latest
|
|
- |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
|
|
// Should NOT show ubuntu-latest since it's already in the list
|
|
expect(result.some(x => x.label === "ubuntu-latest")).toBe(false);
|
|
// But should show other labels
|
|
expect(result.some(x => x.label === "macos-latest")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("expression completions", () => {
|
|
it("include case function when enabled", async () => {
|
|
const input = "on: push\njobs:\n build:\n runs-on: ${{ c|";
|
|
const result = await complete(...getPositionFromCursor(input), {
|
|
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
|
});
|
|
|
|
expect(result).not.toBeUndefined();
|
|
// Expression completions starting with 'c': case, contains
|
|
const labels = result.map(x => x.label);
|
|
expect(labels).toContain("case");
|
|
expect(labels).toContain("contains");
|
|
});
|
|
|
|
it("exclude case function when disabled", async () => {
|
|
const input = "on: push\njobs:\n build:\n runs-on: ${{ c|";
|
|
const result = await complete(...getPositionFromCursor(input), {
|
|
featureFlags: new FeatureFlags({allowCaseFunction: false})
|
|
});
|
|
|
|
expect(result).not.toBeUndefined();
|
|
// Expression completions starting with 'c': contains
|
|
const labels = result.map(x => x.label);
|
|
expect(labels).not.toContain("case");
|
|
expect(labels).toContain("contains");
|
|
});
|
|
});
|
|
});
|