Skip to content

Commit b5f4902

Browse files
committed
wip(cli): pins for the honest banner and the named compile path
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
1 parent 5f828ad commit b5f4902

4 files changed

Lines changed: 126 additions & 12 deletions

File tree

‎packages/cli/src/commands/artifact-child-env.pin.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ import path from 'path';
4444
import ts from 'typescript';
4545
import {
4646
INTERNAL_ARTIFACT_PATH_ENV,
47+
INTERNAL_CONFIG_OUTPUT_PATH_ENV,
4748
childEnvWithResolvedArtifact,
4849
readInternalArtifactPath,
50+
readInternalConfigOutputPath,
4951
} from '../utils/internal-artifact-channel.js';
5052
import {
5153
cwdConfigJoinsBoot,
@@ -124,6 +126,28 @@ describe('the child `serve` env — OS_ARTIFACT_PATH means an operator set it',
124126
}
125127
});
126128

129+
it('carries where the parent compiles the cwd config only when the decision says so — and owns that variable too', () => {
130+
const named = '/srv/app/build/named.json';
131+
const withTarget = childEnvWithResolvedArtifact({}, { kind: 'resolved', path: named, configCompiledTo: named });
132+
expect(withTarget[INTERNAL_CONFIG_OUTPUT_PATH_ENV]).toBe(named);
133+
expect(readInternalConfigOutputPath(withTarget)).toBe(named);
134+
135+
// An inherited copy never speaks for a decision the parent did not make.
136+
const parentEnv = { [INTERNAL_CONFIG_OUTPUT_PATH_ENV]: '/stale/inherited.json' };
137+
for (const decision of [
138+
{ kind: 'resolved', path: ARTIFACT },
139+
{ kind: 'reference' },
140+
{ kind: 'empty' },
141+
] as const) {
142+
const childEnv = childEnvWithResolvedArtifact(parentEnv, decision);
143+
expect(
144+
Object.prototype.hasOwnProperty.call(childEnv, INTERNAL_CONFIG_OUTPUT_PATH_ENV),
145+
`decision ${decision.kind} declared no compile path, so the variable must be absent`,
146+
).toBe(false);
147+
}
148+
expect(readInternalConfigOutputPath({ [INTERNAL_CONFIG_OUTPUT_PATH_ENV]: ' ' })).toBeUndefined();
149+
});
150+
127151
it('reads a blank channel value as no decision at all', () => {
128152
expect(readInternalArtifactPath({})).toBeUndefined();
129153
expect(readInternalArtifactPath({ [INTERNAL_ARTIFACT_PATH_ENV]: '' })).toBeUndefined();
@@ -292,6 +316,27 @@ describe('cwdConfigJoinsBoot — the last rung, one predicate for both ends (#21
292316
}
293317
});
294318

319+
it('a config takes part when the artifact is where THIS command compiled it — a named path (os dev under OS_ARTIFACT_PATH)', () => {
320+
const named = path.join(projectDir, 'build', 'named.json');
321+
expect(cwdConfigJoinsBoot({
322+
configExists: true,
323+
configPath,
324+
artifact: { kind: 'path', path: named, configCompiledTo: named },
325+
})).toBe(true);
326+
expect(isConfigCompiledArtifact(named, configPath, path.join(projectDir, 'build', '.', 'named.json'))).toBe(true);
327+
// Declaring a compile path does not make a DIFFERENT artifact the config's own,
328+
expect(cwdConfigJoinsBoot({
329+
configExists: true,
330+
configPath,
331+
artifact: { kind: 'path', path: path.join(tmpdir(), 'elsewhere.json'), configCompiledTo: named },
332+
})).toBe(false);
333+
// and a URL is never a place a config was compiled to.
334+
expect(isConfigCompiledArtifact('https://cdn.example.com/a.json', configPath, 'https://cdn.example.com/a.json'))
335+
.toBe(false);
336+
// The conventional path stays the config's own output beside a declared one.
337+
expect(isConfigCompiledArtifact(ownArtifact, configPath, named)).toBe(true);
338+
});
339+
295340
it('a config does NOT take part under a reference (OS_ARTIFACT_URL)', () => {
296341
expect(cwdConfigJoinsBoot({ configExists: true, configPath, artifact: { kind: 'reference' } })).toBe(false);
297342
});

‎packages/cli/src/commands/serve-banner-config-row.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,23 @@ describe('resolveBannerConfigRow (#8978)', () => {
5757
})).toEqual({});
5858
});
5959

60+
it('names the compiled bundle — not the config — when a config boot served its app from one (#21501)', () => {
61+
// A non-host config's standalone stack loaded dist/objectstack.json as the
62+
// app bundle: the metadata served came from that file.
63+
expect(resolveBannerConfigRow({
64+
relativeConfig: 'objectstack.config.ts',
65+
useArtifactFallback: false,
66+
configBootBundle: 'dist/objectstack.json',
67+
})).toEqual({ bundleSource: 'dist/objectstack.json' });
68+
// A host config (or a config whose artifact was absent) loaded no bundle,
69+
// so its row stays the config it booted.
70+
expect(resolveBannerConfigRow({
71+
relativeConfig: 'objectstack.config.ts',
72+
useArtifactFallback: false,
73+
configBootBundle: undefined,
74+
})).toEqual({ configFile: 'objectstack.config.ts' });
75+
});
76+
6077
it('omits the row on an empty/quick-start boot (no config, no artifact)', () => {
6178
// `useArtifactFallback` is also set on the `OS_BOOT_EMPTY=1` quick-start
6279
// path — same defect, same fix: nothing was read, so nothing is named.

‎packages/cli/src/utils/format.config-artifact-row.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { printServerReady, type ServerReadyOptions } from './format.js';
1818
const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
1919

2020
describe('printServerReady Config:/Artifact: row (#8978)', () => {
21-
const base: Omit<ServerReadyOptions, 'configFile' | 'artifactSource'> = {
21+
const base: Omit<ServerReadyOptions, 'configFile' | 'artifactSource' | 'bundleSource'> = {
2222
externalBaseOrigin: 'http://localhost:3000',
2323
isDev: true,
2424
pluginCount: 1,
@@ -62,6 +62,12 @@ describe('printServerReady Config:/Artifact: row (#8978)', () => {
6262
expect(configLine()).toBeUndefined();
6363
});
6464

65+
it('prints a plain Artifact: row for a config boot\'s compiled bundle — no OS_ARTIFACT_URL suffix, no Config: row (#21501)', () => {
66+
printServerReady({ ...base, bundleSource: 'dist/objectstack.json' });
67+
expect(artifactLine()).toBe('Artifact: dist/objectstack.json');
68+
expect(configLine()).toBeUndefined();
69+
});
70+
6571
it('omits BOTH rows when the caller has nothing safe to report (plain artifact-fallback path)', () => {
6672
// No configFile, no artifactSource — the plain `dist/objectstack.json`
6773
// fallback and the empty/quick-start boot. Absence beats a fabricated

‎packages/cli/test/artifact-flag-precedence.integration.test.ts‎

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,24 @@
4343
* takes). The served label alone cannot tell these apart under the source
4444
* entry: it runs `NODE_ENV=development`, where the dev metadata door over
4545
* the supervisor's answer serves ALPHA even with the config loaded beside it.
46+
* - the honest ready banner on the bare `os start` path (no flag, the config's
47+
* own dist/ resolved, dist/ holding a different stack): a host config boots
48+
* its own module and the `serve` child's ready banner says `Config:`; a
49+
* non-host config serves its dist/ bundle and the banner says `Artifact:`
50+
* that file. No ready-banner row names a file the boot did not load.
51+
* - `os dev` under a local `OS_ARTIFACT_PATH` at a named path compiles a host
52+
* config INTO that path; the file there is the config's own compiled output,
53+
* so the config still composes its plugins (read through the roster).
4654
* - flag over env — `dev -a` under an `OS_ARTIFACT_URL` naming the other one.
4755
* - the documented first-project path — `start --artifact
48-
* ./dist/objectstack.json` beside its config — is the one boot a config
49-
* still joins (that file is the config's OWN compiled output), and it serves
56+
* ./dist/objectstack.json` beside its config — is a boot the config still
57+
* joins (that file is the config's OWN compiled output), and it serves
5058
* that file even under an exported `OS_ARTIFACT_PATH` naming another: the
5159
* config boot is handed the supervisor's answer instead of re-deriving it.
5260
*
53-
* Each case also checks the supervisor's `Artifact:` row named the file that
54-
* was served: the card's rule is never to print one artifact and serve another.
61+
* The named-artifact cases also check the supervisor's `Artifact:` row named the
62+
* file that was served: the card's rule is never to print one artifact and
63+
* serve another.
5564
*
5665
* ## Spawn shape
5766
*
@@ -83,8 +92,8 @@ import { defineStackSourceFromLiteral, linkSpec, writeDefineStackConfig } from '
8392
/** The banner's tail — every row above it has printed. */
8493
const READY = /Press Ctrl\+C to stop/;
8594
const BOOT_TIMEOUT_MS = 180_000;
86-
/** Eight boots, one after another, each well under its own budget when healthy. */
87-
const ALL_BOOTS_TIMEOUT_MS = 8 * (BOOT_TIMEOUT_MS + 30_000);
95+
/** Ten boots, one after another, each well under its own budget when healthy. */
96+
const ALL_BOOTS_TIMEOUT_MS = 10 * (BOOT_TIMEOUT_MS + 30_000);
8897
/** The host config's plugin — on the boot's plugin roster iff the config was loaded. */
8998
const HOST_MARKER = 'com.example.fx.host-marker';
9099

@@ -218,6 +227,19 @@ function artifactRow(output: string): string | undefined {
218227
return /^.*\bArtifact: [^\n]*$/m.exec(output)?.[0];
219228
}
220229

230+
/**
231+
* The `serve` child's READY banner row — `Config:` or `Artifact:` — read only
232+
* after `Server is ready`, so neither the supervisor's pre-boot rows nor the
233+
* child's boot diagnostics can answer for it. `undefined` when the banner
234+
* carries neither (an artifact-fallback boot omits the row by design).
235+
*/
236+
function readyRow(output: string): { label: 'Config' | 'Artifact'; value: string } | undefined {
237+
const at = output.search(/Server is ready/);
238+
if (at < 0) return undefined;
239+
const m = /^[ \t]+(Config|Artifact):[ \t]+([^\n]*)$/m.exec(output.slice(at));
240+
return m ? { label: m[1] as 'Config' | 'Artifact', value: m[2].trim() } : undefined;
241+
}
242+
221243
interface Reading {
222244
served?: string;
223245
status?: number;
@@ -314,10 +336,10 @@ beforeAll(async () => {
314336
readings.leg2NoDist = await measure([...startArgs(join(root, 'h-leg2-nodist')), '--artifact', alpha], configOnly);
315337
readings.leg2Control = await measure([...startArgs(join(root, 'h-leg2-ctl')), '--artifact', alpha], noConfig);
316338
readings.hostConfig = await measure([...startArgs(join(root, 'h-host')), '--artifact', alpha], hostConfig);
317-
readings.hostConfigOwn = await measure(
318-
[...startArgs(join(root, 'h-host-own')), '--artifact', './dist/objectstack.json'],
319-
hostConfig,
320-
);
339+
// The bare `os start` path beside a config: no flag, the config's own dist/
340+
// resolved. dist/ holds BRAVO, a DIFFERENT stack from either config.
341+
readings.hostConfigOwn = await measure([...startArgs(join(root, 'h-host-own'))], hostConfig);
342+
readings.bareNonHost = await measure([...startArgs(join(root, 'h-bare-nonhost'))], withConfig);
321343
readings.flagOverEnv = await measure([...devArgs, '-a', alpha], bare, {
322344
OS_ARTIFACT_URL: pathToFileURL(bravo).href,
323345
});
@@ -326,6 +348,10 @@ beforeAll(async () => {
326348
withConfig,
327349
{ OS_ARTIFACT_PATH: alpha },
328350
);
351+
// `os dev` under a local OS_ARTIFACT_PATH at a NON-default path compiles the
352+
// host config INTO it (it does not exist yet), so that file is the config's
353+
// own compiled output and the config must still compose its plugins.
354+
readings.devNamedPathHost = await measure([...devArgs], hostConfig, { OS_ARTIFACT_PATH: 'build/named.json' });
329355
}, ALL_BOOTS_TIMEOUT_MS);
330356

331357
afterAll(async () => {
@@ -374,10 +400,30 @@ describe('#21501 — the named artifact is the served stack, beside a config or
374400
expect(r.output).not.toContain(HOST_MARKER);
375401
});
376402

377-
it('a HOST config still boots ITSELF when the artifact is its own compiled output (the marker\'s positive control)', () => {
403+
it('a bare `os start` beside a HOST config with a differing dist/ boots the config itself — and its ready banner says `Config:`', () => {
404+
// Also the roster marker's positive control: the config's own compiled
405+
// output is the one artifact a config still joins.
378406
const r = reading('hostConfigOwn');
379407
expect(r.status).toBe(200);
380408
expect(r.output).toContain(HOST_MARKER);
409+
// The served label is not asserted here: under the source entry
410+
// (`NODE_ENV=development`) the dev metadata door over dist/ composes beside
411+
// the host module — see the header.
412+
expect(readyRow(r.output ?? '')).toEqual({ label: 'Config', value: 'objectstack.config.ts' });
413+
});
414+
415+
it('a bare `os start` beside a NON-host config serves its dist/ bundle — and its ready banner says `Artifact:` that file', () => {
416+
const r = reading('bareNonHost');
417+
expect(r.status).toBe(200);
418+
expect(r.served).toBe('Widget BRAVO');
419+
expect(readyRow(r.output ?? '')).toEqual({ label: 'Artifact', value: 'dist/objectstack.json' });
420+
});
421+
422+
it('`os dev` under OS_ARTIFACT_PATH at a named path compiles a HOST config there, and the config still composes its plugins', () => {
423+
const r = reading('devNamedPathHost');
424+
expect(r.status).toBe(200);
425+
expect(r.artifactRow).toContain('build/named.json');
426+
expect(r.output).toContain(HOST_MARKER);
381427
});
382428

383429
it('flag over env: `os dev -a ALPHA` under OS_ARTIFACT_URL naming BRAVO serves ALPHA', () => {

0 commit comments

Comments
 (0)