|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #18432 — `os build`'s package-docs step line was printed BEFORE the call it |
| 5 | + * announces, so a build that collected nothing read identically to one that |
| 6 | + * collected four documents. |
| 7 | + * |
| 8 | + * if (!flags.json) printStep('Collecting package docs (ADR-0046)...'); |
| 9 | + * const docsResult = collectAndLintDocs(...) |
| 10 | + * |
| 11 | + * The sentence was unconditional and carried no count, so the reassurance it |
| 12 | + * offers — "the docs step ran, and it found your docs" — was true of every run |
| 13 | + * including the ones that found nothing at all. That is the reassurance half of |
| 14 | + * #18170: an exit-0 build with the usual progress line is the shape every |
| 15 | + * reader trusts. #18428 landed the audible half (an uncollected docs directory |
| 16 | + * now speaks); this is the other half. |
| 17 | + * |
| 18 | + * ## WHAT THESE PINS ASSERT — the PAIR, not the sentence |
| 19 | + * |
| 20 | + * A test that only checked "the step line is printed" passes on the defective |
| 21 | + * tree and pins nothing: the defective tree printed it unconditionally. So the |
| 22 | + * behaviour is pinned from both ends over fixtures that differ in exactly one |
| 23 | + * respect — whether `src/docs/` holds anything: |
| 24 | + * |
| 25 | + * - absent `src/docs/` -> `0 collected` |
| 26 | + * - empty `src/docs/` -> `0 collected` |
| 27 | + * - two docs -> `2 collected` |
| 28 | + * |
| 29 | + * Only the count tells the three runs apart, and only a line printed AFTER the |
| 30 | + * collection can carry one — which is why this is the assertion that would have |
| 31 | + * caught the original defect rather than a restatement of it. |
| 32 | + * |
| 33 | + * ## The count is the ARTIFACT's docs, not a decoration |
| 34 | + * |
| 35 | + * `compile.ts` writes `finalBundle.docs = docsResult.docs` from the same value |
| 36 | + * it now prints, so each run's printed count is compared against the emitted |
| 37 | + * artifact. A number that drifted away from the set it describes would be a |
| 38 | + * second silent-reassurance defect wearing the fix's clothes; asserting only |
| 39 | + * the text could not see it. |
| 40 | + * |
| 41 | + * ## `--json` carries no step line, and that is pinned too |
| 42 | + * |
| 43 | + * The printed line lives behind `if (!flags.json)`. The template literal is new |
| 44 | + * and the `--json` face must stay one JSON document, so the machine face is |
| 45 | + * asserted to parse and to carry no step text at all. |
| 46 | + * |
| 47 | + * ALTITUDE: this spawns the CLI (`bin/run-dev.js` through tsx) rather than |
| 48 | + * calling a helper, because the defect is in the ORDER of two statements inside |
| 49 | + * the command body — there is no seam below the process that can observe it. |
| 50 | + * Same spawn shape and the same `childEnv()` as |
| 51 | + * `build-json-advisory-parity.e2e.test.ts`, so nothing here is a new pattern. |
| 52 | + */ |
| 53 | + |
| 54 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 55 | +import { execFile } from 'node:child_process'; |
| 56 | +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } 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 | + |
| 62 | +const HERE = resolve(fileURLToPath(import.meta.url), '..'); |
| 63 | +const CLI = resolve(HERE, '../bin/run-dev.js'); |
| 64 | +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); |
| 65 | + |
| 66 | +interface Run { |
| 67 | + code: number; |
| 68 | + stdout: string; |
| 69 | + stderr: string; |
| 70 | +} |
| 71 | + |
| 72 | +function runCli(args: string[], cwd: string): Promise<Run> { |
| 73 | + return new Promise((resolvePromise) => { |
| 74 | + execFile( |
| 75 | + TSX, |
| 76 | + [CLI, ...args], |
| 77 | + { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, |
| 78 | + (err, stdout, stderr) => { |
| 79 | + resolvePromise({ |
| 80 | + code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, |
| 81 | + stdout: String(stdout), |
| 82 | + stderr: String(stderr), |
| 83 | + }); |
| 84 | + }, |
| 85 | + ); |
| 86 | + }); |
| 87 | +} |
| 88 | + |
| 89 | +/** The step line, matched so the COUNT is captured and nothing else is assumed. */ |
| 90 | +const STEP_LINE = /Collecting package docs \(ADR-0046\)\.\.\.\s*(\d+) collected/; |
| 91 | + |
| 92 | +/** The sentence with NO count — the pre-fix spelling, asserted absent. */ |
| 93 | +const COUNTLESS_STEP_LINE = /Collecting package docs \(ADR-0046\)\.\.\.\s*$/m; |
| 94 | + |
| 95 | +/** What the step line reported on this run, or `null` when it was not printed. */ |
| 96 | +function printedCount(run: Run): number | null { |
| 97 | + const m = STEP_LINE.exec(run.stdout); |
| 98 | + return m ? Number(m[1]) : null; |
| 99 | +} |
| 100 | + |
| 101 | +/** How many docs the emitted artifact actually carries. */ |
| 102 | +function artifactDocCount(dir: string): number { |
| 103 | + const raw = readFileSync(join(dir, 'dist', 'objectstack.json'), 'utf8'); |
| 104 | + const parsed = JSON.parse(raw) as { docs?: unknown }; |
| 105 | + return Array.isArray(parsed.docs) ? parsed.docs.length : 0; |
| 106 | +} |
| 107 | + |
| 108 | +/** |
| 109 | + * A stack that builds cleanly. The namespace is a parameter because ADR-0046 |
| 110 | + * lints doc names for the package's namespace prefix — a fixture whose docs did |
| 111 | + * not carry it would fail the build and every count below would be reading an |
| 112 | + * aborted run. |
| 113 | + */ |
| 114 | +const config = (ns: string) => ` |
| 115 | +export default { |
| 116 | + manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, |
| 117 | + requires: [], |
| 118 | + objects: [ |
| 119 | + { |
| 120 | + name: '${ns}_thing', |
| 121 | + label: 'Thing', |
| 122 | + sharingModel: 'private', |
| 123 | + fields: { title: { type: 'text', label: 'Title' } }, |
| 124 | + }, |
| 125 | + ], |
| 126 | +}; |
| 127 | +`; |
| 128 | + |
| 129 | +const doc = (title: string) => `--- |
| 130 | +title: ${title} |
| 131 | +--- |
| 132 | +
|
| 133 | +# ${title} |
| 134 | +
|
| 135 | +Body text. |
| 136 | +`; |
| 137 | + |
| 138 | +/** `absent` has no `src/docs/` at all; `empty` has the directory and no files. */ |
| 139 | +const dirs: Record<'absent' | 'empty' | 'two', string> = { absent: '', empty: '', two: '' }; |
| 140 | +let root = ''; |
| 141 | + |
| 142 | +beforeAll(() => { |
| 143 | + root = mkdtempSync(join(tmpdir(), 'os-docs-step-')); |
| 144 | + |
| 145 | + dirs.absent = join(root, 'absent'); |
| 146 | + mkdirSync(join(dirs.absent, 'src'), { recursive: true }); |
| 147 | + writeFileSync(join(dirs.absent, 'objectstack.config.ts'), config('dsabsent')); |
| 148 | + |
| 149 | + dirs.empty = join(root, 'empty'); |
| 150 | + mkdirSync(join(dirs.empty, 'src', 'docs'), { recursive: true }); |
| 151 | + writeFileSync(join(dirs.empty, 'objectstack.config.ts'), config('dsempty')); |
| 152 | + |
| 153 | + dirs.two = join(root, 'two'); |
| 154 | + mkdirSync(join(dirs.two, 'src', 'docs'), { recursive: true }); |
| 155 | + writeFileSync(join(dirs.two, 'objectstack.config.ts'), config('dstwo')); |
| 156 | + writeFileSync(join(dirs.two, 'src', 'docs', 'dstwo_intro.md'), doc('Intro')); |
| 157 | + writeFileSync(join(dirs.two, 'src', 'docs', 'dstwo_guide.md'), doc('Guide')); |
| 158 | +}); |
| 159 | + |
| 160 | +afterAll(() => { |
| 161 | + if (root) rmSync(root, { recursive: true, force: true }); |
| 162 | +}); |
| 163 | + |
| 164 | +describe('[#18432] the package-docs step line reports what it collected', () => { |
| 165 | + it('ABSENT `src/docs/`: the step line reads `0 collected`, and the artifact carries no docs', async () => { |
| 166 | + const run = await runCli(['build'], dirs.absent); |
| 167 | + // Asserted first: a non-zero exit would make every claim below vacuous. |
| 168 | + expect(run.code, `stdout:\n${run.stdout}\nstderr:\n${run.stderr}`).toBe(0); |
| 169 | + |
| 170 | + expect(printedCount(run)).toBe(0); |
| 171 | + expect(artifactDocCount(dirs.absent)).toBe(0); |
| 172 | + |
| 173 | + // The pre-fix spelling — the sentence with nothing after the ellipsis — is |
| 174 | + // the shape this card exists to remove. Pinned as absent so a revert of the |
| 175 | + // ordering shows up here rather than in a customer's build log. |
| 176 | + expect(COUNTLESS_STEP_LINE.test(run.stdout)).toBe(false); |
| 177 | + }, 120_000); |
| 178 | + |
| 179 | + it('EMPTY `src/docs/`: also `0 collected` — a present-but-empty directory is not a collection', async () => { |
| 180 | + const run = await runCli(['build'], dirs.empty); |
| 181 | + expect(run.code, `stdout:\n${run.stdout}\nstderr:\n${run.stderr}`).toBe(0); |
| 182 | + |
| 183 | + expect(printedCount(run)).toBe(0); |
| 184 | + expect(artifactDocCount(dirs.empty)).toBe(0); |
| 185 | + }, 120_000); |
| 186 | + |
| 187 | + it('TWO docs: the step line reads `2 collected` — the other end of the pair', async () => { |
| 188 | + const run = await runCli(['build'], dirs.two); |
| 189 | + expect(run.code, `stdout:\n${run.stdout}\nstderr:\n${run.stderr}`).toBe(0); |
| 190 | + |
| 191 | + expect(printedCount(run)).toBe(2); |
| 192 | + // The printed number IS the set the artifact receives, not a tally kept |
| 193 | + // beside it. |
| 194 | + expect(artifactDocCount(dirs.two)).toBe(2); |
| 195 | + }, 120_000); |
| 196 | + |
| 197 | + it('the three runs are DISTINGUISHABLE — the defect was that they were not', async () => { |
| 198 | + // The whole card in one assertion. On the defective tree all three runs |
| 199 | + // printed the identical sentence; the only thing that separates them is the |
| 200 | + // count, and the count is only available after the collection has happened. |
| 201 | + const [absent, empty, two] = await Promise.all([ |
| 202 | + runCli(['build'], dirs.absent), |
| 203 | + runCli(['build'], dirs.empty), |
| 204 | + runCli(['build'], dirs.two), |
| 205 | + ]); |
| 206 | + expect([printedCount(absent), printedCount(empty), printedCount(two)]).toEqual([0, 0, 2]); |
| 207 | + }, 180_000); |
| 208 | + |
| 209 | + it('`--json` prints no step line at all and stays one JSON document', async () => { |
| 210 | + const run = await runCli(['build', '--json'], dirs.two); |
| 211 | + expect(run.code, `stdout:\n${run.stdout}\nstderr:\n${run.stderr}`).toBe(0); |
| 212 | + expect(() => JSON.parse(run.stdout)).not.toThrow(); |
| 213 | + expect(run.stdout).not.toMatch(/Collecting package docs/); |
| 214 | + }, 120_000); |
| 215 | +}); |
0 commit comments