Skip to content

Commit 9c431ba

Browse files
fix(cli): os init -t plugin ships a starter test, so a fresh scaffold's npm test passes (#25196)
Part of #25175 Clause-②: no Metadata-protocol: none **This PR ships the `os init -t plugin` half of #25175. The `os create plugin` half is not in it.** That half cannot land without editing three public docs pages. Those pages are outside the file surface the dispatch declared, and the dispatch says to stop on a breach of it. The section "Why `os create plugin` is not here" below covers what that half needs and which option it takes. When that half lands on this branch, the seat changes the first line from "Part of" to the closing-keyword form. ## What changes `os init NAME -t plugin` declared `"test": "vitest run"` and emitted no test file, so a fresh project's `npm test` exited 1 with `No test files found, exiting with code 1`. The template now emits one starter test, `package.test.ts`, next to `objectstack.config.ts`: - It imports the package's stack. That runs `defineStack()`, whose strict parse is the protocol's own refusal path, so a definition another stack would refuse fails the file before any test runs. - It asserts that the stack declares itself a `plugin` and contains every object that the `src/objects` barrel exports. - Its header links the documented harness for engine-level tests (`@objectstack/verify`, the "Plugin Testing" section of `protocol/kernel/plugin-spec`). The `test` script stays `vitest run` (run mode, which does not watch). Files: `packages/cli/src/commands/init.ts` (`TEMPLATES.plugin.srcFiles` only), the new pin `packages/cli/test/scaffold-test-script-passes.test.ts`, and `.changeset/25175-plugin-scaffold-test.md` (`@objectstack/cli` patch). `SCAFFOLD_ALLOWED_PEER_VERSIONS`, `SCAFFOLD_NPM_OVERRIDES` and `SCAFFOLD_VITEST_RANGE` are untouched. ## The route, and why it is honest The card allows two routes: one meaningful starter test, or a test script that does not fail on an empty suite. This PR takes the first. - **Not `--passWithNoTests`.** It turns "nothing ran" into exit 0, which is the very state the card describes, and it would also hide a misnamed test file or a broken glob. The pin below counts the tests that ran, so it fails on that route too. - **Not a placeholder.** I checked that the emitted test can fail on a real defect, on a real emission in a scratch project: - with the config's `objects:` key emptied, it fails with `AssertionError: expected [] to include 'probe_pkg_item'`; - with an undeclared key in `manifest:`, the file fails to load with `StackSchemaInvalidError: defineStack validation failed ... Unrecognized key(s) on this package manifest`. - **Not an engine boot.** The blank template's test boots the stack with `@objectstack/verify`. That is the documented style, and I measured it on a `-t plugin` emission: exit 0, `Test Files 1 passed`, the engine refused an item saved without its required `name`, about 30 s of wall in-repo. It was not chosen for three reasons: - It needs `@objectstack/verify` declared as a devDependency. That package is already in the install closure through `@objectstack/cli@18.0.0`, so the declaration adds no package. - It needs a `vitest.config.ts` for the boot's hook timeout, console intercept and registry log level. That file would be a second copy of the blank template's, free to drift from it. - Its pin would run a stack boot in Test Core. The shape chosen is the example the dispatch's H1 gives: the package loads and declares what the scaffold wrote. The test asserts what the package declares. It does not assert `required: true`, which would only restate a declaration rather than show it is enforced. ## Measured end to end (npm 10.9.4, registry `@objectstack/*` 18.0.0, vitest 4.1.12) Scaffolded with this branch's `bin/run-dev.js`, then `npx -y npm@10 install` in a scratch directory with a scratch npm cache: | `os init e2e-pkg -t plugin` | exit | reading | |:---|:---:|:---| | `npm test` | 0 | `Test Files 1 passed (1)`, `Tests 1 passed (1)`, 736 ms | | `npm test` with `package.test.ts` removed (baseline) | 1 | `No test files found, exiting with code 1` | | `npm run typecheck` | 0 | | | `npm run build` (`objectstack compile`) | 0 | `dist/objectstack.json` | | `npm run validate` | 0 | `Validation passed` | | `npm run lint` | 0 | `All checks passed` | ## The pin, and its ablation `test/scaffold-test-script-passes.test.ts` (`unit` tier): - It takes every `os init` template whose emitted manifest declares a `test` script (today `plugin`) and renders it through `init`'s own renderers. - It writes the project under the OS temp directory, outside the repository. vitest looks for a config file in the project directory and in every parent, so a project nested in `packages/cli` would run under `packages/cli/vitest.config.ts`. I measured that it did. A case asserts that no ancestor of the sandbox carries a vitest or vite config. - It symlinks each declared dependency to the copy this package resolves. - It runs the emitted script with a JSON reporter added. It asserts exit 0, zero failed tests, at least one passed test, and that the files which ran are exactly the shipped test files. - **Control:** the same project with its shipped tests deleted must fail. That is the defect's own signature. Ablation (one-off, through `scripts/ablation-replace.mjs` with a proven restore; `init.ts` is imported by relative source specifier, so no `dist/` is on the path): | mutation | result | |:---|:---| | emitted key `'package.test.ts'` renamed to `'package.ts'` | red: `` `os init -t plugin` declares a test script and emits no test file: expected 0 to be greater than 0 `` | | shipped assertion `toBe('plugin')` changed to `toBe('app')` | red: `` `vitest run` in a fresh `os init -t plugin` project: package.test.ts > this package is a plugin that hands every object in src/objects to the stack: AssertionError: expected 'plugin' to be 'app' `` | | after each run | restored: blob equals HEAD `3dff72ee5d0a`, `git diff HEAD` empty | `scaffold-emission-typechecks.test.ts` already compiles every `srcFiles` entry under the shipped `tsconfig.json`, so the new test's type layer is covered there. ## Why `os create plugin` is not here `create-plugin-docs-parity.test.ts` holds the file tree printed on `content/docs/plugins/index.mdx`, `content/docs/protocol/kernel/index.mdx` and `content/docs/protocol/kernel/plugin-spec.mdx` equal to the emitted file set. Any starter test file therefore needs a listing line on each of those pages. They are outside the declared surface. I measured the options on a real `os create plugin e2e` scaffold (npm 10.9.4). The baseline `npm test` exits 1 with `No test files found`, and its `test` script is bare `vitest`, which watches in a terminal. | placement | `npm test` | after `npm run build` | `typecheck` covers the test | |:---|:---|:---|:---| | S1 `src/index.test.ts`, tsconfig unchanged | exit 0, 1 file | `tsc` emits `dist/index.test.js` and `.d.ts`; vitest 4's default `exclude` is only `node_modules` and `.git`, so `Test Files 2 passed (2)` | yes | | S2 `src/index.test.ts` plus `tsconfig.build.json` (extends `tsconfig.json`, excludes `src/**/*.test.ts`), build `tsc -p tsconfig.build.json` | exit 0, 1 file | `dist/` holds `index.js` and `index.d.ts` only; still 1 file | yes: a planted type error in the test fails `typecheck` with TS2322 | | S3 `test/index.test.ts` | exit 0 | clean | no: with `rootDir: 'src'`, including it fails with TS6059, under `--noEmit` too | | S4 `vitest run --passWithNoTests` | exit 0, nothing ran | | | S2 is the shape I recommend. The test inside it can use `PluginSchema` from `@objectstack/spec/kernel`, the contract both kernels run at `use()`, and assert `name` and `init`, which the kernel loader requires. The `test` script becomes `vitest run`, as in `-t plugin`. That half touches `create.ts` (the plugin scaffold), the three docs listings, this pin (extended to `os create` templates) and the changeset. ## Verification (head `88a38f333`, after merging `origin/main` `f7d740376`) - `pnpm --filter '@objectstack/cli...' build`: exit 0. - New pin plus every existing pin that sweeps the `os init` templates (17 files: `scaffold-emission-typechecks`, `init-template-comments-self-contained`, `init-scaffold-authoring-rules`, `init-scaffold-file-name-registry-parity`, `init`, `scaffold-manifest-schema`, `scaffold-npm-overrides`, `scaffold-object-declaration-shape`, `scaffold-workspace-consistency`, `starter-field-consumers`, `init-manifest-id`, `scaffold-ci-script-parity`, `create-objectstack-wiring-parity`, `vitest-tiers-partition`, `create`, `create-plugin-docs-parity`): `Test Files 17 passed (17)`, `Tests 268 passed (268)`. - `@objectstack/cli` `unit` tier, full: `Test Files 324 passed (324)`, `Tests 4775 passed (4775)`, lock verdict `command-exit 0`. The `integration` tier is declared to CI, because the diff touches no spawn entry and no integration-tier file. - `pnpm --filter @objectstack/cli typecheck`: exit 0 (`tsc --noEmit`; the test layer `check:test-typecheck` passes with no new error signatures). - Gates: all 69 families from `dispatch-gates.mjs --commands` (paths derived from merge base `f7d740376`), plus `check:template-version-sync`, exit 0. That includes `check:scaffold-emission-policy`, `check:template-version-sync`, `check:cli-test-child-env`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:doc-authoring` and `check:nul-bytes`. Two of them, `check:dual-build-cjs-loads` and `check:i18n-coverage`, first exited 3 with `PREREQUISITE NOT MET`: packages outside the CLI's closure had no `dist/`. Both exit 0 once those packages are built. `--ran` reconciliation: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN, and the zero is derived from the recorded exit codes. ## Acceptance notes - **The new pin's wall.** Its four tests take about 4.7 s. Importing `init.ts` takes about 15 s, a cost every `os init` pin already pays. Locally the file reads about 20 s against the 10 s per-file line, which the timing report flags without failing anything. - **`os init` without a NAME overwrites existing files.** It scaffolds into the current directory and overwrites any existing file at a template path. Only the empty wired barrels are written where no file exists. `package.test.ts` joins the overwritten set, beside `src/objects/index.ts` and the item object, which were already in it. This is an observation from reading the code, not a measured reach. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d0c56a2 commit 9c431ba

3 files changed

Lines changed: 284 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os init -t plugin` ships a starter test, so a new project's `npm test` passes
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A project made by `os init NAME -t plugin` declared `"test": "vitest run"` and contained no test file, so its first `npm test` exited 1 with `No test files found, exiting with code 1`. The template now writes `package.test.ts` beside `objectstack.config.ts`. The test imports the package's stack, which runs `defineStack()` and its protocol check as the file loads, and asserts that the stack declares itself a `plugin` and contains every object `src/objects/index.ts` exports. A fresh project's `npm test` reports `Test Files 1 passed`. The test boots no engine. Its header links the `@objectstack/verify` harness for tests that need one.
11+
12+
**An existing project made by an earlier `os init -t plugin`** still has no test file. Add a `*.test.ts` file, for example a copy of the `package.test.ts` that a new `os init NAME -t plugin` writes.

‎packages/cli/src/commands/init.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,45 @@ const ${toCamelCase(namespace)}Item = ObjectSchema.create({
10831083
});
10841084
10851085
export default ${toCamelCase(namespace)}Item;
1086+
`,
1087+
// The starter test the `test` script above runs (#25175). Without a test
1088+
// file, `vitest run` exits 1 with "No test files found", so a fresh
1089+
// project failed its own `npm test`. It loads the package's stack —
1090+
// `defineStack`'s strict parse, the protocol's own refusal path — and
1091+
// asserts what a host receives: a `plugin` that hands every object the
1092+
// `src/objects` barrel exports to the stack. It deliberately boots no
1093+
// engine: that needs `@objectstack/verify` declared and a vitest config
1094+
// for the boot's timeout and logs, and the comment points the author at
1095+
// the documented harness for that step instead.
1096+
// `test/scaffold-test-script-passes.test.ts` runs it in a sandbox, and
1097+
// `test/scaffold-emission-typechecks.test.ts` type-checks it.
1098+
'package.test.ts': () => `import { describe, expect, it } from 'vitest';
1099+
import stack from './objectstack.config';
1100+
import * as objects from './src/objects';
1101+
1102+
// This package is the stack objectstack.config.ts exports: what another stack
1103+
// receives when it loads the package. Importing that file runs defineStack(),
1104+
// which checks the definition against the ObjectStack protocol and throws on
1105+
// anything the protocol refuses, so a definition another stack would refuse
1106+
// fails this file before any test in it runs.
1107+
//
1108+
// The test below reads what the package declares. To test what the engine
1109+
// does with a record, such as refusing an item saved without its required
1110+
// name, boot the stack with @objectstack/verify:
1111+
// https://objectstack.ai/docs/protocol/kernel/plugin-spec#plugin-testing
1112+
describe('this package', () => {
1113+
it('is a plugin that hands every object in src/objects to the stack', () => {
1114+
expect(stack.manifest?.type).toBe('plugin');
1115+
1116+
// An object src/objects/index.ts exports is loaded only when the config
1117+
// hands it to the stack. One the config drops is never loaded, and
1118+
// \`objectstack validate\` neither counts nor checks it.
1119+
const loaded = (stack.objects ?? []).map((object) => object.name);
1120+
for (const object of Object.values(objects)) {
1121+
expect(loaded).toContain(object.name);
1122+
}
1123+
});
1124+
});
10861125
`,
10871126
}),
10881127
},
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PIN (#25175) — an `os init` template that declares a `test` script ships a
5+
* project whose `test` script passes out of the box.
6+
*
7+
* ## The defect
8+
*
9+
* `os init NAME -t plugin` emitted `scripts.test: 'vitest run'` and no test
10+
* file. Measured after `npm install`: `npm test` exited 1 with `No test files
11+
* found, exiting with code 1`, on vitest 4.1.12 and on 4.0.18 alike. A
12+
* scaffold whose `test` script is red out of the box teaches its user to
13+
* ignore tests. The template now ships one starter test (`package.test.ts`).
14+
*
15+
* ## What is run, and why the project lives OUTSIDE this repository
16+
*
17+
* Every template is materialised through `init`'s own renderers, exactly as
18+
* `Init.run()` writes it, and the command in its emitted `scripts.test` is
19+
* spawned in that directory. The one addition is a JSON reporter, which
20+
* counts the test files and tests that ran rather than trusting an exit code
21+
* alone: `--passWithNoTests` would exit 0 having run nothing.
22+
*
23+
* vitest looks for a config file in the project directory AND EVERY PARENT,
24+
* so a project nested in this package would run under
25+
* `packages/cli/vitest.config.ts`, a config no user's project has (measured:
26+
* it did). The project is therefore created under the OS temp directory, and
27+
* a case below asserts no ancestor of it carries a vitest or vite config. Its
28+
* `node_modules` holds one symlink per DECLARED dependency, to the copy this
29+
* package resolves — the workspace's own build, the stand-in for an install
30+
* the other in-package scaffold pins use too
31+
* (`scaffold-emission-typechecks.test.ts`). A declared dependency this
32+
* package cannot resolve fails the case: the sandbox would not stand in for
33+
* an install.
34+
*
35+
* ## The control
36+
*
37+
* The same project with its emitted test files deleted must FAIL its `test`
38+
* script, which is the defect's own signature. Without it a harness that
39+
* swallowed the child's exit status would read green on every template.
40+
*
41+
* ## Roster
42+
*
43+
* Derived from `TEMPLATES`: every template whose rendered `package.json`
44+
* declares a `test` script. A template that grows one later is swept the day
45+
* it lands. `os create plugin` emits a `test` script too and is not swept
46+
* here yet; its starter test ships with the docs listings its file tree is
47+
* pinned against (`create-plugin-docs-parity.test.ts`).
48+
*
49+
* The type layer of the emitted test is `scaffold-emission-typechecks.test.ts`'s
50+
* axis (it compiles every `srcFiles` entry under the shipped `tsconfig.json`),
51+
* so it is not restated here.
52+
*/
53+
54+
import { afterAll, describe, expect, it } from 'vitest';
55+
import { spawnSync } from 'node:child_process';
56+
import fs from 'node:fs';
57+
import os from 'node:os';
58+
import path from 'node:path';
59+
import { fileURLToPath } from 'node:url';
60+
import {
61+
TEMPLATES,
62+
renderScaffoldPackageJson,
63+
renderScaffoldTsconfig,
64+
sanitizeNamespace,
65+
SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG,
66+
writeTemplateSrcFiles,
67+
} from '../src/commands/init.js';
68+
import { childEnv } from './helpers/serve-process.js';
69+
70+
const HERE = path.dirname(fileURLToPath(import.meta.url));
71+
/** This package's root: where its declared dependencies resolve from. */
72+
const PKG = path.resolve(HERE, '..');
73+
const PKG_NAME = (JSON.parse(fs.readFileSync(path.join(PKG, 'package.json'), 'utf8')) as { name: string }).name;
74+
75+
const PROJECT_NAME = 'my-app';
76+
77+
// Real path: vitest reports test files by it, and the OS temp directory may be
78+
// reached through a symlink (macOS `/var` is `/private/var`).
79+
const TMP_ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'os-scaffold-test-script-')));
80+
afterAll(() => {
81+
fs.rmSync(TMP_ROOT, { recursive: true, force: true });
82+
});
83+
84+
/** The config file names vitest (and vite under it) would pick up from a parent. */
85+
const RUNNER_CONFIG = /^(?:vitest|vite)\.(?:config|workspace)\.[cm]?[jt]s$/;
86+
87+
interface Manifest {
88+
scripts?: Record<string, string>;
89+
dependencies?: Record<string, string>;
90+
devDependencies?: Record<string, string>;
91+
}
92+
93+
/** The `os init` templates whose emitted manifest declares a `test` script. */
94+
const ROSTER = Object.keys(TEMPLATES).filter((key) => {
95+
const manifest = renderScaffoldPackageJson(PROJECT_NAME, TEMPLATES[key]) as Manifest;
96+
return typeof manifest.scripts?.test === 'string';
97+
});
98+
99+
/** Materialise one template the way `Init.run()` writes it; returns the project root. */
100+
function emit(templateKey: string): { root: string; manifest: Manifest; testFiles: string[] } {
101+
const template = TEMPLATES[templateKey];
102+
const namespace = sanitizeNamespace(PROJECT_NAME);
103+
const root = fs.mkdtempSync(path.join(TMP_ROOT, `${templateKey}-`));
104+
const manifest = renderScaffoldPackageJson(PROJECT_NAME, template) as Manifest;
105+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify(manifest, null, 2) + '\n');
106+
fs.writeFileSync(path.join(root, 'objectstack.config.ts'), template.configContent(PROJECT_NAME, namespace));
107+
fs.writeFileSync(
108+
path.join(root, 'tsconfig.json'),
109+
JSON.stringify(
110+
renderScaffoldTsconfig({ rootDir: '.', include: SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG }),
111+
null,
112+
2,
113+
) + '\n',
114+
);
115+
const written = writeTemplateSrcFiles(template.srcFiles, root, PROJECT_NAME, namespace);
116+
linkDeclaredDependencies(root, manifest);
117+
return { root, manifest, testFiles: written.filter((rel) => /\.test\.[cm]?[jt]sx?$/.test(rel)) };
118+
}
119+
120+
/** One symlink per declared dependency, to the copy this package resolves. */
121+
function linkDeclaredDependencies(root: string, manifest: Manifest): void {
122+
const names = Object.keys({ ...manifest.dependencies, ...manifest.devDependencies });
123+
for (const name of names) {
124+
const target = name === PKG_NAME ? PKG : path.join(PKG, 'node_modules', name);
125+
if (!fs.existsSync(target)) {
126+
throw new Error(
127+
`the emitted manifest declares ${name}, which ${PKG_NAME} does not resolve — this sandbox ` +
128+
'cannot stand in for an install of it',
129+
);
130+
}
131+
const link = path.join(root, 'node_modules', name);
132+
fs.mkdirSync(path.dirname(link), { recursive: true });
133+
fs.symlinkSync(fs.realpathSync(target), link, 'junction');
134+
}
135+
}
136+
137+
/** The installed bin a script's first word names, resolved inside the project. */
138+
function resolveBin(root: string, manifest: Manifest, bin: string): string {
139+
const names = Object.keys({ ...manifest.dependencies, ...manifest.devDependencies });
140+
for (const name of names) {
141+
const dir = path.join(root, 'node_modules', name);
142+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) as {
143+
name: string;
144+
bin?: string | Record<string, string>;
145+
};
146+
const entry = typeof pkg.bin === 'string' ? (pkg.name === bin ? pkg.bin : undefined) : pkg.bin?.[bin];
147+
if (entry) return path.join(dir, entry);
148+
}
149+
throw new Error(`no declared dependency of the emitted project provides the \`${bin}\` bin`);
150+
}
151+
152+
/** Spawn the emitted `test` script, plus a JSON reporter, in the project. */
153+
function runTestScript(root: string, manifest: Manifest) {
154+
const [bin, ...args] = manifest.scripts!.test.trim().split(/\s+/);
155+
const report = path.join(root, '.vitest-report.json');
156+
const res = spawnSync(
157+
process.execPath,
158+
[resolveBin(root, manifest, bin), ...args, '--reporter=json', `--outputFile=${report}`],
159+
{ cwd: root, encoding: 'utf-8', env: childEnv({ NO_COLOR: '1', CI: '1' }) },
160+
);
161+
const json = fs.existsSync(report)
162+
? (JSON.parse(fs.readFileSync(report, 'utf8')) as {
163+
numPassedTests: number;
164+
numFailedTests: number;
165+
testResults: {
166+
name: string;
167+
status: string;
168+
message?: string;
169+
assertionResults?: { fullName: string; status: string; failureMessages?: string[] }[];
170+
}[];
171+
})
172+
: null;
173+
// The JSON reporter replaces the console one, so a failing run prints little
174+
// of its own: the report says why it failed, a file that would not load in
175+
// its `message` and a failed test in its `failureMessages`.
176+
const failures = (json?.testResults ?? [])
177+
.filter((r) => r.status !== 'passed')
178+
.flatMap((r) => [
179+
...(r.message ? [`${path.relative(root, r.name)}: ${r.message}`] : []),
180+
...(r.assertionResults ?? [])
181+
.filter((a) => a.status !== 'passed')
182+
.map((a) => `${path.relative(root, r.name)} > ${a.fullName}: ${(a.failureMessages ?? []).join('\n')}`),
183+
])
184+
.join('\n');
185+
return { code: res.status, output: `${res.stdout ?? ''}${res.stderr ?? ''}${failures}`, json };
186+
}
187+
188+
describe('[#25175] an `os init` template with a `test` script passes it out of the box', () => {
189+
it('the roster is populated, and still holds the template the defect was measured on', () => {
190+
// An `it.each` over an empty roster passes having measured nothing.
191+
expect(ROSTER.length).toBeGreaterThan(0);
192+
expect(ROSTER).toContain('plugin');
193+
});
194+
195+
it('no ancestor of the sandbox carries a runner config the emitted project would inherit', () => {
196+
const found: string[] = [];
197+
for (let dir = TMP_ROOT; ; dir = path.dirname(dir)) {
198+
for (const entry of fs.readdirSync(dir)) {
199+
if (RUNNER_CONFIG.test(entry)) found.push(path.join(dir, entry));
200+
}
201+
if (path.dirname(dir) === dir) break;
202+
}
203+
expect(found, 'vitest would load these instead of the defaults a user project runs under').toEqual([]);
204+
});
205+
206+
it.each(ROSTER)(
207+
'`os init -t %s`: the emitted `test` script runs the shipped tests and exits 0',
208+
(templateKey) => {
209+
const { root, manifest, testFiles } = emit(templateKey);
210+
expect(testFiles.length, `\`os init -t ${templateKey}\` declares a test script and emits no test file`).toBeGreaterThan(0);
211+
212+
const { code, output, json } = runTestScript(root, manifest);
213+
214+
expect(code, `\`${manifest.scripts!.test}\` in a fresh \`os init -t ${templateKey}\` project:\n${output}`).toBe(0);
215+
expect(json, `the run wrote no JSON report:\n${output}`).not.toBeNull();
216+
expect(json!.numFailedTests).toBe(0);
217+
expect(json!.numPassedTests).toBeGreaterThan(0);
218+
expect(json!.testResults.map((r) => path.relative(root, r.name)).sort()).toEqual([...testFiles].sort());
219+
expect(json!.testResults.every((r) => r.status === 'passed')).toBe(true);
220+
},
221+
120_000,
222+
);
223+
224+
it('CONTROL: the same project without its shipped tests fails the `test` script (the defect)', () => {
225+
const { root, manifest, testFiles } = emit(ROSTER[0]);
226+
for (const rel of testFiles) fs.rmSync(path.join(root, rel));
227+
228+
const { code, json } = runTestScript(root, manifest);
229+
230+
expect(code, 'an empty suite must fail; a harness that reads 0 here measures nothing').not.toBe(0);
231+
expect(json?.numPassedTests ?? 0).toBe(0);
232+
}, 120_000);
233+
});

0 commit comments

Comments
 (0)