478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
import {MarkupContent, TextEdit} from "vscode-languageserver-types";
|
|
import {complete} from "./complete";
|
|
import {WorkflowContext} from "./context/workflow-context";
|
|
import {registerLogger} from "./log";
|
|
import {getPositionFromCursor} from "./test-utils/cursor-position";
|
|
import {TestLogger} from "./test-utils/logger";
|
|
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
|
import {clearCache} from "./utils/workflow-cache";
|
|
|
|
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();
|
|
expect(result.length).toEqual(12);
|
|
const labels = result.map(x => x.label);
|
|
expect(labels).toContain("macos-latest");
|
|
});
|
|
|
|
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(8);
|
|
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(20);
|
|
});
|
|
|
|
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(result.map(x => x.label)).toEqual([
|
|
"created",
|
|
"deleted",
|
|
"edited",
|
|
"prereleased",
|
|
"published",
|
|
"released",
|
|
"unpublished"
|
|
]);
|
|
});
|
|
|
|
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: async (_: WorkflowContext) => {
|
|
return [{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("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: async (_: WorkflowContext) => {
|
|
return [{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 parent mapping sibling keys", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
container: |
|
|
runs-on: ubuntu-latest`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
expect(result).not.toBeUndefined();
|
|
expect(result.length).toEqual(6);
|
|
// Should not contain other top-level job keys like `if` and `runs-on`
|
|
expect(result.map(x => x.label)).not.toContain("if");
|
|
expect(result.map(x => x.label)).not.toContain("runs-on");
|
|
});
|
|
|
|
it("shows mapping keys within a new map ", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
concurrency: |`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
expect(result).not.toBeUndefined();
|
|
expect(result.map(x => x.label).sort()).toEqual(["cancel-in-progress", "group"]);
|
|
});
|
|
|
|
it("job key", async () => {
|
|
const input = `on: push
|
|
jobs:
|
|
build:
|
|
runs-|`;
|
|
const result = await complete(...getPositionFromCursor(input));
|
|
expect(result).not.toBeUndefined();
|
|
expect(result).toHaveLength(20);
|
|
});
|
|
|
|
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(20);
|
|
});
|
|
|
|
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();
|
|
expect(result).toHaveLength(19);
|
|
});
|
|
|
|
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));
|
|
expect(result).toHaveLength(16);
|
|
});
|
|
|
|
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));
|
|
expect(result).toHaveLength(16);
|
|
let 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);
|
|
|
|
let 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");
|
|
|
|
let 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");
|
|
|
|
let 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
|
|
expect(result.filter(x => x.label === "concurrency").map(x => x.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
|
|
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency"]);
|
|
});
|
|
});
|
|
});
|