|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * PIN — the two CLI scaffolders emit ONE emission policy, not two copies of it. |
| 5 | + * |
| 6 | + * ## The defect this exists for |
| 7 | + * |
| 8 | + * `os init` and `os create` each wrote the third-party ranges and the |
| 9 | + * `tsconfig.json` a new project receives, in their own words. Measured on the |
| 10 | + * tree the day this landed, the TypeScript range — the value that decides |
| 11 | + * whether a scaffolded project type-checks at all — was written in SIX places |
| 12 | + * across three scaffolders and had split into THREE values (`^5.3.0` in |
| 13 | + * `init.ts`, `^5.8.0` in `create.ts`, `^6.0.0` in the bundled |
| 14 | + * `create-objectstack` template). vitest had split into two. The two CLI values |
| 15 | + * were written in the SAME commit and stayed apart for 211 days; the third |
| 16 | + * arrived 102 days before the measurement. |
| 17 | + * |
| 18 | + * The control for that reading sits in the same file as the defect: |
| 19 | + * `SCAFFOLD_PNPM_RANGE` and `renderPnpmWorkspaceYaml()` are IMPORTED by the |
| 20 | + * other scaffolder rather than restated, and across the same five emissions, |
| 21 | + * the same window and the same authors they did not drift at all. |
| 22 | + * |
| 23 | + * ## What is asserted, and why no expected value is written down here |
| 24 | + * |
| 25 | + * Every expectation below is DERIVED — from the renderers, from the other |
| 26 | + * scaffolder, or from the doc page that already states the answer. A test that |
| 27 | + * transcribed `'^5.3.0'` would go green on a tree where one scaffolder had been |
| 28 | + * edited and the other had not, which is the exact state it exists to catch. |
| 29 | + * |
| 30 | + * 1. Across all five emissions, each third-party dependency name resolves to |
| 31 | + * exactly ONE range. This is the property; the value it settles on is not. |
| 32 | + * 2. That one range IS the exported constant, so a template that grows a |
| 33 | + * literal instead of importing turns this red. |
| 34 | + * 3. The surviving TypeScript range is the floor the DOCS state. `^5.3.0` |
| 35 | + * beat `^5.8.0` because two live pages already promise "TypeScript 5.3+"; |
| 36 | + * that is what made the choice a recorded decision rather than a silent |
| 37 | + * pick, and this case is what keeps the two ends tied together. |
| 38 | + * 4. `os init`'s `tsconfig.json` is written inside `run()`, so it is measured |
| 39 | + * by DRIVING the real command into a throwaway directory and reading the |
| 40 | + * bytes off disk — a renderer that is exported but no longer called would |
| 41 | + * pass every in-process assertion here. |
| 42 | + * |
| 43 | + * ⚠️ `create-objectstack`'s `^6.0.0` is deliberately out of scope and is NOT |
| 44 | + * asserted against: that package cannot import from `@objectstack/cli` (the |
| 45 | + * dependency edge runs the other way), and unifying it would change what a |
| 46 | + * scaffolded project installs. |
| 47 | + * |
| 48 | + * Spawned through `bin/run-dev.js` + tsx, so this suite does not depend on |
| 49 | + * `packages/cli/dist` having been built (`@objectstack/cli#test` depends on |
| 50 | + * `^build` only) — the same reason `create-refuses-invalid-project-name.e2e.test.ts` |
| 51 | + * spawns that way. |
| 52 | + */ |
| 53 | + |
| 54 | +import { describe, it, expect } from 'vitest'; |
| 55 | +import { execFile } from 'node:child_process'; |
| 56 | +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; |
| 57 | +import { tmpdir } from 'node:os'; |
| 58 | +import { join, resolve } from 'node:path'; |
| 59 | +import { fileURLToPath } from 'node:url'; |
| 60 | +import { childEnv } from './helpers/serve-process.js'; |
| 61 | +import { |
| 62 | + renderScaffoldPackageJson, |
| 63 | + renderScaffoldTsconfig, |
| 64 | + SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG, |
| 65 | + SCAFFOLD_TSX_RANGE, |
| 66 | + SCAFFOLD_TYPES_NODE_RANGE, |
| 67 | + SCAFFOLD_TYPESCRIPT_RANGE, |
| 68 | + SCAFFOLD_VITEST_RANGE, |
| 69 | + SCAFFOLD_ZOD_RANGE, |
| 70 | + TEMPLATES, |
| 71 | +} from '../src/commands/init.js'; |
| 72 | +import { DEFAULT_PLACEMENT, templates } from '../src/commands/create.js'; |
| 73 | + |
| 74 | +const HERE = resolve(fileURLToPath(import.meta.url), '..'); |
| 75 | +const CLI = resolve(HERE, '../bin/run-dev.js'); |
| 76 | +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); |
| 77 | + |
| 78 | +// One `resolve(HERE, …)` call per line and nothing split across lines: |
| 79 | +// `check:cross-package-test-inputs` reconstructs these reads by SOURCE SCAN, |
| 80 | +// and a spelling it cannot parse leaves the glob declared and held by nothing. |
| 81 | +// Both are declared for `@objectstack/cli` in |
| 82 | +// scripts/cross-package-test-inputs.mjs and mirrored into turbo.json. |
| 83 | +const GETTING_STARTED = resolve(HERE, '../../..', 'content/docs/getting-started/index.mdx'); |
| 84 | +const TROUBLESHOOTING = resolve(HERE, '../../..', 'content/docs/deployment/troubleshooting.mdx'); |
| 85 | + |
| 86 | +/** oclif + tsx cold start with every command module loaded; ~2-10 s when healthy. */ |
| 87 | +const RUN_TIMEOUT_MS = 180_000; |
| 88 | + |
| 89 | +const PROBE_NAME = 'emission-policy-probe'; |
| 90 | + |
| 91 | +/** `@objectstack/*` ranges are the CLI's own version — pinned by `init.test.ts`. */ |
| 92 | +function thirdPartyOnly(deps: Record<string, unknown> | undefined): Array<[string, string]> { |
| 93 | + return Object.entries(deps ?? {}) |
| 94 | + .filter(([name, range]) => !name.startsWith('@objectstack/') && typeof range === 'string') |
| 95 | + .map(([name, range]) => [name, range as string]); |
| 96 | +} |
| 97 | + |
| 98 | +/** |
| 99 | + * Every `package.json` the two commands emit for the shape a reader of the docs |
| 100 | + * actually gets — `os init`'s three templates and `os create`'s two, in its |
| 101 | + * DEFAULT placement. `--in-repo` is excluded on purpose: it emits `workspace:*` |
| 102 | + * and is documented as platform-work-only. |
| 103 | + */ |
| 104 | +function emittedManifests(): Array<{ id: string; manifest: Record<string, unknown> }> { |
| 105 | + const out: Array<{ id: string; manifest: Record<string, unknown> }> = []; |
| 106 | + for (const [key, template] of Object.entries(TEMPLATES)) { |
| 107 | + out.push({ |
| 108 | + id: `os init -t ${key}`, |
| 109 | + manifest: renderScaffoldPackageJson(PROBE_NAME, template), |
| 110 | + }); |
| 111 | + } |
| 112 | + for (const [key, template] of Object.entries(templates)) { |
| 113 | + const render = template.filesFor(DEFAULT_PLACEMENT)['package.json']; |
| 114 | + out.push({ |
| 115 | + id: `os create ${key}`, |
| 116 | + manifest: render(PROBE_NAME) as Record<string, unknown>, |
| 117 | + }); |
| 118 | + } |
| 119 | + return out; |
| 120 | +} |
| 121 | + |
| 122 | +/** `<dependency name> -> every range any emission declares for it`. */ |
| 123 | +function declaredRanges(): Map<string, Map<string, string[]>> { |
| 124 | + const byName = new Map<string, Map<string, string[]>>(); |
| 125 | + for (const { id, manifest } of emittedManifests()) { |
| 126 | + const deps = [ |
| 127 | + ...thirdPartyOnly(manifest.dependencies as Record<string, unknown>), |
| 128 | + ...thirdPartyOnly(manifest.devDependencies as Record<string, unknown>), |
| 129 | + ]; |
| 130 | + for (const [name, range] of deps) { |
| 131 | + const ranges = byName.get(name) ?? new Map<string, string[]>(); |
| 132 | + ranges.set(range, [...(ranges.get(range) ?? []), id]); |
| 133 | + byName.set(name, ranges); |
| 134 | + } |
| 135 | + } |
| 136 | + return byName; |
| 137 | +} |
| 138 | + |
| 139 | +describe('scaffold emission policy — one definition, five emissions', () => { |
| 140 | + it('harvests a non-empty policy from all five emissions (control)', () => { |
| 141 | + // Without this, every assertion below passes over an empty harvest — the |
| 142 | + // vacuity that would make the whole file certify the defect it exists for. |
| 143 | + const manifests = emittedManifests(); |
| 144 | + expect(manifests.map((m) => m.id).sort()).toEqual([ |
| 145 | + 'os create example', |
| 146 | + 'os create plugin', |
| 147 | + 'os init -t app', |
| 148 | + 'os init -t empty', |
| 149 | + 'os init -t plugin', |
| 150 | + ]); |
| 151 | + const names = [...declaredRanges().keys()]; |
| 152 | + expect(names).toContain('typescript'); |
| 153 | + expect(names).toContain('vitest'); |
| 154 | + expect(names.length).toBeGreaterThanOrEqual(4); |
| 155 | + }); |
| 156 | + |
| 157 | + it('declares exactly one range per third-party dependency', () => { |
| 158 | + const disagreements: string[] = []; |
| 159 | + for (const [name, ranges] of declaredRanges()) { |
| 160 | + if (ranges.size === 1) continue; |
| 161 | + const detail = [...ranges] |
| 162 | + .map(([range, emissions]) => `${range} (${emissions.join(', ')})`) |
| 163 | + .join(' vs '); |
| 164 | + disagreements.push(`${name}: ${detail}`); |
| 165 | + } |
| 166 | + expect( |
| 167 | + disagreements, |
| 168 | + 'these dependency names are restated with different ranges by different ' |
| 169 | + + 'scaffolders — declare the range once in init.ts and import it', |
| 170 | + ).toEqual([]); |
| 171 | + }); |
| 172 | + |
| 173 | + it('emits the exported constant rather than a literal, for every policy range', () => { |
| 174 | + const ranges = declaredRanges(); |
| 175 | + const expected: Array<[string, string]> = [ |
| 176 | + ['typescript', SCAFFOLD_TYPESCRIPT_RANGE], |
| 177 | + ['vitest', SCAFFOLD_VITEST_RANGE], |
| 178 | + ['@types/node', SCAFFOLD_TYPES_NODE_RANGE], |
| 179 | + ['tsx', SCAFFOLD_TSX_RANGE], |
| 180 | + ['zod', SCAFFOLD_ZOD_RANGE], |
| 181 | + ]; |
| 182 | + for (const [name, constant] of expected) { |
| 183 | + expect([...(ranges.get(name)?.keys() ?? [])], name).toEqual([constant]); |
| 184 | + } |
| 185 | + }); |
| 186 | +}); |
| 187 | + |
| 188 | +describe('the surviving TypeScript range is the floor the docs already state', () => { |
| 189 | + /** `TypeScript 5.3+` / `TypeScript 5.3.0 or later`, normalised to `major.minor`. */ |
| 190 | + function statedFloors(file: string): string[] { |
| 191 | + const text = readFileSync(file, 'utf8'); |
| 192 | + const out: string[] = []; |
| 193 | + const re = /TypeScript (\d+)\.(\d+)(?:\.\d+)?(?:\+| or later)/g; |
| 194 | + for (const m of text.matchAll(re)) out.push(`${m[1]}.${m[2]}`); |
| 195 | + return out; |
| 196 | + } |
| 197 | + |
| 198 | + it('finds a stated floor on both pages (control)', () => { |
| 199 | + // A regex that matched nothing would make the case below assert `[] === []`. |
| 200 | + expect(statedFloors(GETTING_STARTED).length).toBeGreaterThan(0); |
| 201 | + expect(statedFloors(TROUBLESHOOTING).length).toBeGreaterThan(0); |
| 202 | + }); |
| 203 | + |
| 204 | + it('agrees with what the scaffolders emit', () => { |
| 205 | + const floors = new Set([...statedFloors(GETTING_STARTED), ...statedFloors(TROUBLESHOOTING)]); |
| 206 | + expect([...floors], 'the two pages state different TypeScript floors').toHaveLength(1); |
| 207 | + const [floor] = [...floors]; |
| 208 | + expect( |
| 209 | + SCAFFOLD_TYPESCRIPT_RANGE, |
| 210 | + 'the emitted range and the documented floor have to be the same promise — ' |
| 211 | + + 'change both together, or neither', |
| 212 | + ).toBe(`^${floor}.0`); |
| 213 | + }); |
| 214 | +}); |
| 215 | + |
| 216 | +describe('the emitted tsconfig.json comes from the shared renderer', () => { |
| 217 | + function runCli(args: string[], cwd: string): Promise<{ code: number; stderr: string }> { |
| 218 | + return new Promise((done) => { |
| 219 | + execFile( |
| 220 | + TSX, |
| 221 | + [CLI, ...args], |
| 222 | + { cwd, maxBuffer: 8 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, |
| 223 | + (err, _stdout, stderr) => { |
| 224 | + done({ |
| 225 | + code: err |
| 226 | + ? typeof (err as { code?: unknown }).code === 'number' |
| 227 | + ? (err as unknown as { code: number }).code |
| 228 | + : 1 |
| 229 | + : 0, |
| 230 | + stderr: String(stderr), |
| 231 | + }); |
| 232 | + }, |
| 233 | + ); |
| 234 | + }); |
| 235 | + } |
| 236 | + |
| 237 | + /** Everything but the two keys the emitted shapes legitimately differ on. */ |
| 238 | + function base(tsconfig: Record<string, unknown>): Record<string, unknown> { |
| 239 | + const options = { ...(tsconfig.compilerOptions as Record<string, unknown>) }; |
| 240 | + delete options.rootDir; |
| 241 | + return options; |
| 242 | + } |
| 243 | + |
| 244 | + it( |
| 245 | + 'os init writes exactly what renderScaffoldTsconfig() returns, and os create shares its base', |
| 246 | + { timeout: RUN_TIMEOUT_MS }, |
| 247 | + async () => { |
| 248 | + const sandbox = mkdtempSync(join(tmpdir(), 'emission-policy-')); |
| 249 | + try { |
| 250 | + const run = await runCli(['init', PROBE_NAME, '-t', 'app', '--no-install'], sandbox); |
| 251 | + expect(run.code, run.stderr).toBe(0); |
| 252 | + |
| 253 | + // The emission really happened — an absent or empty directory would let |
| 254 | + // every comparison below run over nothing. |
| 255 | + const projectDir = join(sandbox, PROBE_NAME); |
| 256 | + expect(readdirSync(projectDir).length).toBeGreaterThan(1); |
| 257 | + |
| 258 | + const emitted = readFileSync(join(projectDir, 'tsconfig.json'), 'utf8'); |
| 259 | + const rendered = renderScaffoldTsconfig({ |
| 260 | + rootDir: '.', |
| 261 | + include: SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG, |
| 262 | + }); |
| 263 | + expect(emitted).toBe(`${JSON.stringify(rendered, null, 2)}\n`); |
| 264 | + |
| 265 | + // `os create`'s standalone tsconfigs measured against the bytes `os |
| 266 | + // init` actually wrote, not against a transcription of either. |
| 267 | + const emittedOptions = base(JSON.parse(emitted) as Record<string, unknown>); |
| 268 | + for (const [key, template] of Object.entries(templates)) { |
| 269 | + const render = template.filesFor(DEFAULT_PLACEMENT)['tsconfig.json']; |
| 270 | + const created = render(PROBE_NAME) as Record<string, unknown>; |
| 271 | + expect(base(created), `os create ${key}`).toEqual(emittedOptions); |
| 272 | + } |
| 273 | + } finally { |
| 274 | + rmSync(sandbox, { recursive: true, force: true }); |
| 275 | + } |
| 276 | + }, |
| 277 | + ); |
| 278 | +}); |
0 commit comments