diff --git a/languageserver/src/context-providers/steps.test.ts b/languageserver/src/context-providers/steps.test.ts index d5e1818..42f0c47 100644 --- a/languageserver/src/context-providers/steps.test.ts +++ b/languageserver/src/context-providers/steps.test.ts @@ -1,4 +1,4 @@ -import {data, DescriptionDictionary} from "@actions/expressions"; +import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions"; import {getStepsContext as getDefaultStepsContext} from "@actions/languageservice/context-providers/steps"; import {Octokit} from "@octokit/rest"; import fetchMock from "fetch-mock"; @@ -63,6 +63,43 @@ it("returns default context when job is undefined", async () => { expect(stepsContext).toEqual(defaultContext); }); +it("outputs is an incomplete dictionary to allow dynamic outputs", async () => { + const mock = fetchMock + .sandbox() + .getOnce("https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3", actionMetadata); + + const workflowContext = await createWorkflowContext(workflow, "build"); + const defaultContext = getDefaultStepsContext(workflowContext); + + const stepsContext = await getStepsContext( + new Octokit({ + request: { + fetch: mock + } + }), + new TTLCache(), + defaultContext, + workflowContext + ); + + // Get the step context + const stepContext = stepsContext?.get("cache-primes"); + expect(stepContext).toBeDefined(); + expect(isDescriptionDictionary(stepContext!)).toBe(true); + + // Get the outputs - should be a dictionary, not null + const outputs = (stepContext as DescriptionDictionary).get("outputs"); + expect(outputs).toBeDefined(); + expect(isDescriptionDictionary(outputs!)).toBe(true); + + // Outputs should be marked incomplete to allow dynamic outputs + const outputsDict = outputs as DescriptionDictionary; + expect(outputsDict.complete).toBe(false); + + // Known outputs from action.yml should be present + expect(outputsDict.get("cache-hit")).toBeDefined(); +}); + it("adds action outputs", async () => { const mock = fetchMock .sandbox() @@ -83,17 +120,22 @@ it("adds action outputs", async () => { ); expect(stepsContext).toBeDefined(); + // Create expected outputs dict with complete = false + // (actions can have dynamic outputs beyond what's declared in action.yml) + const expectedOutputs = new DescriptionDictionary({ + key: "cache-hit", + value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"), + description: "A boolean value to indicate an exact match was found for the primary key" + }); + expectedOutputs.complete = false; + expect(stepsContext).toEqual( new DescriptionDictionary({ key: "cache-primes", value: new DescriptionDictionary( { key: "outputs", - value: new DescriptionDictionary({ - key: "cache-hit", - value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"), - description: "A boolean value to indicate an exact match was found for the primary key" - }) + value: expectedOutputs }, { key: "conclusion", diff --git a/languageserver/src/context-providers/steps.ts b/languageserver/src/context-providers/steps.ts index 5aa8731..a78cd9e 100644 --- a/languageserver/src/context-providers/steps.ts +++ b/languageserver/src/context-providers/steps.ts @@ -58,6 +58,8 @@ export async function getStepsContext( continue; } const outputsDict = new DescriptionDictionary(); + // Actions can have dynamic outputs beyond what's declared in action.yml + outputsDict.complete = false; for (const [key, value] of Object.entries(outputs)) { outputsDict.add(key, new data.StringData(value.description), value.description); } diff --git a/languageservice/src/context-providers/steps.test.ts b/languageservice/src/context-providers/steps.test.ts new file mode 100644 index 0000000..ad0795f --- /dev/null +++ b/languageservice/src/context-providers/steps.test.ts @@ -0,0 +1,78 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions"; +import {WorkflowContext} from "../context/workflow-context"; +import {getStepsContext} from "./steps"; + +function createWorkflowContext(stepIds: string[], currentStepId?: string): WorkflowContext { + return { + job: { + steps: stepIds.map(id => ({id})) + }, + step: currentStepId ? {id: currentStepId} : undefined + } as WorkflowContext; +} + +describe("steps context", () => { + it("returns empty dictionary when no job", () => { + const workflowContext = {} as WorkflowContext; + const context = getStepsContext(workflowContext); + expect(context.pairs().length).toBe(0); + }); + + it("returns empty dictionary when no steps", () => { + const workflowContext = {job: {}} as WorkflowContext; + const context = getStepsContext(workflowContext); + expect(context.pairs().length).toBe(0); + }); + + it("includes steps with user-defined ids", () => { + const workflowContext = createWorkflowContext(["step-a", "step-b"]); + const context = getStepsContext(workflowContext); + + expect(context.get("step-a")).toBeDefined(); + expect(context.get("step-b")).toBeDefined(); + }); + + it("excludes generated step ids (starting with __)", () => { + const workflowContext = createWorkflowContext(["step-a", "__generated"]); + const context = getStepsContext(workflowContext); + + expect(context.get("step-a")).toBeDefined(); + expect(context.get("__generated")).toBeUndefined(); + }); + + it("excludes current step and later steps", () => { + const workflowContext = createWorkflowContext(["step-a", "step-b", "step-c"], "step-b"); + const context = getStepsContext(workflowContext); + + expect(context.get("step-a")).toBeDefined(); + expect(context.get("step-b")).toBeUndefined(); + expect(context.get("step-c")).toBeUndefined(); + }); + + describe("step outputs", () => { + it("outputs is a dictionary, not null", () => { + const workflowContext = createWorkflowContext(["step-a"]); + const context = getStepsContext(workflowContext); + + const stepContext = context.get("step-a"); + expect(stepContext).toBeDefined(); + expect(isDescriptionDictionary(stepContext!)).toBe(true); + + const outputs = (stepContext as DescriptionDictionary).get("outputs"); + expect(outputs).toBeDefined(); + expect(isDescriptionDictionary(outputs!)).toBe(true); + }); + + it("outputs is marked incomplete to allow dynamic outputs", () => { + const workflowContext = createWorkflowContext(["step-a"]); + const context = getStepsContext(workflowContext); + + const stepContext = context.get("step-a") as DescriptionDictionary; + const outputs = stepContext.get("outputs") as DescriptionDictionary; + + // Outputs should be incomplete since we can't know what outputs a step will produce + expect(outputs.complete).toBe(false); + }); + }); +}); diff --git a/languageservice/src/context-providers/steps.ts b/languageservice/src/context-providers/steps.ts index 7f3c657..8b0fe54 100644 --- a/languageservice/src/context-providers/steps.ts +++ b/languageservice/src/context-providers/steps.ts @@ -31,7 +31,10 @@ function stepContext(): DescriptionDictionary { // https://docs.github.com/en/actions/learn-github-actions/contexts#steps-context const d = new DescriptionDictionary(); - d.add("outputs", new data.Null(), getDescription("steps", "outputs")); + // Step outputs are dynamic - actions can generate outputs based on their inputs + const outputs = new DescriptionDictionary(); + outputs.complete = false; + d.add("outputs", outputs, getDescription("steps", "outputs")); // Can be "success", "failure", "cancelled", or "skipped" d.add("conclusion", new data.Null(), getDescription("steps", "conclusion"));