diff --git a/docs/CI.md b/docs/CI.md index 4a3af75..cd77261 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -3,6 +3,18 @@ This document describes automated checks for the web app and TypeScript workspaces (`apps/*`, `packages/*`, and `services/*`). +## Root aggregate scripts + +Run all maintained workspace unit tests or TypeScript typechecks from the +repository root in deterministic dependency order: + +```bash +pnpm test # runs test across all 12 JS/TS workspaces +pnpm typecheck # runs typecheck across all 11 TypeScript workspaces +pnpm workspaces:check # verifies every workspace is included in the root lists +pnpm workspaces:test # runs unit tests for the aggregate runner +``` + ## Web checks The `Web checks` workflow (`.github/workflows/web.yml`) runs on pushes to diff --git a/package.json b/package.json index 66e5b7c..00c9741 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,10 @@ } }, "scripts": { + "test": "node scripts/run-workspace-checks.mjs test", + "typecheck": "node scripts/run-workspace-checks.mjs typecheck", + "workspaces:check": "node scripts/run-workspace-checks.mjs --check", + "workspaces:test": "node --test scripts/run-workspace-checks.test.mjs", "env:check": "node scripts/check-env-examples.mjs", "env:test": "node --test scripts/check-env-examples.test.mjs", "logging:test": "pnpm --filter @sub-rosa/logging test && node --test scripts/check-logging.test.mjs", @@ -82,6 +86,7 @@ "receipt:test": "pnpm --filter @sub-rosa/receipt-cli test", "receipt:typecheck": "pnpm --filter @sub-rosa/receipt-cli typecheck", "time:test": "pnpm --filter @sub-rosa/time test", + "time:typecheck": "pnpm --filter @sub-rosa/time typecheck", "time:guard": "node scripts/check-direct-time-access.mjs", "time:guard:test": "node --test scripts/check-direct-time-access.test.mjs", "errors:normalize:check": "node scripts/check-error-normalization.mjs", diff --git a/scripts/run-workspace-checks.mjs b/scripts/run-workspace-checks.mjs new file mode 100644 index 0000000..357a374 --- /dev/null +++ b/scripts/run-workspace-checks.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node +// Copyright (c) 2026 Sub Rosa contributors +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_ROOT = resolve(__dirname, ".."); + +/** + * Deterministic ordered list of maintained workspaces that expose a `test` script. + */ +export const TEST_WORKSPACES = [ + "@sub-rosa/logging", + "@sub-rosa/time", + "@sub-rosa/round-bindings", + "@sub-rosa/tlock", + "@sub-rosa/sdk", + "@sub-rosa/drand-tools", + "@sub-rosa/keeper", + "@sub-rosa/receipt-cli", + "@sub-rosa/appraisal-api", + "@sub-rosa/agent", + "@sub-rosa/auction-template", + "@sub-rosa/web", +]; + +/** + * Deterministic ordered list of maintained TypeScript workspaces that expose a `typecheck` script. + */ +export const TYPECHECK_WORKSPACES = [ + "@sub-rosa/time", + "@sub-rosa/round-bindings", + "@sub-rosa/tlock", + "@sub-rosa/sdk", + "@sub-rosa/drand-tools", + "@sub-rosa/keeper", + "@sub-rosa/receipt-cli", + "@sub-rosa/appraisal-api", + "@sub-rosa/agent", + "@sub-rosa/auction-template", + "@sub-rosa/web", +]; + +const WORKSPACE_CONTAINERS = ["packages", "services", "apps"]; + +export function discoverWorkspacesWithScript(scriptName, rootDir = DEFAULT_ROOT) { + const found = []; + for (const container of WORKSPACE_CONTAINERS) { + const absContainer = join(rootDir, container); + if (!existsSync(absContainer)) continue; + const entries = readdirSync(absContainer, { withFileTypes: true }).sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const pkgPath = join(absContainer, entry.name, "package.json"); + if (!existsSync(pkgPath)) continue; + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + if (pkg.name && pkg.scripts?.[scriptName]) { + found.push(pkg.name); + } + } + } + return found; +} + +export function verifyAggregateCoverage( + rootDir = DEFAULT_ROOT, + { + testWorkspaces = TEST_WORKSPACES, + typecheckWorkspaces = TYPECHECK_WORKSPACES, + } = {}, +) { + const issues = []; + const actualTests = discoverWorkspacesWithScript("test", rootDir); + const actualTypechecks = discoverWorkspacesWithScript("typecheck", rootDir); + + for (const ws of actualTests) { + if (!testWorkspaces.includes(ws)) { + issues.push(`Workspace ${ws} has a "test" script but is missing from TEST_WORKSPACES`); + } + } + for (const ws of testWorkspaces) { + if (!actualTests.includes(ws)) { + issues.push(`TEST_WORKSPACES lists ${ws}, which does not define a "test" script`); + } + } + + for (const ws of actualTypechecks) { + if (!typecheckWorkspaces.includes(ws)) { + issues.push( + `Workspace ${ws} has a "typecheck" script but is missing from TYPECHECK_WORKSPACES`, + ); + } + } + for (const ws of typecheckWorkspaces) { + if (!actualTypechecks.includes(ws)) { + issues.push( + `TYPECHECK_WORKSPACES lists ${ws}, which does not define a "typecheck" script`, + ); + } + } + + const rootPkgPath = join(rootDir, "package.json"); + if (existsSync(rootPkgPath)) { + const rootPkg = JSON.parse(readFileSync(rootPkgPath, "utf8")); + if (!rootPkg.scripts?.test) { + issues.push('Root package.json is missing aggregate "test" script'); + } + if (!rootPkg.scripts?.typecheck) { + issues.push('Root package.json is missing aggregate "typecheck" script'); + } + } else { + issues.push("Missing root package.json"); + } + + return { ok: issues.length === 0, issues }; +} + +function main() { + const args = process.argv.slice(2); + if (args.includes("--check")) { + const res = verifyAggregateCoverage(DEFAULT_ROOT); + if (!res.ok) { + for (const issue of res.issues) { + process.stderr.write(`āŒ ${issue}\n`); + } + process.exit(1); + } + process.stdout.write( + `āœ… Root aggregate test (${TEST_WORKSPACES.length}) and typecheck (${TYPECHECK_WORKSPACES.length}) workspace lists are complete.\n`, + ); + return; + } + + const mode = args[0]; + if (mode !== "test" && mode !== "typecheck") { + process.stderr.write( + "Usage: node scripts/run-workspace-checks.mjs \n", + ); + process.exit(1); + } + + const list = mode === "test" ? TEST_WORKSPACES : TYPECHECK_WORKSPACES; + for (const ws of list) { + process.stdout.write(`\nā–¶ pnpm --filter ${ws} ${mode}\n`); + const res = spawnSync("pnpm", ["--filter", ws, mode], { + cwd: DEFAULT_ROOT, + stdio: "inherit", + shell: process.platform === "win32", + }); + if ((res.status ?? 1) !== 0) { + process.exit(res.status ?? 1); + } + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(); +} diff --git a/scripts/run-workspace-checks.test.mjs b/scripts/run-workspace-checks.test.mjs new file mode 100644 index 0000000..ddf472e --- /dev/null +++ b/scripts/run-workspace-checks.test.mjs @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Sub Rosa contributors +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + TEST_WORKSPACES, + TYPECHECK_WORKSPACES, + verifyAggregateCoverage, +} from "./run-workspace-checks.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, ".."); + +describe("root test and typecheck aggregate scripts", () => { + it("defines root test, typecheck, and time:typecheck scripts in package.json", () => { + const pkg = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")); + assert.equal(pkg.scripts.test, "node scripts/run-workspace-checks.mjs test"); + assert.equal( + pkg.scripts.typecheck, + "node scripts/run-workspace-checks.mjs typecheck", + ); + assert.equal( + pkg.scripts["time:typecheck"], + "pnpm --filter @sub-rosa/time typecheck", + ); + }); + + it("enumerates every maintained workspace with test and typecheck scripts", () => { + const res = verifyAggregateCoverage(REPO_ROOT); + assert.equal(res.ok, true, `Unexpected issues: ${res.issues.join("; ")}`); + assert.equal(TEST_WORKSPACES.length, 12); + assert.equal(TYPECHECK_WORKSPACES.length, 11); + }); + + it("fails when a workspace defines test or typecheck but is omitted from the aggregate list", () => { + const tmp = mkdtempSync(join(tmpdir(), "sub-rosa-agg-")); + try { + mkdirSync(join(tmp, "packages", "alpha"), { recursive: true }); + writeFileSync( + join(tmp, "package.json"), + JSON.stringify({ + scripts: { + test: "node scripts/run-workspace-checks.mjs test", + typecheck: "node scripts/run-workspace-checks.mjs typecheck", + }, + }), + ); + writeFileSync( + join(tmp, "packages", "alpha", "package.json"), + JSON.stringify({ + name: "@sub-rosa/alpha", + scripts: { test: "node --test", typecheck: "tsc --noEmit" }, + }), + ); + + const res = verifyAggregateCoverage(tmp, { + testWorkspaces: [], + typecheckWorkspaces: [], + }); + assert.equal(res.ok, false); + assert.equal(res.issues.length, 2); + assert.match(res.issues[0], /@sub-rosa\/alpha/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +});