Skip to content

Commit eae3368

Browse files
test(cli): dev-no-watch pin reports a failed child as itself and names pnpm's own PACKAGE refusal (#22712)
Fixes #22618 Clause-②: no ## What changed One file, `packages/cli/test/dev-no-watch.pin.test.ts`. Test-only: nothing in `packages/cli`'s `files[]` (`dist`, `README.md`, `CHANGELOG.md`) changes. - **`runDev` reports a failed child as itself.** `Run` now carries `code: number | string | null` and `signal`, taken from `execFile`'s error as it is handed back: the exit status when there is one; `null` plus the signal for a signalled child; Node's own string code for a run that never produced an exit status (a spawn error such as `EAGAIN`, or `ERR_CHILD_PROCESS_STDIO_MAXBUFFER`). Before this change all of those read `1`. - **Every spawned assertion prints the child's whole answer when it fails.** `told(run)` is passed as vitest's `expect(value, message)`, so a red reads `os dev ended with code …, signal …; its output (stdout, then stderr): …` instead of only `expected 1 to be +0`. The control is the case the card names. The siblings get the same message, because the tightened sibling-1 assertion below would otherwise be a second undiagnosable red. - **Sibling 1 (`os dev --watch=false`) now names pnpm's own PACKAGE refusal.** `dev.ts` has no sentence of its own for a PACKAGE that selects nothing: `--fail-if-no-match` hands that answer to pnpm. On that path the CLI's `✗` line (`Development mode failed: Command failed: pnpm --filter false --fail-if-no-match dev`) is the catch-all for every failure of `pnpm … dev`, so it names `false` whether or not pnpm ever ran. The it-block now also asserts pnpm's own no-match line (`PNPM_NO_PROJECTS`, `/^No projects found in "/m`) and keeps the `false` check on the `✗` line. ⛔ No retry wrapper, no wider timeout, no product change. A real regression stays red, and the red now says why. ## Measured All runs are on this branch at `d38422340`. The CLI child is the test's own (`tsx bin/run-dev.js dev`, the same fixture shapes, `NO_COLOR=1`). **The card's sibling-1 claim, reproduced.** With pnpm off `PATH`, the `--watch=false` child still exits 1 and prints the same `✗` line. The only difference is that pnpm's own `No projects found in "DIR"` line is missing (stdout, exit 1; the same under pnpm 10.28.0 and 10.31.0), and `/bin/sh: 1: pnpm: not found` appears instead. Under the same `PATH` the control's child also exits 1, which is the CI red's exact reading, and only its stderr names pnpm. "pnpm off `PATH`" means every `PATH` entry holding an executable `pnpm` is dropped, including pnpm's self-managed `~/.local/share/pnpm/.tools/pnpm/10.31.0/bin`, which `pnpm exec` puts on `PATH`. A first attempt that removed only `/opt/node22/bin` still resolved pnpm and ran 8/8 green. It does not count. | run | input | result | |---|---|---| | R1 | normal `PATH` | 8/8 pass | | R2 | pnpm off `PATH` | 2 fail, 6 pass: sibling 1's refusal assertion (`os dev ended with code 1, signal null; …` then `/bin/sh: 1: pnpm: not found`), and the control (the same message, then `expected 1 to be +0`) | | A1 | pnpm off `PATH`, the `PNPM_NO_PROJECTS` assertion deleted (`scripts/ablation-replace.mjs`, anchor 1 to 0, blob `7e8e5de` to `49986f4`) | 1 fail, 7 pass: sibling 1 is green again, so that one line is what turns it red. Restored: blob equals HEAD, `git diff HEAD` empty | | M1 | `timeout: 1` added to `runDev`'s `execFile` options | 4 fail, each reading `os dev ended with code null, signal "SIGTERM"`. Restored: blob equals HEAD | | M2 | `maxBuffer: 16` | 4 fail, each reading `code "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", signal null`. Restored: blob equals HEAD | A1, M1 and M2 are one-time proofs; no permanent test was added for them. The subject under mutation is the test file itself, which vitest reads from disk, so no `dist/` sits on that path. ## Local verification (at `d38422340`) - `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/dev-no-watch.pin.test.ts`: 8/8 pass. The file is integration-tier because it spawns. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 277 files pass. Two files (`published-subpath-console.pin`, `published-subpath-hook-body.pin`) refused because `packages/cli/dist` was missing. After `pnpm --filter @objectstack/cli build` they pass (2/2 files, 29/29 tests), so the layer is 279/279 files. - `pnpm --filter @objectstack/cli typecheck`: `tsc --noEmit` plus `check:test-typecheck` OK. The file is in `tsconfig.test.json`'s program (`tsc --listFilesOnly`). - Gates: the derived set (`node scripts/pm/dispatch-gates.mjs --commands`: 50 commands, identical to the dispatch's), plus `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. All exit 0. Two first answered a prerequisite (exit 3) and passed once it was met: `check-plugin-teardown-shape.mjs --self-test` (its pinned fixture commit fetched into the shallow clone) and `check:dual-build-cjs-loads` (dists present). `dispatch-gates --ran`: 50 derived, 50 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm lint`, narrowed to the touched file, with the narrowing proven: - eslint's own config puts the file in the population (`isPathIgnored` false; `calculateConfigForFile` applies 5 rules); - `eslint --no-inline-config --format json` on it counts 1 file, 0 errors, 0 warnings; - no config object enables type-aware linting (`parserOptions` is only `ecmaVersion`/`sourceType`), so the diff cannot move any untouched file's verdict. - Not merged with `main`, which is 5 commits ahead at `9f159397f`. None of those commits touches this file, `dev.ts`, `test/helpers/serve-process.ts`, `vitest.config.ts` or `vitest-tiers.ts`. CI and the queue run the merge ref. ## Acceptance notes - The `✗` line's naming of `false` is still asserted. It holds because the catch-all echoes the command pnpm ran; the PACKAGE refusal itself is pnpm's line. - If the next CI red of this file names a signal or a spawn error under load, that reading goes on #22618 (triage's direction). This PR does not give the control a budget of its own. --- _Generated by [Claude Code](https://claude.ai/code/session_019SvPnd2bzECRNmAU9i6E4k)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 615cba8 commit eae3368

1 file changed

Lines changed: 47 additions & 18 deletions

File tree

‎packages/cli/test/dev-no-watch.pin.test.ts‎

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,15 @@
3434
* those cases spawn the source entry — which is what puts this file in the
3535
* `integration` tier (`vitest-tiers.ts`).
3636
*
37-
* ⛔ Refusal prose is not pinned. The spawned cases assert the exit status, the
38-
* value the failure names, and that nothing was started.
37+
* ⛔ This CLI's refusal prose is not pinned. The spawned cases assert the exit
38+
* status, the value the failure names, and that nothing was started. The one
39+
* sentence read verbatim is pnpm's own no-match answer, because on the PACKAGE
40+
* path it is the refusal itself (`PNPM_NO_PROJECTS` says why).
41+
*
42+
* Every spawned assertion carries the child's whole answer as its failure
43+
* message (`told`): this file runs in a shared merge-queue runner, and a red
44+
* reading only `expected 1 to be +0` cannot be told apart from pnpm missing, a
45+
* spawn failure or a killed child.
3946
*/
4047

4148
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -61,8 +68,17 @@ const DEV_SCRIPT_MARK = 'DEV-SCRIPT-RAN';
6168
const parseDev = (argv: string[]) =>
6269
Parser.parse(argv, { flags: Dev.flags, args: Dev.args, strict: true });
6370

71+
/** One `os dev` child, reported as `execFile` handed it back — never folded into `1`. */
6472
interface Run {
65-
code: number;
73+
/**
74+
* The exit status, `0` on success. `null` when the child has none because a
75+
* signal ended it (`signal` names which). A string when Node failed the run
76+
* before any exit status existed: a spawn error (`EAGAIN`, `ENOMEM`,
77+
* `ENOENT`) or `ERR_CHILD_PROCESS_STDIO_MAXBUFFER`.
78+
*/
79+
code: number | string | null;
80+
/** The signal that ended the child, or `null`. */
81+
signal: NodeJS.Signals | null;
6682
out: string;
6783
}
6884

@@ -74,24 +90,36 @@ function runDev(args: string[], cwd: string): Promise<Run> {
7490
{ cwd, maxBuffer: 8 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
7591
(err, stdout, stderr) => {
7692
resolvePromise({
77-
// `err.code` is the real exit status; a signalled child has none and
78-
// is reported as a failure, never as 0.
79-
code: err
80-
? typeof (err as { code?: unknown }).code === 'number'
81-
? (err as unknown as { code: number }).code
82-
: 1
83-
: 0,
93+
code: err ? (err.code ?? null) : 0,
94+
signal: err?.signal ?? null,
8495
out: `${String(stdout)}\n${String(stderr)}`,
8596
});
8697
},
8798
);
8899
});
89100
}
90101

102+
/** A run spelled out as an assertion's failure message, so a red names its own cause. */
103+
const told = (run: Run): string =>
104+
`os dev ended with code ${JSON.stringify(run.code)}, signal ${JSON.stringify(run.signal)}; ` +
105+
`its output (stdout, then stderr):\n${run.out}`;
106+
91107
/** The `✗` line `printError` writes — the one sentence the failure is judged by. */
92108
const failureLine = (out: string): string =>
93109
out.split('\n').find((line) => line.trimStart().startsWith('✗')) ?? '';
94110

111+
/**
112+
* pnpm's own answer to a PACKAGE that selects nothing: the refusal itself, and
113+
* the one line proving pnpm judged the filter. `dev.ts` has no sentence of its
114+
* own for it (pnpm owns the filter grammar, so `--fail-if-no-match` makes pnpm
115+
* the one that answers). The `✗` line on that path is the CLI's catch-all for
116+
* EVERY failure of `pnpm … dev`, and it names `false` only because it echoes
117+
* the command; with pnpm off `PATH` the child prints that same `✗` line and
118+
* still exits 1. Measured on pnpm 10.28.0 and 10.31.0 in a directory holding
119+
* no workspace (`projectDir`'s shape): this line on stdout, exit 1.
120+
*/
121+
const PNPM_NO_PROJECTS = /^No projects found in "/m;
122+
95123
describe('the watch opt-out is reachable', () => {
96124
it('`os dev --no-watch` parses, and the boot it describes runs no watcher', async () => {
97125
const { flags } = await parseDev(['--no-watch']);
@@ -154,26 +182,27 @@ afterAll(() => {
154182

155183
describe('a PACKAGE that selects no workspace project fails', () => {
156184
it('`os dev --watch=false` exits 1 instead of 0', () => {
157-
expect(watchEqualsFalse.code).toBe(1);
185+
expect(watchEqualsFalse.code, told(watchEqualsFalse)).toBe(1);
158186
});
159187

160-
it('and the failure names the value that matched nothing', () => {
161-
expect(failureLine(watchEqualsFalse.out)).toMatch(/(^|\s)false(\s|$)/);
188+
it('and the failure is pnpm refusing the PACKAGE, under a line naming the value that matched nothing', () => {
189+
expect(watchEqualsFalse.out, told(watchEqualsFalse)).toMatch(PNPM_NO_PROJECTS);
190+
expect(failureLine(watchEqualsFalse.out), told(watchEqualsFalse)).toMatch(/(^|\s)false(\s|$)/);
162191
});
163192
});
164193

165194
describe('`--no-watch` where os dev boots no environment is refused, not ignored', () => {
166195
it('the fixture starts its own dev script when asked — the control', () => {
167-
expect(bareAtWorkspace.code).toBe(0);
168-
expect(bareAtWorkspace.out).toContain(DEV_SCRIPT_MARK);
196+
expect(bareAtWorkspace.code, told(bareAtWorkspace)).toBe(0);
197+
expect(bareAtWorkspace.out, told(bareAtWorkspace)).toContain(DEV_SCRIPT_MARK);
169198
});
170199

171200
it('exits 1, naming the flag', () => {
172-
expect(noWatchAtWorkspace.code).toBe(1);
173-
expect(failureLine(noWatchAtWorkspace.out)).toContain('--no-watch');
201+
expect(noWatchAtWorkspace.code, told(noWatchAtWorkspace)).toBe(1);
202+
expect(failureLine(noWatchAtWorkspace.out), told(noWatchAtWorkspace)).toContain('--no-watch');
174203
});
175204

176205
it('and starts nothing', () => {
177-
expect(noWatchAtWorkspace.out).not.toContain(DEV_SCRIPT_MARK);
206+
expect(noWatchAtWorkspace.out, told(noWatchAtWorkspace)).not.toContain(DEV_SCRIPT_MARK);
178207
});
179208
});

0 commit comments

Comments
 (0)