Skip to content

Commit e72a2d2

Browse files
committed
test(cli): pin what a shell sees for both invocation failures (#10111)
The e2e legs spawn real child processes, because `process.exitCode` inside a vitest worker is not an exit status, and they assert the FIRST line of stderr — "somewhere in the output" is the property the usage dump already had. The unit legs pin the entry predicate through a symlink and through `node <dir>`, which is where every `invokedDirectly` spelling measured in #10086 answers false and goes silently inert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
1 parent d47b0cc commit e72a2d2

3 files changed

Lines changed: 405 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
Two ways to invoke the CLI wrong used to present as a crashed boot. Both now say what they are.
6+
7+
`node packages/cli/dist/index.js` — the package `main`, which is a re-export barrel — ran to completion, printed nothing and exited 0. Backgrounded, that is indistinguishable from a server that came up and died. It now writes two lines to stderr, the first saying that running this file starts nothing and the second naming `bin/run.js` as the CLI entry point, and exits 1.
8+
9+
A rejected invocation such as `objectstack dev --no-ui` answered with oclif's error line followed by a full usage dump, and in a background log the dump is what the eye lands on. One line now goes to stderr ahead of it:
10+
11+
```
12+
objectstack: INVOCATION ERROR — Nonexistent flag: --no-ui. The command never ran: nothing was started and nothing is listening. Invoked as: objectstack dev --no-ui
13+
```
14+
15+
No flag surface changed: `dev` still rejects `--no-ui` (only `serve` declares `ui` with `allowNo`). What changed is what the CLI says when it rejects an invocation.
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The judgments behind #10111's two loud failures, unit-tested where a spawn
5+
* cannot reach them.
6+
*
7+
* `test/invocation-loudness.e2e.test.ts` is the end-to-end half — it proves the
8+
* lines actually reach a shell's stderr in the right ORDER, with the right exit
9+
* status. What is pinned here instead is the part that is invisible from
10+
* outside: which invocations the entry predicate calls "this process was
11+
* pointed at me", and which errors count as an invocation error at all.
12+
*
13+
* The symlink and directory legs are the reason this file exists. #10086
14+
* measured ~8 spellings of the same entry guard across `scripts/`, all of them
15+
* blind to symlinks, and every one of them makes its script silently inert —
16+
* exit 0, no output. That is the exact defect #10111 removes, so a guard here
17+
* with the same hole would have been the bug wearing the fix's clothes. Ablate
18+
* `realOrSelf` out of `isProcessEntry` and the symlink and directory cases turn
19+
* red; nothing else in this file moves.
20+
*/
21+
22+
import { describe, expect, it } from 'vitest';
23+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
24+
import { tmpdir } from 'node:os';
25+
import { join } from 'node:path';
26+
import { pathToFileURL } from 'node:url';
27+
import { Flags, Parser } from '@oclif/core';
28+
29+
import { CLI_NAME } from './format.js';
30+
import {
31+
INVOCATION_PREFIX,
32+
invocationFailureLine,
33+
isInvocationError,
34+
isProcessEntry,
35+
moduleEntryMisuseLines,
36+
} from './invocation.js';
37+
38+
/**
39+
* A real `NonExistentFlagsError`, thrown by the REAL parser through its public
40+
* entry point — the same error `os dev --no-ui` produces. Hand-rolling a
41+
* look-alike would test the look-alike: `@oclif/core` does not export
42+
* `CLIParseError`, so the structural predicate is only worth anything if it is
43+
* checked against what oclif actually throws.
44+
*/
45+
async function realNonExistentFlagError(): Promise<unknown> {
46+
try {
47+
await Parser.parse(['--no-ui'], {
48+
flags: { ui: Flags.boolean({ description: 'as `dev` declares it — no allowNo' }) },
49+
strict: true,
50+
});
51+
} catch (error) {
52+
return error;
53+
}
54+
throw new Error('the parser accepted --no-ui: this fixture no longer reproduces the measured failure');
55+
}
56+
57+
function fixtureDir(): string {
58+
const dir = mkdtempSync(join(tmpdir(), 'os-invocation-'));
59+
return dir;
60+
}
61+
62+
describe('[#10111] isProcessEntry', () => {
63+
it('is false when there is no entry argument (node --eval, the REPL)', () => {
64+
expect(isProcessEntry(undefined, pathToFileURL(join(tmpdir(), 'anything.js')).href)).toBe(false);
65+
expect(isProcessEntry('', pathToFileURL(join(tmpdir(), 'anything.js')).href)).toBe(false);
66+
});
67+
68+
it('is true for the plain `node <file>` invocation', () => {
69+
const dir = fixtureDir();
70+
try {
71+
const entry = join(dir, 'entry.js');
72+
writeFileSync(entry, '');
73+
expect(isProcessEntry(entry, pathToFileURL(entry).href)).toBe(true);
74+
} finally {
75+
rmSync(dir, { force: true, recursive: true });
76+
}
77+
});
78+
79+
it('is true through a SYMLINK — the leg every spelling in #10086 gets wrong', () => {
80+
const dir = fixtureDir();
81+
try {
82+
const entry = join(dir, 'entry.js');
83+
const link = join(dir, 'link.js');
84+
writeFileSync(entry, '');
85+
symlinkSync(entry, link);
86+
// `import.meta.url` names the REAL file (node resolves symlinks for the
87+
// module graph); `process.argv[1]` stays as the caller typed it. Comparing
88+
// only those two answers false here, and a guard that answers false goes
89+
// silently inert.
90+
expect(isProcessEntry(link, pathToFileURL(entry).href)).toBe(true);
91+
} finally {
92+
rmSync(dir, { force: true, recursive: true });
93+
}
94+
});
95+
96+
it('is true for `node <dir>`, where the entry argument names the index it resolved to', () => {
97+
const dir = fixtureDir();
98+
try {
99+
const pkg = join(dir, 'dist');
100+
mkdirSync(pkg);
101+
const entry = join(pkg, 'index.js');
102+
writeFileSync(entry, '');
103+
expect(isProcessEntry(pkg, pathToFileURL(entry).href)).toBe(true);
104+
} finally {
105+
rmSync(dir, { force: true, recursive: true });
106+
}
107+
});
108+
109+
it('is false for an unrelated entry — an ordinary `import` must not be aborted', () => {
110+
const dir = fixtureDir();
111+
try {
112+
const entry = join(dir, 'entry.js');
113+
const other = join(dir, 'some-other-tool.js');
114+
writeFileSync(entry, '');
115+
writeFileSync(other, '');
116+
expect(isProcessEntry(other, pathToFileURL(entry).href)).toBe(false);
117+
} finally {
118+
rmSync(dir, { force: true, recursive: true });
119+
}
120+
});
121+
122+
it('is false for a DIFFERENT file with the same basename', () => {
123+
// The other half of #10086's finding: two scripts there match on basename,
124+
// which fires on import as readily as it goes inert.
125+
const dir = fixtureDir();
126+
try {
127+
const here = join(dir, 'a');
128+
const there = join(dir, 'b');
129+
mkdirSync(here);
130+
mkdirSync(there);
131+
writeFileSync(join(here, 'index.js'), '');
132+
writeFileSync(join(there, 'index.js'), '');
133+
expect(isProcessEntry(join(there, 'index.js'), pathToFileURL(join(here, 'index.js')).href)).toBe(false);
134+
} finally {
135+
rmSync(dir, { force: true, recursive: true });
136+
}
137+
});
138+
});
139+
140+
describe('[#10111] moduleEntryMisuseLines', () => {
141+
const [first, second] = moduleEntryMisuseLines('/w/packages/cli/dist/index.js', '/w/packages/cli/bin/run.js');
142+
143+
it('leads with the prefix a runner log can be grepped for', () => {
144+
expect(first.startsWith(`${INVOCATION_PREFIX}: `)).toBe(true);
145+
expect(second.startsWith(`${INVOCATION_PREFIX}: `)).toBe(true);
146+
});
147+
148+
it('says on line one that running this file started nothing', () => {
149+
expect(first).toContain('/w/packages/cli/dist/index.js');
150+
expect(first).toContain('starts nothing');
151+
});
152+
153+
it('names the real entry point — the question the reader is holding', () => {
154+
expect(second).toContain('/w/packages/cli/bin/run.js');
155+
});
156+
157+
it('keeps each line on one line', () => {
158+
expect(first).not.toContain('\n');
159+
expect(second).not.toContain('\n');
160+
});
161+
});
162+
163+
describe('[#10111] isInvocationError', () => {
164+
it('recognises what the real oclif parser throws for an unknown flag', async () => {
165+
expect(isInvocationError(await realNonExistentFlagError())).toBe(true);
166+
});
167+
168+
it('does not claim an ordinary runtime failure', () => {
169+
expect(isInvocationError(new Error('ECONNREFUSED 127.0.0.1:3000'))).toBe(false);
170+
expect(isInvocationError(undefined)).toBe(false);
171+
expect(isInvocationError('a string')).toBe(false);
172+
expect(isInvocationError({ parse: {} })).toBe(false);
173+
});
174+
});
175+
176+
describe('[#10111] invocationFailureLine', () => {
177+
it('is ONE line naming the rejected flag and the fact that nothing ran', async () => {
178+
const line = invocationFailureLine(await realNonExistentFlagError(), ['dev', '--no-ui']);
179+
expect(line).toBeDefined();
180+
expect(line).not.toContain('\n');
181+
expect(line!.startsWith(`${INVOCATION_PREFIX}: INVOCATION ERROR — `)).toBe(true);
182+
expect(line).toContain('Nonexistent flag: --no-ui');
183+
expect(line).toContain('The command never ran');
184+
expect(line).toContain('nothing is listening');
185+
expect(line).toContain(`Invoked as: ${INVOCATION_PREFIX} dev --no-ui`);
186+
});
187+
188+
it('drops oclif’s `See more help with --help` tail, which is the second line of the message', async () => {
189+
const error = await realNonExistentFlagError();
190+
expect(String((error as Error).message)).toContain('See more help with --help');
191+
expect(invocationFailureLine(error, ['dev', '--no-ui'])).not.toContain('See more help');
192+
});
193+
194+
it('returns undefined for a runtime failure, leaving oclif’s reporting untouched', () => {
195+
expect(invocationFailureLine(new Error('boom'), ['serve'])).toBeUndefined();
196+
});
197+
198+
it('caps the echoed invocation so one long argument cannot wrap the line', async () => {
199+
const line = invocationFailureLine(await realNonExistentFlagError(), ['dev', `--app=${'x'.repeat(400)}`]);
200+
expect(line!.length).toBeLessThan(320);
201+
expect(line).toContain('...');
202+
});
203+
});
204+
205+
describe('[#10111] the prefix', () => {
206+
it('is the CLI name `format.ts` declares — kept in sync by this test, not an import', () => {
207+
// `invocation.ts` imports nothing but node builtins on purpose: it is
208+
// reached from the bin shims' failure path, and pulling `format.ts` in
209+
// would drag chalk, zod and @objectstack/spec along with it.
210+
expect(INVOCATION_PREFIX).toBe(CLI_NAME);
211+
});
212+
});

0 commit comments

Comments
 (0)