diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 5d0d661af..6a7ff6c9b 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -268,11 +268,30 @@ jobs: - *checkout-repository - &set-up-node-js name: Set up Node.js + id: node uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node }} cache: npm cache-dependency-path: sdk/typescript/pnpm-lock.yaml + - &resolve-npm-cache + name: Resolve npm download cache + id: npm-cache + if: steps.node.outputs.cache-hit != 'true' + shell: bash + env: + PRIMARY_CACHE_KEY: ${{ steps.node.outputs.cache-primary-key }} + run: | + echo "path=$(npm config get cache)" >> "$GITHUB_OUTPUT" + echo "restore-prefix=${PRIMARY_CACHE_KEY%-*}-" >> "$GITHUB_OUTPUT" + - &restore-npm-cache + name: Restore previous npm downloads + if: steps.node.outputs.cache-hit != 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.npm-cache.outputs.path }} + key: ${{ steps.node.outputs.cache-primary-key }} + restore-keys: ${{ steps.npm-cache.outputs.restore-prefix }} - *download-package-for-this-commit - name: Inspect installed package working-directory: sdk/typescript @@ -556,6 +575,8 @@ jobs: - *checkout-repository - *set-up-python - *set-up-node-js + - *resolve-npm-cache + - *restore-npm-cache - *download-package-for-this-commit - name: Inspect installed package working-directory: sdk/typescript diff --git a/sdk/typescript/tests-ts/cli-skills.test.ts b/sdk/typescript/tests-ts/cli-skills.test.ts index ef7970cff..b696cda29 100644 --- a/sdk/typescript/tests-ts/cli-skills.test.ts +++ b/sdk/typescript/tests-ts/cli-skills.test.ts @@ -158,7 +158,7 @@ describe("CLI skill commands", () => { ); test.each([0, 2])( - "combines inline, file, and directory context and cleans up after exit %s", + "combines inline, file, and directory context and cleans up after exit %d", async (status) => { const directory = await temporaryDirectory("validation-context-"); await mkdir(join(directory, "context", "nested"), { recursive: true }); diff --git a/sdk/typescript/tests-ts/scan-skill-paths.test.ts b/sdk/typescript/tests-ts/scan-skill-paths.test.ts index 7893d803c..ece28dd84 100644 --- a/sdk/typescript/tests-ts/scan-skill-paths.test.ts +++ b/sdk/typescript/tests-ts/scan-skill-paths.test.ts @@ -40,7 +40,7 @@ async function fixture() { } test.each([false, true])( - "disables cached skill files while preserving effective selection (profile=%s)", + "disables cached skill files while preserving effective selection (profile=%j)", async (profile) => { const { root, home, plugin, blocked } = await fixture(); const unrelated = { @@ -117,7 +117,7 @@ test("leaves plugins outside the credential home and absent skills unchanged", a }); test.each([false, true])( - "preserves malformed native skill settings (profile=%s)", + "preserves malformed native skill settings (profile=%j)", async (profile) => { const { home, plugin } = await fixture(); for (const skills of [ diff --git a/sdk/typescript/tests-ts/skeleton.test.ts b/sdk/typescript/tests-ts/skeleton.test.ts index d167645d0..802ff933e 100644 --- a/sdk/typescript/tests-ts/skeleton.test.ts +++ b/sdk/typescript/tests-ts/skeleton.test.ts @@ -1,4 +1,6 @@ -import { readFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, test } from "bun:test"; import { parse } from "smol-toml"; import { @@ -10,9 +12,11 @@ import { } from "../src/index.js"; import { main } from "../src/cli.js"; import { capture } from "./cli-fixtures.js"; +import { bashCommand, runCommand } from "./support/shell.js"; interface WorkflowStep { name?: string; + id?: string; uses?: string; with?: Record; run?: string; @@ -206,6 +210,85 @@ describe("TypeScript package skeleton", () => { } }); + test("reuses npm downloads after lockfile changes without skipping package installation", async () => { + const { jobs } = await workflow("node-ci.yml"); + for (const name of ["compatibility", "windows-verify"]) { + const steps = jobs[name]!.steps!; + const setup = steps.findIndex(({ id }) => id === "node"); + const resolver = steps.findIndex(({ id }) => id === "npm-cache"); + const restore = steps.findIndex(({ uses }) => + uses?.startsWith("actions/cache/restore@"), + ); + const inspection = steps.findIndex( + ({ name }) => name === "Inspect installed package", + ); + expect(setup).toBeGreaterThanOrEqual(0); + expect(setup).toBeLessThan(resolver); + expect(resolver).toBeLessThan(restore); + expect(restore).toBeLessThan(inspection); + expect(steps[setup]?.with?.["cache"]).toBe("npm"); + expect(steps[resolver]?.env).toEqual({ + PRIMARY_CACHE_KEY: "${{ steps.node.outputs.cache-primary-key }}", + }); + for (const index of [resolver, restore]) { + expect(steps[index]?.if).toBe("steps.node.outputs.cache-hit != 'true'"); + } + expect(steps[restore]?.with).toEqual({ + path: "${{ steps.npm-cache.outputs.path }}", + key: "${{ steps.node.outputs.cache-primary-key }}", + "restore-keys": "${{ steps.npm-cache.outputs.restore-prefix }}", + }); + expect(steps[inspection]).not.toHaveProperty("if"); + } + }); + + test("npm cache fallback shares lockfile versions within each platform and architecture", async () => { + const { jobs } = await workflow("node-ci.yml"); + const script = jobs["compatibility"]!.steps!.find( + ({ id }) => id === "npm-cache", + )!.run!; + const directory = await mkdtemp(join(tmpdir(), "npm-cache-workflow-")); + try { + for (const platform of ["Windows-x64", "Windows-arm64", "Linux-x64"]) { + for (const hash of ["a".repeat(64), "b".repeat(64)]) { + const cachePath = platform.startsWith("Windows") + ? "C:\\npm cache" + : "/tmp/npm cache"; + const output = join(directory, "output.txt"); + await rm(output, { force: true }); + const result = await runCommand( + bashCommand(), + [ + "-e", + "-o", + "pipefail", + "-c", + `npm() { test "$*" = "config get cache" && printf '%s\\n' "$TEST_NPM_CACHE"; }\n${script}`, + ], + { + cwd: directory, + env: { + ...process.env, + PRIMARY_CACHE_KEY: `node-cache-${platform}-npm-${hash}`, + GITHUB_OUTPUT: "output.txt", + TEST_NPM_CACHE: cachePath, + }, + }, + ); + expect(result.status, result.stderr).toBe(0); + expect( + (await readFile(output, "utf8")).trim().split(/\r?\n/u), + ).toEqual([ + `path=${cachePath}`, + `restore-prefix=node-cache-${platform}-npm-`, + ]); + } + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + test("randomizes tests and keeps the default and Windows CI timeouts", async () => { const packageJson = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"),