Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/ci-cli-coverage-merge/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ runs:
shell: bash
env:
NODE_AUTH_TOKEN: ${{ github.event_name == 'push' && github.token || '' }}
run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh"
run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" none

- name: Download compiled CLI artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/ci-cli-coverage-shard/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ runs:
shell: bash
env:
NODE_AUTH_TOKEN: ${{ github.event_name == 'push' && github.token || '' }}
run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh"
run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" production

- name: Validate changed live E2E mock parity
if: ${{ inputs.shard == '1' }}
Expand Down
24 changes: 23 additions & 1 deletion .github/actions/ci-install-dependencies.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@

set -euo pipefail

if [ "$#" -gt 1 ]; then
echo "Usage: ci-install-dependencies.sh [full|production|none]" >&2
exit 1
fi

plugin_install_mode="${1:-full}"
plugin_install_args=(--prefix nemoclaw ci)
case "$plugin_install_mode" in
full) ;;
production)
plugin_install_args+=(--omit=dev)
;;
none) ;;
*)
echo "Unsupported plugin dependency install mode: $plugin_install_mode" >&2
exit 1
;;
esac

candidate_npmrc="$(find . -path './.git' -prune -o -name .npmrc -print -quit)"
if [ -n "$candidate_npmrc" ]; then
echo "Candidate repository npm configuration is not allowed during trusted dependency installation." >&2
Expand Down Expand Up @@ -57,4 +76,7 @@ if [ "$package_mode" = "registry" ] && [ -n "${NODE_AUTH_TOKEN:-}" ]; then
fi

npm ci --ignore-scripts --prefer-offline --no-audit --no-fund --cache "$npm_cache"
npm --prefix nemoclaw ci --ignore-scripts --prefer-offline --no-audit --no-fund --cache "$npm_cache"
if [ "$plugin_install_mode" != "none" ]; then
npm "${plugin_install_args[@]}" \
--ignore-scripts --prefer-offline --no-audit --no-fund --cache "$npm_cache"
fi
1 change: 0 additions & 1 deletion ci/cli-test-timing-hints.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,6 @@
"test/onboarding/onboard-terminal-dashboard.test.ts": 11927,
"test/onboarding/onboard.test.ts": 7899,
"test/repository/layer-import-boundaries.test.ts": 5292,
"test/repository/plugin-vitest-project.test.ts": 7766,
"test/repository/source-architecture.test.ts": 5535,
"test/repository/source-require-loader.test.ts": 5155,
"test/runtime/gateway/gateway-drift-preflight.test.ts": 7324,
Expand Down
9 changes: 8 additions & 1 deletion test/automation/pull-requests/pr-workflow-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,14 @@ describe("pull request and main workflow contracts", () => {
})),
);
expect(actions.map((action) => requiredStep(action, "Install dependencies").run)).toEqual(
actions.map(() => 'bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh"'),
[
'bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh"',
'bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh"',
'bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" none',
'bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh"',
'bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" production',
'bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh"',
],
);
});

Expand Down
69 changes: 55 additions & 14 deletions test/repository/ci-install-dependencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,27 +42,30 @@ function makeFixture(): { root: string; trace: string; path: string } {
return { root, trace, path: `${bin}:${process.env.PATH || ""}` };
}

function runInstaller(fixture: ReturnType<typeof makeFixture>, args: string[] = []) {
return spawnSync("bash", [installer, ...args], {
cwd: fixture.root,
encoding: "utf8",
env: {
...process.env,
GITHUB_ACTION_PATH: compositeActionPath,
GITHUB_EVENT_NAME: "pull_request",
NPM_CONFIG_CACHE: join(fixture.root, "npm-cache"),
NPM_TRACE: fixture.trace,
PATH: fixture.path,
RUNNER_TEMP: join(fixture.root, "runner-temp"),
},
});
}

afterEach(() => {
for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true });
});

describe("shared CI dependency installer", () => {
it("installs from a composite-action path without lifecycle scripts", () => {
const fixture = makeFixture();

const result = spawnSync("bash", [installer], {
cwd: fixture.root,
encoding: "utf8",
env: {
...process.env,
GITHUB_ACTION_PATH: compositeActionPath,
GITHUB_EVENT_NAME: "pull_request",
NPM_CONFIG_CACHE: join(fixture.root, "npm-cache"),
NPM_TRACE: fixture.trace,
PATH: fixture.path,
RUNNER_TEMP: join(fixture.root, "runner-temp"),
},
});
const result = runInstaller(fixture);

expect(result.status, result.stderr).toBe(0);
expect(readFileSync(fixture.trace, "utf8").trim().split("\n")).toEqual([
Expand All @@ -71,6 +74,44 @@ describe("shared CI dependency installer", () => {
]);
});

it("can install only plugin production dependencies", () => {
const fixture = makeFixture();
const result = runInstaller(fixture, ["production"]);

expect(result.status, result.stderr).toBe(0);
expect(readFileSync(fixture.trace, "utf8").trim().split("\n")).toEqual([
`ci --ignore-scripts --prefer-offline --no-audit --no-fund --cache ${join(fixture.root, "npm-cache")}`,
`--prefix nemoclaw ci --omit=dev --ignore-scripts --prefer-offline --no-audit --no-fund --cache ${join(fixture.root, "npm-cache")}`,
]);
});

it("can skip plugin dependency installation", () => {
const fixture = makeFixture();
const result = runInstaller(fixture, ["none"]);

expect(result.status, result.stderr).toBe(0);
expect(readFileSync(fixture.trace, "utf8").trim()).toBe(
`ci --ignore-scripts --prefer-offline --no-audit --no-fund --cache ${join(fixture.root, "npm-cache")}`,
);
});

it.each([
[["invalid"], "Unsupported plugin dependency install mode: invalid\n"],
[["production", "extra"], "Usage: ci-install-dependencies.sh [full|production|none]\n"],
] as const)("rejects unsupported install arguments before npm runs [case %#]", (args, error) => {
const fixture = makeFixture();

const result = spawnSync("bash", [installer, ...args], {
cwd: fixture.root,
encoding: "utf8",
env: { ...process.env, NPM_TRACE: fixture.trace, PATH: fixture.path },
});

expect(result.status).toBe(1);
expect(result.stderr).toBe(error);
expect(existsSync(fixture.trace)).toBe(false);
});

it("rejects candidate npm configuration before npm receives the package token", () => {
const fixture = makeFixture();
writeFileSync(
Expand Down
69 changes: 49 additions & 20 deletions test/repository/plugin-vitest-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,37 @@ import { describe, expect, it } from "vitest";

const repositoryRoot = path.resolve(import.meta.dirname, "../..");
const rootRequire = createRequire(path.join(repositoryRoot, "package.json"));
const pluginRequire = createRequire(path.join(repositoryRoot, "nemoclaw", "package.json"));
const pluginTypeScript = pluginRequire.resolve("typescript/bin/tsc");
const rootTypeScript = rootRequire.resolve("typescript/bin/tsc");

function installedVersion(requireFromPackage: NodeJS.Require, packageName: string): string {
return (requireFromPackage(`${packageName}/package.json`) as { version: string }).version;
type NpmDependencyTree = {
dependencies?: Record<string, NpmDependencyTree>;
version?: string;
};

function lockedDependencyTree(prefix?: string): NpmDependencyTree {
const prefixArgs = prefix ? ["--prefix", prefix] : [];
return JSON.parse(
execFileSync(
"npm",
[...prefixArgs, "ls", "--package-lock-only", "--json", "typescript", "vitest", "vite"],
{
cwd: repositoryRoot,
encoding: "utf8",
},
),
) as NpmDependencyTree;
}

function requiredLockedVersion(version: string | undefined, dependency: string): string {
expect(version, `${dependency} lockfile version`).toBeTypeOf("string");
expect(version, `${dependency} lockfile version`).not.toBe("");
return version as string;
}

function listedTypeScriptFiles(configPath: string): string[] {
return execFileSync(
process.execPath,
[pluginTypeScript, "--noEmit", "-p", configPath, "--listFilesOnly"],
[rootTypeScript, "--noEmit", "-p", configPath, "--listFilesOnly"],
{ cwd: repositoryRoot, encoding: "utf8" },
)
.trim()
Expand All @@ -28,27 +48,36 @@ function listedTypeScriptFiles(configPath: string): string[] {
}

describe("plugin Vitest project contract", () => {
it.each(["vitest", "vite"] as const)(
"keeps standalone plugin dependencies on the root Vitest toolchain [case %#]",
(packageName) => {
expect(installedVersion(pluginRequire, packageName), packageName).toBe(
installedVersion(rootRequire, packageName),
);
},
);

it("typechecks plugin production and test sources without emitting tests", () => {
it("keeps the standalone plugin lock on the root test toolchain", () => {
const rootTree = lockedDependencyTree();
const pluginTree = lockedDependencyTree("nemoclaw");

expect(requiredLockedVersion(pluginTree.dependencies?.vitest?.version, "plugin vitest")).toBe(
requiredLockedVersion(rootTree.dependencies?.vitest?.version, "root vitest"),
);
expect(
requiredLockedVersion(
pluginTree.dependencies?.vitest?.dependencies?.vite?.version,
"plugin vite",
),
).toBe(
requiredLockedVersion(
rootTree.dependencies?.vitest?.dependencies?.vite?.version,
"root vite",
),
);
expect(
requiredLockedVersion(pluginTree.dependencies?.typescript?.version, "plugin typescript"),
).toBe(requiredLockedVersion(rootTree.dependencies?.typescript?.version, "root typescript"));
});

it("keeps plugin production and test TypeScript projects disjoint", () => {
const productionFiles = listedTypeScriptFiles("nemoclaw/tsconfig.json");
const testFiles = listedTypeScriptFiles("nemoclaw/tsconfig.test.json");
const typecheckOutput = execFileSync("npm", ["--prefix", "nemoclaw", "run", "typecheck"], {
cwd: repositoryRoot,
encoding: "utf8",
});

expect(productionFiles.some((file) => file.endsWith(".test.ts"))).toBe(false);
expect(testFiles).toContain(path.join(repositoryRoot, "nemoclaw", "src", "register.test.ts"));
expect(testFiles).toContain(path.join(repositoryRoot, "nemoclaw", "vitest.config.ts"));
expect(testFiles).toContain(path.join(repositoryRoot, "nemoclaw", "vitest.project.ts"));
expect(typecheckOutput).toContain("tsc --noEmit -p tsconfig.test.json");
});
});
Loading