Skip to content

Commit d15ddba

Browse files
os-zhuangclaude
andauthored
test(cli): pin the conversions-only exit-code cell of os validate --json --strict (#11609)
The documented cell — a config whose ONLY advisories are ADR-0087 D2 load-time conversion notices exits 1 with `{ valid: true, warnings: [], conversions: [...] }` — had never been exercised: every existing fixture raises zero conversions, so a regression narrowing the gate back to the payload's `warnings` field would have left the whole suite green. Adds a minimal pair built from one template, differing in a single key on one `page:header` component: `description` (the live `page-header-subtitle-alias` window) against the canonical `subtitle`. The pin therefore discriminates on the conversion rather than on the presence of a page. Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR Co-authored-by: Claude <noreply@anthropic.com>
1 parent 177ebdc commit d15ddba

1 file changed

Lines changed: 123 additions & 0 deletions

File tree

packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,36 @@
4444
* "fails whenever `--json` sees a warning" — two fixes that pass the matrix
4545
* above identically, only one of which is the one asked for.
4646
*
47+
* ## The conversions-only cell (#11301)
48+
*
49+
* `--strict` gates on the TEXT face's warning list, and that list folds in the
50+
* ADR-0087 D2 load-time conversion notices. The payload carries those notices
51+
* separately, under `conversions`; its own `warnings` field is the five-way
52+
* spread WITHOUT them. So a config whose only advisories are conversion notices
53+
* exits 1 carrying `{ valid: true, warnings: [], conversions: [...] }` — the one
54+
* cell where the exit code is decided by a collection absent from the payload
55+
* field a reader reaches for first. Every fixture above raises zero conversions,
56+
* so a regression narrowing the gate back to `payload.warnings` would restore
57+
* the original divergence for exactly these configs with all of them green.
58+
*
59+
* The two fixtures below are a MINIMAL PAIR from one template, differing in a
60+
* single key on one page-header component: `description`, the alias
61+
* `page-header-subtitle-alias` rewrites at load, against `subtitle`, the
62+
* canonical spelling that converts nothing. So the pin discriminates on the
63+
* CONVERSION and not on "a page is present" — measured on the pair before it
64+
* was written: `description` → text `--strict` 1, `--json --strict` 1, `--json`
65+
* 0, `warnings: []`, one notice; `subtitle` → 0 on every face, no notices.
66+
*
67+
* The non-empty `conversions` assertion is the anti-vacuity guard, and it is
68+
* load-bearing rather than decorative. `page-header-subtitle-alias` is a LIVE
69+
* window that retires from the load path at protocol 18; the day it retires,
70+
* this fixture raises nothing and, without that assertion, the file would keep
71+
* passing while pinning an empty cell — precisely the failure this test exists
72+
* to remove. It goes red instead, and whoever retires the entry re-points the
73+
* fixture at another live conversion. (The obvious candidate for this fixture,
74+
* `object-compactLayout-to-highlightFields`, is already `retiredFromLoadPath`:
75+
* the schema tombstones the key, so it raises a validation ERROR, not a notice.)
76+
*
4777
* ## Why a real child process
4878
*
4979
* `process.exitCode` set inside a vitest worker is not an exit status: the
@@ -103,6 +133,35 @@ export default {
103133
};
104134
`;
105135

136+
/**
137+
* The conversions-only minimal pair (#11301) — `CLEAN_SOURCE`'s shape plus one
138+
* `page:header` component, whose second line is authored under the key named.
139+
*
140+
* Nothing else here raises an advisory: the unknown-key lints run on the
141+
* POST-conversion `normalized`, so what they see is the canonical `subtitle`
142+
* either way, and both members are pinned to `warnings: []` below rather than
143+
* assumed to be.
144+
*/
145+
const headerPageSource = (headerTextKey: 'description' | 'subtitle'): string => `
146+
export default {
147+
manifest: { id: 'com.example.strictexit', name: 'strictexit', version: '1.0.0', type: 'app', namespace: 'strictexit' },
148+
objects: [{
149+
name: 'strictexit_ticket',
150+
label: 'Ticket',
151+
sharingModel: 'private',
152+
fields: { title: { type: 'text', label: 'Title' } },
153+
}],
154+
apps: [{ name: 'strictexit_app', label: 'Strict Exit App' }],
155+
pages: [{
156+
name: 'strictexit_home',
157+
label: 'Home',
158+
regions: [{ name: 'main', components: [
159+
{ type: 'page:header', properties: { title: 'Tickets', ${headerTextKey}: 'All open tickets' } },
160+
] }],
161+
}],
162+
};
163+
`;
164+
106165
interface Run {
107166
code: number;
108167
stdout: string;
@@ -128,17 +187,25 @@ function runCli(args: string[], cwd: string): Promise<Run> {
128187

129188
let warnsDir: string;
130189
let cleanDir: string;
190+
let conversionsDir: string;
191+
let conversionsCanonDir: string;
131192

132193
beforeAll(() => {
133194
warnsDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-warns-'));
134195
writeFileSync(join(warnsDir, 'objectstack.config.ts'), WARNS_SOURCE);
135196
cleanDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-clean-'));
136197
writeFileSync(join(cleanDir, 'objectstack.config.ts'), CLEAN_SOURCE);
198+
conversionsDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-'));
199+
writeFileSync(join(conversionsDir, 'objectstack.config.ts'), headerPageSource('description'));
200+
conversionsCanonDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-canon-'));
201+
writeFileSync(join(conversionsCanonDir, 'objectstack.config.ts'), headerPageSource('subtitle'));
137202
});
138203

139204
afterAll(() => {
140205
rmSync(warnsDir, { recursive: true, force: true });
141206
rmSync(cleanDir, { recursive: true, force: true });
207+
rmSync(conversionsDir, { recursive: true, force: true });
208+
rmSync(conversionsCanonDir, { recursive: true, force: true });
142209
});
143210

144211
describe('#11174 — --strict reaches the same exit status on both faces', () => {
@@ -186,6 +253,62 @@ describe('#11174 — --strict reaches the same exit status on both faces', () =>
186253
expect(json.code, `json --strict:\n${json.stdout}\n${json.stderr}`).toBe(0);
187254
}, 120_000);
188255

256+
it('conversions-only: the cell where --strict is decided by a collection the payload keeps OUT of `warnings`', async () => {
257+
const text = await runCli(['validate', '--strict'], conversionsDir);
258+
const json = await runCli(['validate', '--json', '--strict'], conversionsDir);
259+
260+
// Same floor as the first case: equality is only worth asserting over a run
261+
// that genuinely had something to fail on.
262+
expect(
263+
text.code,
264+
`text --strict must fail on the conversions-only config:\n${text.stdout}\n${text.stderr}`,
265+
).not.toBe(0);
266+
267+
expect(
268+
json.code,
269+
`--json --strict exited ${json.code} where --strict exited ${text.code}, same config.\n` +
270+
`json stdout:\n${json.stdout}\njson stderr:\n${json.stderr}`,
271+
).toBe(text.code);
272+
273+
const payload = JSON.parse(json.stdout) as {
274+
valid?: unknown;
275+
warnings?: unknown;
276+
conversions?: unknown;
277+
};
278+
279+
// The cell spelled out. `warnings: []` is asserted, not tolerated: it is the
280+
// whole point — narrow the gate to this field and the run above drops to 0
281+
// while the text face stays at 1.
282+
expect(payload.valid).toBe(true);
283+
expect(payload.warnings).toEqual([]);
284+
expect(
285+
Array.isArray(payload.conversions) && (payload.conversions as unknown[]).length,
286+
'the fixture raised NO conversion — the alias has most likely retired from ' +
287+
'the load path; re-point `headerPageSource` at a live entry in ' +
288+
'`packages/spec/src/conversions/registry.ts` rather than deleting this line',
289+
).toBeGreaterThan(0);
290+
291+
// Separates "gates on --strict" from "fails whenever a conversion is seen".
292+
// The warnings fixture's own without-strict control cannot cover this: it
293+
// raises no conversions, so it passes under either behaviour.
294+
const loose = await runCli(['validate', '--json'], conversionsDir);
295+
expect(loose.code, `--json without --strict must stay 0:\n${loose.stdout}\n${loose.stderr}`).toBe(0);
296+
}, 120_000);
297+
298+
it('control: the same page under the CANONICAL key converts nothing and exits 0 on both faces', async () => {
299+
// The discriminator. Byte-identical to the fixture above but for one key,
300+
// so a pin that passed here too would be pinning the presence of a page.
301+
const text = await runCli(['validate', '--strict'], conversionsCanonDir);
302+
expect(text.code, `text --strict:\n${text.stdout}\n${text.stderr}`).toBe(0);
303+
304+
const json = await runCli(['validate', '--json', '--strict'], conversionsCanonDir);
305+
expect(json.code, `json --strict:\n${json.stdout}\n${json.stderr}`).toBe(0);
306+
307+
const payload = JSON.parse(json.stdout) as { warnings?: unknown; conversions?: unknown };
308+
expect(payload.warnings).toEqual([]);
309+
expect(payload.conversions).toEqual([]);
310+
}, 120_000);
311+
189312
it('control: without --strict, the same advisory-raising config still exits 0 under --json', async () => {
190313
// Separates "gates on --strict" from "fails whenever --json sees a warning".
191314
// Both satisfy the parity matrix above; only the first is the flag's meaning.

0 commit comments

Comments
 (0)