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
14 changes: 13 additions & 1 deletion scripts/ci/run-extended-validation.sh
Original file line number Diff line number Diff line change
@@ -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
20 changes: 18 additions & 2 deletions scripts/ci/run-fast-checks.sh
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions scripts/smoke/mock-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
});
58 changes: 58 additions & 0 deletions test/ci-scripts.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
45 changes: 32 additions & 13 deletions test/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
67 changes: 53 additions & 14 deletions test/smoke-harness.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<void> {
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", () => {
Expand All @@ -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(
Expand Down