Files
languageservices/languageservice/src/validate-action-reference.test.ts
T
Francesco Renzi 0fe31c6656 Setup CodeActions and add quickfix for missing inputs (#254)
* Setup CodeActions and add quickfix for missing inputs

* PR feedback

* Update languageservice/src/code-actions/quickfix/add-missing-inputs.ts

Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>

* Fix indentSize detection for code actions after rebase

- Add indentSize to MissingInputsDiagnosticData interface
- Pass indentSize parameter from validate.ts to validateActionReference
- Detect indentSize from workflow structure (jobs key to first child)
- Fall back to detecting from with: block children when available

* update typescript

* formatting

* linting

* Gate missing inputs quickfix behind feature flag

* Address PR review: rename files, move position calculation to quickfix

- Rename index.ts files to follow repo patterns:
  - code-actions/index.ts → code-actions/code-actions.ts
  - code-actions/quickfix/index.ts → quickfix/quickfix-providers.ts
- Move position calculation from validation to quickfix:
  - MissingInputsDiagnosticData now passes raw token ranges
  - Quickfix computes insertion position and indentation at code action time
  - detectIndentSize moved from validate.ts to validate-action-reference.ts

* wip

* Remove pointless comment

---------

Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-01-14 15:58:20 +00:00

421 lines
10 KiB
TypeScript

import {DiagnosticSeverity} from "vscode-languageserver-types";
import {ActionMetadata, ActionReference} from "./action.js";
import {registerLogger} from "./log.js";
import {createDocument} from "./test-utils/document.js";
import {TestLogger} from "./test-utils/logger.js";
import {validate, ValidationConfig} from "./validate.js";
import {ValueProviderKind} from "./value-providers/config.js";
import {clearCache} from "./utils/workflow-cache.js";
registerLogger(new TestLogger());
beforeEach(() => {
clearCache();
});
const validationConfig: ValidationConfig = {
actionsMetadataProvider: {
fetchActionMetadata: (ref: ActionReference) => {
let metadata: ActionMetadata | undefined = undefined;
switch (ref.owner + "/" + ref.name + "@" + ref.ref) {
case "actions/checkout@v3":
metadata = {
name: "Checkout",
description: "Checkout a Git repository at a particular version",
inputs: {
repository: {
description: "Repository name with owner",
default: "${{ github.repository }}"
}
}
};
break;
case "actions/setup-node@v1":
metadata = {
name: "Setup Node.js environment",
description:
"Setup a Node.js environment by adding problem matchers and optionally downloading and adding it to the PATH.",
inputs: {
version: {
description: "Deprecated. Use node-version instead. Will not be supported after October 1, 2019",
deprecationMessage:
"The version property will not be supported after October 1, 2019. Use node-version instead"
}
}
};
break;
case "actions/deploy-pages@main":
metadata = {
name: "Deploy GitHub Pages site",
description: "A GitHub Action to deploy an artifact as a GitHub Pages site",
inputs: {
token: {
required: true,
description: "token to use",
default: "${{ github.token }}"
}
}
};
break;
case "actions/cache@v1":
metadata = {
name: "Cache",
description: "Cache artifacts like dependencies and build outputs to improve workflow execution time",
inputs: {
path: {
description: "A directory to store and save the cache",
required: true
},
key: {
description: "An explicit key for restoring and saving the cache",
required: true
},
"restore-keys": {
description: "An ordered list of keys to use for restoring the cache if no cache hit occurred for key",
required: false
}
}
};
break;
case "actions/action-no-input@v1":
metadata = {
name: "Action with no inputs",
description: "An action with no inputs"
};
}
return Promise.resolve(metadata);
}
}
};
describe("validate action steps", () => {
it("valid action reference", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([]);
});
it("no actionsMetadataProvider", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/does-not-exist@v3
`;
const config: ValidationConfig = {};
const result = await validate(createDocument("wf.yaml", input), config);
expect(result).toEqual([]);
});
it("action does not exist", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/does-not-exist@v3
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "Unable to resolve action `actions/does-not-exist@v3`, repository or version not found",
range: {
end: {
character: 41,
line: 6
},
start: {
character: 16,
line: 6
}
},
severity: DiagnosticSeverity.Error
}
]);
});
it("action does not define inputs", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/action-no-input@v1
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([]);
});
it("invalid input", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
notanoption: true
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "Invalid action input 'notanoption'",
range: {
end: {
character: 19,
line: 8
},
start: {
character: 8,
line: 8
}
},
severity: DiagnosticSeverity.Error
}
]);
});
it("deprecated input", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v1
with:
version: 10
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "The version property will not be supported after October 1, 2019. Use node-version instead",
range: {
end: {
character: 15,
line: 8
},
start: {
character: 8,
line: 8
}
},
severity: DiagnosticSeverity.Warning
}
]);
});
it("missing required input", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
key: \${{ runner.os }}-node-\${{ hashFiles('**/package-lock.json') }}
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "Missing required input `path`",
range: {
end: {
character: 10,
line: 7
},
start: {
character: 6,
line: 7
}
},
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
missingInputs: [
{
default: undefined,
name: "path"
}
]
}
}
]);
});
it("required input with default value", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/deploy-pages@main
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([]);
});
it("multiple missing required inputs", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
restore-keys: \${{ runner.os }}-node-
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "Missing required inputs: `path`, `key`",
range: {
end: {
character: 10,
line: 7
},
start: {
character: 6,
line: 7
}
},
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
missingInputs: [
{
default: undefined,
name: "path"
},
{
default: undefined,
name: "key"
}
]
}
}
]);
});
it("missing required inputs without a `with` key", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "Missing required inputs: `path`, `key`",
range: {
end: {
character: 0,
line: 7
},
start: {
character: 6,
line: 6
}
},
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
missingInputs: [
{
default: undefined,
name: "path"
},
{
default: undefined,
name: "key"
}
]
}
}
]);
});
it("skips extra validation from custom value provider", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
notanoption: true
`;
const config = validationConfig;
config.valueProviderConfig = {
"step-with": {
kind: ValueProviderKind.AllowedValues,
get: () => {
return Promise.resolve([{label: "repository", description: "Repository name with owner."}]);
}
}
};
const result = await validate(createDocument("wf.yaml", input), config);
expect(result).toEqual([
{
message: "Invalid action input 'notanoption'",
range: {
end: {
character: 19,
line: 8
},
start: {
character: 8,
line: 8
}
},
severity: DiagnosticSeverity.Error
}
]);
});
});