From d4c163949ce7a639b445799200834f72b4b984c9 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:07:01 +0800 Subject: [PATCH] test(cli): pin serve config-boot for the authored shape #3887 reported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3887 filed `Service 'manifest' is async - use await` from a bare `defineStack()` config with no `dist/objectstack.json`. #4110 fixed that crash under #4085 and never linked #3887, which stayed open — and every fixture pinning the fix is a plain object literal, so the shape the issue actually reported was left unexercised. That is not a cosmetic difference. `defineStack` parses and normalizes, so an authored config reaches boot carrying defaults no literal in the file writes: `datasource: 'default'` on every object, a full descriptor on every field. And such a fixture only resolves where a real `node_modules` does — `@objectstack/spec` is external to the config bundler (BUNDLE_REQUIRE_EXTERNALS) — so it cannot live in the OS tmpdir beside the others. That resolution constraint is why no test had ever booted an authored config, and why the gap outlived the fix. Verified against the issue's verbatim repro: boots, registers the app, and serves reads/writes on `cfg_note` over REST. The new boot asserts the app reaches the started plugin set and that a driver resolved behind the stamped datasource; breaking the fixture's manifest id turns it red while the server still starts, so the claim is the app's, not the banner's. Scope stated in the file: this pins the authored shape end to end, not fault 1's mechanism — ADR-0116 (#4131) made plugin ordering a declared, kernel-enforced contract, so reverting the CLI-side append alone no longer reproduces the crash. Co-Authored-By: Claude Opus 5 --- .../serve-no-artifact-definestack-pin.md | 4 + .../cli/test/serve-no-artifact.e2e.test.ts | 101 +++++++++++++++++- 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 .changeset/serve-no-artifact-definestack-pin.md diff --git a/.changeset/serve-no-artifact-definestack-pin.md b/.changeset/serve-no-artifact-definestack-pin.md new file mode 100644 index 0000000000..038e60b9c6 --- /dev/null +++ b/.changeset/serve-no-artifact-definestack-pin.md @@ -0,0 +1,4 @@ +--- +--- + +test(cli): pin `os serve` config-boot for the shape #3887 actually reported — a config authored through `defineStack()` from `@objectstack/spec`, with no compiled `dist/objectstack.json`. #4110 fixed the crash (#4085) but every fixture pinning it is a plain object literal, so nothing exercised what a real project sends into boot: `defineStack` parses and normalizes, stamping `datasource: 'default'` on every object and full descriptors on every field. Such a fixture only resolves where a real `node_modules` does — `@objectstack/spec` is external to the config bundler — which is why it had to live under the package and why the gap outlived the fix. Test-only. Releases nothing. diff --git a/packages/cli/test/serve-no-artifact.e2e.test.ts b/packages/cli/test/serve-no-artifact.e2e.test.ts index bc467b2ed2..97597e2f42 100644 --- a/packages/cli/test/serve-no-artifact.e2e.test.ts +++ b/packages/cli/test/serve-no-artifact.e2e.test.ts @@ -24,12 +24,36 @@ * Both live above the kernel — in what the command assembles and in what boot * treats as fatal — so only a test driving the real `os serve` process pins * them. These two boots are that pin: no `os compile` anywhere in this file. + * + * framework#3887 filed the same crash from the shape every REAL project has — + * `export default defineStack({…})` — five weeks before #4085 named it, and the + * fix landed without ever linking it. Its repro gets its own boot below, because + * the plain object literals above are not a substitute for it: `defineStack` + * PARSES and NORMALIZES, so what reaches boot from an authored project carries + * defaults no literal here writes — `datasource: 'default'` on every object and + * a fully defaulted descriptor on every field. The literals reach boot as typed; + * only this fixture reaches it as the spec produces it. + * + * That it must live under this package rather than the OS tmpdir is the same + * point in the file system: `@objectstack/spec` is external to the config + * bundler (`BUNDLE_REQUIRE_EXTERNALS`), so an authored config only resolves + * where a real `node_modules` does — which is exactly why no test had ever + * booted one, and why the gap outlived the fix. + * + * Scope, so nobody reads more into a green run than it earns: this pins the + * authored shape END TO END — config loaded, app registered, server ready with + * no `dist/` — not fault 1's mechanism. Registration order stopped being the + * load-bearing thing when ADR-0116 (#4131) made plugin ordering a declared, + * kernel-enforced contract; reverting the CLI-side append alone no longer + * reproduces the crash. Both guarantees are wanted, and they are pinned at + * different layers. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { runServe, randomPort } from './helpers/serve-process.js'; /** An ordinary app: manifest + an object, exactly what `os init` scaffolds. */ @@ -80,9 +104,34 @@ export default { }; `; +/** + * framework#3887's repro, verbatim — an authored config, not a hand-rolled + * envelope. `defineStack` is the documented entry every scaffolded project + * uses, and running it is the whole point: it validates and fills in defaults, + * so this is the only fixture in the file whose objects reach boot carrying a + * `datasource`. + */ +const DEFINE_STACK_CONFIG = ` +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + manifest: { id: 'com.test.cfgload', name: 'CfgLoad', type: 'app', version: '1.0.0' }, + objects: [{ name: 'cfg_note', label: 'Note', fields: { title: { type: 'text' } } }], +}); +`; + +/** + * That fixture has to RESOLVE `@objectstack/spec`, which an OS-tmpdir project + * cannot — so it lives under this package, which declares the dependency, and + * resolves through a real `node_modules` exactly as a user's project does. + * `tmp/` is gitignored repo-wide; `afterAll` removes the directory regardless. + */ +const CLI_PACKAGE_ROOT = resolve(fileURLToPath(import.meta.url), '../..'); + let appDir: string; let bareDir: string; let orphanDir: string; +let defineStackDir: string; beforeAll(() => { appDir = mkdtempSync(join(tmpdir(), 'os-no-artifact-app-')); @@ -93,10 +142,15 @@ beforeAll(() => { orphanDir = mkdtempSync(join(tmpdir(), 'os-no-artifact-orphan-')); writeFileSync(join(orphanDir, 'objectstack.config.ts'), UNREGISTERABLE_CONFIG, 'utf8'); + + const sandbox = join(CLI_PACKAGE_ROOT, 'tmp'); + mkdirSync(sandbox, { recursive: true }); + defineStackDir = mkdtempSync(join(sandbox, 'os-no-artifact-definestack-')); + writeFileSync(join(defineStackDir, 'objectstack.config.ts'), DEFINE_STACK_CONFIG, 'utf8'); }); afterAll(() => { - for (const dir of [appDir, bareDir, orphanDir]) { + for (const dir of [appDir, bareDir, orphanDir, defineStackDir]) { if (dir) rmSync(dir, { recursive: true, force: true }); } }); @@ -138,6 +192,47 @@ describe('os serve — boots without a compiled artifact (#4085)', () => { 240_000, ); + it( + 'serves a config authored through defineStack(), the shape every real project has (#3887)', + async () => { + const { stdout, stderr } = await runServe(defineStackDir, ['--port', randomPort()], { + waitFor: /Press Ctrl\+C to stop/, + timeoutMs: 240_000, + }); + const seen = `\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`; + const out = stdout + stderr; + + // Same premise as every boot in this file: nothing was ever compiled. + expect(existsSync(join(defineStackDir, 'dist/objectstack.json'))).toBe(false); + + expect(stdout, `serve never reported ready${seen}`).toContain('Server is ready'); + // The crash #3887 reported, and the truthful message the kernel replaced + // it with — neither may return through the authored path either. + expect(out).not.toMatch(/Service 'manifest' (is async|not found)/); + expect(out).not.toMatch(/Service 'objectql' (is async|not found)/); + expect(out).not.toContain('rollback complete'); + expect(out).not.toContain('artifact read FAILED'); + + // The fixture `import`s the spec, so a config that failed to load at all + // fails HERE rather than degrading into a green run against nothing: the + // app is named in the started plugin set, which it can only reach by + // having been parsed, registered, and started. + expect(stdout, `app plugin missing from the boot banner${seen}`).toMatch( + /Plugins:[\s\S]*cfgload/, + ); + + // And the datasource half of it, which only this fixture can state: + // `defineStack` stamped `datasource: 'default'` onto `cfg_note`, so the + // app's start had to find a connected driver behind that name. The banner + // reports which one resolved, so assert it rather than leave the claim + // resting on "boot did not throw". + expect(stdout, `no driver resolved for the stamped datasource${seen}`).toMatch( + /Driver:\s+\S+/, + ); + }, + 240_000, + ); + it( 'serves a config with no application at all', async () => {