From 84f91373ffee5d52b352f4f10ea0b8c580fbd301 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 19:06:57 +0000 Subject: [PATCH 1/2] ci: real local gate scripts and deterministic smoke harness Replace the placeholder run-fast-checks.sh / run-extended-validation.sh with the project's real gates (lint + test + build, plus the coverage threshold for extended), keeping them shell-safe for the self-hosted Synology public runner. A new ci-scripts test asserts the real checks are present and the placeholder text cannot regress back in. Make the registration smoke harness deterministic: the mock API now emits a stdout readiness sentinel and the test waits on it event-driven, failing fast on spawn error or early exit instead of polling a log file on a fixed interval that raced the test timeout. Closes #97 Closes #99 https://claude.ai/code/session_01QxQ71Yrf2Cn6zVfM4LY7AR --- scripts/ci/run-extended-validation.sh | 14 +++++- scripts/ci/run-fast-checks.sh | 20 +++++++- scripts/smoke/mock-api.mjs | 3 ++ test/ci-scripts.test.ts | 58 +++++++++++++++++++++++ test/smoke-harness.test.ts | 67 +++++++++++++++++++++------ 5 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 test/ci-scripts.test.ts diff --git a/scripts/ci/run-extended-validation.sh b/scripts/ci/run-extended-validation.sh index fd07826..0fe6f97 100755 --- a/scripts/ci/run-extended-validation.sh +++ b/scripts/ci/run-extended-validation.sh @@ -1,4 +1,16 @@ #!/usr/bin/env bash +# Deeper local validation: everything in run-fast-checks.sh plus the +# coverage-threshold gate. +# +# Mutation testing is intentionally NOT run here. It remains a separate +# CI job ("Mutation Tests" in .github/workflows/extended-validation.yml) +# because it is far slower than the coverage gate and uploads its own +# report artifact; running it inline would blow the extended-checks +# timeout budget on the self-hosted Synology runner. set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" + bash scripts/ci/run-fast-checks.sh -echo "No extended checks configured for the generic archetype yet." +pnpm test:coverage diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index b0fe54e..dced9cc 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -1,4 +1,20 @@ #!/usr/bin/env bash +# Cheap local CI gate: lint, unit tests, and build. +# +# Heavier validation (coverage thresholds, mutation testing) lives in +# scripts/ci/run-extended-validation.sh and the Extended Validation +# workflow. Keep this script shell-safe for the self-hosted Synology +# public runner: no sudo, no extra package installs, only the built-in +# Node/Corepack toolchain. set -euo pipefail -echo "Generic archetype selected." -echo "Add project-specific scripts and tighten scripts/ci/run-fast-checks.sh when the stack is finalized." + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" + +corepack enable +corepack prepare pnpm@10.32.1 --activate + +pnpm install --frozen-lockfile +pnpm lint +pnpm test +pnpm build diff --git a/scripts/smoke/mock-api.mjs b/scripts/smoke/mock-api.mjs index 4533c08..0c95096 100644 --- a/scripts/smoke/mock-api.mjs +++ b/scripts/smoke/mock-api.mjs @@ -36,4 +36,7 @@ server.listen(port, host, () => { `${new Date().toISOString()} listening ${host}:${port}\n`, "utf8" ); + // Deterministic readiness signal: consumers wait on this stdout line + // instead of polling the log file, so startup is event-driven. + process.stdout.write(`ready ${host}:${port}\n`); }); diff --git a/test/ci-scripts.test.ts b/test/ci-scripts.test.ts new file mode 100644 index 0000000..c9b1517 --- /dev/null +++ b/test/ci-scripts.test.ts @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +function readScript(relativePath: string): string { + return fs.readFileSync(path.resolve(relativePath), "utf8"); +} + +const PLACEHOLDER_MARKERS = [ + "Generic archetype selected.", + "No extended checks configured for the generic archetype yet.", + "Add project-specific scripts and tighten" +]; + +describe("local CI gate scripts", () => { + test("fast checks run lint, tests, and build", () => { + const script = readScript("scripts/ci/run-fast-checks.sh"); + + expect(script).toContain("set -euo pipefail"); + expect(script).toContain("pnpm install --frozen-lockfile"); + expect(script).toContain("pnpm lint"); + expect(script).toContain("pnpm test"); + expect(script).toContain("pnpm build"); + + for (const marker of PLACEHOLDER_MARKERS) { + expect(script).not.toContain(marker); + } + }); + + test("extended validation runs fast checks plus the coverage gate", () => { + const script = readScript("scripts/ci/run-extended-validation.sh"); + + expect(script).toContain("set -euo pipefail"); + expect(script).toContain("bash scripts/ci/run-fast-checks.sh"); + expect(script).toContain("pnpm test:coverage"); + + for (const marker of PLACEHOLDER_MARKERS) { + expect(script).not.toContain(marker); + } + }); + + test("extended validation documents that mutation testing stays a separate CI job", () => { + const script = readScript("scripts/ci/run-extended-validation.sh"); + + expect(script).toMatch(/mutation testing is intentionally not run here/i); + expect(script).not.toContain("pnpm mutation-test"); + }); + + test("the extended validation workflow keeps mutation testing as its own job", () => { + const workflow = fs.readFileSync( + path.resolve(".github/workflows/extended-validation.yml"), + "utf8" + ); + + expect(workflow).toContain("pnpm mutation-test"); + expect(workflow).toContain("run-extended-validation.sh"); + }); +}); diff --git a/test/smoke-harness.test.ts b/test/smoke-harness.test.ts index 7cfe206..9c47bb8 100644 --- a/test/smoke-harness.test.ts +++ b/test/smoke-harness.test.ts @@ -1,4 +1,4 @@ -import { spawn, spawnSync } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -18,19 +18,58 @@ function makeTempRoot() { return tempRoot; } -async function waitForReady(logPath: string, host: string, port: number) { - for (let attempt = 0; attempt < 50; attempt += 1) { - if ( - fs.existsSync(logPath) && - fs.readFileSync(logPath, "utf8").includes(`listening ${host}:${port}`) - ) { - return; - } +// Resolve as soon as the mock API prints its readiness sentinel on +// stdout. This is event-driven (no fixed-interval polling), and rejects +// immediately if the child fails to spawn or exits early, so the only +// way to hit `timeoutMs` is a genuine startup hang. +function waitForReady( + server: ChildProcess, + host: string, + port: number, + timeoutMs = 15000 +): Promise { + return new Promise((resolve, reject) => { + let buffer = ""; + let settled = false; + + const cleanup = () => { + clearTimeout(timer); + server.stdout?.off("data", onData); + server.off("error", onError); + server.off("exit", onExit); + }; + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + cleanup(); + fn(); + }; - await new Promise((resolve) => setTimeout(resolve, 100)); - } + const onData = (chunk: Buffer) => { + buffer += chunk.toString("utf8"); + if (buffer.includes(`ready ${host}:${port}`)) { + settle(resolve); + } + }; + const onError = (error: Error) => { + settle(() => reject(error)); + }; + const onExit = (code: number | null) => { + settle(() => + reject(new Error(`mock API exited before becoming ready (code ${code})`)) + ); + }; - throw new Error("mock API did not become ready"); + const timer = setTimeout(() => { + settle(() => + reject(new Error(`mock API did not become ready within ${timeoutMs}ms`)) + ); + }, timeoutMs); + + server.stdout?.on("data", onData); + server.once("error", onError); + server.once("exit", onExit); + }); } describe("runner registration smoke harness", () => { @@ -46,11 +85,11 @@ describe("runner registration smoke harness", () => { MOCK_LOG_PATH: logPath, MOCK_PORT: String(port), }, - stdio: "ignore", + stdio: ["ignore", "pipe", "ignore"], }); try { - await waitForReady(logPath, host, port); + await waitForReady(server, host, port); await expect( fetch( From 610ee76b9d1fe88d6ab9265544b38f320a92bb54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 03:24:48 +0000 Subject: [PATCH 2/2] test: isolate doctor env-sensitivity tests from ambient environment #108 merged main after #109 landed without the env-isolation fix, so its doctor.test.ts missing-env/missing-host assertions failed on the self-hosted runner (GITHUB_PAT is exported there). Wrap those cases in withEnv to clear the relevant variables, matching the existing missing-env test pattern. Mirrors PR #110. https://claude.ai/code/session_01QxQ71Yrf2Cn6zVfM4LY7AR --- test/doctor.test.ts | 45 ++++++++++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/test/doctor.test.ts b/test/doctor.test.ts index 106a15a..0e25322 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -1228,11 +1228,20 @@ pools: "utf8" ); - const report = await runDoctor({ - mode: "synology", - envPath, - configPath: poolsPath - }); + const report = await withEnv( + { + GITHUB_PAT: undefined, + GITHUB_TOKEN: undefined, + GH_TOKEN: undefined, + SYNOLOGY_HOST: undefined + }, + () => + runDoctor({ + mode: "synology", + envPath, + configPath: poolsPath + }) + ); const envCheck = findCheck(report, "synology-env"); expect(envCheck.status).toBe("fail"); @@ -1436,14 +1445,24 @@ pools: "utf8" ); - const report = await runDoctor({ - mode: "windows-docker", - envPath, - windowsConfigPath: windowsPath, - fetchImpl: vi.fn(async () => { - throw new Error("runner-group verification should not run"); - }) - }); + const report = await withEnv( + { + GITHUB_PAT: undefined, + GITHUB_TOKEN: undefined, + GH_TOKEN: undefined, + WINDOWS_DOCKER_HOST: undefined, + WINDOWS_DOCKER_USERNAME: undefined + }, + () => + runDoctor({ + mode: "windows-docker", + envPath, + windowsConfigPath: windowsPath, + fetchImpl: vi.fn(async () => { + throw new Error("runner-group verification should not run"); + }) + }) + ); const configCheck = findCheck(report, "windows-docker-config"); expect(configCheck.status).toBe("fail");