|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * PIN (#19098) — the retired-generator door of `os generate`, in the run that |
| 5 | + * gates the merge queue. |
| 6 | + * |
| 7 | + * `os generate schema` was retired by maintainer ruling (comment 5856790152 on |
| 8 | + * #19098, letter C): the JSON Schema it wrote passed configs the platform then |
| 9 | + * refused. Retiring it moved the `RETIRED_GENERATORS` lookup to the top of |
| 10 | + * `Generate.run`, ahead of the sub-command routing and the `<name>` |
| 11 | + * requirement, because `schema` was a routed sub-command that took no name: |
| 12 | + * behind either of those, the ledger was never read for it. The lookup is an |
| 13 | + * own-key read, so an inherited name such as `constructor` is not taken for a |
| 14 | + * retired type (it used to be, and the refusal then crashed on |
| 15 | + * `retired.detail`). |
| 16 | + * |
| 17 | + * Held here, each to exit 1 and bytes on disk: |
| 18 | + * |
| 19 | + * 1. `os generate schema`, the documented spelling, with no name: the |
| 20 | + * retirement refusal, not "Missing required argument" (what the old |
| 21 | + * position answered) and not "Unknown type:"; it names the ruling, |
| 22 | + * `os validate` and the per-type schemas `@objectstack/spec` publishes; |
| 23 | + * and it writes nothing, not even the default `objectstack.schema.json`. |
| 24 | + * 2. `os g schema -o FILE`: the same door for the alias with the old flag, |
| 25 | + * and the `-o` target stays unwritten. |
| 26 | + * 3. `os g constructor NAME`: an own-key miss. It is not answered as |
| 27 | + * retired, it does not crash, and it writes nothing. |
| 28 | + * |
| 29 | + * One control keeps the refusals from being satisfied by a command that |
| 30 | + * refuses everything: in the same directory `os g object customer --dry-run` |
| 31 | + * exits 0 and previews exactly what the object template emits (anchored to |
| 32 | + * the exported template rather than to a copied line of it). |
| 33 | + * |
| 34 | + * ## Why a child process, and why this file is NOT named `.e2e` |
| 35 | + * |
| 36 | + * The same reasons `generate-refuses-namespace-prefix.test.ts` gives: an exit |
| 37 | + * code plus bytes on disk are the contract, `process.exitCode` inside a |
| 38 | + * vitest worker is not an exit status, and `printError` writes to stdout. |
| 39 | + * Spawning puts the file in the `integration` project; the name keeps it in |
| 40 | + * the per-PR run. `generate-schema-retired.e2e.test.ts` and |
| 41 | + * `generate-agent-retired.e2e.test.ts` hold the fuller refusal texts nightly; |
| 42 | + * this file is the per-PR guard for the door. |
| 43 | + */ |
| 44 | + |
| 45 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; |
| 46 | +import { execFile } from 'node:child_process'; |
| 47 | +import { mkdtempSync, readdirSync, rmSync } from 'node:fs'; |
| 48 | +import { tmpdir } from 'node:os'; |
| 49 | +import { join, resolve } from 'node:path'; |
| 50 | +import { fileURLToPath } from 'node:url'; |
| 51 | +import { GENERATOR_SCAFFOLD_TARGETS } from '../src/commands/generate.js'; |
| 52 | +import { childEnv } from './helpers/serve-process.js'; |
| 53 | + |
| 54 | +const HERE = resolve(fileURLToPath(import.meta.url), '..'); |
| 55 | +const CLI = resolve(HERE, '../bin/run-dev.js'); |
| 56 | +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); |
| 57 | + |
| 58 | +/** oclif + tsx cold starts, four of them, sequential. */ |
| 59 | +const RUN_TIMEOUT_MS = 240_000; |
| 60 | + |
| 61 | +interface Run { |
| 62 | + code: number; |
| 63 | + stdout: string; |
| 64 | + stderr: string; |
| 65 | +} |
| 66 | + |
| 67 | +function runTsx(args: string[], cwd: string): Promise<Run> { |
| 68 | + return new Promise((resolvePromise) => { |
| 69 | + execFile( |
| 70 | + TSX, |
| 71 | + args, |
| 72 | + { cwd, maxBuffer: 8 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, |
| 73 | + (err, stdout, stderr) => { |
| 74 | + resolvePromise({ |
| 75 | + // `err.code` is the real exit status; null/undefined means the child |
| 76 | + // was signalled — a different failure, never reported as 0. |
| 77 | + code: err |
| 78 | + ? typeof (err as { code?: unknown }).code === 'number' |
| 79 | + ? (err as unknown as { code: number }).code |
| 80 | + : 1 |
| 81 | + : 0, |
| 82 | + stdout: String(stdout), |
| 83 | + stderr: String(stderr), |
| 84 | + }); |
| 85 | + }, |
| 86 | + ); |
| 87 | + }); |
| 88 | +} |
| 89 | + |
| 90 | +const objectTemplate = GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === 'object'); |
| 91 | + |
| 92 | +let dir: string; |
| 93 | +let schema: Run; |
| 94 | +let schemaAlias: Run; |
| 95 | +let inherited: Run; |
| 96 | +let control: Run; |
| 97 | + |
| 98 | +/** The directory as each refusal left it, read before the control runs. */ |
| 99 | +let afterSchemaRuns: string[]; |
| 100 | +let afterInherited: string[]; |
| 101 | + |
| 102 | +beforeAll(async () => { |
| 103 | + dir = mkdtempSync(join(tmpdir(), 'os-g-refuses-retired-')); |
| 104 | + |
| 105 | + // Sequential on purpose: cold tsx starts in a container several agents share. |
| 106 | + // Every refusal runs BEFORE the control, so the listings below are what each |
| 107 | + // refusal was measured against. |
| 108 | + schema = await runTsx([CLI, 'generate', 'schema'], dir); |
| 109 | + schemaAlias = await runTsx([CLI, 'g', 'schema', '-o', 'custom.schema.json'], dir); |
| 110 | + afterSchemaRuns = readdirSync(dir); |
| 111 | + inherited = await runTsx([CLI, 'g', 'constructor', 'thing'], dir); |
| 112 | + afterInherited = readdirSync(dir); |
| 113 | + control = await runTsx([CLI, 'g', 'object', 'customer', '--dry-run'], dir); |
| 114 | +}, RUN_TIMEOUT_MS); |
| 115 | + |
| 116 | +afterAll(() => { |
| 117 | + if (dir) rmSync(dir, { recursive: true, force: true }); |
| 118 | +}); |
| 119 | + |
| 120 | +describe('[#19098] `os generate schema` answers the retirement refusal', () => { |
| 121 | + it('exits 1, for the documented spelling and for the alias with `-o`', () => { |
| 122 | + expect(schema.code, schema.stdout + schema.stderr).toBe(1); |
| 123 | + expect(schemaAlias.code, schemaAlias.stdout + schemaAlias.stderr).toBe(1); |
| 124 | + }); |
| 125 | + |
| 126 | + it('writes nothing: neither the default `objectstack.schema.json` nor the `-o` target', () => { |
| 127 | + expect(afterSchemaRuns).toEqual([]); |
| 128 | + }); |
| 129 | + |
| 130 | + it('is the retirement, not a missing name and not an unknown type', () => { |
| 131 | + for (const run of [schema, schemaAlias]) { |
| 132 | + expect(run.stdout).toContain('`os g schema` was retired'); |
| 133 | + expect(run.stdout).not.toContain('Missing required argument'); |
| 134 | + expect(run.stdout).not.toContain('Unknown type:'); |
| 135 | + } |
| 136 | + }); |
| 137 | + |
| 138 | + it('names the ruling, `os validate` and the per-type schemas `@objectstack/spec` publishes', () => { |
| 139 | + expect(schema.stdout).toContain('maintainer ruling'); |
| 140 | + expect(schema.stdout).toContain('os validate'); |
| 141 | + expect(schema.stdout).toContain('@objectstack/spec/json-schema/'); |
| 142 | + }); |
| 143 | +}); |
| 144 | + |
| 145 | +describe('[#19098] the ledger is read by own key only', () => { |
| 146 | + it('`os g constructor thing` is not taken for a retired type, and does not crash', () => { |
| 147 | + expect(inherited.code, inherited.stdout + inherited.stderr).toBe(1); |
| 148 | + expect(inherited.stdout).not.toContain('was retired'); |
| 149 | + expect(`${inherited.stdout}\n${inherited.stderr}`).not.toContain('TypeError'); |
| 150 | + }); |
| 151 | + |
| 152 | + it('writes nothing', () => { |
| 153 | + expect(afterInherited).toEqual([]); |
| 154 | + }); |
| 155 | +}); |
| 156 | + |
| 157 | +describe('[#19098] CONTROL — the door is not a command that refuses everything', () => { |
| 158 | + it('`os g object customer --dry-run` exits 0 and previews what the object template emits', () => { |
| 159 | + expect(control.code, control.stdout + control.stderr).toBe(0); |
| 160 | + expect(control.stdout).toContain('Dry run'); |
| 161 | + expect(objectTemplate).toBeDefined(); |
| 162 | + // The preview prints the template's output, each line indented two spaces; |
| 163 | + // this directory has no config, so the template is called with no namespace. |
| 164 | + const preview = objectTemplate!.generate('customer').split('\n').map((l) => ` ${l}`).join('\n'); |
| 165 | + expect(control.stdout).toContain(preview); |
| 166 | + }); |
| 167 | +}); |
0 commit comments