|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The `bin/run.js` build-state prerequisite says what to run, and says it in |
| 5 | + * the CALLER's terms (#12539). |
| 6 | + * |
| 7 | + * ── What this file is for ──────────────────────────────────────────────── |
| 8 | + * |
| 9 | + * A handful of e2e files in this directory spawn `bin/run.js` rather than |
| 10 | + * `bin/run-dev.js`, because what they measure only exists when oclif resolves |
| 11 | + * the command from the BUILT artifact. On a worktree where only the dependency |
| 12 | + * closure was built — `pnpm --filter '@objectstack/cli^...' build`, the |
| 13 | + * documented first command — the child answers |
| 14 | + * ` › Error: command serve not found` and the harness reports |
| 15 | + * `serve exited 2 before "Server is ready"`. Neither sentence says "run the |
| 16 | + * build", so a build-state prerequisite arrives dressed as a regression on a |
| 17 | + * file with no visible connection to a build step. That is the card. |
| 18 | + * |
| 19 | + * `requireBuiltCli()` (`helpers/serve-process.ts`) is the answer, and the ONLY |
| 20 | + * part of it a reader ever acts on is the sentence it throws. That sentence can |
| 21 | + * only be produced on an unbuilt tree, and no test can produce an unbuilt tree |
| 22 | + * without breaking every neighbouring file in the same run — so without this |
| 23 | + * file the wording would be the one part of the guard nothing checks. A refusal |
| 24 | + * that forgets to name the build command is the false red restated, not fixed. |
| 25 | + * |
| 26 | + * ── Why the mechanism is a PARAMETER, and why that is pinned here ───────── |
| 27 | + * |
| 28 | + * Until #12539 the guard lived as three byte-identical private copies, each |
| 29 | + * carrying `"This file spawns bin/run.js with NODE_ENV unset…"` inline. That |
| 30 | + * sentence is true of the `bin/run.js` + unset-`NODE_ENV` spawn and of nothing |
| 31 | + * else: a `bin/run-dev.js` + tsx child reads `src/`, and a hoisted copy |
| 32 | + * carrying the text outward would be a FALSE EXPLANATION attached to a true |
| 33 | + * refusal — the class #12498 and #12561 were filed for. So the reason is the |
| 34 | + * caller's to supply, and `it('carries the caller's mechanism…')` below is what |
| 35 | + * keeps it that way: it passes a foreign mechanism and demands the `bin/run.js` |
| 36 | + * sentence be absent. |
| 37 | + * |
| 38 | + * ⚠️ `helpers/serve-process.ts` is a TEST helper, so `check:cross-package-test- |
| 39 | + * inputs` and the source-alias gate both already see it; nothing here reads |
| 40 | + * outside `packages/cli`. |
| 41 | + */ |
| 42 | + |
| 43 | +import { describe, it, expect } from 'vitest'; |
| 44 | +import { existsSync, readFileSync } from 'node:fs'; |
| 45 | +import { resolve } from 'node:path'; |
| 46 | +import { fileURLToPath } from 'node:url'; |
| 47 | +import { |
| 48 | + RUN_JS_RESOLVES_FROM_DIST, |
| 49 | + requireBuiltCli, |
| 50 | + unbuiltCliError, |
| 51 | +} from './helpers/serve-process.js'; |
| 52 | + |
| 53 | +const HERE = resolve(fileURLToPath(import.meta.url), '..'); |
| 54 | +/** `packages/cli` — this package's own root, never another package's. */ |
| 55 | +const PACKAGE_ROOT = resolve(HERE, '..'); |
| 56 | + |
| 57 | +/** A mechanism belonging to some OTHER entrypoint — the misuse the guard must not commit. */ |
| 58 | +const FOREIGN_MECHANISM = 'This file execs the packed tarball, which has no src/ to fall back to.'; |
| 59 | + |
| 60 | +describe('#12539: the unbuilt-CLI refusal is legible', () => { |
| 61 | + it('names the build command, so the reader knows what to run', () => { |
| 62 | + const message = unbuiltCliError('/repo/packages/cli/dist/commands/serve.js', RUN_JS_RESOLVES_FROM_DIST).message; |
| 63 | + // The whole point of the card. A refusal that stops at "not built" costs |
| 64 | + // the reader the same round the false red did. |
| 65 | + expect(message).toContain('Run: pnpm exec turbo run build --filter=@objectstack/cli'); |
| 66 | + }); |
| 67 | + |
| 68 | + it('names the artifact it looked for, not just the package', () => { |
| 69 | + const message = unbuiltCliError('/repo/packages/cli/dist/commands/serve.js', RUN_JS_RESOLVES_FROM_DIST).message; |
| 70 | + expect(message).toContain('/repo/packages/cli/dist/commands/serve.js'); |
| 71 | + expect(message).toContain('packages/cli is not built'); |
| 72 | + }); |
| 73 | + |
| 74 | + it('says why CI never sees this, so a green CI is not read as a contradiction', () => { |
| 75 | + const message = unbuiltCliError('/x/serve.js', RUN_JS_RESOLVES_FROM_DIST).message; |
| 76 | + expect(message).toContain('@objectstack/cli#test dependsOn build'); |
| 77 | + expect(message).toContain('a direct vitest run does not'); |
| 78 | + }); |
| 79 | + |
| 80 | + // ── The instrument can say no ────────────────────────────────────────── |
| 81 | + it("carries the CALLER's mechanism, and no other entrypoint's", () => { |
| 82 | + const message = unbuiltCliError('/x/serve.js', FOREIGN_MECHANISM).message; |
| 83 | + expect(message).toContain(FOREIGN_MECHANISM); |
| 84 | + // ⛔ The load-bearing half. If the `bin/run.js` sentence is ever inlined |
| 85 | + // back into the helper "so callers do not have to pass one", this goes red |
| 86 | + // — which is the only thing standing between a shared refusal and a shared |
| 87 | + // WRONG explanation. |
| 88 | + expect(message).not.toContain('bin/run.js'); |
| 89 | + expect(message).not.toContain('transpiling src/'); |
| 90 | + }); |
| 91 | + |
| 92 | + it('reproduces, byte for byte, what the three private copies threw before the hoist', () => { |
| 93 | + // #12539 moved this refusal out of three files; it did not reword it. The |
| 94 | + // literal below is the message measured on `09b4f4e4e`, so a rewording has |
| 95 | + // to be a deliberate edit here rather than a side effect of the move. |
| 96 | + expect(unbuiltCliError('/repo/packages/cli/dist/commands/serve.js', RUN_JS_RESOLVES_FROM_DIST).message).toBe( |
| 97 | + 'packages/cli is not built: /repo/packages/cli/dist/commands/serve.js does not exist.\n' + |
| 98 | + 'This file spawns bin/run.js with NODE_ENV unset, which is what makes oclif resolve the ' + |
| 99 | + 'command from dist/ instead of transpiling src/ — so on an unbuilt tree the child answers ' + |
| 100 | + '"command serve not found" and every boot below times out.\n' + |
| 101 | + 'CI declares the build (turbo: @objectstack/cli#test dependsOn build); a direct vitest run does not.\n' + |
| 102 | + 'Run: pnpm exec turbo run build --filter=@objectstack/cli', |
| 103 | + ); |
| 104 | + }); |
| 105 | +}); |
| 106 | + |
| 107 | +describe('#12539: the guard probes the DECLARED command target, and is silent when it is there', () => { |
| 108 | + /** |
| 109 | + * The path the guard must be looking at, derived the same way it derives it — |
| 110 | + * from `oclif.commands.target`, which is where `dist/commands` is decided. |
| 111 | + * Restating `dist/commands` here would pin the guard to a path the CLI is |
| 112 | + * free to move, which is the copy this whole card is about. |
| 113 | + */ |
| 114 | + const declared = (): string => { |
| 115 | + const target = JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf8'))?.oclif?.commands?.target; |
| 116 | + expect(typeof target, 'oclif.commands.target is what the guard reads; a bare string breaks it').toBe('string'); |
| 117 | + return resolve(PACKAGE_ROOT, String(target).replace(/^\.\//, ''), 'serve.js'); |
| 118 | + }; |
| 119 | + |
| 120 | + it('reads the target off the CLI declaration rather than restating it', () => { |
| 121 | + expect(declared().endsWith('serve.js')).toBe(true); |
| 122 | + expect(declared().startsWith(PACKAGE_ROOT)).toBe(true); |
| 123 | + }); |
| 124 | + |
| 125 | + /** |
| 126 | + * ⭐ BOTH directions, decided by the tree this run is actually on — which is |
| 127 | + * why it is one `it()` and not two. |
| 128 | + * |
| 129 | + * On CI, and on any tree where `@objectstack/cli` is built, this asserts the |
| 130 | + * expensive half: the guard is SILENT. A guard that fires on a correctly |
| 131 | + * built tree is strictly worse than the false red it replaces, because then |
| 132 | + * every green in this directory is a coin flip. On an unbuilt tree it asserts |
| 133 | + * the other half — that it fires, and names the artifact it looked for. |
| 134 | + */ |
| 135 | + it('is silent when the declared command file exists, and refuses naming it when it does not', () => { |
| 136 | + const commandFile = declared(); |
| 137 | + if (existsSync(commandFile)) { |
| 138 | + expect(() => requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST)).not.toThrow(); |
| 139 | + } else { |
| 140 | + expect(() => requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST)).toThrow(commandFile); |
| 141 | + } |
| 142 | + }); |
| 143 | +}); |
0 commit comments