From 3c8dc909fd4ee3b94136b90e81ccdc8f85a2e000 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 10 Sep 2026 18:11:45 +0530 Subject: [PATCH 01/10] feat(cli): declare the webjs.ci step list and its reader Local CI (#1471) needs a step list an app declares once and every tool can read without importing app code, so it lives in the package.json `webjs` block next to the #550 dev/start orchestration. This lands the key in the three-surface lockstep (the JSON Schema with recursive definitions for a step, the WebjsConfig type, KNOWN_KEYS plus a nested guard for the recursive shape) and the pure CLI reader that normalizes the shorthand and reports every malformed entry with its JSON path. The reader validates the shapes itself because the boot validator only checks top-level key membership and never follows a $ref, and a step that is silently dropped is a check that never ran, the exact false green local CI exists to prevent. A group nested inside a parallel group takes one slot and runs sequentially, so a `parallel` on it is reported rather than honoured, the same rule Rails' runner applies. The runner and the `ci` command follow in the next commits. --- packages/cli/lib/ci-config.js | 212 ++++++++++++++++++ .../cli/test/ci-config/ci-config.test.mjs | 130 +++++++++++ packages/core/index.d.ts | 6 + packages/core/src/webjs-config.d.ts | 47 ++++ packages/server/AGENTS.md | 6 +- packages/server/src/webjs-config-validate.js | 6 +- .../test/config/webjs-config-schema.test.js | 32 +++ packages/server/webjs-config.schema.json | 64 ++++++ test/types/webjs-config.test-d.ts | 42 ++++ 9 files changed, 540 insertions(+), 5 deletions(-) create mode 100644 packages/cli/lib/ci-config.js create mode 100644 packages/cli/test/ci-config/ci-config.test.mjs diff --git a/packages/cli/lib/ci-config.js b/packages/cli/lib/ci-config.js new file mode 100644 index 000000000..715a51fb6 --- /dev/null +++ b/packages/cli/lib/ci-config.js @@ -0,0 +1,212 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Read the local-CI step list from an app's `package.json` `"webjs": { "ci" }` + * block (#1471), the list `webjs ci` runs. Modeled on Rails 8.1's `config/ci.rb` + * and shaped like the #550 `dev` / `start` orchestration: data in the `webjs` + * block, read by the CLI, never by the server, so every tool (the JSON Schema, + * `webjs doctor`, a cloud workflow that calls `npm run ci`) learns the list + * without importing app code. + * + * Shape: + * "webjs": { "ci": { "steps": [ + * "webjs check", // shorthand: title = command + * { "title": "Types", "run": "webjs typecheck" }, + * { "title": "Checks", "parallel": 2, "steps": [ + * { "title": "Tests", "steps": [ // a nested group takes ONE slot + * { "title": "e2e", "run": "webjs test --server", "env": { "WEBJS_E2E": "1" } } + * ] } + * ] } + * ] } } + * + * The boot validator (`@webjsdev/server` webjs-config-validate.js) checks only + * top-level key membership and never follows the schema's `$ref`, so this + * reader validates the step shapes itself and reports every problem with its + * JSON path, rather than silently skipping a malformed entry: a step that is + * dropped is a check that never ran, which is the exact false green local CI + * exists to prevent. The bin refuses to run on any problem. + * + * Pure (reads one file, never spawns / prints / exits), with the reader + * injectable, matching `app-tasks.js`. + * + * @typedef {{ kind: 'step', title: string, run: string, env: Record }} CiStep + * @typedef {{ kind: 'group', title: string, parallel: number, steps: CiNode[] }} CiGroup + * @typedef {CiStep | CiGroup} CiNode + */ + +/** + * @param {string} appDir + * @param {(p: string) => string} [readFile] injectable reader for tests + * @returns {{ declared: boolean, steps: CiNode[], problems: string[] }} + * `declared` is false when there is no `webjs.ci` block at all (the bin + * turns that into a "nothing declared" error naming where to declare one), + * as opposed to a block that is present but malformed (`problems`). + */ +export function readCiConfig(appDir, readFile) { + const read = readFile || ((p) => readFileSync(p, 'utf8')); + let pkg; + try { + pkg = JSON.parse(read(join(appDir, 'package.json'))); + } catch { + return { declared: false, steps: [], problems: [] }; + } + const webjs = pkg && typeof pkg === 'object' ? pkg.webjs : null; + const ci = webjs && typeof webjs === 'object' ? webjs.ci : undefined; + if (ci === undefined) return { declared: false, steps: [], problems: [] }; + if (!isPlainObject(ci)) { + return { declared: true, steps: [], problems: ['webjs.ci must be an object holding a `steps` array'] }; + } + const problems = []; + for (const key of Object.keys(ci)) { + if (key !== 'steps') problems.push(`webjs.ci has an unknown key "${key}" (only \`steps\` is read)`); + } + if (ci.steps === undefined) { + problems.push('webjs.ci.steps is missing'); + return { declared: true, steps: [], problems }; + } + const r = normalizeSteps(ci.steps, 'webjs.ci.steps', false); + problems.push(...r.problems); + return { declared: true, steps: r.steps, problems }; +} + +/** + * Normalize a raw step array into `CiNode`s, collecting every shape problem + * with its JSON path. A string is shorthand for a command titled by itself; an + * object with `steps` is a group; an object with `run` is a command. A group + * inside a PARALLEL group runs its steps sequentially in one slot, so a + * `parallel` on it is a contradiction and is reported rather than honoured + * (the Rails rule: sub-groups cannot be parallelized). + * + * @param {unknown} raw + * @param {string} path JSON path used in problem messages + * @param {boolean} inParallel whether an ancestor group runs in parallel + * @returns {{ steps: CiNode[], problems: string[] }} + */ +export function normalizeSteps(raw, path = 'webjs.ci.steps', inParallel = false) { + /** @type {CiNode[]} */ + const steps = []; + /** @type {string[]} */ + const problems = []; + if (!Array.isArray(raw)) { + return { steps, problems: [`${path} must be an array of steps`] }; + } + raw.forEach((item, i) => { + const at = `${path}[${i}]`; + if (typeof item === 'string') { + const run = item.trim(); + if (!run) problems.push(`${at} is an empty command`); + else steps.push({ kind: 'step', title: run, run, env: {} }); + return; + } + if (!isPlainObject(item)) { + problems.push(`${at} must be a command string, a { title, run } object, or a { title, steps } group`); + return; + } + const title = typeof item.title === 'string' ? item.title.trim() : ''; + if (Object.prototype.hasOwnProperty.call(item, 'steps')) { + for (const key of Object.keys(item)) { + if (!['title', 'steps', 'parallel'].includes(key)) { + problems.push(`${at} has an unknown key "${key}" (a group takes title, steps, parallel)`); + } + } + if (!title) problems.push(`${at} (a group) needs a non-empty title`); + let parallel = 1; + if (item.parallel !== undefined) { + if (!Number.isInteger(item.parallel) || item.parallel < 1) { + problems.push(`${at}.parallel must be an integer of at least 1`); + } else if (inParallel && item.parallel > 1) { + problems.push( + `${at}.parallel is not allowed on a group nested inside a parallel group (it takes one slot and runs its steps in order)`, + ); + } else { + parallel = item.parallel; + } + } + const inner = normalizeSteps(item.steps, `${at}.steps`, inParallel || parallel > 1); + problems.push(...inner.problems); + if (Array.isArray(item.steps) && item.steps.length === 0) problems.push(`${at}.steps is empty`); + steps.push({ kind: 'group', title: title || `group ${i}`, parallel, steps: inner.steps }); + return; + } + for (const key of Object.keys(item)) { + if (!['title', 'run', 'env'].includes(key)) { + problems.push(`${at} has an unknown key "${key}" (a command takes title, run, env)`); + } + } + const run = typeof item.run === 'string' ? item.run.trim() : ''; + if (!run) problems.push(`${at}.run must be a non-empty command string`); + if (!title) problems.push(`${at}.title must be a non-empty string`); + /** @type {Record} */ + const env = {}; + if (item.env !== undefined) { + if (!isPlainObject(item.env)) { + problems.push(`${at}.env must be an object of string values`); + } else { + for (const [k, v] of Object.entries(item.env)) { + if (typeof v === 'string') env[k] = v; + else problems.push(`${at}.env.${k} must be a string`); + } + } + } + if (run && title) steps.push({ kind: 'step', title, run, env }); + }); + return { steps, problems }; +} + +/** + * Select the steps `--only ` names. A matched group is taken WHOLE (its + * children are not searched further); matching is case-insensitive on the + * trimmed title. A title that matches nothing is a problem rather than a + * silent empty run, since "ran zero steps" reads as green. + * + * @param {CiNode[]} steps + * @param {string[]} only + * @returns {{ steps: CiNode[], problems: string[] }} + */ +export function selectSteps(steps, only) { + if (!only || only.length === 0) return { steps, problems: [] }; + const wanted = only.map((t) => t.trim().toLowerCase()); + const hit = new Set(); + /** @type {CiNode[]} */ + const picked = []; + const walk = (nodes) => { + for (const node of nodes) { + const key = node.title.trim().toLowerCase(); + const idx = wanted.indexOf(key); + if (idx !== -1) { + hit.add(idx); + picked.push(node); + continue; + } + if (node.kind === 'group') walk(node.steps); + } + }; + walk(steps); + const problems = only + .filter((_, i) => !hit.has(i)) + .map((t) => `--only "${t}" matches no step or group title`); + return { steps: picked, problems }; +} + +/** + * Every command step in tree order (groups flattened), for counting and for + * the JSON report. + * + * @param {CiNode[]} steps + * @returns {CiStep[]} + */ +export function flattenSteps(steps) { + /** @type {CiStep[]} */ + const out = []; + for (const node of steps) { + if (node.kind === 'group') out.push(...flattenSteps(node.steps)); + else out.push(node); + } + return out; +} + +/** @param {unknown} v */ +function isPlainObject(v) { + return !!v && typeof v === 'object' && !Array.isArray(v); +} diff --git a/packages/cli/test/ci-config/ci-config.test.mjs b/packages/cli/test/ci-config/ci-config.test.mjs new file mode 100644 index 000000000..e12250a79 --- /dev/null +++ b/packages/cli/test/ci-config/ci-config.test.mjs @@ -0,0 +1,130 @@ +/** + * `readCiConfig` / `normalizeSteps` / `selectSteps` (#1471): the pure reader + * behind `webjs ci`. Injects the file reader like the app-tasks tests do, so + * nothing touches disk. Every rule the reader enforces has a counterfactual: + * the malformed shape produces a PROBLEM naming its JSON path rather than a + * silently dropped step (a dropped step is a check that never ran). + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readCiConfig, normalizeSteps, selectSteps, flattenSteps } from '../../lib/ci-config.js'; + +function reader(pkgJson) { + return (_p) => (pkgJson === null ? (() => { throw new Error('ENOENT'); })() : pkgJson); +} + +test('no package.json, or no webjs.ci block, reads as not declared with no problems', () => { + assert.deepEqual(readCiConfig('/app', reader(null)), { declared: false, steps: [], problems: [] }); + assert.deepEqual(readCiConfig('/app', reader('{}')), { declared: false, steps: [], problems: [] }); + assert.deepEqual( + readCiConfig('/app', reader(JSON.stringify({ webjs: { dev: { before: ['x'] } } }))), + { declared: false, steps: [], problems: [] }, + ); +}); + +test('a declared block normalizes strings, commands, and groups, in order', () => { + const pkg = JSON.stringify({ + webjs: { + ci: { + steps: [ + 'webjs check', + { title: 'Types', run: 'webjs typecheck' }, + { + title: 'Checks', + parallel: 2, + steps: [ + 'webjs doctor', + { title: 'Tests', steps: [{ title: 'e2e', run: 'webjs test --server', env: { WEBJS_E2E: '1' } }] }, + ], + }, + ], + }, + }, + }); + const r = readCiConfig('/app', reader(pkg)); + assert.equal(r.declared, true); + assert.deepEqual(r.problems, []); + assert.deepEqual(r.steps, [ + { kind: 'step', title: 'webjs check', run: 'webjs check', env: {} }, + { kind: 'step', title: 'Types', run: 'webjs typecheck', env: {} }, + { + kind: 'group', + title: 'Checks', + parallel: 2, + steps: [ + { kind: 'step', title: 'webjs doctor', run: 'webjs doctor', env: {} }, + { + kind: 'group', + title: 'Tests', + parallel: 1, + steps: [{ kind: 'step', title: 'e2e', run: 'webjs test --server', env: { WEBJS_E2E: '1' } }], + }, + ], + }, + ]); + assert.deepEqual(flattenSteps(r.steps).map((s) => s.title), ['webjs check', 'Types', 'webjs doctor', 'e2e']); +}); + +test('a malformed block is declared AND reports each problem with its JSON path', () => { + const cases = [ + [{ ci: [] }, /webjs\.ci must be an object/], + [{ ci: {} }, /webjs\.ci\.steps is missing/], + [{ ci: { steps: 'webjs check' } }, /webjs\.ci\.steps must be an array/], + [{ ci: { steps: [], stpes: [] } }, /unknown key "stpes"/], + [{ ci: { steps: [''] } }, /steps\[0\] is an empty command/], + [{ ci: { steps: [42] } }, /steps\[0\] must be a command string/], + [{ ci: { steps: [{ run: 'x' }] } }, /steps\[0\]\.title must be a non-empty string/], + [{ ci: { steps: [{ title: 'x' }] } }, /steps\[0\]\.run must be a non-empty command string/], + [{ ci: { steps: [{ title: 'x', run: 'y', evn: {} }] } }, /steps\[0\] has an unknown key "evn"/], + [{ ci: { steps: [{ title: 'x', run: 'y', env: { A: 1 } }] } }, /steps\[0\]\.env\.A must be a string/], + [{ ci: { steps: [{ title: 'g', steps: [] }] } }, /steps\[0\]\.steps is empty/], + [{ ci: { steps: [{ title: 'g', parallel: 0, steps: ['x'] }] } }, /steps\[0\]\.parallel must be an integer of at least 1/], + [{ ci: { steps: [{ title: 'g', steps: ['x'], run: 'y' }] } }, /steps\[0\] has an unknown key "run"/], + ]; + for (const [webjs, re] of cases) { + const r = readCiConfig('/app', reader(JSON.stringify({ webjs }))); + assert.equal(r.declared, true, JSON.stringify(webjs)); + assert.ok(r.problems.some((p) => re.test(p)), `${JSON.stringify(webjs)} -> ${JSON.stringify(r.problems)}`); + } +}); + +test('a group nested inside a PARALLEL group may not itself be parallel (it takes one slot)', () => { + const r = normalizeSteps([ + { title: 'outer', parallel: 2, steps: [{ title: 'inner', parallel: 3, steps: ['a', 'b'] }] }, + ]); + assert.equal(r.problems.length, 1); + assert.match(r.problems[0], /steps\[0\]\.steps\[0\]\.parallel is not allowed on a group nested inside a parallel group/); + // The offending value is NOT honoured: the nested group is normalized to one slot. + assert.equal(r.steps[0].steps[0].parallel, 1); + // Counterfactual: the same nesting under a SEQUENTIAL parent is fine. + const ok = normalizeSteps([{ title: 'outer', steps: [{ title: 'inner', parallel: 3, steps: ['a', 'b'] }] }]); + assert.deepEqual(ok.problems, []); + assert.equal(ok.steps[0].steps[0].parallel, 3); +}); + +test('a problem never drops a sibling step silently: valid neighbours survive', () => { + const r = normalizeSteps(['webjs check', { title: 'bad' }, 'webjs typecheck']); + assert.equal(r.problems.length, 1); + assert.deepEqual(r.steps.map((s) => s.title), ['webjs check', 'webjs typecheck']); +}); + +test('selectSteps picks by title (case-insensitive), takes a matched group whole, and reports a miss', () => { + const { steps } = normalizeSteps([ + 'webjs check', + { title: 'Checks', parallel: 2, steps: ['webjs doctor', { title: 'Tests', steps: ['webjs test'] }] }, + ]); + const one = selectSteps(steps, ['tests']); + assert.deepEqual(one.problems, []); + assert.deepEqual(one.steps.map((s) => s.title), ['Tests']); + const group = selectSteps(steps, ['CHECKS']); + assert.deepEqual(group.steps.map((s) => s.title), ['Checks']); + assert.equal(group.steps[0].steps.length, 2, 'the whole group, not its children individually'); + const many = selectSteps(steps, ['webjs check', 'Tests']); + assert.deepEqual(many.steps.map((s) => s.title), ['webjs check', 'Tests']); + // Counterfactual: an unknown title is a problem, never a silent empty run. + const miss = selectSteps(steps, ['nope']); + assert.deepEqual(miss.steps, []); + assert.deepEqual(miss.problems, ['--only "nope" matches no step or group title']); + // No --only at all passes the list through untouched. + assert.equal(selectSteps(steps, []).steps, steps); +}); diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index 9dd4bf8cc..cf925a89a 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -54,6 +54,12 @@ export type { WebjsCspConfig, WebjsDoctorConfig, WebjsDoctorSeverity, + WebjsCiConfig, + WebjsCiStep, + WebjsCiNestedStep, + WebjsCiCommand, + WebjsCiGroup, + WebjsCiNestedGroup, } from './src/webjs-config.d.ts'; // Compile-time serializability typing for server actions (#488): the opt-in diff --git a/packages/core/src/webjs-config.d.ts b/packages/core/src/webjs-config.d.ts index 89e95fec7..94a2b7119 100644 --- a/packages/core/src/webjs-config.d.ts +++ b/packages/core/src/webjs-config.d.ts @@ -144,6 +144,46 @@ export interface WebjsStartTasks { before?: string[]; } +/** One command step of `webjs ci` (#1471). */ +export interface WebjsCiCommand { + /** Shown in the step heading and its result line. */ + title: string; + /** The shell command, run with `node_modules/.bin` on PATH and `CI=true` in the environment. */ + run: string; + /** Extra environment for this step only, e.g. `{ WEBJS_E2E: '1' }`. */ + env?: Record<string, string>; +} + +/** A step inside a group: a nested group takes ONE slot of its parent and runs sequentially, so it cannot declare `parallel`. */ +export interface WebjsCiNestedGroup { + /** The group name. */ + title: string; + /** The group's steps, run in order. */ + steps: WebjsCiNestedStep[]; +} + +/** A top-level group of `webjs ci` steps. */ +export interface WebjsCiGroup extends WebjsCiNestedGroup { + /** How many steps run at once (default 1, sequential). Captured output is replayed whole per step, never interleaved. */ + parallel?: number; +} + +/** A top-level `webjs ci` step: a string is shorthand for a command whose title is the command. */ +export type WebjsCiStep = string | WebjsCiCommand | WebjsCiGroup; + +/** A step inside a group: the same shapes, minus `parallel` on a nested group. */ +export type WebjsCiNestedStep = string | WebjsCiCommand | WebjsCiNestedGroup; + +/** + * Local CI in `webjs.ci` (#1471): the step list `webjs ci` runs, and the same + * list a cloud pipeline runs through `npm run ci`. Read by the CLI + * (`packages/cli/lib/ci-config.js`), not the server. + */ +export interface WebjsCiConfig { + /** The steps, run in order. A failing step fails the run. */ + steps?: WebjsCiStep[]; +} + /** * A severity a `webjs.doctor.gate` entry may declare, mirroring ESLint's * three-level scale. `error` fails the `webjs doctor` exit, `warn` reports @@ -238,6 +278,13 @@ export interface WebjsConfig { */ db?: Record<string, string>; + /** + * Local CI (#1471): the step list `webjs ci` runs locally and a cloud + * pipeline runs through `npm run ci`, so the two cannot drift. Read by the + * CLI (`packages/cli/lib/ci-config.js`), NOT the server. + */ + ci?: WebjsCiConfig; + /** * `webjs doctor` policy (#1257): which project-health checks the project * treats as fatal. Read by the CLI (`packages/cli/lib/doctor.js`), NOT the diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index 83caa9817..c0384d481 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -165,10 +165,12 @@ in THREE co-located places that MUST stay in lockstep: `readBodyLimits` / `computeServerTimeouts` (`body-limit.js`, the byte caps + timeouts), `readAllowedOrigins` (`csrf.js`, `allowedOrigins`), `readRegenerateRules` (`dev-regenerate.js`, `dev.regenerate`, #967), and - `readDevWatchPathsFromApp` (`dev.js`, `dev.watch`, #894). Four readers + `readDevWatchPathsFromApp` (`dev.js`, `dev.watch`, #894). Six readers live in the CLI rather than here: `readAppTasks` (`packages/cli/lib/app-tasks.js`, `dev.before` / `dev.parallel` / - `start.before`, #550), `readDoctorPolicy` + `start.before`, #550), `readDbCommands` (`packages/cli/lib/app-tasks.js`, + `db`, #1468), `readCiConfig` (`packages/cli/lib/ci-config.js`, `ci.steps`, + #1471, the local-CI step list `webjs ci` runs), `readDoctorPolicy` (`packages/cli/lib/doctor.js`, `doctor.gate`, #1257), and two more inside `lib/doctor.js` that read keys the server also reads, so a change to either key's semantics needs BOTH implementations updated: diff --git a/packages/server/src/webjs-config-validate.js b/packages/server/src/webjs-config-validate.js index 2fc798261..9c8d54d71 100644 --- a/packages/server/src/webjs-config-validate.js +++ b/packages/server/src/webjs-config-validate.js @@ -41,9 +41,9 @@ import { fileURLToPath } from 'node:url'; * `integer` leaf types. * * So a value is type-checked only when its schema declares `type: "boolean"` or - * `type: "integer"`, or an `enum`. Today that is 9 of the 18 top-level keys; the - * other 9 (`headers`, `redirects`, `allowedOrigins`, `basePath`, `csp`, `dev`, - * `start`, `db`, `doctor`) pass whatever they hold, `csp` because its schema is a + * `type: "integer"`, or an `enum`. Today that is 9 of the 19 top-level keys; the + * other 10 (`headers`, `redirects`, `allowedOrigins`, `basePath`, `csp`, `dev`, + * `start`, `db`, `doctor`, `ci`) pass whatever they hold, `csp` because its schema is a * `oneOf` with no `type` at all and the rest because theirs is `array`, * `string`, or `object`. That is enough for the case this exists to close, a * typo'd top-level key silently dropped, plus the leaf kinds a schema can decide diff --git a/packages/server/test/config/webjs-config-schema.test.js b/packages/server/test/config/webjs-config-schema.test.js index 75f07cf28..d159ec5fe 100644 --- a/packages/server/test/config/webjs-config-schema.test.js +++ b/packages/server/test/config/webjs-config-schema.test.js @@ -69,6 +69,7 @@ const KNOWN_KEYS = [ 'start', // readAppTasks (cli/lib/app-tasks.js), CLI-read (#550) 'db', // readDbCommands (cli/lib/app-tasks.js), CLI-read (#1468) 'doctor', // readDoctorPolicy (cli/lib/doctor.js), CLI-read (#1257) + 'ci', // readCiConfig (cli/lib/ci-config.js), CLI-read (#1471) ]; test('schema file is valid JSON and parses', () => { @@ -217,6 +218,37 @@ test('webjs.dev.regenerate is declared in both the schema and the WebjsConfig ty assert.match(src, /regenerate\?:\s*WebjsRegenerateRule\[\]/, 'WebjsDevTasks carries regenerate'); }); +// The `webjs.ci` step list (#1471) is recursive (a group holds steps that may +// be groups), which the top-level checks above cannot see: the boot validator +// never follows a `$ref`, so the CLI reader validates the shapes itself. Guard +// the schema's shape here (the ref, the three step forms, and the rule that a +// NESTED group cannot declare `parallel`) alongside the type, so an editor's +// schema, the type, and the reader agree about what a step is. +test('webjs.ci.steps is a recursive step list in both the schema and the WebjsConfig type (#1471)', () => { + const schema = JSON.parse(readFileSync(schemaPath, 'utf8')); + const steps = schema.properties?.ci?.properties?.steps; + assert.ok(steps, 'schema declares webjs.ci.steps'); + assert.equal(steps.type, 'array', 'steps is an array'); + assert.equal(steps.items?.$ref, '#/definitions/ciStep', 'each step refs the ciStep definition'); + const defs = schema.definitions || {}; + assert.equal(defs.ciStep?.oneOf?.length, 3, 'a step is a string, a command, or a group'); + assert.equal(defs.ciNestedStep?.oneOf?.length, 3, 'a nested step has the same three forms'); + const group = defs.ciStep.oneOf[2]; + const nested = defs.ciNestedStep.oneOf[2]; + assert.ok(group.properties?.parallel, 'a top-level group may declare parallel'); + assert.equal(nested.properties?.parallel, undefined, 'a nested group cannot declare parallel'); + assert.equal(nested.additionalProperties, false, 'so a nested parallel is flagged, not ignored'); + assert.deepEqual((defs.ciCommand?.required || []).sort(), ['run', 'title'], 'a command needs title + run'); + + const dtsPath = fileURLToPath( + new URL('../../../core/src/webjs-config.d.ts', import.meta.url), + ); + const src = readFileSync(dtsPath, 'utf8'); + assert.match(src, /interface WebjsCiConfig/, 'the type declares WebjsCiConfig'); + assert.match(src, /ci\?:\s*WebjsCiConfig/, 'WebjsConfig carries ci'); + assert.match(src, /type WebjsCiNestedStep = string \| WebjsCiCommand \| WebjsCiNestedGroup/, 'a nested step excludes the parallel group'); +}); + test('representative valid configs pass the structural validator', () => { const schema = JSON.parse(readFileSync(schemaPath, 'utf8')); const valids = [ diff --git a/packages/server/webjs-config.schema.json b/packages/server/webjs-config.schema.json index ed3a3cd29..3b0af5ec6 100644 --- a/packages/server/webjs-config.schema.json +++ b/packages/server/webjs-config.schema.json @@ -237,6 +237,70 @@ } } } + }, + "ci": { + "description": "Local CI (#1471): the step list `webjs ci` runs on a developer machine, and the same list a cloud pipeline runs by calling `npm run ci`, so the two cannot drift. Read by the CLI (readCiConfig in packages/cli/lib/ci-config.js), NOT the server. A step is a string (shorthand for a command whose title is the command), a { title, run, env? } command, or a { title, steps, parallel? } group. `parallel` is a slot count (default 1, sequential); a group nested inside a parallel group takes ONE slot and runs its steps in order, so it cannot declare `parallel` itself.", + "type": "object", + "additionalProperties": false, + "properties": { + "steps": { + "description": "The steps, run in order. Every child gets CI=true and node_modules/.bin on PATH. A failing step fails the run (exit 1); --fail-fast stops at the first failure.", + "type": "array", + "items": { "$ref": "#/definitions/ciStep" } + } + } + } + }, + "definitions": { + "ciCommand": { + "description": "One command step of `webjs ci`.", + "type": "object", + "additionalProperties": false, + "required": ["title", "run"], + "properties": { + "title": { "description": "Shown in the step heading and its result line.", "type": "string", "minLength": 1 }, + "run": { "description": "The shell command, run with node_modules/.bin on PATH and CI=true in the environment.", "type": "string", "minLength": 1 }, + "env": { + "description": "Extra environment for this step only, e.g. { \"WEBJS_E2E\": \"1\" }.", + "type": "object", + "additionalProperties": { "type": "string" } + } + } + }, + "ciStep": { + "description": "A top-level `webjs ci` step: a string (a command whose title is the command), a command object, or a group that may run its steps in parallel.", + "oneOf": [ + { "type": "string", "minLength": 1 }, + { "$ref": "#/definitions/ciCommand" }, + { + "description": "A group of steps. `parallel` is a slot count; each nested group takes one slot and runs sequentially.", + "type": "object", + "additionalProperties": false, + "required": ["title", "steps"], + "properties": { + "title": { "description": "The group name, shown in the progress line.", "type": "string", "minLength": 1 }, + "parallel": { "description": "How many steps run at once (default 1, sequential).", "type": "integer", "minimum": 1 }, + "steps": { "description": "The group's steps.", "type": "array", "minItems": 1, "items": { "$ref": "#/definitions/ciNestedStep" } } + } + } + ] + }, + "ciNestedStep": { + "description": "A step inside a group: the same shapes as a top-level step, except a nested group cannot declare `parallel` (it occupies one slot of its parent and runs sequentially).", + "oneOf": [ + { "type": "string", "minLength": 1 }, + { "$ref": "#/definitions/ciCommand" }, + { + "description": "A nested group: one slot of the parent, steps run in order.", + "type": "object", + "additionalProperties": false, + "required": ["title", "steps"], + "properties": { + "title": { "description": "The group name.", "type": "string", "minLength": 1 }, + "steps": { "description": "The group's steps.", "type": "array", "minItems": 1, "items": { "$ref": "#/definitions/ciNestedStep" } } + } + } + ] } } } diff --git a/test/types/webjs-config.test-d.ts b/test/types/webjs-config.test-d.ts index caac2be0a..a60a34277 100644 --- a/test/types/webjs-config.test-d.ts +++ b/test/types/webjs-config.test-d.ts @@ -18,6 +18,7 @@ import type { WebjsTrailingSlash, WebjsDoctorConfig, WebjsDoctorSeverity, + WebjsCiConfig, } from '@webjsdev/core'; /* ------------- A fully-populated, valid config ------------- */ @@ -43,9 +44,50 @@ const full: WebjsConfig = { headersTimeoutMs: 20000, keepAliveTimeoutMs: 5000, doctor: { gate: { UNMARKED_ASSET_LINKS: 'error', ELISION_CARRIERS: 'off', ENV_DRIFT: 'warn' } }, + ci: { + steps: [ + 'webjs check', + { title: 'Types', run: 'webjs typecheck' }, + { + title: 'Checks', + parallel: 2, + steps: [ + 'webjs doctor', + { title: 'Tests', steps: [{ title: 'e2e', run: 'webjs test --server', env: { WEBJS_E2E: '1' } }] }, + ], + }, + ], + }, }; void full; +/* ------------- Local CI (#1471) ------------- */ + +const ciConfig: WebjsCiConfig = { steps: ['webjs check'] }; +void ciConfig; + +// An empty ci block is valid: `steps` is optional. +const emptyCi: WebjsConfig = { ci: {} }; +void emptyCi; + +const badCiRun: WebjsConfig = { + // @ts-expect-error a command step's `run` is a string, not a number + ci: { steps: [{ title: 'x', run: 1 }] }, +}; +void badCiRun; + +const badCiNestedParallel: WebjsConfig = { + // @ts-expect-error a group nested inside a group cannot declare `parallel` (it takes one slot) + ci: { steps: [{ title: 'g', steps: [{ title: 'h', parallel: 2, steps: ['x'] }] }] }, +}; +void badCiNestedParallel; + +const badCiKey: WebjsConfig = { + // @ts-expect-error `step` is not a key of the ci block (it is `steps`) + ci: { step: ['webjs check'] }, +}; +void badCiKey; + /* ------------- The doctor gate (#1257) ------------- */ const doctorConfig: WebjsDoctorConfig = { gate: { NODE_VERSION: 'off' } }; From aba248bd8f035b9bde9c3733b01d3c0dd5cdb373 Mon Sep 17 00:00:00 2001 From: Vivek <vivek7405@gmail.com> Date: Thu, 10 Sep 2026 18:16:32 +0530 Subject: [PATCH 02/10] feat(cli): add the local CI step runner The runner behind the ci command (#1471), modeled on Rails 8.1's ActiveSupport::ContinuousIntegration: a heading and a timed result line per step, a failure list plus one total line, fail-fast, and parallel groups whose steps run on N slots with output captured and replayed whole so nothing interleaves, a nested group taking one slot. Two spawn shapes on purpose. A sequential step inherits stdio and is not detached, so it owns the terminal and Ctrl-C reaches it natively, the split webjs dev already makes for before-steps versus watchers. A captured step is detached (its own process group, reaped on interrupt), has stdin ignored so a TTY-reading tool cannot stop on SIGTTIN and hang the pool, and resolves on close rather than exit, with a bounded grace so a leaked grandchild holding the pipe cannot hang the run. FORCE_COLOR reaches captured children only when the parent's stdout is a TTY; Node has no PTY without a native dependency. Under GitHub Actions each step is folded into a log group and a failure is annotated, so one cloud job running the whole list still names the layer that broke, which is what the per-layer jobs used to buy. Pure of process.exit, console, and the clock, so the slot cap, the fail-fast cutoff, replay atomicity, exit-then-data ordering, and the grace path are all proven with a scripted fake child. --- packages/cli/AGENTS.md | 12 + packages/cli/lib/ci-runner.js | 475 ++++++++++++++++++ packages/cli/lib/run-tasks.js | 4 +- .../cli/test/ci-runner/ci-runner.test.mjs | 337 +++++++++++++ 4 files changed, 826 insertions(+), 2 deletions(-) create mode 100644 packages/cli/lib/ci-runner.js create mode 100644 packages/cli/test/ci-runner/ci-runner.test.mjs diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index c915c383f..5d7a1dc6d 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -114,6 +114,18 @@ lib/ scaffold-template-validation.test.js` + `test/scaffolds/scaffold-integration.test.js` (the emitted `.env.example` line). + ci-config.js PURE reader for the `webjs.ci` step list (#1471): normalizes + the string shorthand, validates every shape with its JSON path + (the boot validator never follows the schema's $ref), and + resolves `--only` titles. Nested-in-parallel `parallel` is a + problem, not honoured. + ci-runner.js The `webjs ci` runner (#1471): sequential steps inherit stdio, + parallel-group steps are captured (detached, stdin ignored, + resolved on `close` with a bounded grace) and replayed whole, + a TTY-only progress line, Rails-shaped result lines, GitHub + Actions log groups + annotations, the step-summary table. Pure + of process.exit / console / the clock (spawn, write, now, + timers injectable). check-target.js PURE invocation-target guard for `webjs check` (#1301). `findCheckTarget(cwd)` returns `{ isApp, workspaceApps }`, `notAnAppMessage()` renders the stderr refusal and diff --git a/packages/cli/lib/ci-runner.js b/packages/cli/lib/ci-runner.js new file mode 100644 index 000000000..9fa90d6f9 --- /dev/null +++ b/packages/cli/lib/ci-runner.js @@ -0,0 +1,475 @@ +import { spawn as nodeSpawn } from 'node:child_process'; +import { envWithLocalBin, killChildTree } from './run-tasks.js'; + +/** + * The `webjs ci` runner (#1471): executes a normalized step tree (see + * `ci-config.js`) the way Rails 8.1's `ActiveSupport::ContinuousIntegration` + * does. Each step prints a heading (title + command), runs, and prints + * `✅ <title> passed in 2.11s` or `❌ <title> failed in 0.01s`; the run ends + * with a failure list and one total line. A group with `parallel > 1` runs + * its steps on that many slots with each step's output CAPTURED and replayed + * whole when it finishes, so two steps never interleave, and a progress line + * names what is running; a group nested inside it takes ONE slot and runs its + * steps in order. + * + * Two spawn shapes, deliberately different: + * + * - A SEQUENTIAL step inherits stdio and is NOT detached, so it owns the + * terminal while it runs and a Ctrl-C reaches it natively, the same split + * `webjs dev` makes for its `before` steps versus its `parallel` watchers + * (`run-tasks.js`). It resolves on `exit`. + * - A CAPTURED step is detached (its own process group, so `interrupt()` can + * take down the whole tree, shell wrapper included) with stdin IGNORED (a + * child that reads the TTY would otherwise stop on SIGTTIN and hang the + * pool), stdout and stderr piped and buffered in arrival order, and it + * resolves on `close`, not `exit`, because data can still be in the pipe + * after `exit`. A bounded grace after `exit` covers a leaked grandchild that + * holds the pipe open forever: the step completes with what was captured + * and is marked truncated instead of hanging the run. + * + * Every child gets `CI=true` (so an app can branch on it, as under any CI + * provider) and every ancestor `node_modules/.bin` on PATH (`envWithLocalBin`, + * the npm-run behaviour), then the step's own `env`. A captured child gets + * `FORCE_COLOR=1` only when the PARENT's stdout is a TTY, so a terminal keeps + * the tools' colours through the pipe and a log file never gets escape codes. + * Node has no PTY without a native dependency, which a buildless framework + * will not take on, so this is the whole colour story. + * + * Pure of `process.exit`, `console`, and the real clock: the bin owns the exit + * code and the writer, `spawn` / `now` / `timers` / `write` are injectable, so + * the pool cap, the fail-fast cutoff, the replay atomicity, and the + * exit-then-data ordering are all deterministically unit-testable with a + * fake child (the same discipline as `run-tasks.js`). + * + * @typedef {import('./ci-config.js').CiNode} CiNode + * @typedef {import('./ci-config.js').CiStep} CiStep + * @typedef {{ + * title: string, run: string, group: string | null, + * ok: boolean, code: number | null, signal: string | null, interrupted: boolean, + * seconds: number, output: string | null, truncated: boolean, + * }} StepResult + * @typedef {{ ok: boolean, seconds: number, steps: StepResult[], interrupted: boolean }} CiResult + */ + +const COLORS = { + banner: '\x1b[1;32m', + title: '\x1b[1;35m', + subtitle: '\x1b[1;90m', + error: '\x1b[1;31m', + success: '\x1b[1;32m', + progress: '\x1b[1;36m', +}; +const RESET = '\x1b[0m'; + +/** Grace after `exit` for a captured child whose pipe a grandchild still holds. */ +const CLOSE_GRACE_MS = 2000; +const PROGRESS_INTERVAL_MS = 100; + +/** + * @param {string} text + * @param {keyof typeof COLORS} type + * @param {boolean} color + */ +export function colorize(text, type, color) { + return color ? `${COLORS[type]}${text}${RESET}` : text; +} + +/** + * Rails' `format_elapsed`: `2.11s`, or `1m2.11s` past a minute. + * @param {number} seconds + */ +export function formatElapsed(seconds) { + const s = Math.max(0, seconds); + const min = Math.floor(s / 60); + const sec = s - min * 60; + return `${min > 0 ? `${min}m` : ''}${sec.toFixed(2)}s`; +} + +/** The brief form the progress line uses (`12s`, `1m3s`). @param {number} seconds */ +function formatElapsedBrief(seconds) { + const s = Math.max(0, seconds); + const min = Math.floor(s / 60); + const sec = Math.floor(s - min * 60); + return `${min > 0 ? `${min}m` : ''}${sec}s`; +} + +/** + * A step heading: the title in the title colour, the command as a subtitle, + * padded by two blank lines like Rails' `heading`. + * @param {{ title: string, run: string }} step + * @param {boolean} color + */ +export function formatHeading(step, color) { + return `\n\n${colorize(step.title, 'title', color)}\n${colorize(step.run, 'subtitle', color)}\n`; +} + +/** + * The per-step result line, and the total line (same shape, Rails' `result_line`). + * @param {{ title: string, ok: boolean, seconds: number, interrupted?: boolean }} r + * @param {boolean} color + */ +export function formatResult(r, color) { + if (r.interrupted) return `\n${colorize(`❌ ${r.title} interrupted`, 'error', color)}\n`; + const elapsed = formatElapsed(r.seconds); + return r.ok + ? `\n${colorize(`✅ ${r.title} passed in ${elapsed}`, 'success', color)}\n` + : `\n${colorize(`❌ ${r.title} failed in ${elapsed}`, 'error', color)}\n`; +} + +/** + * The single-line progress indicator for a parallel group: which steps are + * running and for how long the group has been at it. + * @param {string} label the group title + * @param {number} seconds elapsed since the group started + * @param {string[]} running titles currently in flight + * @param {boolean} color + */ +export function formatProgress(label, seconds, running, color) { + return colorize(`${label} (${formatElapsedBrief(seconds)}) - ${running.join(' | ')}...`, 'progress', color); +} + +/** + * The end-of-run block: every failed step (when more than one step ran, as + * Rails does), then the total line. + * @param {CiResult} result + * @param {string} title + * @param {boolean} color + */ +export function formatSummary(result, title, color) { + let out = ''; + const failed = result.steps.filter((s) => !s.ok); + if (failed.length > 0 && result.steps.length > 1) { + for (const s of failed) { + out += `${colorize(` ↳ ${s.title} ${s.interrupted ? 'interrupted' : 'failed'}`, 'error', color)}\n`; + } + } + out += formatResult({ title, ok: result.ok, seconds: result.seconds, interrupted: result.interrupted && result.ok }, color); + return out; +} + +/** + * A GitHub Actions job-summary table (`$GITHUB_STEP_SUMMARY`), so a single + * cloud job running the whole list still shows one row per layer. Pipes are + * escaped because a title or command may carry one. + * @param {CiResult} result + */ +export function stepSummaryMarkdown(result) { + const esc = (s) => String(s).replace(/\|/g, '\\|'); + const rows = result.steps.map((s) => { + const state = s.interrupted ? '⏹ interrupted' : s.ok ? '✅ passed' : `❌ failed (exit ${s.code ?? s.signal})`; + return `| ${esc(s.title)} | \`${esc(s.run)}\` | ${state} | ${formatElapsed(s.seconds)} |`; + }); + const head = result.ok ? '✅ Local CI passed' : '❌ Local CI failed'; + return `### ${head} in ${formatElapsed(result.seconds)}\n\n| Step | Command | Result | Time |\n|---|---|---|---|\n${rows.join('\n')}\n`; +} + +/** + * Run a step tree. Returns `{ done, interrupt }`: `done` resolves to the + * `CiResult` (never rejects; a spawn error is a failed step), `interrupt()` is + * what the bin wires to SIGINT: it kills every running child (the whole + * process group of a captured one), stops any further dequeue, and lets the + * run wind down to a result flagged `interrupted`. + * + * @param {CiNode[]} steps + * @param {string} cwd + * @param {{ + * spawn?: typeof nodeSpawn, + * write?: (s: string) => void, + * isTTY?: boolean, + * color?: boolean, + * now?: () => number, + * timers?: { setInterval: Function, clearInterval: Function, setTimeout: Function, clearTimeout: Function }, + * failFast?: boolean, + * captureAll?: boolean, + * env?: NodeJS.ProcessEnv, + * actions?: boolean, + * closeGraceMs?: number, + * }} [opts] + * @returns {{ done: Promise<CiResult>, interrupt: () => void }} + */ +export function runCi(steps, cwd, opts = {}) { + const ctx = { + spawn: opts.spawn || nodeSpawn, + write: opts.write || ((s) => { process.stdout.write(s); }), + isTTY: !!opts.isTTY, + color: opts.color ?? !!opts.isTTY, + now: opts.now || (() => performance.now() / 1000), + timers: opts.timers || { setInterval, clearInterval, setTimeout, clearTimeout }, + failFast: !!opts.failFast, + captureAll: !!opts.captureAll, + baseEnv: envWithLocalBin(cwd, opts.env || process.env), + actions: !!opts.actions, + closeGraceMs: opts.closeGraceMs ?? CLOSE_GRACE_MS, + cwd, + /** @type {StepResult[]} */ + results: [], + /** @type {Set<import('node:child_process').ChildProcess>} */ + running: new Set(), + interrupted: false, + /** Fail-fast cutoff: set on the first failure when `failFast` is on. */ + stopped: false, + }; + + const interrupt = () => { + if (ctx.interrupted) return; + ctx.interrupted = true; + ctx.stopped = true; + for (const child of ctx.running) { + if (child.__webjsDetached) killChildTree(child); + else { try { child.kill('SIGINT'); } catch {} } + } + }; + + const done = (async () => { + const started = ctx.now(); + await runSequence(steps, null, ctx); + const seconds = ctx.now() - started; + const ok = !ctx.interrupted && ctx.results.length > 0 && ctx.results.every((r) => r.ok); + return { ok, seconds, steps: ctx.results, interrupted: ctx.interrupted }; + })(); + + return { done, interrupt }; +} + +/** Whether the run should stop dequeuing (interrupt, or a fail-fast cutoff). */ +function halted(ctx) { + return ctx.stopped; +} + +/** + * Run nodes in order (the sequential path). A parallel group hands off to the + * pool; a sequential group simply flattens into this walk, as Rails' + * `instance_eval` does. `capture` is true when this sequence is itself inside + * a parallel slot, so each of its steps is captured rather than inheriting. + * + * @param {CiNode[]} nodes + * @param {string | null} group + * @param {object} ctx + * @param {boolean} [capture] + */ +async function runSequence(nodes, group, ctx, capture = false) { + for (const node of nodes) { + if (halted(ctx)) return; + if (node.kind === 'group') { + if (node.parallel > 1 && !capture) await runPool(node, ctx); + else await runSequence(node.steps, node.title, ctx, capture); + continue; + } + const r = await runOne(node, group, ctx, capture || ctx.captureAll); + if (capture || ctx.captureAll) report(r, ctx); + } +} + +/** + * A parallel group: N slots pulling from the group's task list. A task is one + * step, or one nested group run sequentially in that slot. Each finished step + * is reported whole (heading + captured output + result line) as it lands. + * Fail-fast stops the DEQUEUE after the first failure; in-flight steps finish. + * + * @param {{ title: string, parallel: number, steps: CiNode[] }} group + * @param {object} ctx + */ +async function runPool(group, ctx) { + const queue = [...group.steps]; + const inFlight = new Set(); + const startedAt = ctx.now(); + const progress = ctx.isTTY ? startProgress(group.title, startedAt, inFlight, ctx) : null; + + const worker = async () => { + while (queue.length > 0 && !halted(ctx)) { + const node = queue.shift(); + if (node.kind === 'group') { + await runSequence(node.steps, node.title, ctx, true); + } else { + inFlight.add(node.title); + const r = await runOne(node, group.title, ctx, true); + inFlight.delete(node.title); + progress?.clear(); + report(r, ctx); + progress?.redraw(); + } + } + }; + const slots = Math.min(group.parallel, queue.length); + await Promise.all(Array.from({ length: slots }, () => worker())); + progress?.stop(); +} + +/** + * The 10 Hz progress line: what the group is running and for how long. TTY + * only (the bin never asks for it otherwise), cleared before every replay so + * it never lands inside a step's output, `unref`ed so a hung child does not + * keep the process alive through the timer. + */ +function startProgress(label, startedAt, inFlight, ctx) { + let visible = false; + const draw = () => { + if (inFlight.size === 0) return; + ctx.write(`\r\x1b[K${formatProgress(label, ctx.now() - startedAt, [...inFlight], ctx.color)}`); + visible = true; + }; + const clear = () => { + if (!visible) return; + ctx.write('\r\x1b[K'); + visible = false; + }; + const handle = ctx.timers.setInterval(draw, PROGRESS_INTERVAL_MS); + if (handle && typeof handle.unref === 'function') handle.unref(); + return { + clear, + redraw: draw, + stop: () => { ctx.timers.clearInterval(handle); clear(); }, + }; +} + +/** + * Write a captured step's heading, its replayed output, and its result line, + * as one uninterrupted sequence (JS is single-threaded, so nothing else can + * write between these calls). A GitHub Actions run additionally folds the + * step into a log group and annotates a failure, which is what keeps "a + * failure names its layer" true inside a single job. + * @param {StepResult} r + * @param {object} ctx + */ +function report(r, ctx) { + if (ctx.actions) ctx.write(`::group::${r.title}\n`); + ctx.write(formatHeading(r, ctx.color)); + if (r.output) ctx.write(r.output.endsWith('\n') ? r.output : `${r.output}\n`); + if (r.truncated) ctx.write(colorize('(output truncated: a child process kept the pipe open after exit)', 'subtitle', ctx.color) + '\n'); + ctx.write(formatResult(r, ctx.color)); + if (ctx.actions) { + ctx.write('::endgroup::\n'); + if (!r.ok) ctx.write(`::error title=${r.title}::${r.title} ${r.interrupted ? 'was interrupted' : `failed (exit ${r.code ?? r.signal})`}\n`); + } +} + +/** + * Run one command step and record its result. Inherit mode writes the heading + * up front (the child owns the terminal next); capture mode returns the + * output for the caller to report atomically. + * + * @param {CiStep} step + * @param {string | null} group + * @param {object} ctx + * @param {boolean} capture + * @returns {Promise<StepResult>} + */ +async function runOne(step, group, ctx, capture) { + const env = { + ...ctx.baseEnv, + CI: 'true', + ...(capture && ctx.isTTY ? { FORCE_COLOR: '1' } : {}), + ...step.env, + }; + if (!capture) { + if (ctx.actions) ctx.write(`::group::${step.title}\n`); + ctx.write(formatHeading(step, ctx.color)); + } + const started = ctx.now(); + const exit = capture ? await spawnCaptured(step, env, ctx) : await spawnInherited(step, env, ctx); + const seconds = ctx.now() - started; + const interrupted = ctx.interrupted && exit.signal !== null; + const ok = exit.code === 0 && exit.signal === null; + /** @type {StepResult} */ + const r = { + title: step.title, + run: step.run, + group, + ok, + code: exit.code, + signal: exit.signal, + interrupted, + seconds, + output: capture ? exit.output : null, + truncated: !!exit.truncated, + }; + ctx.results.push(r); + if (!ok && ctx.failFast) ctx.stopped = true; + if (!capture) { + ctx.write(formatResult(r, ctx.color)); + if (ctx.actions) { + ctx.write('::endgroup::\n'); + if (!ok) ctx.write(`::error title=${r.title}::${r.title} ${interrupted ? 'was interrupted' : `failed (exit ${r.code ?? r.signal})`}\n`); + } + } + return r; +} + +/** @returns {Promise<{ code: number | null, signal: string | null }>} */ +function spawnInherited(step, env, ctx) { + return new Promise((resolve) => { + let child; + try { + child = ctx.spawn(step.run, { shell: true, stdio: 'inherit', cwd: ctx.cwd, env }); + } catch { + resolve({ code: 1, signal: null }); + return; + } + ctx.running.add(child); + let settled = false; + const finish = (code, signal) => { + if (settled) return; + settled = true; + ctx.running.delete(child); + resolve({ code: code ?? (signal ? null : 0), signal: signal || null }); + }; + child.on('exit', (code, signal) => finish(code, signal)); + child.on('error', () => finish(1, null)); + }); +} + +/** + * @returns {Promise<{ code: number | null, signal: string | null, output: string, truncated: boolean }>} + */ +function spawnCaptured(step, env, ctx) { + return new Promise((resolve) => { + let child; + try { + child = ctx.spawn(step.run, { + shell: true, + stdio: ['ignore', 'pipe', 'pipe'], + cwd: ctx.cwd, + env, + detached: true, + }); + } catch (e) { + resolve({ code: 1, signal: null, output: `${e && e.message ? e.message : String(e)}\n`, truncated: false }); + return; + } + child.__webjsDetached = true; + ctx.running.add(child); + /** @type {Buffer[]} */ + const chunks = []; + const collect = (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); }; + child.stdout?.on('data', collect); + child.stderr?.on('data', collect); + + let settled = false; + let exited = null; + let grace = null; + const finish = (truncated) => { + if (settled) return; + settled = true; + if (grace) ctx.timers.clearTimeout(grace); + ctx.running.delete(child); + const { code, signal } = exited || { code: 1, signal: null }; + resolve({ code, signal, output: Buffer.concat(chunks).toString('utf8'), truncated }); + }; + child.on('exit', (code, signal) => { + exited = { code: code ?? (signal ? null : 0), signal: signal || null }; + // `close` normally follows within a tick. A leaked grandchild holding the + // pipe would keep it from ever firing, so bound the wait. + grace = ctx.timers.setTimeout(() => finish(true), ctx.closeGraceMs); + if (grace && typeof grace.unref === 'function') grace.unref(); + }); + child.on('close', (code, signal) => { + if (!exited) exited = { code: code ?? (signal ? null : 0), signal: signal || null }; + finish(false); + }); + child.on('error', (e) => { + chunks.push(Buffer.from(`${e && e.message ? e.message : String(e)}\n`)); + exited = exited || { code: 1, signal: null }; + finish(false); + }); + }); +} diff --git a/packages/cli/lib/run-tasks.js b/packages/cli/lib/run-tasks.js index a6ffdbb5e..58ba9e7ea 100644 --- a/packages/cli/lib/run-tasks.js +++ b/packages/cli/lib/run-tasks.js @@ -12,7 +12,7 @@ import { delimiter, dirname, join } from 'node:path'; * @param {string} cwd * @param {NodeJS.ProcessEnv} [env] */ -function envWithLocalBin(cwd, env = process.env) { +export function envWithLocalBin(cwd, env = process.env) { const bins = []; let dir = cwd; // Walk up to the filesystem root, collecting each node_modules/.bin. @@ -110,7 +110,7 @@ export function startParallelTasks(commands, cwd, opts = {}) { * * @param {import('node:child_process').ChildProcess} child */ -function killChildTree(child) { +export function killChildTree(child) { try { if (typeof child.pid === 'number') process.kill(-child.pid, 'SIGTERM'); else child.kill(); diff --git a/packages/cli/test/ci-runner/ci-runner.test.mjs b/packages/cli/test/ci-runner/ci-runner.test.mjs new file mode 100644 index 000000000..cf2eeaaff --- /dev/null +++ b/packages/cli/test/ci-runner/ci-runner.test.mjs @@ -0,0 +1,337 @@ +/** + * `runCi` (#1471), the `webjs ci` runner, driven entirely by a scripted fake + * child: no real process, no real clock, no real timer. Each test states the + * behaviour AND its counterfactual (the spawn that must NOT have happened, the + * byte that must NOT have been written), so a regression fails by name. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { sep } from 'node:path'; +import { + runCi, + formatElapsed, + formatProgress, + stepSummaryMarkdown, + formatSummary, +} from '../../lib/ci-runner.js'; +import { normalizeSteps } from '../../lib/ci-config.js'; + +/** A fake ChildProcess: emits what the test tells it to, in the order it says. */ +function fakeChild() { + const c = new EventEmitter(); + c.stdout = new EventEmitter(); + c.stderr = new EventEmitter(); + c.killed = null; + c.kill = (sig) => { c.killed = sig || 'SIGTERM'; }; + return c; +} + +/** + * A spawn recorder. `calls[i]` is `{ cmd, opts, child }` in spawn order; the + * test settles each child by hand with `finish()`. + */ +function recorder() { + const calls = []; + const spawn = (cmd, opts) => { + const child = fakeChild(); + calls.push({ cmd, opts, child }); + return child; + }; + return { calls, spawn, byCmd: (cmd) => calls.find((c) => c.cmd === cmd) }; +} + +/** Settle a child: optional output, then exit, then close (the real order). */ +function finish(call, { code = 0, signal = null, out = '', err = '' } = {}) { + if (out) call.child.stdout.emit('data', Buffer.from(out)); + if (err) call.child.stderr.emit('data', Buffer.from(err)); + call.child.emit('exit', code, signal); + call.child.emit('close', code, signal); +} + +const tick = () => new Promise((r) => setImmediate(r)); +/** Let the runner reach its next spawn (several microtask hops deep). */ +async function settle() { for (let i = 0; i < 5; i++) await tick(); } + +/** A fake clock: every read advances one second, so a step always reads 1.00s. */ +function clock() { let t = 0; return () => t++; } + +/** Fake timers that never fire on their own; the test fires them. */ +function timers() { + const intervals = []; + const timeouts = []; + return { + intervals, + timeouts, + api: { + setInterval: (fn) => { intervals.push(fn); return { unref() {} }; }, + clearInterval: () => {}, + setTimeout: (fn) => { const h = { fn, cleared: false, unref() {} }; timeouts.push(h); return h; }, + clearTimeout: (h) => { if (h) h.cleared = true; }, + }, + }; +} + +function sink() { + const chunks = []; + return { chunks, write: (s) => { chunks.push(s); }, text: () => chunks.join('') }; +} + +const base = (extra = {}) => ({ now: clock(), timers: timers().api, env: { PATH: '/usr/bin' }, ...extra }); + +test('sequential steps inherit stdio, are not detached, and carry CI=true + local bin PATH + step env', async () => { + const r = recorder(); + const out = sink(); + const { steps } = normalizeSteps(['echo a', { title: 'B', run: 'echo b', env: { WEBJS_E2E: '1' } }]); + const run = runCi(steps, '/app', base({ spawn: r.spawn, write: out.write })); + await settle(); + assert.equal(r.calls.length, 1, 'sequential: the second step is NOT spawned before the first finishes'); + finish(r.calls[0]); + await settle(); + finish(r.calls[1]); + const result = await run.done; + + assert.equal(result.ok, true); + assert.deepEqual(result.steps.map((s) => [s.title, s.ok, s.code, s.output]), [['echo a', true, 0, null], ['B', true, 0, null]]); + for (const c of r.calls) { + assert.equal(c.opts.stdio, 'inherit'); + assert.equal(c.opts.detached, undefined, 'a sequential step is NOT detached (Ctrl-C must reach it natively)'); + assert.equal(c.opts.shell, true); + assert.equal(c.opts.cwd, '/app'); + assert.equal(c.opts.env.CI, 'true'); + assert.ok(c.opts.env.PATH.startsWith(`/app${sep}node_modules${sep}.bin`), `PATH starts with the app's .bin: ${c.opts.env.PATH}`); + assert.equal(c.opts.env.FORCE_COLOR, undefined, 'no FORCE_COLOR on an inherited step'); + } + assert.equal(r.calls[1].opts.env.WEBJS_E2E, '1', 'the step env is merged in'); + assert.equal(r.calls[0].opts.env.WEBJS_E2E, undefined, 'and only for that step'); + const text = out.text(); + assert.match(text, /echo a\n[\s\S]*✅ echo a passed in 1\.00s/); + assert.match(text, /\nB\necho b\n[\s\S]*✅ B passed in 1\.00s/); + assert.doesNotMatch(text, /\r/, 'no progress line without a TTY'); +}); + +test('a failing step fails the run; --fail-fast stops the dequeue, the default keeps going and lists every failure', async () => { + const { steps } = normalizeSteps(['one', 'two', 'three']); + + const ff = recorder(); + const run1 = runCi(steps, '/app', base({ spawn: ff.spawn, write: () => {}, failFast: true })); + await settle(); + finish(ff.calls[0], { code: 3 }); + const r1 = await run1.done; + assert.equal(r1.ok, false); + assert.deepEqual(ff.calls.map((c) => c.cmd), ['one'], 'counterfactual: nothing after the failure was spawned'); + assert.deepEqual(r1.steps.map((s) => [s.title, s.ok, s.code]), [['one', false, 3]]); + + const all = recorder(); + const out = sink(); + const run2 = runCi(steps, '/app', base({ spawn: all.spawn, write: out.write })); + await settle(); + finish(all.calls[0], { code: 3 }); + await settle(); + finish(all.calls[1]); + await settle(); + finish(all.calls[2], { code: 1 }); + const r2 = await run2.done; + assert.equal(r2.ok, false); + assert.deepEqual(all.calls.map((c) => c.cmd), ['one', 'two', 'three'], 'without --fail-fast every step runs'); + const summary = formatSummary(r2, 'Continuous Integration', false); + assert.match(summary, /↳ one failed\n[\s\S]*↳ three failed\n/); + assert.doesNotMatch(summary, /↳ two/); + assert.match(summary, /❌ Continuous Integration failed in/); +}); + +test('a parallel group never exceeds its slots, captures each child, and replays each output whole', async () => { + const r = recorder(); + const out = sink(); + const { steps } = normalizeSteps([{ title: 'Checks', parallel: 2, steps: ['a', 'b', 'c'] }]); + const run = runCi(steps, '/app', base({ spawn: r.spawn, write: out.write })); + await settle(); + assert.deepEqual(r.calls.map((c) => c.cmd), ['a', 'b'], 'two slots: exactly two in flight, the third waits'); + for (const c of r.calls) { + assert.deepEqual(c.opts.stdio, ['ignore', 'pipe', 'pipe'], 'captured: stdin ignored (no SIGTTIN), pipes for output'); + assert.equal(c.opts.detached, true, 'captured: its own process group so interrupt() reaps the tree'); + assert.equal(c.opts.env.CI, 'true'); + assert.equal(c.opts.env.FORCE_COLOR, undefined, 'no FORCE_COLOR when the parent is not a TTY'); + } + // Interleave the two children's output; each step must still replay contiguously. + r.calls[0].child.stdout.emit('data', Buffer.from('a1\n')); + r.calls[1].child.stdout.emit('data', Buffer.from('b1\n')); + r.calls[0].child.stderr.emit('data', Buffer.from('a2\n')); + r.calls[1].child.stdout.emit('data', Buffer.from('b2\n')); + assert.equal(out.text(), '', 'counterfactual: nothing is written while a captured step is still running'); + finish(r.calls[1], { code: 0 }); + await settle(); + assert.deepEqual(r.calls.map((c) => c.cmd), ['a', 'b', 'c'], 'a freed slot dequeues the third step'); + finish(r.calls[0], { code: 2 }); + await settle(); + finish(r.calls[2], { out: 'c1\n' }); + const result = await run.done; + + const text = out.text(); + assert.match(text, /\nb\nb\nb1\nb2\n\n✅ b passed/, 'b replays heading, output, result as one block'); + assert.match(text, /\na\na\na1\na2\n\n❌ a failed/, 'a replays whole, after b (it finished later)'); + assert.ok(text.indexOf('✅ b passed') < text.indexOf('\na\na\n'), 'b was reported before a'); + assert.match(text, /\nc\nc\nc1\n\n✅ c passed/); + assert.equal(result.ok, false); + assert.deepEqual(result.steps.map((s) => [s.title, s.ok, s.group]), [['b', true, 'Checks'], ['a', false, 'Checks'], ['c', true, 'Checks']]); + assert.equal(result.steps[1].output, 'a1\na2\n', 'the captured output is on the result too (for --json)'); +}); + +test('a group nested inside a parallel group takes ONE slot and runs sequentially', async () => { + const r = recorder(); + const { steps } = normalizeSteps([ + { title: 'Checks', parallel: 2, steps: ['a', { title: 'Tests', steps: ['b', 'c'] }, 'd'] }, + ]); + const run = runCi(steps, '/app', base({ spawn: r.spawn, write: () => {} })); + await settle(); + assert.deepEqual(r.calls.map((c) => c.cmd), ['a', 'b'], 'slot 1 runs a, slot 2 starts the nested group with b'); + finish(r.byCmd('a')); + await settle(); + assert.deepEqual(r.calls.map((c) => c.cmd), ['a', 'b', 'd'], 'a finishing frees slot 1 for d, NOT for c (c belongs to slot 2)'); + finish(r.byCmd('b')); + await settle(); + assert.deepEqual(r.calls.map((c) => c.cmd), ['a', 'b', 'd', 'c'], 'c starts only when b, its group sibling, is done'); + finish(r.byCmd('d')); + finish(r.byCmd('c')); + const result = await run.done; + assert.equal(result.ok, true); + assert.deepEqual(result.steps.filter((s) => s.group === 'Tests').map((s) => s.title), ['b', 'c']); +}); + +test('captured: data arriving after exit but before close is kept', async () => { + const r = recorder(); + const { steps } = normalizeSteps([{ title: 'G', parallel: 2, steps: ['x'] }]); + const run = runCi(steps, '/app', base({ spawn: r.spawn, write: () => {} })); + await settle(); + const c = r.calls[0]; + c.child.stdout.emit('data', Buffer.from('before\n')); + c.child.emit('exit', 0, null); + c.child.stdout.emit('data', Buffer.from('after\n')); + c.child.emit('close', 0, null); + const result = await run.done; + assert.equal(result.steps[0].output, 'before\nafter\n'); + assert.equal(result.steps[0].truncated, false); +}); + +test('captured: a close that never comes is bounded by the grace timer and marked truncated', async () => { + const r = recorder(); + const t = timers(); + const { steps } = normalizeSteps([{ title: 'G', parallel: 2, steps: ['leaky'] }]); + const run = runCi(steps, '/app', base({ spawn: r.spawn, write: () => {}, timers: t.api })); + await settle(); + const c = r.calls[0]; + c.child.stdout.emit('data', Buffer.from('partial\n')); + c.child.emit('exit', 0, null); + assert.equal(t.timeouts.length, 1, 'the grace timer is armed on exit'); + let resolved = false; + run.done.then(() => { resolved = true; }); + await settle(); + assert.equal(resolved, false, 'counterfactual: without close (or the grace firing) the step is still pending'); + t.timeouts[0].fn(); + const result = await run.done; + assert.equal(result.steps[0].ok, true, 'exit 0 still counts as a pass'); + assert.equal(result.steps[0].truncated, true); + assert.equal(result.steps[0].output, 'partial\n'); +}); + +test('interrupt() kills every running child, stops the dequeue, and reports the run interrupted', async () => { + const r = recorder(); + const out = sink(); + const { steps } = normalizeSteps([{ title: 'G', parallel: 2, steps: ['a', 'b', 'c'] }, 'after']); + const run = runCi(steps, '/app', base({ spawn: r.spawn, write: out.write })); + await settle(); + assert.deepEqual(r.calls.map((c) => c.cmd), ['a', 'b']); + run.interrupt(); + assert.equal(r.calls[0].child.killed, 'SIGTERM', 'a is killed (fake child has no pid, so the group kill falls back to kill())'); + assert.equal(r.calls[1].child.killed, 'SIGTERM'); + finish(r.calls[0], { code: null, signal: 'SIGTERM' }); + finish(r.calls[1], { code: null, signal: 'SIGTERM' }); + const result = await run.done; + assert.deepEqual(r.calls.map((c) => c.cmd), ['a', 'b'], 'counterfactual: neither c nor the trailing step was spawned'); + assert.equal(result.interrupted, true); + assert.equal(result.ok, false); + assert.deepEqual(result.steps.map((s) => [s.title, s.interrupted, s.ok]), [['a', true, false], ['b', true, false]]); + assert.match(out.text(), /❌ a interrupted/); +}); + +test('on a TTY the progress line renders only while the pool runs, is cleared before a replay, and colours captured children', async () => { + const r = recorder(); + const out = sink(); + const t = timers(); + const { steps } = normalizeSteps(['first', { title: 'Checks', parallel: 2, steps: ['a', 'b'] }]); + const run = runCi(steps, '/app', base({ spawn: r.spawn, write: out.write, isTTY: true, timers: t.api })); + await settle(); + assert.equal(t.intervals.length, 0, 'counterfactual: no progress timer during an inherited step'); + assert.equal(r.calls[0].opts.env.FORCE_COLOR, undefined, 'an inherited step never gets FORCE_COLOR'); + finish(r.calls[0]); + await settle(); + assert.equal(t.intervals.length, 1, 'the pool arms the progress timer'); + assert.equal(r.calls[1].opts.env.FORCE_COLOR, '1', 'a captured child on a TTY keeps its colours'); + t.intervals[0](); + const drawn = out.text(); + assert.match(drawn, /\r\x1b\[K.*Checks \(\d+s\) - a \| b\.\.\./); + finish(r.calls[1], { out: 'A\n' }); + await settle(); + const text = out.text(); + const clearIdx = text.lastIndexOf('\r\x1b[K', text.indexOf('A\n')); + assert.ok(clearIdx !== -1 && clearIdx < text.indexOf('A\n'), 'the progress line is cleared before the replay'); + finish(r.calls[2]); + await run.done; +}); + +test('GitHub Actions mode folds each step into a log group and annotates a failure', async () => { + const r = recorder(); + const out = sink(); + const { steps } = normalizeSteps(['ok', { title: 'G', parallel: 2, steps: ['bad'] }]); + const run = runCi(steps, '/app', base({ spawn: r.spawn, write: out.write, actions: true })); + await settle(); + finish(r.calls[0]); + await settle(); + finish(r.calls[1], { code: 7, out: 'boom\n' }); + await run.done; + const text = out.text(); + assert.match(text, /::group::ok\n[\s\S]*✅ ok passed[\s\S]*::endgroup::\n/); + assert.doesNotMatch(text, /::error title=ok/); + assert.match(text, /::group::bad\n[\s\S]*boom\n[\s\S]*❌ bad failed[\s\S]*::endgroup::\n::error title=bad::bad failed \(exit 7\)\n/); +}); + +test('captureAll captures sequential steps too (the --json path), and a spawn error is a failed step', async () => { + const r = recorder(); + const out = sink(); + const { steps } = normalizeSteps(['one']); + const run = runCi(steps, '/app', base({ spawn: r.spawn, write: out.write, captureAll: true })); + await settle(); + assert.deepEqual(r.calls[0].opts.stdio, ['ignore', 'pipe', 'pipe']); + r.calls[0].child.emit('error', new Error('spawn ENOENT')); + const result = await run.done; + assert.equal(result.ok, false); + assert.equal(result.steps[0].code, 1); + assert.match(result.steps[0].output, /spawn ENOENT/); + assert.match(out.text(), /spawn ENOENT[\s\S]*❌ one failed/); +}); + +test('formatters: Rails elapsed shape, the progress line, and a pipe-safe step summary', () => { + assert.equal(formatElapsed(2.113), '2.11s'); + assert.equal(formatElapsed(62.5), '1m2.50s'); + assert.equal(formatElapsed(0), '0.00s'); + assert.equal(formatProgress('Checks', 75.9, ['a', 'b'], false), 'Checks (1m15s) - a | b...'); + const md = stepSummaryMarkdown({ + ok: false, + seconds: 3, + interrupted: false, + steps: [ + { title: 'a | b', run: 'echo "x|y"', ok: true, code: 0, signal: null, interrupted: false, seconds: 1 }, + { title: 'c', run: 'false', ok: false, code: 1, signal: null, interrupted: false, seconds: 2 }, + ], + }); + assert.match(md, /^### ❌ Local CI failed in 3\.00s/); + assert.match(md, /\| a \\\| b \| `echo "x\\\|y"` \| ✅ passed \| 1\.00s \|/); + assert.match(md, /\| c \| `false` \| ❌ failed \(exit 1\) \| 2\.00s \|/); +}); + +test('an empty step list is not a pass', async () => { + const run = runCi([], '/app', base({ spawn: () => { throw new Error('never'); }, write: () => {} })); + const result = await run.done; + assert.equal(result.ok, false); + assert.deepEqual(result.steps, []); +}); From cc98a3d7f5369a89b10f97a4be02d4767c86404f Mon Sep 17 00:00:00 2001 From: Vivek <vivek7405@gmail.com> Date: Thu, 10 Sep 2026 18:21:25 +0530 Subject: [PATCH 03/10] feat(cli): add the ci command The local CI command (#1471): run the step list package.json declares under webjs.ci, with -f/--fail-fast, --only <title>, --json, and an opt-in --signoff that posts a green commit status through gh signoff after a green run, the Rails 8.1 bin/ci posture. The predicate is the config, not an app/ directory, unlike webjs check, because a workspace root is a legitimate target and this monorepo declares its own list. Nothing declared is exit 1 rather than 0, since a run of zero steps would read as green; the refusal names the workspace members that do declare one. A malformed block refuses with every problem's JSON path and runs nothing. Under --json stdout carries exactly one document and the human report moves to stderr; the exit code is set through exitCode so a non-TTY stdout is never truncated. .env is loaded before the steps, like dev and start, so a local db migrate step sees DATABASE_URL while a CI runner's explicit env still wins. The prose hook's CLI subcommand list gains ci so its drift test keeps passing, and the end-to-end CLI test and the Node + Bun proof script cover the exit codes, the output shape, the env every child sees, fail-fast, --only, the refusals, the GitHub Actions log groups and step summary, and a real interrupt reaping a detached sleep. --- .claude/hooks/block-prose-punctuation.sh | 2 +- packages/cli/AGENTS.md | 1 + packages/cli/bin/webjs.js | 141 +++++++++++++++ packages/cli/lib/check-target.js | 2 +- packages/cli/lib/ci-config.js | 33 ++++ .../cli/test/ci-config/ci-config.test.mjs | 13 +- test/bun/ci-runner.mjs | 72 ++++++++ test/bun/ci-runner.test.mjs | 11 ++ test/cli/ci.test.mjs | 165 ++++++++++++++++++ 9 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 test/bun/ci-runner.mjs create mode 100644 test/bun/ci-runner.test.mjs create mode 100644 test/cli/ci.test.mjs diff --git a/.claude/hooks/block-prose-punctuation.sh b/.claude/hooks/block-prose-punctuation.sh index eb409e3a5..a4e741b96 100755 --- a/.claude/hooks/block-prose-punctuation.sh +++ b/.claude/hooks/block-prose-punctuation.sh @@ -306,7 +306,7 @@ fi # positives (a wrongly blocked write), the same tradeoff as the rules above: # e.g. a sentence-ending "built on webjs." is not flagged (trailing period), # and the `bin/webjs.js` "webjs commands:" usage banner may rarely trip it. -webjs_cli='create|dev|start|test|check|routes|elision|db|ui|doctor|types|typecheck|mcp|vendor|help|version|add|init|generate|migrate|push|studio|seed|pin|unpin|list|audit|outdated|update|view|diff|info|build' +webjs_cli='create|dev|start|test|check|ci|routes|elision|db|ui|doctor|types|typecheck|mcp|vendor|help|version|add|init|generate|migrate|push|studio|seed|pin|unpin|list|audit|outdated|update|view|diff|info|build' # Scan copy: drop fenced code blocks, inline code spans, and emphasis markers # so a `webjs` inside code is never considered and **webjs** still matches. diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 5d7a1dc6d..a85e96474 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -189,6 +189,7 @@ README.md npm-facing package readme. | `webjs dev` | Re-execs itself under the host runtime's hot-reload supervisor, then `startServer({ dev: true })` in the child. The supervisor is runtime-specific (#514, `lib/dev-supervisor.js`): `node --watch` on Node (restart-on-change, fresh ESM cache, plus the dev re-import's `?t=` query); `bun --hot` on Bun (in-place module invalidation, since Bun keys its cache by path and ignores `?t=`, so `node --watch` would leave a server-module edit stale). `--no-hot` opts out and runs the server in-process on either runtime. In the parent (pre-spawn) it runs the configured dev orchestration (#550, `lib/run-tasks.js`): the `webjs.dev.before` steps (one-shot) to completion, then the `webjs.dev.parallel` watchers (e.g. the Tailwind CLI) alongside the server, torn down on exit. So a bare `webjs dev` runs the same before-steps and watchers as `npm run dev`. The scaffold ships `webjs db migrate` as both a `dev.before` and a `start.before` step (#725), so a `db:generate`'d migration is applied on the next boot in dev and prod alike with no manual `db:migrate`; `.env` is loaded before the dev before-steps (same as start), so a Postgres dev migrate sees `DATABASE_URL`. Local binaries (`drizzle-kit`, `tailwindcss`) resolve because the spawn PATH is prepended with the ancestor `node_modules/.bin` dirs, npm-style. **Before dispatching either dev or start**, a directory-relative resolve probe (`checkFrameworkResolves` from `lib/doctor.js`) checks that `@webjsdev/core` resolves from `process.cwd()`; if not (the fresh-git-worktree-without-node_modules trap, #954), it prints the cause + remedy and exits 1 instead of letting a raw `ERR_MODULE_NOT_FOUND` bubble from deep in SSR. A no-op single resolve on the happy path. | | `webjs start` | `startServer({ dev: false })`, plain HTTP/1.1 (front a reverse proxy for TLS + HTTP/2). Shares the dev framework-resolve preflight above (#954). | | `webjs test [--server\|--browser]` | Runtime-native test runner (#570): server tests run under `node --test` on Node and `bun test` on Bun (`bun --test` is invalid), dispatched on `process.versions.bun`; browser tests run the app's resolved `@web/test-runner` (`wtr`) bin via `process.execPath` (no `npx`). | +| `webjs ci [-f\|--fail-fast] [--only <title>]... [--json] [--signoff]` | Local CI (#1471), the Rails `bin/ci` posture: runs the step list `package.json` declares under `webjs.ci.steps` (`readCiConfig` in `lib/ci-config.js`, `runCi` in `lib/ci-runner.js`). Each step is timed with a `✅` / `❌` result line, the run ends with a failure list and one total line, and the exit is 1 on any failure (130 on interrupt). A group with `parallel: N` runs N steps at once with each step's output captured and replayed whole; a group nested in it takes one slot and runs sequentially. Every child gets `CI=true`, `node_modules/.bin` on PATH, and the step's `env`; `.env` is loaded first like dev / start. The predicate is the CONFIG, not an `app/` directory, so a workspace root (this monorepo) is a legitimate target; no block is exit 1 naming the workspace members that declare one (`--json`: `{ error: { code: 'NO_CI_CONFIG' } }`), a malformed block is `INVALID_CI_CONFIG` with every problem's JSON path, an unknown `--only` title is `UNKNOWN_ONLY`. `--json` puts one document on stdout (`{ ok, seconds, interrupted, steps[] }`, failed steps carrying their captured output) and the human report on stderr. Under `GITHUB_ACTIONS` each step is a `::group::` and a failure an `::error::` annotation, and a `$GITHUB_STEP_SUMMARY` table is appended, so one cloud job running the whole list still names the layer that broke. `--signoff` runs `gh signoff` after a green run (a red run prints the do-not-merge heading). Tests: `test/cli/ci.test.mjs`, `test/bun/ci-runner.mjs` (both runtimes), `test/ci-config/`, `test/ci-runner/` | | `webjs check [--rules] [--json]` | `checkConventions()` from `@webjsdev/server/check`. `--rules` lists the checks. `--json` emits the structured violations + a summary count as JSON (via `projectCheck` from `@webjsdev/mcp/check-report`, the same projector the MCP `check` tool uses, #415), so an agent in a loop consumes structured data instead of regex-scraping stdout; the non-zero exit on violations is preserved. Report-only: each violation carries a prose `fix` hint, but there is no `--fix` autofix flag (the rules either rewrite code or rename files, so an automatic codemod is not safe). Refuses with exit 1 in a directory that has no `app/`, naming the workspace member apps to run it in when the directory declares `workspaces` (#1301): every rule assumes ONE application, so a workspace root reports collisions no single runtime ever sees (67 false findings at this repo's root). The predicate is the `app/` directory alone, which both scaffold templates create, and `--rules` is exempt and works anywhere. Under `--json` the refusal is the SECOND shape this flag can emit, `{ error: { code: 'NOT_AN_APP', message, cwd, apps } }`, and it deliberately carries neither `violations` nor `summary`, so a consumer that ignores the exit code and reads `report.violations.length` throws instead of being told a workspace is clean. Branch on `error` before reading `violations`. Guard in `lib/check-target.js` | | `webjs routes [--json\|--table] [--no-headers]` | Prints the route table to stdout (#975): every page (path, owner file, dynamic params) and every `route.{js,ts}` handler (path, owner file, HTTP methods). Reuses `buildRouteTable` from `@webjsdev/server` (the ONE walker, shared with `webjs types` + the dev server) and the shared `projectRoutes` projector from `@webjsdev/mcp/routes-report`, so `--json` is byte-identical to the MCP `list_routes` tool (the same split as `check --json` / `check-report.js`). Default is a grouped tree; `--table` is aligned KIND/PATH/METHODS/FILE columns and `--no-headers` drops the header row for piping. Read-only. Tests: `test/cli/routes.test.mjs` | | `webjs elision [--json] [--verify] [--routes <paths>]` | `analyzeAppElision()` from `@webjsdev/server` (#1308). Prints the display-only elision verdict: every component module as elided or shipped (a shipped one naming the EVIDENCE that forced it, `own` / `observed` / `closure` / `render` / `import` / `unreadable`, and the module that did the forcing), every page/layout as inert / import-only / ships-whole, and every ORPHAN class, one with no registration call or a computed tag, which the scanner cannot see either way. `--json` is byte-identical to the MCP `list_elision` tool (drift-tested). `--verify` boots two `createRequestHandler` instances with `WEBJS_ELIDE` flipped, renders the app's static page corpus through both, and diffs the masked SSR bytes via the shared `maskJsSet` / `staticPageRoutes` leaf: the framework's own differential guard pointed at an arbitrary app. It FORCES the ON side on (the override wins over `webjs.elide`, so an opted-out app still gets a real pair), skips dynamic routes by name (`--routes` adds real paths), skips a nondeterministic route rather than failing it, and exits non-zero on a divergence OR on a corpus where nothing was compared. It reports how many modules elision dropped, so a pass over a corpus with nothing elidable is visibly trivial rather than mistaken for proof | diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index f0b23e97f..98e1bf944 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -90,6 +90,11 @@ const USAGE = `webjs commands: webjs start [--port 8080] Start production server (serves source directly, no build step) webjs test [--server|--browser] Run server + browser tests webjs check [--json] Run correctness checks (--json emits structured violations) + webjs ci [-f|--fail-fast] [--only <title>] [--json] [--signoff] + Run the local CI steps declared under "webjs": { "ci": { "steps": [...] } } + in package.json (timed, one result line per step, parallel groups replayed whole, + exit 1 on any failure). The same list a cloud pipeline runs via "npm run ci". + --only runs one step or group by title; --signoff runs "gh signoff" after a green run webjs routes [--json|--table] [--no-headers] Print the route table (path / owner file / methods). Default tree; --json matches the MCP list_routes shape; --no-headers drops the --table header webjs elision [--json] [--verify] Report which component modules are elided and why each shipped one ships; --verify diffs SSR output with elision on vs off (exits non-zero on a divergence) @@ -174,6 +179,25 @@ const HELP = { ], examples: ['webjs check', 'webjs check --json', 'webjs check --rules'], }, + ci: { + usage: 'webjs ci [-f|--fail-fast] [--only <title>]... [--json] [--signoff]', + summary: + 'Run the local CI steps declared in package.json under "webjs": { "ci": { "steps": [...] } }: ' + + 'each step is timed and reported, a parallel group replays each step\'s output whole, and the exit is 1 on any failure. ' + + 'A cloud pipeline runs the same list through `npm run ci`, so the two cannot drift.', + options: [ + { flag: '-f, --fail-fast', description: 'Stop after the first failing step instead of running every step and listing every failure.' }, + { flag: '--only <title>', description: 'Run only the step or group with this title (repeatable, case-insensitive; a matched group runs whole).' }, + { flag: '--json', description: 'Emit one JSON document on stdout ({ ok, seconds, steps[] }, failed steps carry their output); the human output goes to stderr.' }, + { flag: '--signoff', description: 'After a green run, run `gh signoff` (basecamp/gh-signoff) to post a green commit status; a red run prints the do-not-merge heading instead.' }, + ], + notesTitle: 'Environment:', + notes: [ + 'Every step runs with CI=true and node_modules/.bin on PATH, plus the step\'s own `env`.', + 'Under GitHub Actions each step is a log group and a failure is annotated; a $GITHUB_STEP_SUMMARY table is appended.', + ], + examples: ['webjs ci', 'webjs ci --fail-fast', 'webjs ci --only Tests', 'webjs ci --json', 'webjs ci --signoff'], + }, routes: { usage: 'webjs routes [--json | --table] [--no-headers]', summary: 'Print the route table: each page/route path, its owner file, and (for route handlers) its HTTP methods.', @@ -750,6 +774,123 @@ async function main() { console.log('\nwebjs test: done ✓'); break; } + case 'ci': { + // Local CI (#1471): run the step list `webjs.ci` declares, the Rails + // `bin/ci` posture. The predicate is the CONFIG, not an `app/` dir (unlike + // `webjs check`), because a workspace root is a legitimate target: this + // monorepo declares its own list. Nothing declared is exit 1, not 0, since + // "ran zero steps" would read as green. + const cwd = process.cwd(); + const json = rest.includes('--json'); + const failFast = rest.includes('--fail-fast') || rest.includes('-f'); + const signoff = rest.includes('--signoff'); + const only = []; + for (let i = 0; i < rest.length; i++) { + if (rest[i] === '--only' && rest[i + 1] !== undefined) only.push(rest[++i]); + } + const { readCiConfig, selectSteps, noCiConfigMessage } = await import('../lib/ci-config.js'); + const { runCi, formatSummary, stepSummaryMarkdown, colorize } = await import('../lib/ci-runner.js'); + // Under --json stdout carries exactly one document, so the human output + // moves to stderr; every step is captured so its output can ride the + // document (failed steps only) instead of the terminal. + const out = json ? (s) => { process.stderr.write(s); } : (s) => { process.stdout.write(s); }; + const isTTY = !json && !!process.stdout.isTTY; + const refuse = (message, code, extra) => { + if (json) console.log(JSON.stringify({ error: { code, message, cwd, ...extra } })); + else console.error(message); + process.exitCode = 1; + }; + + const cfg = readCiConfig(cwd); + if (!cfg.declared) { + const { workspaceApps } = await import('../lib/check-target.js'); + const members = (await workspaceApps(cwd)).filter((app) => readCiConfig(join(cwd, app)).declared); + refuse(noCiConfigMessage(cwd, members), 'NO_CI_CONFIG', { apps: members }); + break; + } + if (cfg.problems.length > 0) { + refuse( + `webjs ci: package.json has ${cfg.problems.length} problem(s) in the "webjs": { "ci" } block, so nothing ran:\n` + + cfg.problems.map((p) => ` - ${p}`).join('\n'), + 'INVALID_CI_CONFIG', + { problems: cfg.problems }, + ); + break; + } + const selected = selectSteps(cfg.steps, only); + if (selected.problems.length > 0) { + refuse(`webjs ci: ${selected.problems.join('; ')}`, 'UNKNOWN_ONLY', { problems: selected.problems }); + break; + } + + // `.env` first, like dev / start (#447), so a local `webjs db migrate` + // step reads DATABASE_URL from it. A real env var still wins (loadEnvFile + // never overrides), so a CI runner's explicit env is untouched. + loadAppEnv(cwd); + const title = 'Continuous Integration'; + out(`${colorize(title, 'banner', isTTY)}\n${colorize('Running the steps declared in package.json under webjs.ci', 'subtitle', isTTY)}\n`); + const run = runCi(selected.steps, cwd, { + write: out, + isTTY, + failFast, + captureAll: json, + actions: !!process.env.GITHUB_ACTIONS, + }); + const onSignal = () => run.interrupt(); + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + const result = await run.done; + process.off('SIGINT', onSignal); + process.off('SIGTERM', onSignal); + out(formatSummary(result, title, isTTY)); + + if (process.env.GITHUB_STEP_SUMMARY) { + const { appendFileSync } = await import('node:fs'); + try { appendFileSync(process.env.GITHUB_STEP_SUMMARY, stepSummaryMarkdown(result)); } catch {} + } + + // The Rails signoff step, opt-in. A green run posts a green commit status + // (`gh signoff`, which branch protection can require); a red run says so + // and posts nothing. It goes through the same runner so it reads as one + // more step, and a missing `gh` is a failed step, never a silent skip. + let signoffOk = true; + if (signoff) { + if (result.ok) { + const so = runCi( + [{ kind: 'step', title: 'Signoff: All systems go. Ready for merge and deploy.', run: 'gh signoff', env: {} }], + cwd, + { write: out, isTTY, captureAll: json }, + ); + signoffOk = (await so.done).ok; + } else { + out(`\n\n${colorize('Signoff: CI failed. Do not merge or deploy.', 'error', isTTY)}\n${colorize('Fix the issues and try again.', 'subtitle', isTTY)}\n`); + } + } + + if (json) { + console.log(JSON.stringify({ + ok: result.ok && signoffOk, + seconds: result.seconds, + interrupted: result.interrupted, + steps: result.steps.map((s) => ({ + title: s.title, + run: s.run, + group: s.group, + ok: s.ok, + code: s.code, + signal: s.signal, + interrupted: s.interrupted, + seconds: s.seconds, + ...(s.ok ? {} : { output: s.output ?? '' }), + })), + })); + } + // exitCode rather than a process.exit() call, because a run writes far + // more than `check` does and exit() truncates pending pipe writes when + // stdout is not a TTY. + process.exitCode = result.interrupted ? 130 : result.ok && signoffOk ? 0 : 1; + break; + } case 'check': { const { checkConventions, RULES } = await import('@webjsdev/server/check'); diff --git a/packages/cli/lib/check-target.js b/packages/cli/lib/check-target.js index ea6d25514..d22c5d643 100644 --- a/packages/cli/lib/check-target.js +++ b/packages/cli/lib/check-target.js @@ -69,7 +69,7 @@ export async function findCheckTarget(cwd) { * @param {string} cwd * @returns {Promise<string[]>} */ -async function workspaceApps(cwd) { +export async function workspaceApps(cwd) { let patterns; try { const pkg = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')); diff --git a/packages/cli/lib/ci-config.js b/packages/cli/lib/ci-config.js index 715a51fb6..8d0af3eee 100644 --- a/packages/cli/lib/ci-config.js +++ b/packages/cli/lib/ci-config.js @@ -206,6 +206,39 @@ export function flattenSteps(steps) { return out; } +/** + * The refusal `webjs ci` prints when the directory declares no `webjs.ci` + * block: what is missing, where it goes, and (at a workspace root) which + * member apps already declare one. Mirrors `notAnAppMessage` in + * check-target.js. A run with nothing declared exits 1 rather than 0, because + * "ran zero steps" would read as green. + * + * @param {string} cwd + * @param {string[]} apps workspace members that DO declare a `webjs.ci` block + */ +export function noCiConfigMessage(cwd, apps) { + const lines = [ + 'webjs ci: nothing to run, this package.json declares no "webjs": { "ci" } block.', + '', + ` ${cwd}`, + '', + 'Declare the steps once and every tool reads the same list:', + '', + ' "webjs": { "ci": { "steps": [', + ' "webjs check",', + ' { "title": "Tests", "run": "webjs test" }', + ' ] } }', + '', + ]; + if (apps.length > 0) { + lines.push('These workspace members declare one. Run it inside each:', ''); + for (const app of apps) lines.push(` ( cd ${app} && npx webjs ci )`); + lines.push(''); + } + lines.push('`webjs help ci` shows the flags.'); + return lines.join('\n'); +} + /** @param {unknown} v */ function isPlainObject(v) { return !!v && typeof v === 'object' && !Array.isArray(v); diff --git a/packages/cli/test/ci-config/ci-config.test.mjs b/packages/cli/test/ci-config/ci-config.test.mjs index e12250a79..87ce227bb 100644 --- a/packages/cli/test/ci-config/ci-config.test.mjs +++ b/packages/cli/test/ci-config/ci-config.test.mjs @@ -7,7 +7,18 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readCiConfig, normalizeSteps, selectSteps, flattenSteps } from '../../lib/ci-config.js'; +import { readCiConfig, normalizeSteps, selectSteps, flattenSteps, noCiConfigMessage } from '../../lib/ci-config.js'; + +test('the missing-config refusal names the directory, the block shape, and any workspace member that declares one', () => { + const alone = noCiConfigMessage('/app', []); + assert.match(alone, /declares no "webjs": \{ "ci" \} block/); + assert.match(alone, /\n \/app\n/); + assert.match(alone, /"webjs": \{ "ci": \{ "steps": \[/); + assert.doesNotMatch(alone, /workspace members/); + const root = noCiConfigMessage('/repo', ['apps/a', 'apps/b']); + assert.match(root, /These workspace members declare one/); + assert.match(root, /\( cd apps\/a && npx webjs ci \)\n \( cd apps\/b && npx webjs ci \)/); +}); function reader(pkgJson) { return (_p) => (pkgJson === null ? (() => { throw new Error('ENOENT'); })() : pkgJson); diff --git a/test/bun/ci-runner.mjs b/test/bun/ci-runner.mjs new file mode 100644 index 000000000..5e55c0cd3 --- /dev/null +++ b/test/bun/ci-runner.mjs @@ -0,0 +1,72 @@ +/** + * Cross-runtime assert (#1471): the `webjs ci` runner in + * `packages/cli/lib/ci-runner.js` must behave identically on Node and Bun. It + * spawns child processes two ways (inherited stdio for a sequential step, + * detached + piped for a captured one), reads real exit codes, captures piped + * output through `close`, and reaps a detached process GROUP on interrupt. + * Runnable as `node test/bun/ci-runner.mjs` AND `bun test/bun/ci-runner.mjs`. + * Plain assertions, no node:test. Every spawned process is awaited, so a leak + * fails as a bounded assertion rather than a hang. + */ +import assert from 'node:assert/strict'; +import { runCi } from '../../packages/cli/lib/ci-runner.js'; +import { normalizeSteps } from '../../packages/cli/lib/ci-config.js'; + +const runtime = globalThis.Bun ? 'bun' : 'node'; +const quiet = { write: () => {}, isTTY: false }; + +// 1. Sequential steps run REAL commands, report the real exit code, in order. +{ + const { steps } = normalizeSteps(['exit 0', 'exit 7', 'echo after']); + const r = await runCi(steps, process.cwd(), quiet).done; + assert.equal(r.ok, false, `[${runtime}] a failing step fails the run`); + assert.deepEqual(r.steps.map((s) => [s.title, s.ok, s.code]), [['exit 0', true, 0], ['exit 7', false, 7], ['echo after', true, 0]], + `[${runtime}] every step ran and the real exit codes propagated`); +} + +// 2. Fail-fast: the step after the failure is never spawned. +{ + const { steps } = normalizeSteps(['exit 2', 'echo never']); + const r = await runCi(steps, process.cwd(), { ...quiet, failFast: true }).done; + assert.deepEqual(r.steps.map((s) => s.title), ['exit 2'], `[${runtime}] fail-fast stopped after the first failure`); +} + +// 3. A parallel group captures each child's stdout AND stderr through the +// pipe (resolved on close, so nothing is lost) and replays them whole. +{ + const chunks = []; + const { steps } = normalizeSteps([{ title: 'G', parallel: 2, steps: [ + { title: 'both', run: 'echo out; echo err 1>&2' }, + { title: 'env', run: 'echo CI=$CI' }, + ] }]); + const r = await runCi(steps, process.cwd(), { write: (s) => chunks.push(s), isTTY: false }).done; + assert.equal(r.ok, true, `[${runtime}] both captured steps passed`); + const both = r.steps.find((s) => s.title === 'both'); + assert.match(both.output, /out\n/, `[${runtime}] captured stdout`); + assert.match(both.output, /err\n/, `[${runtime}] captured stderr`); + assert.equal(both.truncated, false, `[${runtime}] the pipe closed normally`); + const env = r.steps.find((s) => s.title === 'env'); + assert.equal(env.output.trim(), 'CI=true', `[${runtime}] the child saw CI=true`); + const text = chunks.join(''); + assert.match(text, /\nboth\necho out; echo err 1>&2\n(out\nerr|err\nout)\n\n✅ both passed/, `[${runtime}] replayed as one block`); +} + +// 4. interrupt() reaps a captured child's whole process GROUP (the `sh -c` +// wrapper AND the sleep it spawned), the step reads as interrupted, and the +// run winds down instead of hanging. Awaited with a bound. +{ + const { steps } = normalizeSteps([{ title: 'G', parallel: 2, steps: ['sleep 30', 'sleep 30'] }, 'echo never']); + const run = runCi(steps, process.cwd(), quiet); + await new Promise((res) => setTimeout(res, 300)); // let the two sleeps come up + run.interrupt(); + const r = await Promise.race([ + run.done, + new Promise((res) => setTimeout(() => res(null), 5000)), + ]); + assert.ok(r, `[${runtime}] the interrupted run settled within the bound (no orphaned sleep held it open)`); + assert.equal(r.interrupted, true, `[${runtime}] the run is flagged interrupted`); + assert.deepEqual(r.steps.map((s) => s.title).sort(), ['sleep 30', 'sleep 30'], `[${runtime}] the trailing step never ran`); + assert.ok(r.steps.every((s) => s.interrupted && !s.ok), `[${runtime}] each running step reads as interrupted, not failed`); +} + +console.log(`[${runtime}] ci-runner cross-runtime asserts passed`); diff --git a/test/bun/ci-runner.test.mjs b/test/bun/ci-runner.test.mjs new file mode 100644 index 000000000..efd5424e6 --- /dev/null +++ b/test/bun/ci-runner.test.mjs @@ -0,0 +1,11 @@ +/** + * node:test wrapper so the cross-runtime proof in `ci-runner.mjs` runs under + * the Node matrix too (the proof file is named `.mjs`, not `.test.mjs`, so the + * runner does not double-run it). The CI bun job runs the same file under + * `bun` directly. + */ +import { test } from 'node:test'; + +test('the webjs ci runner spawns, captures, fails, and interrupts identically on this runtime (#1471)', async () => { + await import('./ci-runner.mjs'); +}); diff --git a/test/cli/ci.test.mjs b/test/cli/ci.test.mjs new file mode 100644 index 000000000..918437447 --- /dev/null +++ b/test/cli/ci.test.mjs @@ -0,0 +1,165 @@ +/** + * `webjs ci` end to end (#1471): the real bin against temp apps whose + * package.json declares a `webjs.ci` step list, with real child processes. + * Pins the exit codes, the Rails-shaped output, the JSON document shape, the + * env every step sees (CI=true, the step's own env), fail-fast, --only, the + * missing- and malformed-config refusals, and the GitHub Actions surfaces. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(__dirname, '..', '..'); +const CLI = resolve(REPO, 'packages', 'cli', 'bin', 'webjs.js'); +/** The current runtime, quoted, so a step can run JS without relying on PATH. */ +const NODE = JSON.stringify(process.execPath); + +function ci(cwd, args = [], env = {}) { + return spawnSync(process.execPath, [CLI, 'ci', ...args], { + cwd, + encoding: 'utf8', + env: { ...process.env, ...env, NO_COLOR: '1' }, + }); +} + +async function fixture(t, webjs) { + const dir = await mkdtemp(join(tmpdir(), 'webjs-ci-')); + t.after(() => rm(dir, { recursive: true, force: true })); + if (webjs !== undefined) { + await writeFile(join(dir, 'package.json'), JSON.stringify({ name: 'app', type: 'module', webjs }, null, 2)); + } + return dir; +} + +test('no webjs.ci block: exit 1, names the missing block, and --json carries error.code NO_CI_CONFIG', async (t) => { + const dir = await fixture(t, {}); + const r = ci(dir); + assert.equal(r.status, 1); + assert.match(r.stderr, /declares no "webjs": \{ "ci" \} block/); + assert.equal(r.stdout, '', 'nothing ran, so nothing was reported as a step'); + const j = ci(dir, ['--json']); + assert.equal(j.status, 1); + const doc = JSON.parse(j.stdout); + assert.equal(doc.error.code, 'NO_CI_CONFIG'); + assert.equal(doc.steps, undefined, 'the refusal shape carries no steps, so a consumer cannot read an empty run as green'); +}); + +test('a workspace root with no block names the members that declare one', async (t) => { + const dir = await fixture(t, undefined); + await writeFile(join(dir, 'package.json'), JSON.stringify({ name: 'root', private: true, workspaces: ['apps/*'] })); + await mkdir(join(dir, 'apps', 'with', 'app'), { recursive: true }); + await writeFile(join(dir, 'apps', 'with', 'package.json'), JSON.stringify({ name: 'with', webjs: { ci: { steps: ['echo x'] } } })); + await mkdir(join(dir, 'apps', 'without', 'app'), { recursive: true }); + await writeFile(join(dir, 'apps', 'without', 'package.json'), JSON.stringify({ name: 'without' })); + const r = ci(dir); + assert.equal(r.status, 1); + assert.match(r.stderr, /\( cd apps\/with && npx webjs ci \)/); + assert.doesNotMatch(r.stderr, /apps\/without/, 'a member with no block is not suggested'); +}); + +test('a malformed block: exit 1 naming each problem by JSON path, and nothing runs', async (t) => { + const marker = join(tmpdir(), `webjs-ci-marker-${process.pid}`); + const dir = await fixture(t, { ci: { steps: [`${NODE} -e "require('fs').writeFileSync('${marker}', '')"`, { title: 'x' }] } }); + const r = ci(dir); + assert.equal(r.status, 1); + assert.match(r.stderr, /webjs\.ci\.steps\[1\]\.run must be a non-empty command string/); + assert.equal(existsSync(marker), false, 'counterfactual: the valid neighbour did NOT run either'); + const j = ci(dir, ['--json']); + assert.equal(JSON.parse(j.stdout).error.code, 'INVALID_CI_CONFIG'); +}); + +test('a green list: exit 0, a heading + result line per step, a total line; CI=true and the step env reach the child', async (t) => { + const dir = await fixture(t, { + ci: { + steps: [ + { title: 'Sees CI', run: `${NODE} -e "process.exit(process.env.CI === 'true' ? 0 : 1)"` }, + { title: 'Sees env', run: `${NODE} -e "process.exit(process.env.WEBJS_E2E === '1' ? 0 : 1)"`, env: { WEBJS_E2E: '1' } }, + { title: 'Checks', parallel: 2, steps: ['echo alpha', { title: 'Tests', steps: ['echo beta', 'echo gamma'] }] }, + ], + }, + }); + const r = ci(dir); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /^Continuous Integration\n/); + assert.match(r.stdout, /\nSees CI\n[\s\S]*✅ Sees CI passed in \d+\.\d\ds/); + assert.match(r.stdout, /✅ Sees env passed in/); + assert.match(r.stdout, /\necho alpha\necho alpha\nalpha\n\n✅ echo alpha passed/, 'a captured step replays heading, output, result together'); + assert.match(r.stdout, /\nbeta\n[\s\S]*\ngamma\n/, 'the nested group ran both steps'); + assert.match(r.stdout, /✅ Continuous Integration passed in \d+\.\d\ds\n$/); + assert.doesNotMatch(r.stdout, /\r/, 'no progress line off a TTY'); +}); + +test('a red list: exit 1, the failed steps are listed, and --fail-fast stops before the next step', async (t) => { + const marker = join(tmpdir(), `webjs-ci-ff-${process.pid}`); + const dir = await fixture(t, { + ci: { steps: [{ title: 'Breaks', run: 'exit 3' }, { title: 'After', run: `${NODE} -e "require('fs').writeFileSync('${marker}', '')"` }] }, + }); + const r = ci(dir); + assert.equal(r.status, 1); + assert.match(r.stdout, /❌ Breaks failed in/); + assert.match(r.stdout, /✅ After passed in/, 'without --fail-fast the rest still runs'); + assert.match(r.stdout, /↳ Breaks failed\n[\s\S]*❌ Continuous Integration failed in/); + await rm(marker, { force: true }); + const ff = ci(dir, ['--fail-fast']); + assert.equal(ff.status, 1); + assert.doesNotMatch(ff.stdout, /After/, 'counterfactual: --fail-fast never reached the second step'); + assert.equal(existsSync(marker), false); + const short = ci(dir, ['-f']); + assert.doesNotMatch(short.stdout, /After/, '-f is the short form'); +}); + +test('--json: stdout is exactly one document, failed steps carry their output, human text goes to stderr', async (t) => { + const dir = await fixture(t, { + ci: { steps: ['echo fine', { title: 'Noisy failure', run: `${NODE} -e "console.log('why'); console.error('oh no'); process.exit(2)"` }] }, + }); + const r = ci(dir, ['--json']); + assert.equal(r.status, 1); + const doc = JSON.parse(r.stdout); + assert.equal(doc.ok, false); + assert.equal(typeof doc.seconds, 'number'); + assert.deepEqual(doc.steps.map((s) => [s.title, s.ok, s.code]), [['echo fine', true, 0], ['Noisy failure', false, 2]]); + assert.equal(doc.steps[0].output, undefined, 'a passing step carries no output'); + assert.match(doc.steps[1].output, /why\n/); + assert.match(doc.steps[1].output, /oh no\n/); + assert.match(r.stderr, /❌ Noisy failure failed/, 'the human report went to stderr'); +}); + +test('--only runs the named step or group (case-insensitive) and refuses an unknown title', async (t) => { + const dir = await fixture(t, { + ci: { steps: ['echo one', { title: 'Group', parallel: 2, steps: ['echo two', 'echo three'] }] }, + }); + const r = ci(dir, ['--only', 'group']); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /two\n/); + assert.match(r.stdout, /three\n/); + assert.doesNotMatch(r.stdout, /echo one\none\n/, 'counterfactual: the unselected step did not run'); + const miss = ci(dir, ['--only', 'nope']); + assert.equal(miss.status, 1); + assert.match(miss.stderr, /--only "nope" matches no step or group title/); +}); + +test('under GitHub Actions each step is a log group, a failure is annotated, and the step summary is appended', async (t) => { + const dir = await fixture(t, { ci: { steps: ['echo ok', { title: 'Bad | pipe', run: 'exit 4' }] } }); + const summary = join(dir, 'summary.md'); + const r = ci(dir, [], { GITHUB_ACTIONS: 'true', GITHUB_STEP_SUMMARY: summary }); + assert.equal(r.status, 1); + assert.match(r.stdout, /::group::echo ok\n[\s\S]*✅ echo ok passed[\s\S]*::endgroup::\n/); + assert.match(r.stdout, /::error title=Bad \| pipe::Bad \| pipe failed \(exit 4\)\n/); + const md = await readFile(summary, 'utf8'); + assert.match(md, /^### ❌ Local CI failed in/); + assert.match(md, /\| Bad \\\| pipe \| `exit 4` \| ❌ failed \(exit 4\) \|/); +}); + +test('webjs help ci documents every flag', () => { + const r = spawnSync(process.execPath, [CLI, 'help', 'ci'], { encoding: 'utf8' }); + assert.equal(r.status, 0); + for (const flag of ['--fail-fast', '--only', '--json', '--signoff']) { + assert.match(r.stdout, new RegExp(flag.replace(/-/g, '\\-')), `help names ${flag}`); + } +}); From cec1976f472039f01714267e2964055ac79d4689 Mon Sep 17 00:00:00 2001 From: Vivek <vivek7405@gmail.com> Date: Thu, 10 Sep 2026 18:27:17 +0530 Subject: [PATCH 04/10] feat(cli): scaffold a local CI list and a one-job workflow that runs it Every new app now declares its CI once, in package.json under webjs.ci (#1471): a Setup step, then a Checks group running two at a time (webjs check, webjs doctor, webjs typecheck, a dependency audit) with a sequential Tests sub-group (the server, browser, and e2e layers), plus a `ci` script. The Tests group stays sequential because the server and e2e layers share one SQLite file. Every step is a bare `webjs ...` command, the same bar the before-steps meet, and a Bun app audits with bun audit. The generated GitHub workflow collapses from four hand-restated jobs to one job that prepares the runner and runs `npm run ci`, so the cloud and local lists cannot drift, which is the Rails guide's rule for every provider. The cloud practices stay: a read-only token, a bounded job, a concurrency cancel, and, through the runner's Actions mode, a log group per step, an annotation per failure, and a step table in the job summary, so a failure still names its layer. A team wanting per-layer required checks runs `webjs ci --only` in a matrix, as the workflow comment says. `webjs create --skip-ci` omits the workflow and nothing else (rails new parity); the local list always ships. The pre-commit hook is unchanged per #174: `npm run ci` is the pre-push gate, and the scaffold's agent rule files, playbooks, and PR template now say so. --- packages/cli/bin/webjs.js | 9 +- packages/cli/lib/create.js | 46 ++++++- .../cli/templates/.agents/rules/workflow.md | 24 ++-- .../.github/pull_request_template.md | 7 +- .../cli/templates/.github/workflows/ci.yml | 126 ++++++------------ packages/cli/templates/.hooks/pre-commit | 9 +- .../templates/partials/agents-playbook-api.md | 22 +-- .../partials/agents-playbook-fullstack.md | 26 ++-- .../runtime-rewrite/runtime-rewrite.test.mjs | 5 + test/scaffolds/scaffold-integration.test.js | 34 +++-- test/scaffolds/scaffold-runtime.test.js | 13 +- .../scaffold-template-validation.test.js | 36 ++++- 12 files changed, 216 insertions(+), 141 deletions(-) diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 98e1bf944..474e9d059 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -105,7 +105,7 @@ const USAGE = `webjs commands: in package.json, so CI gates on a chosen subset without every warning becoming fatal webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params) webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors) - webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install] Scaffold a new webjs app + webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install] [--skip-ci] Scaffold a new webjs app <name> must be a valid package name (letters, digits, - . _, starts with a letter or digit) (only 2 templates exist. default: full-stack, Drizzle, --db sqlite, --runtime node) --runtime bun emits a Bun-flavored app (bun.lock, bun Dockerfile/CI, bun docs); @@ -255,7 +255,7 @@ const HELP = { examples: ['webjs typecheck', 'webjs typecheck --watch'], }, create: { - usage: 'webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install]', + usage: 'webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install] [--skip-ci]', summary: 'Scaffold a new app. Defaults: full-stack template, Drizzle + SQLite, Node runtime.', options: [ // Kept to one terminal line like every other row: printHelp does not @@ -268,6 +268,7 @@ const HELP = { { flag: '--db <d>', description: 'sqlite (default) or postgres.' }, { flag: '--runtime <r>', description: 'node (default) or bun.' }, { flag: '--no-install', description: 'Skip the package-manager install step.' }, + { flag: '--skip-ci', description: 'Omit the GitHub workflow (.github/workflows/ci.yml); the local `npm run ci` list is always emitted.' }, ], examples: [ 'webjs create my-app', @@ -1485,6 +1486,8 @@ Full docs: https://webjs.dev/docs`); process.exit(1); } const noInstall = rest.includes('--no-install'); + // --skip-ci omits the GitHub workflow (#1471); the local ci list stays. + const skipCi = rest.includes('--skip-ci'); // --db picks the database dialect: sqlite (default) or postgres. const db = flag(rest, '--db', 'sqlite'); // --runtime picks the target runtime: node (default) or bun. Orthogonal @@ -1496,7 +1499,7 @@ Full docs: https://webjs.dev/docs`); process.exit(1); } const { scaffoldApp } = await import('../lib/create.js'); - await scaffoldApp(name, process.cwd(), { template, db, runtime, install: !noInstall }); + await scaffoldApp(name, process.cwd(), { template, db, runtime, install: !noInstall, skipCi }); break; } case 'vendor': { diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index d8a35c7a4..c0d469ab1 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -292,6 +292,10 @@ export async function scaffoldApp(name, cwd, opts = {}) { // points (`webjs create` and `npx create-webjs-app`) explicitly set // `install: true` unless the user passes `--no-install`. const shouldInstall = opts.install === true; + // `--skip-ci` (#1471, `rails new --skip-ci` parity): omit the GitHub + // workflow. The local `webjs ci` list in package.json is always emitted, so + // the app still has its gate; only the cloud half is optional. + const skipCi = opts.skipCi === true; // Defence in depth. The CLI already validates this, but library // callers (tests, programmatic use) might pass anything. const VALID_TEMPLATES = ['full-stack', 'api']; @@ -421,6 +425,10 @@ export async function scaffoldApp(name, cwd, opts = {}) { // the environment-shaped checks (env drift, pin freshness over the // network, the git hook) stay warns and cannot make CI flaky. doctor: 'webjs doctor', + // Local CI (#1471): every check above plus the test layers, one command, + // from the step list in the `webjs.ci` block below. Runtime-neutral like + // the other tooling scripts (it spawns `webjs ...` children). + ci: 'webjs ci', 'db:generate': 'webjs db generate', 'db:migrate': 'webjs db migrate', 'db:push': 'webjs db push', @@ -568,6 +576,39 @@ export async function scaffoldApp(name, cwd, opts = {}) { // everything else keeps its default warn. Add a code with "off" to // silence it, or "error" to make it fatal too. doctor: { gate: { UNMARKED_ASSET_LINKS: 'error' } }, + // Local CI (#1471), the Rails `bin/ci` posture. `npm run ci` runs this + // list on a developer machine and the generated GitHub workflow runs the + // SAME list through the same command, so the two cannot drift. Bare + // `webjs ...` commands, like the before-steps above (a Bun app's image + // has no npm). The `Checks` group runs two steps at a time with each + // step's output replayed whole; `Tests` inside it stays SEQUENTIAL, one + // slot, because the server and e2e layers share one SQLite file. + ci: { + steps: [ + { title: 'Setup', run: 'webjs db migrate' }, + { + title: 'Checks', + parallel: 2, + steps: [ + { title: 'Conventions', run: 'webjs check' }, + { title: 'Health', run: 'webjs doctor' }, + { title: 'Types', run: 'webjs typecheck' }, + { + title: 'Security: dependency audit', + run: isBun ? 'bun audit --audit-level=high' : 'npm audit --audit-level=high', + }, + { + title: 'Tests', + steps: [ + { title: 'Tests: server', run: 'webjs test --server' }, + { title: 'Tests: browser', run: 'webjs test --browser' }, + { title: 'Tests: e2e', run: 'webjs test --server', env: { WEBJS_E2E: '1' } }, + ], + }, + ], + }, + ], + }, }, }, null, 2) + '\n'); @@ -663,7 +704,8 @@ export async function scaffoldApp(name, cwd, opts = {}) { // Shipped without a dot (npm strips a published .gitignore) and renamed on copy. 'gitignore', '.github/pull_request_template.md', - // CI runs webjs check + the test layers on every PR and push to main. + // The cloud half of CI: one job that runs `npm run ci`, the same step list + // the app declares under `webjs.ci` (#1471). Omitted by `--skip-ci`. '.github/workflows/ci.yml', '.editorconfig', '.vscode/settings.json', @@ -692,6 +734,8 @@ export async function scaffoldApp(name, cwd, opts = {}) { '.github/workflows/ci.yml': bunifyCi, }; for (const f of templateFiles) { + // `--skip-ci` drops only the workflow; the PR template still ships. + if (skipCi && f === '.github/workflows/ci.yml') continue; const src = join(TEMPLATES, f); if (existsSync(src)) { // `gitignore` ships without a dot (npm strips a published `.gitignore`) diff --git a/packages/cli/templates/.agents/rules/workflow.md b/packages/cli/templates/.agents/rules/workflow.md index 3cfbf8b4e..a587e56ba 100644 --- a/packages/cli/templates/.agents/rules/workflow.md +++ b/packages/cli/templates/.agents/rules/workflow.md @@ -50,15 +50,21 @@ Read `AGENTS.md` first. Full hosted docs are at https://webjs.dev/docs. 2. Browser tests in `test/<feature>/browser/*.test.js` for hydration, DOM, slots, and the client router. 3. Documentation stays in sync on the SAME PR as the code, never a follow-up. -4. `npm run check` must pass (correctness), and so must `npm run doctor` - (project health). CI runs both. Doctor fails on whatever your `package.json` - `webjs.doctor.gate` marks `error`, which starts as the un-versioned - stylesheet link check, plus the two hard toolchain checks that default to - `error` with no gate entry at all: `NODE_VERSION` (the Node floor) and - `TSCONFIG_ERASABLE` (`erasableSyntaxOnly` missing from an existing - tsconfig), either of which would 500 the app at runtime. Everything else it - reports is a warning that cannot fail the build. Widen or narrow the gate in - `package.json` rather than in the workflow. +4. `npm run ci` must pass before you push. It runs the step list declared in + `package.json` under `webjs.ci`, one result line per step: `webjs check` + (correctness), `webjs doctor` (project health), `webjs typecheck`, a + dependency audit, then the server, browser, and e2e test layers. The GitHub + workflow runs the same list on every PR and push, so the two cannot drift; + `npm run ci -- --only Tests` runs one layer while you iterate, and + `npm run ci -- --signoff` posts a green commit status (basecamp/gh-signoff) + a branch-protection rule can require. Doctor fails on whatever your + `package.json` `webjs.doctor.gate` marks `error`, which starts as the + un-versioned stylesheet link check, plus the two hard toolchain checks that + default to `error` with no gate entry at all: `NODE_VERSION` (the Node + floor) and `TSCONFIG_ERASABLE` (`erasableSyntaxOnly` missing from an + existing tsconfig), either of which would 500 the app at runtime. Everything + else it reports is a warning that cannot fail the build. Widen or narrow the + gate, and the step list, in `package.json` rather than in the workflow. How a PR gets REVIEWED is deliberately not specified here. Use whatever your team already does. WebJs has opinions about the code (the conventions above, diff --git a/packages/cli/templates/.github/pull_request_template.md b/packages/cli/templates/.github/pull_request_template.md index c698a389e..739d5e352 100644 --- a/packages/cli/templates/.github/pull_request_template.md +++ b/packages/cli/templates/.github/pull_request_template.md @@ -4,10 +4,9 @@ ## Test plan -- [ ] Unit tests added/updated (`webjs test` passes) -- [ ] E2E tests added/updated for user-facing changes (`webjs test --e2e` passes) -- [ ] `webjs check` passes (no convention violations) -- [ ] `webjs doctor` passes (project health; it fails on whatever `webjs.doctor.gate` marks `error`, plus the hard `NODE_VERSION` / `TSCONFIG_ERASABLE` checks) +- [ ] `webjs ci` passes locally (the `webjs.ci` step list in package.json: `webjs check`, `webjs doctor`, `webjs typecheck`, the dependency audit, and the server / browser / e2e test layers; CI runs the same list) +- [ ] Unit tests added/updated +- [ ] E2E tests added/updated for user-facing changes (`WEBJS_E2E=1 webjs test`) ## Definition of done diff --git a/packages/cli/templates/.github/workflows/ci.yml b/packages/cli/templates/.github/workflows/ci.yml index 736557859..2307d8b4f 100644 --- a/packages/cli/templates/.github/workflows/ci.yml +++ b/packages/cli/templates/.github/workflows/ci.yml @@ -1,15 +1,25 @@ name: CI -# The test gate for {{APP_NAME}}. Runs the full test pyramid on every PR -# into main and on every push to main. This is the gate the local -# pre-commit hook deliberately leaves out, so `git commit` stays fast and -# the test gate runs in one authoritative place a local --no-verify cannot -# skip. Same posture as the webjs framework's own CI. +# The cloud half of CI for {{APP_NAME}}. The step list itself lives in +# package.json under "webjs": { "ci": { "steps": [...] } }, and `npm run ci` +# runs it: on a developer machine before a push, and here on every PR into +# main and every push to main, so the two can never drift. This job only +# prepares the runner (Node, dependencies, a browser, the database) and then +# runs that one command. # -# The four layers run as separate jobs so a failure names the layer that -# broke. Mark all four as required status checks in the branch-protection -# rule for main so a PR can only merge when every layer is green. Free on -# public repos (ubuntu-latest has unlimited Actions minutes). +# One job on purpose. Each step is still a collapsible log group with its own +# result line, a failing step is annotated, and the run's step table lands in +# the job summary, so a failure names the layer that broke. Mark this job as a +# required status check in the branch-protection rule for main. A team that +# wants one required check PER LAYER can add a matrix job that runs +# `npx webjs ci --only "<group title>"` per entry. +# +# The local pre-commit hook deliberately runs none of this, so a commit stays +# fast; `npm run ci` before pushing is the local gate. To hold a merge until a +# LOCAL run is green, `npm run ci -- --signoff` posts a green commit status +# through basecamp/gh-signoff, which branch protection can require (`gh signoff +# install`), the Rails posture. Free on public repos (ubuntu-latest has +# unlimited Actions minutes). on: pull_request: @@ -17,40 +27,21 @@ on: push: branches: [main] +permissions: + contents: read + # A newer push to the same branch cancels the older in-flight run. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: - conventions: - name: Conventions (webjs check) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: '24' - cache: npm - - run: npm ci - - run: npm run check - # Project health, on top of the correctness checks. WHICH findings are - # fatal is your call, declared in package.json under - # "webjs": { "doctor": { "gate": { "<CODE>": "off" | "warn" | "error" } } }, - # so this step and a local `npm run doctor` always agree. The scaffold - # starts with UNMARKED_ASSET_LINKS at error (an un-versioned /public url - # is a real deploy-staleness bug). Two checks fail with no gate entry at - # all, NODE_VERSION and TSCONFIG_ERASABLE, because either would 500 the - # app at runtime; everything else stays a warn and cannot fail this job. - # Widen or narrow the gate in package.json, not - # here. Deliberately not --strict: the git-hook, env-drift, vendor-pin, - # and framework-resolve checks are environment-shaped and would fail a - # perfectly healthy runner. - - run: npm run doctor - - unit: - name: Unit + integration (node --test) + ci: + name: CI (npm run ci) runs-on: ubuntu-latest + timeout-minutes: 20 + env: + DATABASE_URL: file:./ci.db steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 @@ -58,58 +49,17 @@ jobs: node-version: '24' cache: npm - run: npm ci - - name: Set up the database (generate + apply migrations) - run: npm run db:generate && npm run db:migrate - env: - DATABASE_URL: file:./ci.db - # --server keeps this job to node:test (the browser layer is its own - # job below). Without WEBJS_E2E the e2e folders are skipped too. - - run: npm run test:server - env: - DATABASE_URL: file:./ci.db - - browser: - name: Browser (web-test-runner / Playwright) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: '24' - cache: npm - - run: npm ci - - name: Install Playwright Chromium + - name: Install Playwright Chromium (browser + e2e layers) run: npx playwright install --with-deps chromium - - run: npm run test:browser - - e2e: - name: E2E (full app boot) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: '24' - cache: npm - - run: npm ci - - name: Set up the database (generate + apply migrations) - run: npm run db:generate && npm run db:migrate - env: - DATABASE_URL: file:./ci.db - # The scaffold's e2e test (test/hello/e2e/) drives a real browser - # via puppeteer-core, which is not a default dependency (the test - # skips when it is absent). Install it and Chromium so the e2e - # layer actually runs in CI rather than skipping silently. - - name: Install puppeteer-core + Chromium - run: | - npm install --no-save puppeteer-core - npx playwright install --with-deps chromium + # The scaffold's e2e test (test/hello/e2e/) drives a real browser via + # puppeteer-core, which is not a default dependency (the test skips when + # it is absent). Install it so the e2e layer runs here rather than + # skipping silently. + - name: Install puppeteer-core + run: npm install --no-save puppeteer-core - name: Resolve the Chromium binary path run: echo "CHROMIUM_PATH=$(node -e "console.log(require('playwright-core').chromium.executablePath())")" >> "$GITHUB_ENV" - # --server with WEBJS_E2E=1 runs node:test including the e2e folders - # (the runner gates them on that env var) and skips the browser layer. - - name: Run e2e - env: - WEBJS_E2E: '1' - DATABASE_URL: file:./ci.db - run: npm run test:server + - name: Set up the database (generate + apply migrations) + run: npm run db:generate && npm run db:migrate + # Everything above prepares the runner. This is the whole gate. + - run: npm run ci diff --git a/packages/cli/templates/.hooks/pre-commit b/packages/cli/templates/.hooks/pre-commit index c1cc08f14..144e90122 100644 --- a/packages/cli/templates/.hooks/pre-commit +++ b/packages/cli/templates/.hooks/pre-commit @@ -7,10 +7,11 @@ # # To bypass in emergencies: git commit --no-verify # -# Tests and convention checks run in CI (.github/workflows/ci.yml), not -# here, so a commit stays fast and the test gate cannot be skipped by a -# local --no-verify. The CI workflow runs `webjs check` + `webjs test` -# on every push and pull request. +# Tests and convention checks do not run here, so a commit stays fast. The +# step list lives in package.json under "webjs": { "ci" }. Run it locally +# with `npm run ci` before pushing (the local gate), and the CI workflow +# (.github/workflows/ci.yml) runs the same list on every push and pull +# request, where a local --no-verify cannot skip it. # # Running more than one AI agent on this repo at once? Give each task its own # git worktree, not a shared checkout. Two agents in one working directory diff --git a/packages/cli/templates/partials/agents-playbook-api.md b/packages/cli/templates/partials/agents-playbook-api.md index ba1f0b84d..bbc79de9e 100644 --- a/packages/cli/templates/partials/agents-playbook-api.md +++ b/packages/cli/templates/partials/agents-playbook-api.md @@ -44,15 +44,20 @@ cross-origin access use the `cors()` middleware from `@webjsdev/server`; with ### 5. Verify before you call it done -Run each of these and fix what it reports, in order: +Run `npm run ci` and fix what it reports. It is one command for every gate, +the step list declared in `package.json` under `webjs.ci`, with a result line +per step: -- `npm run check` (correctness: no browser-import or boundary violation). -- `npm run doctor` (project health; CI runs it too). It fails on whatever - `package.json` `webjs.doctor.gate` marks `error`, plus the two hard toolchain - checks that are fatal with no gate entry, `NODE_VERSION` and - `TSCONFIG_ERASABLE`. -- `npm run typecheck` (zero type errors). -- `npm test` (unit tests for the endpoints and modules you built). +- `webjs check` (correctness: no browser-import or boundary violation). +- `webjs doctor` (project health). It fails on whatever `package.json` + `webjs.doctor.gate` marks `error`, plus the two hard toolchain checks that + are fatal with no gate entry, `NODE_VERSION` and `TSCONFIG_ERASABLE`. +- `webjs typecheck` (zero type errors). +- A dependency audit. +- The test layers for the endpoints and modules you built. + +The GitHub workflow runs the same list, so a green local run predicts CI. +While iterating, `npm run ci -- --only Tests` runs one layer. Then boot `npm run dev` and probe each endpoint for the expected status and JSON shape. @@ -66,6 +71,7 @@ npm run dev # dev server at http://localhost:8080 npm run start # production server npm test # unit + browser tests npm run typecheck +npm run ci # every gate, one command (the webjs.ci steps in package.json) npm run check # correctness checks npm run doctor # project health (severity per check: webjs.doctor.gate) npm run db:generate && npm run db:migrate diff --git a/packages/cli/templates/partials/agents-playbook-fullstack.md b/packages/cli/templates/partials/agents-playbook-fullstack.md index 7b1d77f6f..832f05e5d 100644 --- a/packages/cli/templates/partials/agents-playbook-fullstack.md +++ b/packages/cli/templates/partials/agents-playbook-fullstack.md @@ -95,16 +95,21 @@ accessor). Use the shorthand for primitives ### 7. Verify before you call it done -Run each of these and fix what it reports, in order: - -- `npm run check` (correctness: no browser-import or boundary violation). -- `npm run doctor` (project health; CI runs it too). It fails on whatever - `package.json` `webjs.doctor.gate` marks `error`, plus the two hard toolchain - checks that are fatal with no gate entry, `NODE_VERSION` and - `TSCONFIG_ERASABLE`. -- `npm run typecheck` (zero type errors). -- `npm test` (unit and browser tests for the features you built). -- `npm run css:build` (compile Tailwind). +Run `npm run ci` and fix what it reports. It is one command for every gate, +the step list declared in `package.json` under `webjs.ci`, with a result line +per step: + +- `webjs check` (correctness: no browser-import or boundary violation). +- `webjs doctor` (project health). It fails on whatever `package.json` + `webjs.doctor.gate` marks `error`, plus the two hard toolchain checks that + are fatal with no gate entry, `NODE_VERSION` and `TSCONFIG_ERASABLE`. +- `webjs typecheck` (zero type errors). +- A dependency audit. +- The server, browser, and e2e test layers for the features you built. + +The GitHub workflow runs the same list, so a green local run predicts CI. +While iterating, `npm run ci -- --only Tests` runs one layer. Then +`npm run css:build` (compile Tailwind). Then boot `npm run dev`, confirm every page route returns HTTP 200, and open every route you changed in a real browser and play through its states: `check` @@ -121,6 +126,7 @@ npm run start # production server npm test # unit + browser tests npm run typecheck npm run css:build # compile Tailwind +npm run ci # every gate, one command (the webjs.ci steps in package.json) npm run check # correctness checks npm run doctor # project health (severity per check: webjs.doctor.gate) npx webjsdev ui add <name> # copy a ui primitive into components/ui/ diff --git a/packages/cli/test/runtime-rewrite/runtime-rewrite.test.mjs b/packages/cli/test/runtime-rewrite/runtime-rewrite.test.mjs index a971a94c7..2a2160524 100644 --- a/packages/cli/test/runtime-rewrite/runtime-rewrite.test.mjs +++ b/packages/cli/test/runtime-rewrite/runtime-rewrite.test.mjs @@ -133,8 +133,13 @@ test('bunifyCi: keeps setup-node, adds setup-bun, bun install, plain bun run', ( ' - run: npm run db:generate && npm run db:migrate', ' - run: npm install --no-save puppeteer-core', ' - run: npx playwright install --with-deps chromium', + ' - run: npm run ci', ].join('\n'); const out = bunifyCi(node); + // `npm run ci` (the local CI list, #1471) is rewritten by the generic + // `npm run ` rule and must NOT be caught by the `npm ci` install rewrite. + assert.match(out, /- run: bun run ci$/m); + assert.doesNotMatch(out, /bun install ci|bun run install/); // Node is kept (webjs test/db tooling runs on it); Bun is added for install. assert.match(out, /uses: actions\/setup-node@v6/); assert.match(out, /uses: oven-sh\/setup-bun@v2/); diff --git a/test/scaffolds/scaffold-integration.test.js b/test/scaffolds/scaffold-integration.test.js index de4cc67df..a95de04fb 100644 --- a/test/scaffolds/scaffold-integration.test.js +++ b/test/scaffolds/scaffold-integration.test.js @@ -277,18 +277,32 @@ test('scaffoldApp full-stack: writes the canonical full-stack app layout', async assert.doesNotMatch(preCommit, /no test is staged/, 'pre-commit no longer carries the require-tests floor (moved to CI)'); - // CI carries the test gate: webjs check + the unit / browser / e2e - // layers on every PR and push to main. + // The test gate is the `webjs.ci` step list in package.json (#1471): + // `npm run ci` runs it locally, and the ONE-job workflow runs the same + // list on every PR and push to main, so the two cannot drift. const ciWorkflow = readFileSync( join(appDir, '.github/workflows/ci.yml'), 'utf8'); - assert.match(ciWorkflow, /npm run check/, - 'CI runs webjs check'); - assert.match(ciWorkflow, /npm run test:server/, - 'CI runs the unit + integration suite (server layer)'); - assert.match(ciWorkflow, /npm run test:browser/, - 'CI runs the browser suite'); - assert.match(ciWorkflow, /WEBJS_E2E/, - 'CI runs the e2e layer'); + assert.match(ciWorkflow, /^\s+- run: npm run ci$/m, + 'the workflow runs the declared step list through npm run ci'); + assert.doesNotMatch(ciWorkflow, /npm run check|npm run test:server/, + 'counterfactual: the workflow no longer restates the steps by hand'); + assert.match(ciWorkflow, /^permissions:\n contents: read$/m, + 'the workflow token is read-only'); + assert.match(ciWorkflow, /timeout-minutes: \d+/, 'the job is bounded'); + const ciPkg = JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf8')); + assert.equal(ciPkg.scripts.ci, 'webjs ci', 'the ci script is the runtime-neutral webjs command'); + const flatten = (steps) => steps.flatMap((s) => + typeof s === 'string' ? [{ run: s }] : s.steps ? flatten(s.steps) : [s]); + const ciSteps = flatten(ciPkg.webjs.ci.steps); + const runs = ciSteps.map((s) => s.run); + for (const expected of ['webjs check', 'webjs doctor', 'webjs typecheck', 'webjs test --server', 'webjs test --browser']) { + assert.ok(runs.includes(expected), `the ci list runs ${expected}`); + } + assert.ok(ciSteps.some((s) => s.env?.WEBJS_E2E === '1'), 'the ci list runs the e2e layer via its env'); + const checks = ciPkg.webjs.ci.steps.find((s) => s.title === 'Checks'); + assert.equal(checks.parallel, 2, 'the Checks group runs two steps at a time'); + const tests = checks.steps.find((s) => s.title === 'Tests'); + assert.equal(tests.parallel, undefined, 'the Tests sub-group is sequential (one SQLite file)'); // Production / deploy scaffolding ships with every app. assert.ok(existsSync(join(appDir, 'Dockerfile')), 'Dockerfile scaffolded'); diff --git a/test/scaffolds/scaffold-runtime.test.js b/test/scaffolds/scaffold-runtime.test.js index b097c28a2..b59fd3586 100644 --- a/test/scaffolds/scaffold-runtime.test.js +++ b/test/scaffolds/scaffold-runtime.test.js @@ -58,8 +58,15 @@ test('bun scaffold: package.json scripts, trustedDependencies, lockfile flavor', inputs: ['app', 'components', 'modules', 'lib', 'public/input.css'], }]); const regenCmds = p.webjs.dev.regenerate.map((r) => r.command); - for (const step of [...p.webjs.dev.before, ...p.webjs.start.before, ...regenCmds]) { - assert.doesNotMatch(step, /npm run/, 'no npm in a Bun app before/regenerate step (the image has no npm)'); + // The local CI list (#1471) is held to the same bar, and its audit step is + // the Bun one (the app has bun.lock, not package-lock.json). + const flatten = (steps) => steps.flatMap((s) => + typeof s === 'string' ? [s] : s.steps ? flatten(s.steps) : [s.run]); + const ciCmds = flatten(p.webjs.ci.steps); + assert.ok(ciCmds.includes('bun audit --audit-level=high'), 'a Bun app audits with bun audit'); + assert.ok(!ciCmds.some((c) => /^npm /.test(c)), 'no npm command in a Bun app ci step'); + for (const step of [...p.webjs.dev.before, ...p.webjs.start.before, ...regenCmds, ...ciCmds]) { + assert.doesNotMatch(step, /npm run/, 'no npm in a Bun app before/regenerate/ci step (the image has no npm)'); } } finally { restore(); @@ -109,7 +116,7 @@ test('bun scaffold: Dockerfile / compose / CI run on Bun', async () => { assert.match(ci, /oven-sh\/setup-bun@v2/); assert.match(ci, /actions\/setup-node/); // kept: webjs tooling runs on node assert.match(ci, /- run: bun install/); - assert.match(ci, /- run: bun run check/); + assert.match(ci, /- run: bun run ci$/m, 'the one-job workflow runs the declared list under bun'); assert.doesNotMatch(ci, /bun --bun run/); // tooling stays on node assert.doesNotMatch(ci, /npm ci/); } finally { diff --git a/test/scaffolds/scaffold-template-validation.test.js b/test/scaffolds/scaffold-template-validation.test.js index efe089bdb..9df956806 100644 --- a/test/scaffolds/scaffold-template-validation.test.js +++ b/test/scaffolds/scaffold-template-validation.test.js @@ -138,7 +138,9 @@ for (const template of ['full-stack', 'api']) { assert.equal(pkg.webjs.doctor.gate.UNMARKED_ASSET_LINKS, 'error'); assert.equal(pkg.scripts.doctor, 'webjs doctor'); const ci = await readFile(join(cwd, 'my-app', '.github', 'workflows', 'ci.yml'), 'utf8'); - assert.match(ci, /^\s+- run: npm run doctor$/m, 'the conventions job runs doctor'); + assert.match(ci, /^\s+- run: npm run ci$/m, 'the workflow runs the declared step list'); + const flat = JSON.stringify(pkg.webjs.ci.steps); + assert.match(flat, /"webjs doctor"/, 'the ci list runs doctor, which reads the gate'); } finally { console.log = restoreLog; await rm(cwd, { recursive: true, force: true }); @@ -146,6 +148,38 @@ for (const template of ['full-stack', 'api']) { }); } +// `--skip-ci` (#1471, `rails new --skip-ci` parity) drops ONLY the cloud half: +// the workflow file. The local `webjs.ci` list and the `ci` script are always +// emitted (the app keeps its gate), and the PR template is not CI, so it stays. +test('--skip-ci omits the GitHub workflow and nothing else (library option + CLI flag)', async () => { + const cwd = await tempCwd(); + const restoreLog = console.log; + console.log = () => {}; + try { + await scaffoldApp('lib-app', cwd, { template: 'full-stack', install: false, skipCi: true }); + const app = join(cwd, 'lib-app'); + await assert.rejects(readFile(join(app, '.github', 'workflows', 'ci.yml')), 'no workflow with skipCi'); + await readFile(join(app, '.github', 'pull_request_template.md'), 'utf8'); + const pkg = JSON.parse(await readFile(join(app, 'package.json'), 'utf8')); + assert.equal(pkg.scripts.ci, 'webjs ci', 'the local gate is still scripted'); + assert.ok(Array.isArray(pkg.webjs.ci.steps) && pkg.webjs.ci.steps.length > 0, 'the local list is still declared'); + + // Counterfactual: the default keeps generating the workflow. + await scaffoldApp('default-app', cwd, { template: 'full-stack', install: false }); + await readFile(join(cwd, 'default-app', '.github', 'workflows', 'ci.yml'), 'utf8'); + + // The CLI flag reaches the same option. + const bin = fileURLToPath(new URL('../../packages/cli/bin/webjs.js', import.meta.url)); + const r = spawnSync(process.execPath, [bin, 'create', 'cli-app', '--no-install', '--skip-ci'], { cwd, encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + await assert.rejects(readFile(join(cwd, 'cli-app', '.github', 'workflows', 'ci.yml')), 'the CLI flag omits the workflow'); + await readFile(join(cwd, 'cli-app', '.github', 'pull_request_template.md'), 'utf8'); + } finally { + console.log = restoreLog; + await rm(cwd, { recursive: true, force: true }); + } +}); + test('an uppercase name scaffolds a working app end to end', async () => { // The rule deliberately allows uppercase, and the claim that goes with it is // that a capital letter is safe as the DIRECTORY, in the package.json From 673c06954e5738325a984c43f46e19ce35be0f55 Mon Sep 17 00:00:00 2001 From: Vivek <vivek7405@gmail.com> Date: Thu, 10 Sep 2026 18:30:46 +0530 Subject: [PATCH 05/10] feat: run local CI in the monorepo and its in-repo apps The framework dogfoods its own local CI (#1471). The root package.json declares a webjs.ci list mirroring the GitHub jobs (setup, the per-app check and doctor plus the two source invariants three at a time, the root test suite, the in-repo app typechecks and suites, the browser suite, the blog e2e, the Bun matrix) behind `npm run ci`, and gallery, examples/blog, and website each declare a shorter list behind their own `ci` script. The root script and every root step invoke this checkout's CLI by path rather than a hoisted bin, because in a linked worktree node_modules/.bin resolves into the primary checkout. The app lists go through their npm scripts because the website's pretest hook copies the ui registry, which a bare test invocation would skip. A repo-health test parses all four blocks through the same reader the command uses and checks that every npm script a step names exists in the package it targets, so a stale step fails here rather than at run time with a message that never mentions the block. The GitHub workflow itself is unchanged: its jobs are the required merge checks. --- examples/blog/package.json | 18 ++++- framework-dev.md | 31 ++++++++ gallery/package.json | 18 ++++- package.json | 47 +++++++++++- test/repo-health/in-repo-ci-blocks.test.mjs | 79 +++++++++++++++++++++ website/package.json | 17 ++++- 6 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 test/repo-health/in-repo-ci-blocks.test.mjs diff --git a/examples/blog/package.json b/examples/blog/package.json index 9688f3c1c..13897e1c4 100644 --- a/examples/blog/package.json +++ b/examples/blog/package.json @@ -14,7 +14,8 @@ "db:migrate": "webjs db migrate", "db:seed": "webjs db seed", "typecheck": "webjs typecheck", - "test": "webjs test --server" + "test": "webjs test --server", + "ci": "webjs ci" }, "webjs": { "dev": { @@ -40,6 +41,21 @@ "gate": { "UNMARKED_ASSET_LINKS": "error" } + }, + "ci": { + "steps": [ + { "title": "Setup", "run": "webjs db migrate && webjs db seed" }, + { + "title": "Checks", + "parallel": 2, + "steps": [ + { "title": "Conventions", "run": "webjs check" }, + { "title": "Health", "run": "webjs doctor" }, + { "title": "Types", "run": "npm run typecheck" }, + { "title": "Tests: node", "run": "npm test" } + ] + } + ] } }, "dependencies": { diff --git a/framework-dev.md b/framework-dev.md index 550576070..a1b8b35e2 100644 --- a/framework-dev.md +++ b/framework-dev.md @@ -172,6 +172,37 @@ So prevention lives one layer up, and the rest is repair: Regression tests: `test/hooks/block-install-in-linked-worktree.test.mjs`, `test/repo-health/warn-worktree-install.test.mjs`, `test/repo-health/link-worktree-deps.test.mjs`, `test/hooks/cleanup-merged-worktree.test.mjs`, `test/cli/doctor.test.mjs`. +### Local CI: `npm run ci` at the root and inside each app (#1471) + +The monorepo runs its own local CI the way a scaffolded app does. The root +`package.json` declares a `webjs.ci` step list mirroring the GitHub jobs +(`.github/workflows/ci.yml` stays the required merge gate and is not converted): +a Setup group (the blog and gallery databases, the core dist), a Conventions +group running three at a time (`webjs check` and `webjs doctor` per in-repo +app, the buildless-packages invariant, the em-dash scan), the root `npm test`, +the in-repo app typechecks and suites plus the website boot-check, the browser +suite, the blog e2e, and the Bun matrix. `npm run ci` runs the lot; `npm run ci +-- --only Conventions` runs one group, and `--json` gives an agent the verdict +as data. The root script runs `node packages/cli/bin/webjs.js ci` rather than a +hoisted `webjs` bin because in a linked worktree `node_modules/.bin/webjs` +resolves into the PRIMARY checkout, which may not carry the branch's CLI, and +every root step invokes the in-repo CLI the same way for the same reason. +`test/repo-health/in-repo-ci-blocks.test.mjs` pins that. + +`gallery`, `examples/blog`, and `website` each declare their own shorter list +(setup, then `webjs check` / `webjs doctor` / typecheck / tests two at a time) +and a `ci` script, so `npm run ci` inside an app is the app's gate. Their steps +go through the app's npm scripts on purpose: the website's `pretest` hook runs +`scripts/copy-registry.mjs`, which a bare `webjs test` would skip. Inside a +linked worktree run an app's list as `node ../packages/cli/bin/webjs.js ci` +(`../../` from the blog) until the branch's CLI is on `main`. + +Two things the root list cannot do for you. The e2e step needs a Chromium +(`CHROMIUM_PATH`, see the e2e job), and a linked worktree reds the handful of +tests that always fail there (the listener and elision assertions in +`reference` notes), so a red root run in a worktree is read against that +baseline, not taken at face value. + ### `webjs check` runs per app, and the repo root refuses (#1301) `webjs check` is an APP-level tool: every rule assumes one application, meaning one module graph, one custom-element registry, one runtime. This repo's root is none of those, it is a workspace holding two apps plus every package's test suite plus editor fixtures plus the scaffold templates, so a root-level run used to walk all of it and report 67 collisions that no single runtime ever sees. `my-counter`, for instance, was reported as duplicated across a blog component, an editor-plugin fixture, two unit tests, and a type fixture, five files that never load together. diff --git a/gallery/package.json b/gallery/package.json index 53636abab..35c121a69 100644 --- a/gallery/package.json +++ b/gallery/package.json @@ -15,7 +15,8 @@ "db:seed": "webjs db seed", "typecheck": "webjs typecheck", "test": "webjs test", - "test:browser": "webjs test --browser" + "test:browser": "webjs test --browser", + "ci": "webjs ci" }, "webjs": { "dev": { @@ -42,6 +43,21 @@ "webjs db migrate", "npm run css:build" ] + }, + "ci": { + "steps": [ + { "title": "Setup", "run": "webjs db migrate" }, + { + "title": "Checks", + "parallel": 2, + "steps": [ + { "title": "Conventions", "run": "webjs check" }, + { "title": "Health", "run": "webjs doctor" }, + { "title": "Types", "run": "npm run typecheck" }, + { "title": "Tests: node + browser", "run": "npm test" } + ] + } + ] } }, "dependencies": { diff --git a/package.json b/package.json index aeea2ee2a..5588fbbd3 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,52 @@ "test:browser:example-blog": "node scripts/run-example-blog-browser-e2e.js", "test:browser:gallery": "npm run test:browser --workspace=@webjsdev/gallery", "test:e2e": "WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs test/e2e/dev-seed-observability.test.mjs", - "test:all": "npm test && npm run test:browser" + "test:all": "npm test && npm run test:browser", + "ci": "node packages/cli/bin/webjs.js ci" + }, + "webjs": { + "ci": { + "steps": [ + { + "title": "Setup", + "steps": [ + { "title": "Setup: blog database", "run": "[ -f examples/blog/.env ] || cp examples/blog/.env.example examples/blog/.env; npm run db:migrate --workspace=@webjsdev/example-blog && npm run db:seed --workspace=@webjsdev/example-blog" }, + { "title": "Setup: gallery database", "run": "[ -f gallery/.env ] || cp gallery/.env.example gallery/.env; npm run db:migrate --workspace=@webjsdev/gallery" }, + { "title": "Setup: core dist", "run": "npm run build:dist --workspace=@webjsdev/core" } + ] + }, + { + "title": "Conventions", + "parallel": 3, + "steps": [ + { "title": "webjs check (blog)", "run": "cd examples/blog && node ../../packages/cli/bin/webjs.js check" }, + { "title": "webjs check (gallery)", "run": "cd gallery && node ../packages/cli/bin/webjs.js check" }, + { "title": "webjs check (website)", "run": "cd website && node ../packages/cli/bin/webjs.js check" }, + { "title": "webjs doctor (blog)", "run": "cd examples/blog && node ../../packages/cli/bin/webjs.js doctor" }, + { "title": "webjs doctor (gallery)", "run": "cd gallery && node ../packages/cli/bin/webjs.js doctor" }, + { "title": "webjs doctor (website)", "run": "cd website && node ../packages/cli/bin/webjs.js doctor" }, + { "title": "Buildless framework packages (no .ts source)", "run": "hits=$(git ls-files 'packages/core/**/*.ts' 'packages/server/**/*.ts' 'packages/cli/**/*.ts' 'packages/editors/**/*.ts' | grep -vE '\\.d\\.ts$|/templates/' || true); [ -z \"$hits\" ] || { echo \"$hits\"; exit 1; }" }, + { "title": "No em-dash in source (invariant 11)", "run": "hits=$(git grep -lP '\\x{2014}' -- '*.js' '*.ts' '*.md' ':!changelog/' ':!**/node_modules/**' ':!.claude/skills/**' ':!.agents/skills/**' || true); [ -z \"$hits\" ] || { echo \"$hits\"; exit 1; }" } + ] + }, + { "title": "Unit + integration (node --test)", "run": "npm test" }, + { + "title": "In-repo app tests", + "steps": [ + { "title": "website typecheck", "run": "npm run typecheck --workspace=@webjsdev/website" }, + { "title": "blog typecheck", "run": "npm run typecheck --workspace=@webjsdev/example-blog" }, + { "title": "gallery typecheck", "run": "npm run typecheck --workspace=@webjsdev/gallery" }, + { "title": "website tests (node + browser)", "run": "npm test --workspace=@webjsdev/website" }, + { "title": "blog tests (node)", "run": "npm test --workspace=@webjsdev/example-blog" }, + { "title": "gallery tests (node + browser)", "run": "npm test --workspace=@webjsdev/gallery" }, + { "title": "App boot-check on Node (website incl. /docs + /ui)", "run": "node test/bun/app-boot.mjs" } + ] + }, + { "title": "Browser (web-test-runner / Playwright)", "run": "npm run test:browser" }, + { "title": "E2E (Puppeteer against the blog example)", "run": "npm run test:e2e" }, + { "title": "Bun runtime smoke + test matrix", "run": "node scripts/run-bun-tests.js" } + ] + } }, "devDependencies": { "@tailwindcss/cli": "^4.2.2", diff --git a/test/repo-health/in-repo-ci-blocks.test.mjs b/test/repo-health/in-repo-ci-blocks.test.mjs new file mode 100644 index 000000000..2a015ccf2 --- /dev/null +++ b/test/repo-health/in-repo-ci-blocks.test.mjs @@ -0,0 +1,79 @@ +/** + * The monorepo and its three in-repo apps each declare a `webjs.ci` step list + * (#1471), so `npm run ci` runs local CI at the root and inside each app. This + * guard keeps those blocks honest without running them: each parses with zero + * problems through the same reader `webjs ci` uses, and every step that goes + * through an npm script names a script that exists in the package it targets + * (a step naming a missing script fails at run time with a message that never + * mentions the block). It also pins that every step at the root runs the CLI + * from THIS checkout rather than a hoisted `webjs` bin, since a linked + * worktree's node_modules/.bin resolves into the primary checkout. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readCiConfig, flattenSteps } from '../../packages/cli/lib/ci-config.js'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const PACKAGES = { '.': 'root', gallery: 'gallery', 'examples/blog': 'blog', website: 'website' }; + +/** `@webjsdev/<name>` workspace -> its directory, for `npm <cmd> --workspace=` steps. */ +function workspaceDirs() { + const root = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); + const dirs = new Map(); + const candidates = ['gallery', 'examples/blog', 'website']; + for (const d of readdirSync(join(ROOT, 'packages'))) candidates.push(`packages/${d}`); + for (const rel of candidates) { + let pkg; + try { pkg = JSON.parse(readFileSync(join(ROOT, rel, 'package.json'), 'utf8')); } catch { continue; } + if (pkg.name) dirs.set(pkg.name, rel); + } + assert.ok(Array.isArray(root.workspaces), 'the root declares workspaces'); + return dirs; +} + +/** The npm scripts a shell step invokes, each resolved to the package it targets. */ +function scriptRefs(run, selfDir, dirs) { + const refs = []; + const re = /\bnpm (?:run |test\b)([\w:.-]+)?((?:\s+--workspace=\S+)?)/g; + for (const m of run.matchAll(re)) { + const script = m[0].includes('npm test') && !m[1] ? 'test' : m[1]; + if (!script) continue; + const ws = /--workspace=(\S+)/.exec(m[2] || '')?.[1]; + const dir = ws ? dirs.get(ws) : selfDir; + assert.ok(dir, `${run}: unknown workspace ${ws}`); + refs.push({ script, dir }); + } + return refs; +} + +for (const [rel, label] of Object.entries(PACKAGES)) { + test(`${label}: the webjs.ci block parses clean and every npm script it names exists`, () => { + const dir = join(ROOT, rel); + const cfg = readCiConfig(dir); + assert.equal(cfg.declared, true, `${rel}/package.json declares webjs.ci`); + assert.deepEqual(cfg.problems, [], `${rel}: webjs.ci has no shape problems`); + const steps = flattenSteps(cfg.steps); + assert.ok(steps.length > 0, `${rel}: at least one step`); + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); + assert.ok(pkg.scripts && pkg.scripts.ci, `${rel}: a ci script exists so npm run ci works`); + const dirs = workspaceDirs(); + for (const step of steps) { + for (const { script, dir: target } of scriptRefs(step.run, rel, dirs)) { + const targetPkg = JSON.parse(readFileSync(join(ROOT, target, 'package.json'), 'utf8')); + assert.ok(targetPkg.scripts?.[script], `${rel}: step "${step.title}" names npm script "${script}" which ${target}/package.json does not define`); + } + } + }); +} + +test('the root list runs the CLI from this checkout, never a hoisted bin', () => { + const cfg = readCiConfig(ROOT); + const root = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); + assert.match(root.scripts.ci, /^node packages\/cli\/bin\/webjs\.js ci$/, 'the root ci script runs this checkout\'s CLI'); + for (const step of flattenSteps(cfg.steps)) { + assert.doesNotMatch(step.run, /(^|[\s(;&|])webjs /, `root step "${step.title}" must not call a bare bin (a linked worktree resolves it into the primary): ${step.run}`); + } +}); diff --git a/website/package.json b/website/package.json index 4caa6b038..43016d887 100644 --- a/website/package.json +++ b/website/package.json @@ -14,7 +14,8 @@ "test": "webjs test", "pretest:browser": "node scripts/copy-registry.mjs", "test:browser": "webjs test --browser", - "css:build": "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify" + "css:build": "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify", + "ci": "webjs ci" }, "webjs": { "seed": false, @@ -64,6 +65,20 @@ "gate": { "UNMARKED_ASSET_LINKS": "error" } + }, + "ci": { + "steps": [ + { + "title": "Checks", + "parallel": 2, + "steps": [ + { "title": "Conventions", "run": "webjs check" }, + { "title": "Health", "run": "webjs doctor" }, + { "title": "Types", "run": "npm run typecheck" }, + { "title": "Tests: node + browser", "run": "npm test" } + ] + } + ] } }, "dependencies": { From 6d0f4a257bea373378b9898f591eb5437991493b Mon Sep 17 00:00:00 2001 From: Vivek <vivek7405@gmail.com> Date: Thu, 10 Sep 2026 18:33:36 +0530 Subject: [PATCH 06/10] docs: document local CI across every surface The ci command and the webjs.ci config key (#1471) on each surface the doc-sync map names: the AGENTS.md CLI reference, config-block bullet, and code-workflow items; the agent skill (a Local CI section in built-ins.md, a one-command section in testing.md, a Testing Defaults bullet in SKILL.md); the docs site (a webjs ci entry under CLI Options on the configuration page with the step shapes, the flags, the Actions surfaces, and the signoff merge gate, a webjs ci command section on the testing page, a deployment-checklist item, and the known-key count); the root and CLI READMEs; and framework-dev.md for the monorepo's own list. --- .agents/skills/webjs/SKILL.md | 1 + .agents/skills/webjs/references/built-ins.md | 24 ++++++++++++++++ .agents/skills/webjs/references/testing.md | 11 +++++++ AGENTS.md | 9 +++--- README.md | 6 ++-- packages/cli/README.md | 5 ++++ website/app/docs/configuration/page.ts | 30 +++++++++++++++++++- website/app/docs/deployment/page.ts | 1 + website/app/docs/testing/page.ts | 16 +++++++++++ 9 files changed, 96 insertions(+), 7 deletions(-) diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index 0591ba15c..845d44f26 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -264,6 +264,7 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a ## Testing Defaults +- `npm run ci` before every push: it runs the `webjs.ci` step list in `package.json` (correctness, project health, types, a dependency audit, then the server, browser, and e2e test layers) with a result line per step, and CI runs the same list, so a green local run predicts the pipeline. `npm run ci -- --only Tests` runs one layer while iterating. See `references/testing.md` and `references/built-ins.md`. - Prefer server/handler tests first: drive the app with `handle()` from `@webjsdev/server/testing` and assert on the `Response`. - Add a browser test (`npm run test:browser`) for anything touching hydration, the client router, slots, or custom-element upgrade. A unit test is necessary but NOT sufficient for a browser-facing change. - Render the app and LOOK for any UI change: `npm run check` and `npm run typecheck` pass even when a layout collapses. Static tools give no signal for a visual defect. diff --git a/.agents/skills/webjs/references/built-ins.md b/.agents/skills/webjs/references/built-ins.md index cde3d9ff3..1e216ddfa 100644 --- a/.agents/skills/webjs/references/built-ins.md +++ b/.agents/skills/webjs/references/built-ins.md @@ -216,6 +216,30 @@ An over-limit body responds `413` without buffering the whole payload. `before` runs to completion first (a non-zero exit aborts the boot). `parallel` (dev only) runs long-lived watchers alongside the server and tears them down on exit. `watch` (dev only) adds extra live-reload directories outside the app tree. +### Local CI (`webjs.ci`) + +`webjs ci` runs the step list the block declares, the Rails 8.1 `bin/ci` posture: your machine is the first CI runner, and a cloud pipeline runs the SAME list by calling `npm run ci`, so the two cannot drift. Each step prints a heading, then `✅ <title> passed in 2.11s` or `❌ <title> failed in 0.01s`; the run ends with every failure listed and one total line, and exits 1 on any failure. + +```jsonc +{ "webjs": { "ci": { "steps": [ + { "title": "Setup", "run": "webjs db migrate" }, + { "title": "Checks", "parallel": 2, "steps": [ // two at a time + "webjs check", // a string is a command titled by itself + { "title": "Types", "run": "webjs typecheck" }, + { "title": "Tests", "steps": [ // a nested group takes ONE slot, runs in order + { "title": "Tests: server", "run": "webjs test --server" }, + { "title": "Tests: e2e", "run": "webjs test --server", "env": { "WEBJS_E2E": "1" } } + ] } + ] } +] } } } +``` + +A step is a string, a `{ title, run, env? }` command, or a `{ title, steps, parallel? }` group. `parallel` is a slot count (default 1); a parallel group captures each step's output and replays it whole when the step finishes, so two steps never interleave, and a group nested inside it takes one slot and runs sequentially (it cannot declare `parallel`, which the reader reports rather than honours). `env` is per-step, so the e2e opt-in does not depend on a shell prefix. Every child runs with `CI=true`, `node_modules/.bin` on PATH, and `.env` loaded first (a real env var wins), so a `webjs db migrate` step sees `DATABASE_URL`. + +Flags: `-f` / `--fail-fast` stops after the first failure (the default runs everything and lists every failure), `--only <title>` runs one step or group by title (repeatable, an unknown title is an error rather than an empty green run), `--json` emits one document on stdout (`{ ok, seconds, steps: [{ title, run, group, ok, code, seconds, output? }] }`, failed steps carrying their captured output, the human report on stderr) for an agent loop, and `--signoff` runs `gh signoff` after a green run. To hold a merge until a LOCAL run is green, install `basecamp/gh-signoff`, run `gh signoff install` once (a branch-protection rule requiring the `signoff` status), and run `npm run ci -- --signoff`; a red run posts nothing. + +Under GitHub Actions each step is a `::group::` in the log, a failed step is an `::error::` annotation, and a step table is appended to the job summary, so a single job running the whole list still names the layer that broke. The scaffold's workflow is exactly that one job; `webjs create --skip-ci` omits it and the local list always ships. A malformed block (a group with no title, a nested `parallel`, an unknown key) refuses to run and names every problem by JSON path, because a silently dropped step is a check that never ran. Nothing declared is exit 1 too, naming any workspace member that declares one, since "ran zero steps" would read as green. + ### Bring your own ORM (`webjs.db`) Drizzle is the scaffold DEFAULT, not lock-in. The runtime never imports it, `db/connection.server.ts` is the app's own file, and `webjs db` is adapter-driven: a `db` block maps each verb to the shell command `webjs db <verb>` runs instead of the drizzle-kit default (node_modules/.bin on PATH like a `before` step, extra CLI args appended). diff --git a/.agents/skills/webjs/references/testing.md b/.agents/skills/webjs/references/testing.md index ba00fa10b..7f8ad9fce 100644 --- a/.agents/skills/webjs/references/testing.md +++ b/.agents/skills/webjs/references/testing.md @@ -189,6 +189,17 @@ WEBJS_ELIDE=0 npm run test:e2e A test that passes under one and fails under the other is a wrong verdict, and `webjs elision` tells you which module and on what evidence. If the component's interactivity is genuinely invisible to static analysis, the fix is `static interactive = true` on it; see `components.md` for what that override does and does not rescue. +## One command for every layer (`webjs ci`) + +```sh +npm run ci # the webjs.ci list: check, doctor, typecheck, audit, then every test layer +npm run ci -- --only Tests # one step or group by title +npm run ci -- --fail-fast # stop at the first failure +npm run ci -- --json # one JSON document for an agent loop +``` + +The scaffold declares its gate once, in `package.json` under `webjs.ci`, and `npm run ci` runs it with a timed result line per step (the Rails `bin/ci` model). The generated GitHub workflow runs the same list, so a green local run predicts CI. Run it before every push; the pre-commit hook deliberately runs none of it so a commit stays fast. Browser and e2e steps need a Chromium on the machine (`npx playwright install chromium`, plus `puppeteer-core` for the e2e layer), which the workflow installs for itself. The list, the flags, the `--signoff` merge gate, and the JSON shape are in `references/built-ins.md` under "Local CI". + ## Type-checking your tests (`webjs typecheck`) Your tests are inside the tsconfig `include`, so `npm run typecheck` reads them (#1299). Treat a type error in a test as a failed gate, not a review catch: the checker sees a wrong argument shape or an unannotated parameter in a test the same way it sees one in `app/`. diff --git a/AGENTS.md b/AGENTS.md index 1c0a333f9..5d091386e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,10 +131,10 @@ verification commands in `references/module-structure.md`. Every code change MUST include, automatically: -1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). **Bun parity is part of the task, not an afterthought:** WebJs runs on Node 24+ AND Bun (#508), so a change to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve` listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) MUST be proven on Bun (`node scripts/run-bun-tests.js` + the touched `test/bun/*.mjs` under `bun`) AND ship an added/updated `test/bun/<feature>.mjs` cross-runtime assertion. `npm test` does NOT run browser, e2e, or Bun; run them yourself and report the result. Never report work done with failing or missing tests. See `references/testing.md`. Enforced by `.claude/hooks/require-tests-with-src.sh` (the scaffold variant WARNS unless `WEBJS_TEST_GATE=block`) and `.claude/hooks/require-bun-parity-with-runtime-src.sh` (BLOCKS a commit that stages runtime-sensitive source with no `test/bun/**` test; escape hatch `WEBJS_BUN_VERIFIED=1`). +1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). **Bun parity is part of the task, not an afterthought:** WebJs runs on Node 24+ AND Bun (#508), so a change to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve` listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) MUST be proven on Bun (`node scripts/run-bun-tests.js` + the touched `test/bun/*.mjs` under `bun`) AND ship an added/updated `test/bun/<feature>.mjs` cross-runtime assertion. `npm test` does NOT run browser, e2e, or Bun; run them yourself and report the result (`npm run ci` at the root runs every layer as one command, #1471). Never report work done with failing or missing tests. See `references/testing.md`. Enforced by `.claude/hooks/require-tests-with-src.sh` (the scaffold variant WARNS unless `WEBJS_TEST_GATE=block`) and `.claude/hooks/require-bun-parity-with-runtime-src.sh` (BLOCKS a commit that stages runtime-sensitive source with no `test/bun/**` test; escape hatch `WEBJS_BUN_VERIFIED=1`). 2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + the skill at `.agents/skills/webjs/` (SKILL.md + references/) for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`website/app/docs/<topic>`), the marketing `website/`, the scaffold templates (`packages/cli/templates/` per-agent rule files), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`). 3. **Scaffold + skill sync (when a feature changes what apps should do).** The scaffold `webjs create` emits is a gallery index home + a root layout + db wiring, a densely-commented feature gallery (`gallery/**`, single-concept demos under `app/features/` plus the `app/examples/todo` app, shipped in every UI template) and the api backend-features showcase (`packages/cli/lib/api-gallery.js`), plus the one cross-agent skill at `packages/cli/templates/.agents/skills/webjs/` (SKILL.md + references). So when a WebJs feature is added or changed, ask: does the generator (`packages/cli/lib/{create,api-gallery}.js`), a gallery demo (`gallery/`), or the agent skill (`.agents/skills/webjs/SKILL.md` + its `references/`) need to move so a freshly scaffolded app and the skill teach the new reality? Verify by generating an app and running `generate + boot + webjs check` (the generators emit strings, so an escaping bug only shows in a freshly generated app). See `framework-dev.md`. -4. **Convention validation.** Run `webjs check` and fix violations. Run it from INSIDE an app, never from the repo root: the root is a workspace, not an app, so the command refuses there with exit 1 rather than reporting the cross-app collisions no single runtime ever sees (#1301). In this repo that means `( cd gallery && npx webjs check )`, `( cd examples/blog && npx webjs check )` and `( cd website && npx webjs check )`. Run `webjs doctor` too when you touched an in-repo app (`gallery`, `examples/blog`, `website`): the required `conventions` CI job runs it over all three, and it fails on a hard toolchain check or on whatever that app's `webjs.doctor.gate` marks `error` (today `UNMARKED_ASSET_LINKS` in `website` and `examples/blog`), so a clean `webjs check` alone is not enough to predict that job (#1257). +4. **Convention validation.** Run `webjs check` and fix violations. Run it from INSIDE an app, never from the repo root: the root is a workspace, not an app, so the command refuses there with exit 1 rather than reporting the cross-app collisions no single runtime ever sees (#1301). In this repo that means `( cd gallery && npx webjs check )`, `( cd examples/blog && npx webjs check )` and `( cd website && npx webjs check )`. Run `webjs doctor` too when you touched an in-repo app (`gallery`, `examples/blog`, `website`): the required `conventions` CI job runs it over all three, and it fails on a hard toolchain check or on whatever that app's `webjs.doctor.gate` marks `error` (today `UNMARKED_ASSET_LINKS` in `website` and `examples/blog`), so a clean `webjs check` alone is not enough to predict that job (#1257). **`npm run ci` is the one-command form of all of this (#1471):** at the repo root it runs the `webjs.ci` list that mirrors the GitHub jobs (per-app `webjs check` and `webjs doctor`, the source invariants, `npm test`, the app suites, browser, e2e, the Bun matrix; `npm run ci -- --only Conventions` for one group), and inside `gallery` / `examples/blog` / `website` it runs that app's own list. In a linked worktree invoke the branch's CLI by path (`node ../packages/cli/bin/webjs.js ci` inside an app) because the hoisted bin resolves into the primary checkout; the root script already does. ### Git workflow (mandatory) @@ -570,6 +570,7 @@ webjs dev [--port N] [--no-hot] # dev server with live reload (node --watch o webjs start [--port N] # prod server; source IS the runtime, plain HTTP/1.1 (reverse-proxy for TLS + HTTP/2). Runs webjs.start.before first (#550) webjs test [--server] [--browser] [--watch] webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop +webjs ci [-f|--fail-fast] [--only <title>]... [--json] [--signoff] # local CI (#1471, the Rails bin/ci posture): run the step list package.json declares under webjs.ci.steps, one timed result line per step, a parallel group's output replayed whole per step, exit 1 on any failure. The same list a cloud pipeline runs via `npm run ci`, so the two cannot drift. --only runs one step or group by title; --json emits one document (failed steps carry their output, the human report goes to stderr); --signoff runs `gh signoff` after a green run so branch protection can require a LOCAL green run. Every child gets CI=true + node_modules/.bin on PATH; under GitHub Actions each step is a log group, a failure an annotation, and a step table lands in the job summary. Runs wherever package.json declares the block (a workspace root included: this repo's `npm run ci`); nothing declared is exit 1 naming the members that declare one webjs routes [--json] [--table] [--no-headers] # print the route table (path / owner file / methods, #975). Default tree; --json is byte-identical to the MCP list_routes tool; --no-headers drops the --table header for piping webjs elision [--json] [--verify] [--routes <paths>] # the elision verdict (#1308): every component module as elided or shipped (a shipped one naming the EVIDENCE that forced it and the module that did the forcing), every page/layout as inert / import-only / ships-whole, and every orphan class that gets no verdict at all (either no registration call, or a computed tag; the scanner matches only a literal one). --json is byte-identical to the MCP list_elision tool. --verify renders every static page route with elision on and off and diffs the observable SSR bytes (the framework's own differential guard, pointed at your app): exit 0 on parity, non-zero on a divergence OR on a corpus where nothing could be compared. It proves elision did not change the bytes you SERVE, NOT post-hydration behaviour (a wrongly dropped module is a dead click, not different bytes), so run your browser/e2e suite twice under WEBJS_ELIDE=1 / WEBJS_ELIDE=0 for that half. Dynamic routes are skipped by name; --routes adds real paths webjs mcp # read-only MCP: routes, actions (RPC hashes), components, elision (what the browser drops, and why each shipped module ships), check, ui kit @@ -578,7 +579,7 @@ webjs types # generate .webjs/routes.d.ts (typed Route un webjs version # print the installed @webjsdev/cli version (also: webjs --version / -v, #975) webjs help [command] # full usage banner, or per-command usage + Options + Examples (e.g. webjs help routes, #975). Flag forms: webjs --help / -h (banner), webjs <command> --help / -h (that command). typecheck/db/ui --help forward to their wrapped tool; an unknown topic exits 1 webjs typecheck [tsc args...] # the project's own tsc --noEmit -webjs create <name> [--template api] +webjs create <name> [--template api] [--skip-ci] # --skip-ci omits the GitHub workflow (rails new parity, #1471); the local webjs.ci list and `ci` script always ship webjs db <generate|migrate|push|studio|seed|verb> [args] # wraps drizzle-kit by default (+ runs db/seed.server.ts). Bring your own ORM (#1468): a `"webjs": { "db": { "<verb>": "<command>" } }` block in package.json runs that shell command instead (node_modules/.bin on PATH, extra args appended), any key is a verb, an unmapped verb keeps its default, so `webjs db migrate` is one spelling across ORMs and the scaffolded start.before / Dockerfile / CI keep working after a swap webjs ui init | add <names...> | list | view <name> webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importmap pinning, .webjs/vendor/importmap.json @@ -591,7 +592,7 @@ webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importma ## Environment, server config, caching, observability - **Env vars.** `process.env.X` reads are server-only; `WEBJS_PUBLIC_`-prefixed names are exposed in the browser via an inline `<script>` (no build); `NODE_ENV` is defined both sides. See `references/built-ins.md`. -- **The `package.json` `"webjs"` block.** Security headers (on by default, per-path `webjs.headers` overrides), CSP (opt-in nonce, `webjs.csp`), declarative `webjs.redirects` (#254), `webjs.trailingSlash` (#255), `webjs.basePath` (#256), ingress caps (`maxBodyBytes` / `maxMultipartBytes` / server timeouts), and dev/start task orchestration (`webjs.dev.before` / `webjs.dev.parallel` / `webjs.dev.regenerate` / `webjs.start.before`, #550 + #967, the orchestration `webjs dev`/`start` run so they match `npm run dev`/`start`; `regenerate` recompiles a stale served build output like `public/tailwind.css` ON REQUEST in dev, replacing a fragile `--watch`), the doctor severity gate (`webjs.doctor.gate`, #1257, mapping a stable doctor code to `off` / `warn` / `error` so CI fails on a chosen subset of project-health checks instead of on every warning), and the `webjs db` verb map (`webjs.db`, #1468, bring your own ORM: `{ "migrate": "prisma migrate deploy" }` makes `webjs db migrate` run that command instead of drizzle-kit, so the spelling every deploy surface uses is stable across ORMs; Drizzle stays the default by omission and the scaffold emits no block). Type it with `WebjsConfig` + the JSON Schema. See `references/built-ins.md`. +- **The `package.json` `"webjs"` block.** Security headers (on by default, per-path `webjs.headers` overrides), CSP (opt-in nonce, `webjs.csp`), declarative `webjs.redirects` (#254), `webjs.trailingSlash` (#255), `webjs.basePath` (#256), ingress caps (`maxBodyBytes` / `maxMultipartBytes` / server timeouts), and dev/start task orchestration (`webjs.dev.before` / `webjs.dev.parallel` / `webjs.dev.regenerate` / `webjs.start.before`, #550 + #967, the orchestration `webjs dev`/`start` run so they match `npm run dev`/`start`; `regenerate` recompiles a stale served build output like `public/tailwind.css` ON REQUEST in dev, replacing a fragile `--watch`), the doctor severity gate (`webjs.doctor.gate`, #1257, mapping a stable doctor code to `off` / `warn` / `error` so CI fails on a chosen subset of project-health checks instead of on every warning), the local CI step list (`webjs.ci.steps`, #1471: the list `webjs ci` runs on a developer machine and the same list a cloud pipeline runs through `npm run ci`, so the two cannot drift; a string is a command, a `{ title, run, env }` object a titled command, a `{ title, steps, parallel }` group runs its steps N at a time with each step's output replayed whole, and a group nested in a parallel group takes one slot and runs sequentially, so it cannot declare `parallel` itself), and the `webjs db` verb map (`webjs.db`, #1468, bring your own ORM: `{ "migrate": "prisma migrate deploy" }` makes `webjs db migrate` run that command instead of drizzle-kit, so the spelling every deploy surface uses is stable across ORMs; Drizzle stays the default by omission and the scaffold emits no block). Type it with `WebjsConfig` + the JSON Schema. See `references/built-ins.md`. - **Caching + file storage** (`references/built-ins.md`). HTTP `Cache-Control`, the `cache()` query helper + `revalidateTag`, the server HTML response cache (`export const revalidate` + `revalidatePath`, #241), content-hash asset URLs (`?v=`, #243), conditional GET (ETag, #240), and `FileStore` + `diskStore` (streaming, traversal-safe, signed URLs, S3-pluggable, #247). - **Observability** (`references/built-ins.md`). Access log, `requestId()` + `X-Request-Id`, the `onError` APM hook, `GET /__webjs/version` (#239). diff --git a/README.md b/README.md index f716f68a3..90906f898 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ packages/ # Framework (the things webjs ships at runtime) core/ # @webjsdev/core: html, css, WebComponent, renderers, client router server/ # @webjsdev/server: dev/prod server, router, SSR, actions, WS - cli/ # @webjsdev/cli: webjs dev/start/create/db/test/check/ui + cli/ # @webjsdev/cli: webjs dev/start/create/db/test/check/ci/ui intellisense/ # @webjsdev/intellisense: standalone editor intelligence (own template parser, no Lit dep) ui/ # @webjsdev/ui: AI-first component library + CLI @@ -313,7 +313,9 @@ Pre-1.0, released continuously. Current versions of every package (`@webjsdev/core`, `@webjsdev/server`, `@webjsdev/cli`, `@webjsdev/ui`) are on the [changelog](https://webjs.dev/changelog). Behaviour is covered by unit, browser (web-test-runner), and puppeteer e2e suites, plus example-app smoke -tests. Key features: +tests. `npm run ci` runs the whole pyramid locally, in this repo and in every +scaffolded app, from a step list declared in `package.json` (the Rails `bin/ci` +model); the generated GitHub workflow runs the same list. Key features: - **Core:** Signals (`signal`, `computed`, `effect`, `batch`, TC39 Stage 1 shape) as the default state primitive, with WebComponent's built-in SignalWatcher auto-tracking `.get()` reads inside `render()`. Reactive properties via the declare-free base-class factory `extends WebComponent({ count: Number })` (the `prop()` helper carries options like `reflect` / `state` / `attribute` / `default`), reserved for HTML attribute round-trip (a direct `static properties` block throws at runtime, flagged by the `no-static-properties` rule, and a class-field initializer on a factory prop is caught by `reactive-props-no-class-field`). Full lit-API parity: ReactiveController hooks (`hostConnected`, `hostDisconnected`, `hostUpdate`, `hostUpdated`) and lifecycle (`shouldUpdate`, `willUpdate`, `update`, `updated`, `firstUpdated`, `updateComplete`), 12 directives (`repeat`, `unsafeHTML`, `live`, `keyed`, `guard`, `templateContent`, `ref` + `createRef`, `cache`, `until`, `asyncAppend`, `asyncReplace`, `watch`). SSR with DSD (opt-in) + light-DOM hydration (default), light-DOM `<slot>` projection (framework-driven, same API as shadow DOM), fine-grained client renderer, `Suspense()`, client router with `composedPath()` for shadow DOM, mixed-attribute interpolation, MutationObserver upgrade safety net. - **Data:** Server actions with webjs's built-in serializer (`Date`, `Map`, `Set`, `BigInt`, `TypedArray`, `Blob`, `File`, `FormData`, reference cycles all survive the wire). Two-marker server-file convention: `.server.{js,ts}` for path-level source-protection (browser imports get a throw-at-load stub), `'use server'` for RPC registration (file is also browser-callable). REST over HTTP via a `route.ts` (or the `route()` adapter) with an optional `validate` config export. `json()` + `richFetch()` for content-negotiated APIs. `cache()` for server-side query caching with TTL + `invalidate()`. `WEBJS_PUBLIC_*` env vars injected into `window.process.env` at SSR (no build step, no transform). diff --git a/packages/cli/README.md b/packages/cli/README.md index 8822d94b0..68146452f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -48,6 +48,10 @@ webjs start # production server (no build step, serves source webjs check # validate source-code conventions (CI gate) webjs doctor # verify the project/toolchain setup (per-check severity via webjs.doctor.gate, so CI can gate a subset) webjs test # run server + browser tests +webjs ci # local CI: run the webjs.ci step list in package.json (check, doctor, + # typecheck, audit, every test layer), one result line per step; + # --only <title>, --fail-fast, --json, --signoff (gh signoff after a green run) +webjs create <name> --skip-ci # omit the GitHub workflow (the local ci list always ships) webjs vendor pin [--download] # pin client deps to a committable importmap (offline/reproducible) webjs db <generate|migrate|push|studio|seed> # drizzle-kit passthrough (+ seed) by default; a package.json webjs.db block maps any verb to your own ORM's command @@ -70,6 +74,7 @@ The scaffold seeds opinionated defaults so AI agents produce consistent code: - `AGENTS.md` + `CONVENTIONS.md` + `.agents/skills/webjs/` (single cross-agent source of truth) - `.agents/rules/workflow.md` & `.claude/` protective hooks +- A `webjs.ci` step list in `package.json` (`npm run ci`, local CI) and a one-job GitHub workflow that runs the same list - `test/<feature>/` (with optional `browser/` / `e2e/` subfolders per kind) with example tests - Tailwind CSS via CLI (no browser runtime at build time) - TypeScript, `.editorconfig`, `.gitignore` diff --git a/website/app/docs/configuration/page.ts b/website/app/docs/configuration/page.ts index 632594315..fe2bbe81c 100644 --- a/website/app/docs/configuration/page.ts +++ b/website/app/docs/configuration/page.ts @@ -63,6 +63,34 @@ webjs doctor --strict # also fail on EVERY remaining warning, not just hard fa <p><code>error</code> fails the exit, <code>warn</code> reports without failing, and <code>off</code> silences the check: its finding is not printed and it cannot fail the exit, including under <code>--strict</code>. A silenced check still appears on the checklist as <code>[off]</code> and in the summary's silenced count, so it is never invisible, and <code>--json</code> still carries its whole result. A code with no entry keeps its default (<code>error</code> for a hard toolchain failure, <code>warn</code> otherwise), so an app that declares nothing behaves exactly as it did before. Two guarantees make it safe to put in a required CI job: a result that could not check, such as a network or toolchain outage, is capped at <code>warn</code> and can never be escalated, and a malformed gate exits 1 naming the offender rather than being ignored, so a typo cannot silently un-gate the build. That last one covers an unknown code, a bad severity, a wrong shape (a non-object <code>doctor</code> or <code>gate</code>), and a misspelled sibling of <code>gate</code> such as <code>gates</code>; under <code>--json</code> they come back as a <code>configErrors</code> array alongside an empty <code>results</code>.</p> <p>Verifies project health: the Node version floor, <code>erasableSyntaxOnly</code>, <code>.env</code> drift, vendor-pin freshness, importmap coherence, <code>@webjsdev/*</code> version coherence, framework resolvability, framework link integrity, the git hook, a page/layout elision advisory, and a warning when a route module writes a <code><link rel="stylesheet"></code> without <code>asset()</code> (so its url is un-versioned and a deploy cannot bust a cached copy). Each result carries a stable machine <code>code</code> (for example <code>NODE_VERSION</code>, <code>TSCONFIG_ERASABLE</code>, <code>IMPORTMAP_COHERENCE</code>) so an agent branches on the failure kind, not the message text. The <code>--json</code> payload is an object <code>{ results, summary }</code> (the <code>results</code> array holds the per-check objects, each with its <code>code</code> and its effective <code>severity</code>; the <code>summary</code> counts <code>pass</code> / <code>warn</code> / <code>fail</code> / <code>off</code>). A rejected <code>webjs.doctor</code> config is the one path that adds a third key, <code>configErrors</code>, with <code>results</code> empty because no check ran. The exit is non-zero on a hard <em>toolchain</em> failure, and on any check the app gated <code>error</code> (see the severity gate above); <code>--strict</code> additionally fails on every remaining warning, so it can gate a fully-clean fix loop the way <code>webjs check --json</code> does.</p> + <h3>webjs ci</h3> + <code-block>webjs ci # run the step list declared under "webjs": { "ci" } in package.json +webjs ci --only Tests # one step or group by title (repeatable, case-insensitive) +webjs ci --fail-fast # stop after the first failing step (-f) +webjs ci --json # one JSON document on stdout, the human report on stderr +webjs ci --signoff # after a green run, post a green commit status via gh signoff</code-block> + <p>Local CI, the Rails <code>bin/ci</code> model: your machine is the first CI runner. Each step prints a heading, then <code>✅ <title> passed in 2.11s</code> or <code>❌ <title> failed in 0.01s</code>; the run ends with every failure listed and one total line, and exits 1 on any failure. A cloud pipeline runs the same list by calling <code>npm run ci</code>, so the two cannot drift, which is exactly what the scaffold's one-job GitHub workflow does (<code>webjs create --skip-ci</code> omits the workflow; the local list always ships).</p> + <code-block>{ + "webjs": { + "ci": { + "steps": [ + { "title": "Setup", "run": "webjs db migrate" }, + { "title": "Checks", "parallel": 2, "steps": [ + "webjs check", + { "title": "Types", "run": "webjs typecheck" }, + { "title": "Tests", "steps": [ + { "title": "Tests: server", "run": "webjs test --server" }, + { "title": "Tests: e2e", "run": "webjs test --server", "env": { "WEBJS_E2E": "1" } } + ] } + ] } + ] + } + } +}</code-block> + <p>A step is a string (a command titled by itself), a <code>{ title, run, env? }</code> command, or a <code>{ title, steps, parallel? }</code> group. <code>parallel</code> is a slot count (default 1). A parallel group captures each step's output and replays it whole when the step finishes, so two steps never interleave, and a group nested inside it takes one slot and runs its steps in order (it cannot declare <code>parallel</code> itself). <code>env</code> is per step, so the e2e opt-in needs no shell prefix. Every child runs with <code>CI=true</code>, <code>node_modules/.bin</code> on <code>PATH</code>, and <code>.env</code> loaded first (a real environment variable wins).</p> + <p><strong>Agents and cloud runners.</strong> <code>--json</code> emits <code>{ ok, seconds, steps: [{ title, run, group, ok, code, seconds, output? }] }</code>, failed steps carrying their captured output. Under GitHub Actions each step is a collapsible log group, a failed step is an annotation, and a step table is appended to the job summary, so one job running the whole list still names the layer that broke. To hold a merge until a <em>local</em> run is green, install <code>basecamp/gh-signoff</code>, run <code>gh signoff install</code> once (a branch-protection rule requiring the <code>signoff</code> status), and run <code>npm run ci -- --signoff</code>; a red run posts nothing.</p> + <p>A malformed block (a group with no title, a nested <code>parallel</code>, an unknown key) refuses to run and names every problem by JSON path, because a silently dropped step is a check that never ran. Nothing declared is exit 1 too, naming any workspace member that declares one, since a run of zero steps would read as green. The command runs wherever <code>package.json</code> declares the block, a workspace root included.</p> + <h3>webjs version</h3> <code-block>webjs version # print the installed @webjsdev/cli version webjs --version / -v # the same, flag form</code-block> @@ -164,7 +192,7 @@ webjs routes --help # one command's help (flag form)</code-block> <h2>Config typos are reported at boot</h2> <p>Every key in the <code>webjs</code> block is optional, which means an unknown one has no way to announce itself: write <code>"redirect"</code> for <code>"redirects"</code> and the key is simply never read, the feature sits at its default, and the app looks configured. So WebJs validates the block against its published JSON Schema once per boot, in development and in production alike, and prints a single warning naming what it ignored.</p> <p>It is a <strong>warning, never a failure</strong>. A typo costs one feature its setting, and refusing to boot over that would cost the whole app, usually mid-deploy.</p> - <p><strong>What it catches, and what it does not.</strong> It reports any unknown top-level key, whatever it is called. It also checks the value of 9 of the 18 known keys: one against an allowed set (<code>trailingSlash</code>), and eight against a boolean or whole-number type (<code>elide</code>, <code>seed</code>, <code>clientRouter</code>, <code>maxBodyBytes</code>, <code>maxMultipartBytes</code>, and the three timeouts). It does not descend into nested objects, so a key misspelled inside <code>webjs.dev</code> or <code>webjs.start</code> is missed, and it does not check the type of the other 9 (<code>headers</code>, <code>redirects</code>, <code>basePath</code>, <code>allowedOrigins</code>, <code>csp</code>, <code>dev</code>, <code>start</code>, <code>db</code>, <code>doctor</code>), whose schemas are the free-form ones a blunt check would start rejecting working configs over. Give one of those the wrong type outright, as in <code>"headers": "x"</code>, and it passes this check. Whether anything downstream then says so is up to that key's own reader, and the two array-valued ones do: <code>webjs.headers</code> and <code>webjs.redirects</code> each warn when the key is present but not an array, and again for every individual rule or entry they drop, naming what was ignored. An absent key is the default and says nothing.</p> + <p><strong>What it catches, and what it does not.</strong> It reports any unknown top-level key, whatever it is called. It also checks the value of 9 of the 19 known keys: one against an allowed set (<code>trailingSlash</code>), and eight against a boolean or whole-number type (<code>elide</code>, <code>seed</code>, <code>clientRouter</code>, <code>maxBodyBytes</code>, <code>maxMultipartBytes</code>, and the three timeouts). It does not descend into nested objects, so a key misspelled inside <code>webjs.dev</code> or <code>webjs.start</code> is missed, and it does not check the type of the other 9 (<code>headers</code>, <code>redirects</code>, <code>basePath</code>, <code>allowedOrigins</code>, <code>csp</code>, <code>dev</code>, <code>start</code>, <code>db</code>, <code>doctor</code>), whose schemas are the free-form ones a blunt check would start rejecting working configs over. Give one of those the wrong type outright, as in <code>"headers": "x"</code>, and it passes this check. Whether anything downstream then says so is up to that key's own reader, and the two array-valued ones do: <code>webjs.headers</code> and <code>webjs.redirects</code> each warn when the key is present but not an array, and again for every individual rule or entry they drop, naming what was ignored. An absent key is the default and says nothing.</p> <p>Editors catch more of this earlier still: a scaffolded app wires the schema into <code>.vscode/settings.json</code>, and the <code>WebjsConfig</code> type from <code>@webjsdev/core</code> types the block while you author it. And <code>webjs.doctor</code> is checked by a different tool entirely: the server never reads it, and <code>webjs doctor</code> exits non-zero on a malformed one, because a silently ignored gate would leave CI un-gated while looking gated.</p> <h2>Environment Variables</h2> diff --git a/website/app/docs/deployment/page.ts b/website/app/docs/deployment/page.ts index d3a237f43..e3120f414 100644 --- a/website/app/docs/deployment/page.ts +++ b/website/app/docs/deployment/page.ts @@ -418,6 +418,7 @@ pm2 start "webjs start" --name my-app</code-block> <li>Configure health checks against <code>/__webjs/health</code>.</li> <li><strong>HTTP/2 at the edge is recommended.</strong> PaaS deploys (Railway, Fly, Render, Vercel, Cloudflare Pages, Heroku) give you HTTP/2 to the browser automatically. For bare-VM deploys, front <code>npm run start</code> with nginx, Caddy, or Traefik.</li> <li>Set up log aggregation (WebJs outputs structured JSON in production).</li> + <li>Point your CI provider at <code>npm run ci</code>. It runs the <code>webjs.ci</code> step list from <code>package.json</code>, the same list you run locally, so the pipeline and the developer machine cannot drift; the scaffold's GitHub workflow is exactly that one job.</li> </ul> `; } diff --git a/website/app/docs/testing/page.ts b/website/app/docs/testing/page.ts index 12aa4c014..ad089bb3e 100644 --- a/website/app/docs/testing/page.ts +++ b/website/app/docs/testing/page.ts @@ -198,6 +198,22 @@ webjs test --browser</code-block> <li><code>test/<feature>/e2e/<name>.test.{ts,mjs}</code>: e2e (opt in with <code>WEBJS_E2E=1</code>)</li> </ul> + <h2>webjs ci command</h2> + <p>One command for every layer. A scaffolded app declares its gate once, in <code>package.json</code> under <code>webjs.ci</code>, and <code>npm run ci</code> runs it with a timed result line per step, the Rails <code>bin/ci</code> model:</p> + <code-block># Every step: check, doctor, typecheck, a dependency audit, then the server, +# browser, and e2e test layers +npm run ci + +# One step or group by title, while iterating +npm run ci -- --only Tests + +# Stop at the first failure +npm run ci -- --fail-fast + +# One JSON document, for an agent loop +npm run ci -- --json</code-block> + <p>The generated GitHub workflow runs the same list through the same command, so a green local run predicts CI. The pre-commit hook deliberately runs none of it (a commit stays fast); run it before every push. The browser and e2e steps need a Chromium on the machine (<code>npx playwright install chromium</code>, plus <code>puppeteer-core</code> for the e2e layer), which the workflow installs for itself. The step shapes, the flags, and the <code>--signoff</code> merge gate are on the <a href="/docs/configuration">configuration</a> page.</p> + <h2>Browser Tests (WTR + Playwright)</h2> <p>Browser tests launch real Chromium to exercise hydration, the DOM, slots, the client router, and custom-element upgrade. <code>ssrFixture()</code> server-renders a template then hydrates it in the real browser:</p> <code-block>import { html } from '@webjsdev/core'; From d4e27804081c7483f7dd0ca80570a8fcec6004f6 Mon Sep 17 00:00:00 2001 From: Vivek <vivek7405@gmail.com> Date: Thu, 10 Sep 2026 22:54:49 +0530 Subject: [PATCH 07/10] fix(cli): act on the review of the ci runner and command Eight findings from the review on #1472, each with its counterfactual. A captured step whose grandchild kept the pipe open resolved as truncated but left the group alive and the pipe handles open, so the bin printed its summary and then sat until the grandchild died. The grace bound now reaps the group (the child is detached, so it is still addressable) and destroys both pipes; the Node and Bun proof runs a `sleep & echo; exit 0` step and asserts the run settles through the grace and pgrep finds nothing. The reader honoured `parallel` on a group nested inside a sequential group while the schema, the type, and the docs all said a nested group never declares it. One rule now: a nested group refuses `parallel` whatever its parent, and `steps` is required on the schema, the type, and the reader alike (a block with no steps is refused, since zero steps would read as green). A nested group inside a pool bypassed the progress bookkeeping, so on a TTY its replays did not clear the line and no line showed while it ran, which is the scaffold's default shape (Tests inside Checks). Every step in a slot now shares one path. A second Ctrl-C exits with 130 outright, the way Rails' bin/ci lets a repeated interrupt through, so a captured child that ignores SIGTERM cannot trap the user. The total line reads "interrupted" after an interrupt instead of "failed" (the old expression was dead). A bare `--only`, or one followed by a flag, is refused instead of running the whole list. Colour follows NO_COLOR on a TTY, for the runner's own lines and for the FORCE_COLOR handed to captured children. The configuration page's free-form key list names `ci` and counts ten. --- packages/cli/bin/webjs.js | 35 +++++++++-- packages/cli/lib/ci-config.js | 27 +++++---- packages/cli/lib/ci-runner.js | 58 +++++++++++++------ .../cli/test/ci-config/ci-config.test.mjs | 21 +++++-- .../cli/test/ci-runner/ci-runner.test.mjs | 42 ++++++++++++++ packages/core/src/webjs-config.d.ts | 4 +- packages/server/webjs-config.schema.json | 3 +- test/bun/ci-runner.mjs | 25 ++++++++ test/cli/ci.test.mjs | 39 +++++++++++++ test/types/webjs-config.test-d.ts | 12 +++- website/app/docs/configuration/page.ts | 2 +- 11 files changed, 222 insertions(+), 46 deletions(-) diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 474e9d059..f9dc2df62 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -786,8 +786,14 @@ async function main() { const failFast = rest.includes('--fail-fast') || rest.includes('-f'); const signoff = rest.includes('--signoff'); const only = []; + let onlyMissing = false; for (let i = 0; i < rest.length; i++) { - if (rest[i] === '--only' && rest[i + 1] !== undefined) only.push(rest[++i]); + if (rest[i] !== '--only') continue; + const value = rest[i + 1]; + // A bare `--only` (or one followed by another flag) must NOT fall + // through to the whole list: that inverts the flag. Refuse it below. + if (value === undefined || value.startsWith('--')) onlyMissing = true; + else only.push(rest[++i]); } const { readCiConfig, selectSteps, noCiConfigMessage } = await import('../lib/ci-config.js'); const { runCi, formatSummary, stepSummaryMarkdown, colorize } = await import('../lib/ci-runner.js'); @@ -796,11 +802,18 @@ async function main() { // document (failed steps only) instead of the terminal. const out = json ? (s) => { process.stderr.write(s); } : (s) => { process.stdout.write(s); }; const isTTY = !json && !!process.stdout.isTTY; + // Colour on a TTY unless NO_COLOR is set; the runner passes the same + // decision down as FORCE_COLOR for captured children. + const color = isTTY && !process.env.NO_COLOR; const refuse = (message, code, extra) => { if (json) console.log(JSON.stringify({ error: { code, message, cwd, ...extra } })); else console.error(message); process.exitCode = 1; }; + if (onlyMissing) { + refuse('webjs ci: --only needs a step or group title (webjs ci --only "Tests")', 'INVALID_ONLY', {}); + break; + } const cfg = readCiConfig(cwd); if (!cfg.declared) { @@ -829,21 +842,31 @@ async function main() { // never overrides), so a CI runner's explicit env is untouched. loadAppEnv(cwd); const title = 'Continuous Integration'; - out(`${colorize(title, 'banner', isTTY)}\n${colorize('Running the steps declared in package.json under webjs.ci', 'subtitle', isTTY)}\n`); + out(`${colorize(title, 'banner', color)}\n${colorize('Running the steps declared in package.json under webjs.ci', 'subtitle', color)}\n`); const run = runCi(selected.steps, cwd, { write: out, isTTY, + color, failFast, captureAll: json, actions: !!process.env.GITHUB_ACTIONS, }); - const onSignal = () => run.interrupt(); + // The first signal winds the run down (children killed, results kept). + // A SECOND one exits outright, the way Rails' bin/ci lets a repeated + // interrupt through: a captured child that ignores SIGTERM (a hung + // browser under web-test-runner) would otherwise leave no way out. + let interrupting = false; + const onSignal = () => { + if (interrupting) process.exit(130); + interrupting = true; + run.interrupt(); + }; process.on('SIGINT', onSignal); process.on('SIGTERM', onSignal); const result = await run.done; process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal); - out(formatSummary(result, title, isTTY)); + out(formatSummary(result, title, color)); if (process.env.GITHUB_STEP_SUMMARY) { const { appendFileSync } = await import('node:fs'); @@ -860,11 +883,11 @@ async function main() { const so = runCi( [{ kind: 'step', title: 'Signoff: All systems go. Ready for merge and deploy.', run: 'gh signoff', env: {} }], cwd, - { write: out, isTTY, captureAll: json }, + { write: out, isTTY, color, captureAll: json }, ); signoffOk = (await so.done).ok; } else { - out(`\n\n${colorize('Signoff: CI failed. Do not merge or deploy.', 'error', isTTY)}\n${colorize('Fix the issues and try again.', 'subtitle', isTTY)}\n`); + out(`\n\n${colorize('Signoff: CI failed. Do not merge or deploy.', 'error', color)}\n${colorize('Fix the issues and try again.', 'subtitle', color)}\n`); } } diff --git a/packages/cli/lib/ci-config.js b/packages/cli/lib/ci-config.js index 8d0af3eee..970a702d4 100644 --- a/packages/cli/lib/ci-config.js +++ b/packages/cli/lib/ci-config.js @@ -73,17 +73,19 @@ export function readCiConfig(appDir, readFile) { /** * Normalize a raw step array into `CiNode`s, collecting every shape problem * with its JSON path. A string is shorthand for a command titled by itself; an - * object with `steps` is a group; an object with `run` is a command. A group - * inside a PARALLEL group runs its steps sequentially in one slot, so a - * `parallel` on it is a contradiction and is reported rather than honoured - * (the Rails rule: sub-groups cannot be parallelized). + * object with `steps` is a group; an object with `run` is a command. Only a + * TOP-LEVEL group may declare `parallel`: a nested group takes one slot of + * its parent and runs its steps in order, so `parallel` on it is reported + * rather than honoured (the Rails rule: sub-groups cannot be parallelized), + * which is exactly what the JSON Schema's `ciNestedStep` and the + * `WebjsCiNestedGroup` type say. * * @param {unknown} raw * @param {string} path JSON path used in problem messages - * @param {boolean} inParallel whether an ancestor group runs in parallel + * @param {boolean} nested whether these steps sit inside a group * @returns {{ steps: CiNode[], problems: string[] }} */ -export function normalizeSteps(raw, path = 'webjs.ci.steps', inParallel = false) { +export function normalizeSteps(raw, path = 'webjs.ci.steps', nested = false) { /** @type {CiNode[]} */ const steps = []; /** @type {string[]} */ @@ -113,17 +115,20 @@ export function normalizeSteps(raw, path = 'webjs.ci.steps', inParallel = false) if (!title) problems.push(`${at} (a group) needs a non-empty title`); let parallel = 1; if (item.parallel !== undefined) { - if (!Number.isInteger(item.parallel) || item.parallel < 1) { - problems.push(`${at}.parallel must be an integer of at least 1`); - } else if (inParallel && item.parallel > 1) { + if (nested) { + // One rule on every surface (the schema's ciNestedStep, the + // WebjsCiNestedGroup type, the docs): a nested group never declares + // parallel, whatever its parent is. It takes one slot and runs in order. problems.push( - `${at}.parallel is not allowed on a group nested inside a parallel group (it takes one slot and runs its steps in order)`, + `${at}.parallel is not allowed on a nested group (it takes one slot of its parent and runs its steps in order)`, ); + } else if (!Number.isInteger(item.parallel) || item.parallel < 1) { + problems.push(`${at}.parallel must be an integer of at least 1`); } else { parallel = item.parallel; } } - const inner = normalizeSteps(item.steps, `${at}.steps`, inParallel || parallel > 1); + const inner = normalizeSteps(item.steps, `${at}.steps`, true); problems.push(...inner.problems); if (Array.isArray(item.steps) && item.steps.length === 0) problems.push(`${at}.steps is empty`); steps.push({ kind: 'group', title: title || `group ${i}`, parallel, steps: inner.steps }); diff --git a/packages/cli/lib/ci-runner.js b/packages/cli/lib/ci-runner.js index 9fa90d6f9..5e5ff12e7 100644 --- a/packages/cli/lib/ci-runner.js +++ b/packages/cli/lib/ci-runner.js @@ -30,8 +30,10 @@ import { envWithLocalBin, killChildTree } from './run-tasks.js'; * Every child gets `CI=true` (so an app can branch on it, as under any CI * provider) and every ancestor `node_modules/.bin` on PATH (`envWithLocalBin`, * the npm-run behaviour), then the step's own `env`. A captured child gets - * `FORCE_COLOR=1` only when the PARENT's stdout is a TTY, so a terminal keeps - * the tools' colours through the pipe and a log file never gets escape codes. + * `FORCE_COLOR=1` only when the runner itself colours (the PARENT's stdout is + * a TTY and NO_COLOR is unset), so a terminal keeps the tools' colours through + * the pipe, a log file never gets escape codes, and a user's NO_COLOR reaches + * the tools instead of being overridden. * Node has no PTY without a native dependency, which a buildless framework * will not take on, so this is the whole colour story. * @@ -143,7 +145,7 @@ export function formatSummary(result, title, color) { out += `${colorize(` ↳ ${s.title} ${s.interrupted ? 'interrupted' : 'failed'}`, 'error', color)}\n`; } } - out += formatResult({ title, ok: result.ok, seconds: result.seconds, interrupted: result.interrupted && result.ok }, color); + out += formatResult({ title, ok: result.ok, seconds: result.seconds, interrupted: result.interrupted }, color); return out; } @@ -275,19 +277,29 @@ async function runPool(group, ctx) { const startedAt = ctx.now(); const progress = ctx.isTTY ? startProgress(group.title, startedAt, inFlight, ctx) : null; - const worker = async () => { - while (queue.length > 0 && !halted(ctx)) { - const node = queue.shift(); + // One slot's work: a step, or a nested group's steps in order (a nested + // group is always sequential, the reader refuses `parallel` on it). Every + // step goes through the SAME bookkeeping, so a nested step shows in the + // progress line and its replay clears the line first; the scaffold's + // default list nests its three longest steps this way. + const runInSlot = async (nodes, groupTitle) => { + for (const node of nodes) { + if (halted(ctx)) return; if (node.kind === 'group') { - await runSequence(node.steps, node.title, ctx, true); - } else { - inFlight.add(node.title); - const r = await runOne(node, group.title, ctx, true); - inFlight.delete(node.title); - progress?.clear(); - report(r, ctx); - progress?.redraw(); + await runInSlot(node.steps, node.title); + continue; } + inFlight.add(node.title); + const r = await runOne(node, groupTitle, ctx, true); + inFlight.delete(node.title); + progress?.clear(); + report(r, ctx); + progress?.redraw(); + } + }; + const worker = async () => { + while (queue.length > 0 && !halted(ctx)) { + await runInSlot([queue.shift()], group.title); } }; const slots = Math.min(group.parallel, queue.length); @@ -358,7 +370,10 @@ async function runOne(step, group, ctx, capture) { const env = { ...ctx.baseEnv, CI: 'true', - ...(capture && ctx.isTTY ? { FORCE_COLOR: '1' } : {}), + // Colour for a captured child follows the runner's own colour decision + // (a TTY with no NO_COLOR), so a user's NO_COLOR is honoured inside the + // tools too rather than overridden by FORCE_COLOR. + ...(capture && ctx.color ? { FORCE_COLOR: '1' } : {}), ...step.env, }; if (!capture) { @@ -458,8 +473,17 @@ function spawnCaptured(step, env, ctx) { child.on('exit', (code, signal) => { exited = { code: code ?? (signal ? null : 0), signal: signal || null }; // `close` normally follows within a tick. A leaked grandchild holding the - // pipe would keep it from ever firing, so bound the wait. - grace = ctx.timers.setTimeout(() => finish(true), ctx.closeGraceMs); + // pipe would keep it from ever firing, so bound the wait, and when the + // bound fires REAP the group (it is still addressable, the child is + // detached) and drop the pipe handles. Without both, the open pipes keep + // the parent's event loop alive and the bin, which exits through + // exitCode, sits after its summary until the grandchild dies on its own. + grace = ctx.timers.setTimeout(() => { + killChildTree(child); + try { child.stdout?.destroy(); } catch {} + try { child.stderr?.destroy(); } catch {} + finish(true); + }, ctx.closeGraceMs); if (grace && typeof grace.unref === 'function') grace.unref(); }); child.on('close', (code, signal) => { diff --git a/packages/cli/test/ci-config/ci-config.test.mjs b/packages/cli/test/ci-config/ci-config.test.mjs index 87ce227bb..a5d6f1d25 100644 --- a/packages/cli/test/ci-config/ci-config.test.mjs +++ b/packages/cli/test/ci-config/ci-config.test.mjs @@ -99,18 +99,27 @@ test('a malformed block is declared AND reports each problem with its JSON path' } }); -test('a group nested inside a PARALLEL group may not itself be parallel (it takes one slot)', () => { +test('a NESTED group may never declare parallel, whatever its parent (one rule with the schema and the type)', () => { const r = normalizeSteps([ { title: 'outer', parallel: 2, steps: [{ title: 'inner', parallel: 3, steps: ['a', 'b'] }] }, ]); assert.equal(r.problems.length, 1); - assert.match(r.problems[0], /steps\[0\]\.steps\[0\]\.parallel is not allowed on a group nested inside a parallel group/); + assert.match(r.problems[0], /steps\[0\]\.steps\[0\]\.parallel is not allowed on a nested group/); // The offending value is NOT honoured: the nested group is normalized to one slot. assert.equal(r.steps[0].steps[0].parallel, 1); - // Counterfactual: the same nesting under a SEQUENTIAL parent is fine. - const ok = normalizeSteps([{ title: 'outer', steps: [{ title: 'inner', parallel: 3, steps: ['a', 'b'] }] }]); - assert.deepEqual(ok.problems, []); - assert.equal(ok.steps[0].steps[0].parallel, 3); + // The same rule under a SEQUENTIAL parent: the schema's ciNestedStep and the + // WebjsCiNestedGroup type have no `parallel` at all, so the reader agrees. + const seq = normalizeSteps([{ title: 'outer', steps: [{ title: 'inner', parallel: 3, steps: ['a', 'b'] }] }]); + assert.equal(seq.problems.length, 1); + assert.match(seq.problems[0], /not allowed on a nested group/); + assert.equal(seq.steps[0].steps[0].parallel, 1); + // Even `parallel: 1` is refused on a nested group: the key is simply not part of that shape. + const one = normalizeSteps([{ title: 'outer', steps: [{ title: 'inner', parallel: 1, steps: ['a'] }] }]); + assert.equal(one.problems.length, 1); + // Counterfactual: a TOP-LEVEL group declares it freely. + const top = normalizeSteps([{ title: 'outer', parallel: 3, steps: ['a', 'b'] }]); + assert.deepEqual(top.problems, []); + assert.equal(top.steps[0].parallel, 3); }); test('a problem never drops a sibling step silently: valid neighbours survive', () => { diff --git a/packages/cli/test/ci-runner/ci-runner.test.mjs b/packages/cli/test/ci-runner/ci-runner.test.mjs index cf2eeaaff..df6c930b8 100644 --- a/packages/cli/test/ci-runner/ci-runner.test.mjs +++ b/packages/cli/test/ci-runner/ci-runner.test.mjs @@ -22,6 +22,8 @@ function fakeChild() { const c = new EventEmitter(); c.stdout = new EventEmitter(); c.stderr = new EventEmitter(); + c.stdout.destroy = () => { c.stdout.destroyed = true; }; + c.stderr.destroy = () => { c.stderr.destroyed = true; }; c.killed = null; c.kill = (sig) => { c.killed = sig || 'SIGTERM'; }; return c; @@ -232,6 +234,45 @@ test('captured: a close that never comes is bounded by the grace timer and marke assert.equal(result.steps[0].ok, true, 'exit 0 still counts as a pass'); assert.equal(result.steps[0].truncated, true); assert.equal(result.steps[0].output, 'partial\n'); + // The bound also REAPS the group and drops the pipe handles: otherwise the + // open pipes keep the event loop alive and the bin sits after its summary. + assert.equal(c.child.killed, 'SIGTERM', 'the leaked group is killed when the grace fires'); + assert.equal(c.child.stdout.destroyed, true, 'stdout pipe destroyed'); + assert.equal(c.child.stderr.destroyed, true, 'stderr pipe destroyed'); +}); + +test('a nested group inside a pool shares the progress bookkeeping (its steps show in the line, replays clear it)', async () => { + const r = recorder(); + const out = sink(); + const t = timers(); + const { steps } = normalizeSteps([{ title: 'Checks', parallel: 2, steps: ['a', { title: 'Tests', steps: ['b', 'c'] }] }]); + const run = runCi(steps, '/app', base({ spawn: r.spawn, write: out.write, isTTY: true, timers: t.api })); + await settle(); + assert.deepEqual(r.calls.map((x) => x.cmd), ['a', 'b']); + t.intervals[0](); + assert.match(out.text(), /Checks \(\d+s\) - a \| b\.\.\./, 'the nested step b is in the progress line'); + finish(r.byCmd('b'), { out: 'B\n' }); + await settle(); + const text = out.text(); + const bAt = text.indexOf('B\n'); + assert.ok(text.lastIndexOf('\r\x1b[K', bAt) > text.lastIndexOf('a | b', bAt), 'the line is cleared before the nested replay'); + t.intervals[0](); + assert.match(out.text().slice(bAt), /Checks \(\d+s\) - a \| c\.\.\./, 'c took over b\'s slot and shows in the line'); + finish(r.byCmd('a')); + finish(r.byCmd('c')); + const result = await run.done; + assert.equal(result.ok, true); + assert.deepEqual(result.steps.map((s) => [s.title, s.group]), [['b', 'Tests'], ['a', 'Checks'], ['c', 'Tests']]); +}); + +test('color:false on a TTY keeps FORCE_COLOR away from captured children (NO_COLOR honoured)', async () => { + const r = recorder(); + const { steps } = normalizeSteps([{ title: 'G', parallel: 2, steps: ['x'] }]); + const run = runCi(steps, '/app', base({ spawn: r.spawn, write: () => {}, isTTY: true, color: false })); + await settle(); + assert.equal(r.calls[0].opts.env.FORCE_COLOR, undefined); + finish(r.calls[0]); + await run.done; }); test('interrupt() kills every running child, stops the dequeue, and reports the run interrupted', async () => { @@ -252,6 +293,7 @@ test('interrupt() kills every running child, stops the dequeue, and reports the assert.equal(result.ok, false); assert.deepEqual(result.steps.map((s) => [s.title, s.interrupted, s.ok]), [['a', true, false], ['b', true, false]]); assert.match(out.text(), /❌ a interrupted/); + assert.match(formatSummary(result, 'Continuous Integration', false), /❌ Continuous Integration interrupted\n$/, 'the total line says interrupted, not failed'); }); test('on a TTY the progress line renders only while the pool runs, is cleared before a replay, and colours captured children', async () => { diff --git a/packages/core/src/webjs-config.d.ts b/packages/core/src/webjs-config.d.ts index 94a2b7119..5d678493f 100644 --- a/packages/core/src/webjs-config.d.ts +++ b/packages/core/src/webjs-config.d.ts @@ -180,8 +180,8 @@ export type WebjsCiNestedStep = string | WebjsCiCommand | WebjsCiNestedGroup; * (`packages/cli/lib/ci-config.js`), not the server. */ export interface WebjsCiConfig { - /** The steps, run in order. A failing step fails the run. */ - steps?: WebjsCiStep[]; + /** The steps, run in order. A failing step fails the run. Required: a block with no steps is refused, since a run of zero steps would read as green. */ + steps: WebjsCiStep[]; } /** diff --git a/packages/server/webjs-config.schema.json b/packages/server/webjs-config.schema.json index 3b0af5ec6..20f032aeb 100644 --- a/packages/server/webjs-config.schema.json +++ b/packages/server/webjs-config.schema.json @@ -242,9 +242,10 @@ "description": "Local CI (#1471): the step list `webjs ci` runs on a developer machine, and the same list a cloud pipeline runs by calling `npm run ci`, so the two cannot drift. Read by the CLI (readCiConfig in packages/cli/lib/ci-config.js), NOT the server. A step is a string (shorthand for a command whose title is the command), a { title, run, env? } command, or a { title, steps, parallel? } group. `parallel` is a slot count (default 1, sequential); a group nested inside a parallel group takes ONE slot and runs its steps in order, so it cannot declare `parallel` itself.", "type": "object", "additionalProperties": false, + "required": ["steps"], "properties": { "steps": { - "description": "The steps, run in order. Every child gets CI=true and node_modules/.bin on PATH. A failing step fails the run (exit 1); --fail-fast stops at the first failure.", + "description": "The steps, run in order. Required: a block with no steps is refused, since a run of zero steps would read as green. Every child gets CI=true and node_modules/.bin on PATH. A failing step fails the run (exit 1); --fail-fast stops at the first failure.", "type": "array", "items": { "$ref": "#/definitions/ciStep" } } diff --git a/test/bun/ci-runner.mjs b/test/bun/ci-runner.mjs index 5e55c0cd3..681cbdc7e 100644 --- a/test/bun/ci-runner.mjs +++ b/test/bun/ci-runner.mjs @@ -69,4 +69,29 @@ const quiet = { write: () => {}, isTTY: false }; assert.ok(r.steps.every((s) => s.interrupted && !s.ok), `[${runtime}] each running step reads as interrupted, not failed`); } +// 5. A captured child that EXITS while a grandchild keeps the pipe open (the +// leaked-dev-server shape): the grace bound resolves the step as truncated, +// REAPS the group so the grandchild does not outlive the run, and drops the +// pipe handles so the process can exit. Without the reap, `pgrep` still +// finds the sleep and this script's exit waits on its handles. +{ + const marker = `sleep 31.7${runtime === 'bun' ? '1' : '3'}`; + const { steps } = normalizeSteps([{ title: 'G', parallel: 2, steps: [{ title: 'leaky', run: `${marker} & echo hi; exit 0` }] }]); + const t0 = Date.now(); + const r = await Promise.race([ + runCi(steps, process.cwd(), { ...quiet, closeGraceMs: 300 }).done, + new Promise((res) => setTimeout(() => res(null), 5000)), + ]); + assert.ok(r, `[${runtime}] the run settled within the bound despite the leaked grandchild`); + assert.ok(Date.now() - t0 < 4000, `[${runtime}] settled through the grace, not by waiting on the sleep`); + const leaky = r.steps[0]; + assert.equal(leaky.ok, true, `[${runtime}] exit 0 still passes`); + assert.equal(leaky.truncated, true, `[${runtime}] marked truncated`); + assert.match(leaky.output, /hi\n/, `[${runtime}] the output before the leak was kept`); + await new Promise((res) => setTimeout(res, 200)); + const { spawnSync } = await import('node:child_process'); + const left = spawnSync('pgrep', ['-f', marker], { encoding: 'utf8' }); + assert.equal(left.status, 1, `[${runtime}] the leaked grandchild was reaped with its group (pgrep found: ${left.stdout.trim()})`); +} + console.log(`[${runtime}] ci-runner cross-runtime asserts passed`); diff --git a/test/cli/ci.test.mjs b/test/cli/ci.test.mjs index 918437447..b7b746d04 100644 --- a/test/cli/ci.test.mjs +++ b/test/cli/ci.test.mjs @@ -142,6 +142,45 @@ test('--only runs the named step or group (case-insensitive) and refuses an unkn const miss = ci(dir, ['--only', 'nope']); assert.equal(miss.status, 1); assert.match(miss.stderr, /--only "nope" matches no step or group title/); + // A bare --only (or one followed by a flag) must not fall through to the whole list. + const bare = ci(dir, ['--only']); + assert.equal(bare.status, 1); + assert.match(bare.stderr, /--only needs a step or group title/); + assert.doesNotMatch(bare.stdout, /echo one/, 'counterfactual: nothing ran'); + const flagged = ci(dir, ['--only', '--json']); + assert.equal(flagged.status, 1); + assert.equal(JSON.parse(flagged.stdout).error.code, 'INVALID_ONLY'); +}); + +test('Ctrl-C: the first signal winds the run down as interrupted (exit 130), a second one exits outright', async (t) => { + const { spawn } = await import('node:child_process'); + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const exited = (child) => new Promise((r) => child.on('exit', (code, signal) => r({ code, signal }))); + + const dir = await fixture(t, { ci: { steps: [{ title: 'G', parallel: 2, steps: [{ title: 'sleeper', run: 'sleep 20' }] }] } }); + const one = spawn(process.execPath, [CLI, 'ci'], { cwd: dir, env: { ...process.env, NO_COLOR: '1' }, stdio: ['ignore', 'pipe', 'pipe'] }); + let out1 = ''; + one.stdout.on('data', (d) => { out1 += d; }); + await wait(700); + one.kill('SIGINT'); + const r1 = await Promise.race([exited(one), wait(6000).then(() => null)]); + assert.ok(r1, 'a single interrupt winds the run down within the bound (the detached sleep is reaped)'); + assert.equal(r1.code, 130); + assert.match(out1, /❌ sleeper interrupted[\s\S]*❌ Continuous Integration interrupted/); + + // A captured child that ignores SIGTERM cannot be wound down; the second + // Ctrl-C is the door out. + const stubborn = await fixture(t, { ci: { steps: [{ title: 'G', parallel: 2, steps: [{ title: 'stubborn', run: "trap '' TERM INT; sleep 4" }] }] } }); + const two = spawn(process.execPath, [CLI, 'ci'], { cwd: stubborn, env: { ...process.env, NO_COLOR: '1' }, stdio: ['ignore', 'pipe', 'pipe'] }); + await wait(700); + two.kill('SIGINT'); + await wait(400); + const stillRunning = two.exitCode === null; + two.kill('SIGINT'); + const r2 = await Promise.race([exited(two), wait(3000).then(() => null)]); + assert.ok(stillRunning, 'after one interrupt the stubborn child kept the run alive'); + assert.ok(r2, 'the second interrupt exits within the bound'); + assert.equal(r2.code, 130); }); test('under GitHub Actions each step is a log group, a failure is annotated, and the step summary is appended', async (t) => { diff --git a/test/types/webjs-config.test-d.ts b/test/types/webjs-config.test-d.ts index a60a34277..cc64bfc35 100644 --- a/test/types/webjs-config.test-d.ts +++ b/test/types/webjs-config.test-d.ts @@ -66,10 +66,18 @@ void full; const ciConfig: WebjsCiConfig = { steps: ['webjs check'] }; void ciConfig; -// An empty ci block is valid: `steps` is optional. -const emptyCi: WebjsConfig = { ci: {} }; +const emptyCi: WebjsConfig = { + // @ts-expect-error `steps` is required: a block with no steps is refused, not a green run + ci: {}, +}; void emptyCi; +const nestedUnderSequential: WebjsConfig = { + // @ts-expect-error a nested group cannot declare `parallel` even under a sequential parent + ci: { steps: [{ title: 'g', steps: [{ title: 'h', parallel: 2, steps: ['x'] }] }] }, +}; +void nestedUnderSequential; + const badCiRun: WebjsConfig = { // @ts-expect-error a command step's `run` is a string, not a number ci: { steps: [{ title: 'x', run: 1 }] }, diff --git a/website/app/docs/configuration/page.ts b/website/app/docs/configuration/page.ts index fe2bbe81c..8527a2387 100644 --- a/website/app/docs/configuration/page.ts +++ b/website/app/docs/configuration/page.ts @@ -192,7 +192,7 @@ webjs routes --help # one command's help (flag form)</code-block> <h2>Config typos are reported at boot</h2> <p>Every key in the <code>webjs</code> block is optional, which means an unknown one has no way to announce itself: write <code>"redirect"</code> for <code>"redirects"</code> and the key is simply never read, the feature sits at its default, and the app looks configured. So WebJs validates the block against its published JSON Schema once per boot, in development and in production alike, and prints a single warning naming what it ignored.</p> <p>It is a <strong>warning, never a failure</strong>. A typo costs one feature its setting, and refusing to boot over that would cost the whole app, usually mid-deploy.</p> - <p><strong>What it catches, and what it does not.</strong> It reports any unknown top-level key, whatever it is called. It also checks the value of 9 of the 19 known keys: one against an allowed set (<code>trailingSlash</code>), and eight against a boolean or whole-number type (<code>elide</code>, <code>seed</code>, <code>clientRouter</code>, <code>maxBodyBytes</code>, <code>maxMultipartBytes</code>, and the three timeouts). It does not descend into nested objects, so a key misspelled inside <code>webjs.dev</code> or <code>webjs.start</code> is missed, and it does not check the type of the other 9 (<code>headers</code>, <code>redirects</code>, <code>basePath</code>, <code>allowedOrigins</code>, <code>csp</code>, <code>dev</code>, <code>start</code>, <code>db</code>, <code>doctor</code>), whose schemas are the free-form ones a blunt check would start rejecting working configs over. Give one of those the wrong type outright, as in <code>"headers": "x"</code>, and it passes this check. Whether anything downstream then says so is up to that key's own reader, and the two array-valued ones do: <code>webjs.headers</code> and <code>webjs.redirects</code> each warn when the key is present but not an array, and again for every individual rule or entry they drop, naming what was ignored. An absent key is the default and says nothing.</p> + <p><strong>What it catches, and what it does not.</strong> It reports any unknown top-level key, whatever it is called. It also checks the value of 9 of the 19 known keys: one against an allowed set (<code>trailingSlash</code>), and eight against a boolean or whole-number type (<code>elide</code>, <code>seed</code>, <code>clientRouter</code>, <code>maxBodyBytes</code>, <code>maxMultipartBytes</code>, and the three timeouts). It does not descend into nested objects, so a key misspelled inside <code>webjs.dev</code> or <code>webjs.start</code> is missed, and it does not check the type of the other 10 (<code>headers</code>, <code>redirects</code>, <code>basePath</code>, <code>allowedOrigins</code>, <code>csp</code>, <code>dev</code>, <code>start</code>, <code>db</code>, <code>doctor</code>, <code>ci</code>), whose schemas are the free-form ones a blunt check would start rejecting working configs over. Give one of those the wrong type outright, as in <code>"headers": "x"</code>, and it passes this check. Whether anything downstream then says so is up to that key's own reader, and the two array-valued ones do: <code>webjs.headers</code> and <code>webjs.redirects</code> each warn when the key is present but not an array, and again for every individual rule or entry they drop, naming what was ignored. An absent key is the default and says nothing.</p> <p>Editors catch more of this earlier still: a scaffolded app wires the schema into <code>.vscode/settings.json</code>, and the <code>WebjsConfig</code> type from <code>@webjsdev/core</code> types the block while you author it. And <code>webjs.doctor</code> is checked by a different tool entirely: the server never reads it, and <code>webjs doctor</code> exits non-zero on a malformed one, because a silently ignored gate would leave CI un-gated while looking gated.</p> <h2>Environment Variables</h2> From 879e140d787249671e39630e33d70d02815587b8 Mon Sep 17 00:00:00 2001 From: Vivek <vivek7405@gmail.com> Date: Thu, 10 Sep 2026 23:03:46 +0530 Subject: [PATCH 08/10] chore: require the renamed in-repo app tests check on main The apps job became "In-repo app tests (website + blog + gallery)" in #1371, but scripts/protect-main.sh, and the protection it had applied, still required the old name, so that required check could never report and every PR since has shown as blocked on a check that no longer exists. Name the real job so a green run satisfies the gate. --- scripts/protect-main.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/protect-main.sh b/scripts/protect-main.sh index 384c90b34..f0790d046 100755 --- a/scripts/protect-main.sh +++ b/scripts/protect-main.sh @@ -39,7 +39,7 @@ gh api -X PUT "repos/${REPO}/branches/main/protection" \ "Browser (web-test-runner / Playwright)", "E2E (Puppeteer against the blog example)", "Build (@webjsdev/core dist)", - "In-repo app tests (website + blog)" + "In-repo app tests (website + blog + gallery)" ] }, "enforce_admins": false, From 0db2086d5f9feef3480367e1c0058c36916babc4 Mon Sep 17 00:00:00 2001 From: Vivek <vivek7405@gmail.com> Date: Thu, 10 Sep 2026 23:05:09 +0530 Subject: [PATCH 09/10] feat(create-webjs): forward --skip-ci to the scaffold `npm create webjs` and `bun create webjs` run this wrapper, so the flag `webjs create` gained in #1471 has to reach scaffoldApp from here too, or `npm create webjs my-app -- --skip-ci` would silently scaffold the workflow, the same gap --db once had. The usage and README name it, and a source-level test pins the forwarding, because the wrapper resolves the CLI by bare specifier and a linked worktree would run the primary checkout's copy. --- packages/wrappers/create-webjs/README.md | 1 + .../wrappers/create-webjs/bin/create-webjs.js | 8 +++++++- .../scaffold-template-validation.test.js | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/wrappers/create-webjs/README.md b/packages/wrappers/create-webjs/README.md index 23888b81c..4e06a7a7b 100644 --- a/packages/wrappers/create-webjs/README.md +++ b/packages/wrappers/create-webjs/README.md @@ -27,6 +27,7 @@ Only three templates exist; the CLI rejects anything else. |---|---|---| | `--template <full-stack \| api \| saas>` | `full-stack` | Pick the scaffold variant. | | `--no-install` | install runs | Skip the post-scaffold `<pm> install`. | +| `--skip-ci` | the workflow ships | Omit `.github/workflows/ci.yml`; the local `npm run ci` step list in `package.json` is always emitted. | | `-h`, `--help` | | Show help. | The package manager is detected from `npm_config_user_agent`: pnpm / yarn / bun users get their own. diff --git a/packages/wrappers/create-webjs/bin/create-webjs.js b/packages/wrappers/create-webjs/bin/create-webjs.js index 642a339bf..38824c9ad 100755 --- a/packages/wrappers/create-webjs/bin/create-webjs.js +++ b/packages/wrappers/create-webjs/bin/create-webjs.js @@ -51,6 +51,8 @@ Options: (bun.lock, bun Dockerfile/CI, bun docs). Auto-detected as bun when invoked via \`bun create webjs\`. --no-install skip running the package manager's install in the new directory + --skip-ci omit the GitHub workflow (.github/workflows/ci.yml); the local + \`npm run ci\` step list in package.json is always emitted -h, --help show this help`; if (args.length === 0 || args.includes('-h') || args.includes('--help')) { @@ -109,4 +111,8 @@ const db = flagValue('--db'); const noInstall = args.includes('--no-install'); -await scaffoldApp(name, process.cwd(), { template, db, runtime, install: !noInstall }); +// --skip-ci (#1471), forwarded so the wrapper matches `webjs create`: omits +// the GitHub workflow only; the local ci list always ships. +const skipCi = args.includes('--skip-ci'); + +await scaffoldApp(name, process.cwd(), { template, db, runtime, install: !noInstall, skipCi }); diff --git a/test/scaffolds/scaffold-template-validation.test.js b/test/scaffolds/scaffold-template-validation.test.js index 9df956806..a8e787a10 100644 --- a/test/scaffolds/scaffold-template-validation.test.js +++ b/test/scaffolds/scaffold-template-validation.test.js @@ -219,6 +219,22 @@ test('the CLI rejects a bad app name non-zero, before writing anything', async ( } }); +// The wrapper resolves `@webjsdev/cli` by bare specifier, which in a linked +// worktree lands in the primary checkout, so this pins the forwarding in the +// wrapper's SOURCE rather than by running it: the flag must reach scaffoldApp +// as `skipCi`, and the usage must name it, so `npm create webjs -- --skip-ci` +// cannot silently scaffold the workflow the way `--db` once silently scaffolded +// sqlite. +test('the create-webjs wrapper forwards --skip-ci to scaffoldApp (#1471)', async () => { + const src = await readFile( + new URL('../../packages/wrappers/create-webjs/bin/create-webjs.js', import.meta.url), + 'utf8', + ); + assert.match(src, /const skipCi = args\.includes\('--skip-ci'\)/, 'the flag is read'); + assert.match(src, /scaffoldApp\(name, process\.cwd\(\), \{[^}]*skipCi[^}]*\}\)/, 'and forwarded to scaffoldApp'); + assert.match(src, /--skip-ci\s+omit the GitHub workflow/, 'and documented in the usage'); +}); + test('the create-webjs wrapper rejects a bad name too (npm / bun create webjs)', async () => { // `npm create webjs` and `bun create webjs` route through this wrapper, not // through `webjs create`, so its guard is a third entry point and needs its From c85643930d866f08e4afbf152f7978e81f0adb21 Mon Sep 17 00:00:00 2001 From: Vivek <vivek7405@gmail.com> Date: Thu, 10 Sep 2026 23:45:28 +0530 Subject: [PATCH 10/10] perf: run the monorepo's local CI gate three steps at a time The root webjs.ci list ran its big steps one after another, so a full run took 9m22s on a 24-core machine although no single step needed more than 4m30s. After Setup, everything now sits in one Gate group with three slots, longest first (the blog e2e, the Bun matrix, the browser suite, then the root test suite, the in-repo app suites, and the Conventions checks as slots free up), so the wall-clock is the longest step rather than the sum. Each slot's output is captured and replayed whole, so nothing interleaves. Three rather than six: six overloaded the box, and web-test-runner's Firefox could not launch a test page inside its 30s start timeout. With the e2e alone as the floor, three slots cost no wall-clock. The Conventions group loses its own three-way parallelism, which cost nothing measurable (eight sub-second steps). The repo-health guard still parses the block clean, and framework-dev.md describes the shape. --- framework-dev.md | 16 +++--- package.json | 129 ++++++++++++++++++++++++++++++++++++----------- 2 files changed, 109 insertions(+), 36 deletions(-) diff --git a/framework-dev.md b/framework-dev.md index a1b8b35e2..93be4084c 100644 --- a/framework-dev.md +++ b/framework-dev.md @@ -177,13 +177,15 @@ Regression tests: `test/hooks/block-install-in-linked-worktree.test.mjs`, `test/ The monorepo runs its own local CI the way a scaffolded app does. The root `package.json` declares a `webjs.ci` step list mirroring the GitHub jobs (`.github/workflows/ci.yml` stays the required merge gate and is not converted): -a Setup group (the blog and gallery databases, the core dist), a Conventions -group running three at a time (`webjs check` and `webjs doctor` per in-repo -app, the buildless-packages invariant, the em-dash scan), the root `npm test`, -the in-repo app typechecks and suites plus the website boot-check, the browser -suite, the blog e2e, and the Bun matrix. `npm run ci` runs the lot; `npm run ci --- --only Conventions` runs one group, and `--json` gives an agent the verdict -as data. The root script runs `node packages/cli/bin/webjs.js ci` rather than a +a Setup group (the blog and gallery databases, the core dist), then one `Gate` +group that runs THREE slots at once, longest first (six overloaded a 24-core box: Firefox timed out launching a test page): the blog e2e, the Bun matrix, +the browser suite, the root `npm test`, the in-repo app typechecks and suites +plus the website boot-check, and the Conventions group (`webjs check` and +`webjs doctor` per in-repo app, the buildless-packages invariant, the em-dash +scan). Each slot's output is replayed whole when it finishes, so the six never +interleave, and the wall-clock is the longest step rather than the sum. `npm +run ci` runs the lot; `npm run ci -- --only Conventions` runs one group, and +`--json` gives an agent the verdict as data. The root script runs `node packages/cli/bin/webjs.js ci` rather than a hoisted `webjs` bin because in a linked worktree `node_modules/.bin/webjs` resolves into the PRIMARY checkout, which may not carry the branch's CLI, and every root step invokes the in-repo CLI the same way for the same reason. diff --git a/package.json b/package.json index 5588fbbd3..7a10cf6ca 100644 --- a/package.json +++ b/package.json @@ -42,41 +42,112 @@ { "title": "Setup", "steps": [ - { "title": "Setup: blog database", "run": "[ -f examples/blog/.env ] || cp examples/blog/.env.example examples/blog/.env; npm run db:migrate --workspace=@webjsdev/example-blog && npm run db:seed --workspace=@webjsdev/example-blog" }, - { "title": "Setup: gallery database", "run": "[ -f gallery/.env ] || cp gallery/.env.example gallery/.env; npm run db:migrate --workspace=@webjsdev/gallery" }, - { "title": "Setup: core dist", "run": "npm run build:dist --workspace=@webjsdev/core" } + { + "title": "Setup: blog database", + "run": "[ -f examples/blog/.env ] || cp examples/blog/.env.example examples/blog/.env; npm run db:migrate --workspace=@webjsdev/example-blog && npm run db:seed --workspace=@webjsdev/example-blog" + }, + { + "title": "Setup: gallery database", + "run": "[ -f gallery/.env ] || cp gallery/.env.example gallery/.env; npm run db:migrate --workspace=@webjsdev/gallery" + }, + { + "title": "Setup: core dist", + "run": "npm run build:dist --workspace=@webjsdev/core" + } ] }, { - "title": "Conventions", + "title": "Gate", "parallel": 3, "steps": [ - { "title": "webjs check (blog)", "run": "cd examples/blog && node ../../packages/cli/bin/webjs.js check" }, - { "title": "webjs check (gallery)", "run": "cd gallery && node ../packages/cli/bin/webjs.js check" }, - { "title": "webjs check (website)", "run": "cd website && node ../packages/cli/bin/webjs.js check" }, - { "title": "webjs doctor (blog)", "run": "cd examples/blog && node ../../packages/cli/bin/webjs.js doctor" }, - { "title": "webjs doctor (gallery)", "run": "cd gallery && node ../packages/cli/bin/webjs.js doctor" }, - { "title": "webjs doctor (website)", "run": "cd website && node ../packages/cli/bin/webjs.js doctor" }, - { "title": "Buildless framework packages (no .ts source)", "run": "hits=$(git ls-files 'packages/core/**/*.ts' 'packages/server/**/*.ts' 'packages/cli/**/*.ts' 'packages/editors/**/*.ts' | grep -vE '\\.d\\.ts$|/templates/' || true); [ -z \"$hits\" ] || { echo \"$hits\"; exit 1; }" }, - { "title": "No em-dash in source (invariant 11)", "run": "hits=$(git grep -lP '\\x{2014}' -- '*.js' '*.ts' '*.md' ':!changelog/' ':!**/node_modules/**' ':!.claude/skills/**' ':!.agents/skills/**' || true); [ -z \"$hits\" ] || { echo \"$hits\"; exit 1; }" } + { + "title": "E2E (Puppeteer against the blog example)", + "run": "npm run test:e2e" + }, + { + "title": "Bun runtime smoke + test matrix", + "run": "node scripts/run-bun-tests.js" + }, + { + "title": "Browser (web-test-runner / Playwright)", + "run": "npm run test:browser" + }, + { + "title": "Unit + integration (node --test)", + "run": "npm test" + }, + { + "title": "In-repo app tests", + "steps": [ + { + "title": "website typecheck", + "run": "npm run typecheck --workspace=@webjsdev/website" + }, + { + "title": "blog typecheck", + "run": "npm run typecheck --workspace=@webjsdev/example-blog" + }, + { + "title": "gallery typecheck", + "run": "npm run typecheck --workspace=@webjsdev/gallery" + }, + { + "title": "website tests (node + browser)", + "run": "npm test --workspace=@webjsdev/website" + }, + { + "title": "blog tests (node)", + "run": "npm test --workspace=@webjsdev/example-blog" + }, + { + "title": "gallery tests (node + browser)", + "run": "npm test --workspace=@webjsdev/gallery" + }, + { + "title": "App boot-check on Node (website incl. /docs + /ui)", + "run": "node test/bun/app-boot.mjs" + } + ] + }, + { + "title": "Conventions", + "steps": [ + { + "title": "webjs check (blog)", + "run": "cd examples/blog && node ../../packages/cli/bin/webjs.js check" + }, + { + "title": "webjs check (gallery)", + "run": "cd gallery && node ../packages/cli/bin/webjs.js check" + }, + { + "title": "webjs check (website)", + "run": "cd website && node ../packages/cli/bin/webjs.js check" + }, + { + "title": "webjs doctor (blog)", + "run": "cd examples/blog && node ../../packages/cli/bin/webjs.js doctor" + }, + { + "title": "webjs doctor (gallery)", + "run": "cd gallery && node ../packages/cli/bin/webjs.js doctor" + }, + { + "title": "webjs doctor (website)", + "run": "cd website && node ../packages/cli/bin/webjs.js doctor" + }, + { + "title": "Buildless framework packages (no .ts source)", + "run": "hits=$(git ls-files 'packages/core/**/*.ts' 'packages/server/**/*.ts' 'packages/cli/**/*.ts' 'packages/editors/**/*.ts' | grep -vE '\\.d\\.ts$|/templates/' || true); [ -z \"$hits\" ] || { echo \"$hits\"; exit 1; }" + }, + { + "title": "No em-dash in source (invariant 11)", + "run": "hits=$(git grep -lP '\\x{2014}' -- '*.js' '*.ts' '*.md' ':!changelog/' ':!**/node_modules/**' ':!.claude/skills/**' ':!.agents/skills/**' || true); [ -z \"$hits\" ] || { echo \"$hits\"; exit 1; }" + } + ] + } ] - }, - { "title": "Unit + integration (node --test)", "run": "npm test" }, - { - "title": "In-repo app tests", - "steps": [ - { "title": "website typecheck", "run": "npm run typecheck --workspace=@webjsdev/website" }, - { "title": "blog typecheck", "run": "npm run typecheck --workspace=@webjsdev/example-blog" }, - { "title": "gallery typecheck", "run": "npm run typecheck --workspace=@webjsdev/gallery" }, - { "title": "website tests (node + browser)", "run": "npm test --workspace=@webjsdev/website" }, - { "title": "blog tests (node)", "run": "npm test --workspace=@webjsdev/example-blog" }, - { "title": "gallery tests (node + browser)", "run": "npm test --workspace=@webjsdev/gallery" }, - { "title": "App boot-check on Node (website incl. /docs + /ui)", "run": "node test/bun/app-boot.mjs" } - ] - }, - { "title": "Browser (web-test-runner / Playwright)", "run": "npm run test:browser" }, - { "title": "E2E (Puppeteer against the blog example)", "run": "npm run test:e2e" }, - { "title": "Bun runtime smoke + test matrix", "run": "node scripts/run-bun-tests.js" } + } ] } },