Skip to content

Commit d6c3791

Browse files
fix(cli): a refused Console mount answers 503 naming the remedy, not a bare 404 (#22562)
Fixes #22420 Clause-②: no A refused Console mount answers `503` naming the remedy instead of the router's bare `404` on the same routes, as the not-built position already does (PR #22419, `Clause-②: no`). No accepted input, key or export changes. ## What changes `os serve` sometimes decides not to serve the Console. On two of those boots, the HTTP surface used to say nothing: `GET /_console/` and `GET /` answered the router's bare `404 ENDPOINT_NOT_FOUND`, which looks the same as a wrong URL. Both boots now mount a responder on the routes the not-built answer (PR #22419) already uses: - `GET /_console/*` answers `503`, `text/plain`, `no-store`. The body is the remedy the boot message prints, rendered without absolute host paths. - `GET /` and `GET /_console` redirect to `/_console/`, as they do when the Console is served. | position | terminal | HTTP before | HTTP after | |:--|:--|:--|:--| | package resolves, no built `dist/` | `formatConsoleDistMissingWarning` | `503` + redirects (PR #22419) | unchanged | | dev, built `dist/.objectui-sha` is not the repo pin | `formatConsoleShaDriftRefusal` (byte-identical) | bare `404` | `503` naming `pnpm objectui:build`, both SHAs and `OS_ALLOW_CONSOLE_DRIFT=1`; the pin file is named without its absolute path; `/` redirects | | package does not resolve at all (`consolePath` null, Console enabled) | nothing | bare `404` | the not-built warning's null arm (``Console package `@objectstack/console` not found``, then the reinstall sentence); `503` saying the Console is not installed; `/` redirects | | `--no-ui` / `--no-console` | the operator's choice | bare `404` | bare `404`, by design | Files: - `packages/cli/src/utils/console.ts`: - `createConsoleUnavailablePlugin` (not exported) is now the ONE route-mounting body. Before, that body was inline in `createConsoleNotBuiltPlugin`. - Two responders delegate to it. `createConsoleNotBuiltPlugin(consolePath: string | null)` is unchanged except that it now accepts `null`. `createConsoleShaDriftRefusalPlugin(drift)` is new. - `describeConsoleShaDrift(drift, hostPaths)` (not exported) is the drift refusal's text below its headline. Both the terminal block and the HTTP answer render it. - `describeConsoleDistMissing` (the not-built picker) gains a `null` arm. - `packages/cli/src/commands/serve.ts`, Console mount branch only: - the drift arm now mounts the drift responder; - the not-built arm is now `else if (consoleEnabled)`, so it also covers `consolePath === null`. - Outside the branch, the import list gains one name. - `packages/cli/src/utils/console-route-ledger.ts`: the `family` (now `console-static | console-unavailable`) and the `note` of `GET /`, `GET /_console` and `GET /_console/*` name every responder that mounts them. - Tests: - `console.sha-drift.test.ts`: the drift responder on a real Hono app, from a fixture tree with a made-up pin and stamp. - `console.not-built.test.ts`: the `null` position. - `test/published-subpath-console.pin.test.ts`: `INTERNAL_SINCE_RETIREMENT` records the new internal export. The module's own partition pin requires this. The `./console` barrel still publishes exactly three names. - `.changeset/22420-console-drift-responder.md`: `@objectstack/cli` patch. ## Mechanism hypotheses, measured - **H1, confirmed.** The branch at `serve.ts:4961` (base `4638625e07`) printed `formatConsoleShaDriftRefusal` on `refusedForDrift && consoleDrift` and mounted nothing. The no-dist arm mounted `createConsoleNotBuiltPlugin(consolePath)`. Measured live (below): drift answered `404 ENDPOINT_NOT_FOUND` at `/_console/`, `/` and `/_console`. - **H2, done as "a sibling that shares its route mounting".** There is one route-mounting body. Each position has one picker: - not-built: `describeConsoleDistMissing`; - drift: `describeConsoleShaDrift`. The terminal and HTTP forms render the same picker output. The not-built warning, the drift refusal and the drift advisory render byte-identical to base: a script printed all three for a fixed drift from a BASE worktree and from HEAD, and the two outputs have the same sha256, `a9a832a6aa7d…`. - **H3, confirmed.** The refusal names `drift.pinFile`, an absolute path. The HTTP form writes `.objectui-sha at the checkout root` instead, and the terminal keeps the path. The SHAs stay in both, because they are commits of the public objectui repository. Ablation: making the HTTP form name `drift.pinFile` turns exactly one pin red, "with only the absolute pin-file path left out" (1 failed / 13 passed). The mutation and its restore were both proven on disk by `scripts/ablation-replace.mjs` (blob `e8dd212b0e75` → `5c5be9625144` → back to `e8dd212b0e75`, `git diff HEAD` empty). - **H4, covered in this PR, with no second remedy picker.** When `consolePath` is `null`, the install is one step past an install with no `dist/`: `@objectstack/console` is a hard dependency of `@objectstack/cli`. The existing picker's install arm already names the fix for that ("reinstall it at the same version as the CLI"). So `describeConsoleDistMissing` gains a `null` arm. It changes only what it says is missing (`Console package ... not found` instead of `Console dist not found`), and its reinstall sentence is the same; a pin compares the two tails. The responder is the same `createConsoleNotBuiltPlugin`, with the headline "is not installed". - Reach is **NOT MEASURED** live, because it cannot be reached inside this repository. The CLI always resolves its own workspace `packages/console`. Measured: a fixture with no console package in its `node_modules` still resolved the workspace package, and the boot took the not-built arm. - The position is pinned in-process (`createConsoleNotBuiltPlugin(null)` on a real Hono app). ## Live measurement: before and after The harness boots `bin/run-dev.js serve --dev` through tsx, using an `OS_ARTIFACT_PATH` artifact from a scratch project. That project's `node_modules/@objectstack/console` is a fixture dist, and its own `.objectui-sha` is a fixture pin (`aaaaaaaaaaaa…`, stamp `bbbbbbbbbbbb…`). The live pin is never used. Each child was stopped by PID. | case | `GET /_console/` | `GET /` | `GET /_console` | |:--|:--|:--|:--| | drift, base `4638625e07` | `404` `ENDPOINT_NOT_FOUND` JSON | `404` `ENDPOINT_NOT_FOUND` | `404` | | drift, head `9cadb71c00` | `503` `text/plain` `no-store`, refusal text, 0 occurrences of the scratch path | `302` to `/_console/` | `302` to `/_console/` | | matching sha (control), base and head | `200` SPA shell | `302` | `302` | | `--no-console` (control), base and head | `404` `ENDPOINT_NOT_FOUND` | `404` | `404` | | package without `dist/` (neighbour), head | `503` not-built answer | `302` | `302` | The drift answer on head: ```text The ObjectStack Console here was built from a different objectui commit than this checkout pins, so this dev server refuses to serve it at /_console/. pinned (.objectui-sha at the checkout root): objectui@aaaaaaaaaaaa built (console/dist/.objectui-sha): objectui@bbbbbbbbbbbb packages/console/dist is a gitignored local build that 'turbo run build' does NOT refresh, ... pnpm objectui:build ... To boot anyway with the stale bundle: OS_ALLOW_CONSOLE_DRIFT=1 — the API still serves either way. Then restart the server. ``` ## Tests and gates All readings below are against head `9cadb71c00`, and every lock-held run printed `VERDICT command-exit 0` unless stated otherwise. - **Red against base.** `console.ts` was restored to `4638625e07` (blob `0a8b6a55bd46` proven on disk, then restored by trap to the HEAD blob `e8dd212b0e75`, with `git diff HEAD` empty). Then 2 files failed and 11 new pins never passed: - the drift block failed with `createConsoleShaDriftRefusalPlugin is not a function`; - the `null` block failed with `The "path" argument must be of type string. Received null`. The 18 pre-existing tests stayed green. - **New and touched pins:** `console.not-built.test.ts`, `console.sha-drift.test.ts`, `console-route-ledger.conformance.test.ts` and `test/console-resolve.test.ts` passed, 4 files and 62 tests. `test/published-subpath-console.pin.test.ts` (whose partition check reads the packed `.d.ts`), `test/vitest-tiers-partition.test.ts` and the ledger conformance passed, 3 files and 54 tests. - **cli:** `pnpm --filter @objectstack/cli typecheck` passed: `tsc --noEmit`, and `check:test-typecheck` on `tsconfig.test.json` with its debt unchanged (3 files, 28 errors held). - **cli unit layer** (`vitest run --project unit`): 276 files, 4086 tests passed. - **cli integration layer** (`--project integration`, run locally because `serve.ts` is a spawn entry): 102 files, 945 passed and 2 skipped. - **Dogfood.** The one dogfood file that reads `/_console`, `showcase-public-form-redirect.dogfood.test.ts`, imports `utils/console.ts` as source, so it ran locally: 9 of 9 passed. No other file under `packages/qa/dogfood` names `_console`, per `git grep`. - **Lint, a proven narrowing** (the full `pnpm lint` is CI's): 1. Population: eslint's own config. `--print-config` resolves all 6 touched `.ts` files into linted config objects with 5 or 6 rules each. 2. Count: the `--format json` output reports 6 files, 0 errors and 0 warnings. 3. Invariance: `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), and its local plugins are per-file AST rules. So this diff cannot change the verdict on any untouched file. - **Gates:** - `node scripts/pm/dispatch-gates.mjs --commands`, re-derived at `9cadb71c00` with no paths, lists 67 commands: the dispatched 66 plus `pnpm check:cli-test-child-env`, because of the pin-test edit. - All 67 ran. - `--ran` reconciliation: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN". - `check:i18n-coverage` first refused with exit 3 ("COULD NOT MEASURE", because `@objectstack/connector-slack` was unbuilt). After the build it named, it re-ran: "OK (13 config(s), 621 baselined untranslated string(s), none new)". - `check:type-check-debt` and `check:dual-build-cjs-loads` ran last, after every locked suite, and both passed. - **`main`:** `origin/main` (`6a3f82efa7`) has moved since the base, but no new commit touches a file in this diff, and `git merge-tree` is clean. So there is no merge (per the fence). ## Acceptance notes - **Stale prose, outside this card's surface, not edited.** - The header of `console-route-ledger.conformance.test.ts` says both factories in `utils/console.ts` "return early … unless a resolvable HTTP server AND a built `dist/` exist". That has been false for the not-built factory since PR #22419, and it is false for the drift responder here. The census reads source, so the claim does not affect any verdict. - The ledger header's SCOPE paragraph ("four routes across two plugin factories") is dated by its own commit, `2ba4329e`. - Neither is a defect. Carrier: whoever next edits those files. - **Banner.** On a refused boot, the ready banner shows no Console link: it prints one only when `ConsoleUI` is loaded (`serve.ts`, outside the fence). That matches the not-built position and is unchanged. - **`GET /forms/:slug`.** It is still mounted only with a served Console, so on any refused boot it falls through to `404`. This is PR #22419's observation, unchanged. - **No boot-level pin was added.** This follows PR #22419. The pins are in-process on a real Hono app, and `serve.ts`'s wiring is proven by the live before/after table above. A spawning pin would put one more `serve` boot into the per-PR integration layer (or into the nightly tier, if it were named `.e2e`). --- _Generated by [Claude Code](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f89b5f5 commit d6c3791

7 files changed

Lines changed: 349 additions & 72 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): a dev server that refuses a drifted Console answers `503` at `/_console/` naming the rebuild, instead of a bare `404` (#22420)
6+
7+
Clause-②: no
8+
9+
A refused Console mount answers `503` naming the remedy instead of the router's bare `404` on the same routes, as the not-built position already does. No accepted input, key or export changes.
10+
11+
`os dev` refuses to mount a Console whose built `dist/.objectui-sha` differs from the repository's `.objectui-sha` pin. The terminal printed the refusal, but nothing was mounted, so `GET /_console/` and `GET /` answered `404 ENDPOINT_NOT_FOUND`, the same answer as a wrong URL. That boot now mounts the same routes as the not-built answer:
12+
13+
- `GET /_console/` and every path under it answer `503` (`text/plain`, `no-store`). The body is the refusal's own text: both objectui SHAs, why the stale build is refused, `pnpm objectui:build`, and the `OS_ALLOW_CONSOLE_DRIFT=1` escape hatch. The pin file is named without its absolute path.
14+
- `GET /` and `GET /_console` redirect to `/_console/`, as they do when the Console is served.
15+
16+
The same answer now covers an install where `@objectstack/console` does not resolve at all. That boot used to print nothing and mount nothing. It now prints ``Console package `@objectstack/console` not found``, followed by the reinstall sentence an install with no `dist/` already gets. `/_console/` answers `503` saying the Console is not installed.
17+
18+
`--no-ui` and `--no-console` still mount nothing, so those routes answer `404`.

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

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ import {
174174
formatConsoleDistMissingWarning,
175175
createConsoleStaticPlugin,
176176
createConsoleNotBuiltPlugin,
177+
createConsoleShaDriftRefusalPlugin,
177178
createRuntimeAssetsPlugin,
178179
type ConsoleShaDrift,
179180
} from '../utils/console.js';
@@ -4954,25 +4955,27 @@ export default class Serve extends Command {
49544955
// present, it owns the root `/` redirect (preferred default UI). It is
49554956
// optional — we only mount it when the package resolves and a
49564957
// pre-built `dist/` is present, and — in dev — only when that build
4957-
// matches the repo's objectui pin (#7752). A package that resolves
4958-
// with no `dist/` gets the not-built answer on those same routes, so
4959-
// `/_console/` names the remedy the boot warning prints instead of
4960-
// answering a bare 404.
4961-
if (consolePath) {
4962-
if (consoleWillMount) {
4963-
if (consoleDrift) {
4964-
console.warn(chalk.yellow(formatConsoleShaDriftWarning(consoleDrift)));
4965-
}
4966-
const consoleDistPath = path.join(consolePath, 'dist');
4967-
await kernel.use(createConsoleStaticPlugin(consoleDistPath, { isDev }));
4968-
trackPlugin('ConsoleUI');
4969-
} else if (refusedForDrift && consoleDrift) {
4970-
console.error(chalk.red(formatConsoleShaDriftRefusal(consoleDrift)));
4971-
} else {
4972-
console.warn(chalk.yellow(formatConsoleDistMissingWarning(consolePath)));
4973-
await kernel.use(createConsoleNotBuiltPlugin(consolePath));
4974-
trackPlugin('ConsoleNotBuilt');
4958+
// matches the repo's objectui pin (#7752). Every other boot that wants
4959+
// a Console gets a responder on those same routes instead, so
4960+
// `/_console/` answers `503` naming the remedy its boot message prints
4961+
// rather than a bare 404 (#22420): the drift refusal; a package with no
4962+
// `dist/`; no package at all (`consolePath` null). Only `--no-ui` /
4963+
// `--no-console` mount nothing, by the operator's own choice.
4964+
if (consolePath && consoleWillMount) {
4965+
if (consoleDrift) {
4966+
console.warn(chalk.yellow(formatConsoleShaDriftWarning(consoleDrift)));
49754967
}
4968+
const consoleDistPath = path.join(consolePath, 'dist');
4969+
await kernel.use(createConsoleStaticPlugin(consoleDistPath, { isDev }));
4970+
trackPlugin('ConsoleUI');
4971+
} else if (refusedForDrift && consoleDrift) {
4972+
console.error(chalk.red(formatConsoleShaDriftRefusal(consoleDrift)));
4973+
await kernel.use(createConsoleShaDriftRefusalPlugin(consoleDrift));
4974+
trackPlugin('ConsoleShaDriftRefused');
4975+
} else if (consoleEnabled) {
4976+
console.warn(chalk.yellow(formatConsoleDistMissingWarning(consolePath)));
4977+
await kernel.use(createConsoleNotBuiltPlugin(consolePath));
4978+
trackPlugin('ConsoleNotBuilt');
49764979
}
49774980
}
49784981

‎packages/cli/src/utils/console-route-ledger.ts‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,28 +113,36 @@ export interface ConsoleRouteLedgerEntry {
113113
}
114114

115115
export const CONSOLE_ROUTE_LEDGER: readonly ConsoleRouteLedgerEntry[] = [
116-
// ── console SPA static serving (createConsoleStaticPlugin) ─────────
116+
// ── console SPA static serving (createConsoleStaticPlugin), and the ────
117+
// ── responders mounted on the same routes when the Console is refused ──
118+
// ── (createConsoleNotBuiltPlugin, createConsoleShaDriftRefusalPlugin) ──
117119
{
118120
route: 'GET /',
119-
family: 'console-static',
121+
family: 'console-static | console-unavailable',
120122
mountedIn: 'utils/console.ts',
121123
disposition: 'static-asset',
122124
conditional: 'options.rootRedirect !== false (default: mounted)',
123125
note:
124126
'redirects the site root to `/_console/`. The Console is the default end-user surface, so claiming `/` is the '
125127
+ 'intended behaviour in both dev and production once the Console is mounted at all; `os serve` gates whether '
126128
+ 'it mounts via `--no-console` / `OS_DISABLE_CONSOLE=1`. Not API surface — a redirect to a static bundle. '
127-
+ 'CONDITIONAL, and the ledger says so because the census reads the mount call and cannot see the branch.',
129+
+ 'CONDITIONAL, and the ledger says so because the census reads the mount call and cannot see the branch. '
130+
+ 'Also mounted, unconditionally, by the `console-unavailable` responders `os serve` mounts on a boot that '
131+
+ 'wants a Console and refuses it — `createConsoleNotBuiltPlugin` (no built `dist/`, or no console package) '
132+
+ 'and `createConsoleShaDriftRefusalPlugin` (a dev build that is not the repo\'s objectui pin): the same '
133+
+ 'redirect, to the `503` that `GET /_console/*` answers there. One mount per boot; the boots are exclusive.',
128134
},
129135
{
130136
route: 'GET /_console',
131-
family: 'console-static',
137+
family: 'console-static | console-unavailable',
132138
mountedIn: 'utils/console.ts',
133139
disposition: 'static-asset',
134140
note:
135141
'redirects the bare mount path to its trailing-slash form, the ordinary SPA convention — the Console is built '
136142
+ 'with `base: \'/_console/\'`, so relative asset URLs only resolve from the slashed path. Pure navigation '
137-
+ 'plumbing for a static bundle; there is nothing here for an SDK to express.',
143+
+ 'plumbing for a static bundle; there is nothing here for an SDK to express. Also mounted by the '
144+
+ '`console-unavailable` responders (`createConsoleNotBuiltPlugin`, `createConsoleShaDriftRefusalPlugin`) on a '
145+
+ 'boot that refuses the Console: the same redirect.',
138146
},
139147
{
140148
route: 'GET /forms/:slug',
@@ -151,14 +159,18 @@ export const CONSOLE_ROUTE_LEDGER: readonly ConsoleRouteLedgerEntry[] = [
151159
},
152160
{
153161
route: 'GET /_console/*',
154-
family: 'console-static',
162+
family: 'console-static | console-unavailable',
155163
mountedIn: 'utils/console.ts',
156164
disposition: 'static-asset',
157165
note:
158166
'serves the pre-built Console SPA verbatim from `dist/`, with HTML entry points routed through base-tag '
159167
+ 'injection and an SPA fallback for client-side routes. Reads files off disk behind a path-traversal guard '
160168
+ '(any resolved path escaping `dist/` is refused 403). A file server, not an endpoint: its peer group is a '
161-
+ 'CDN origin, so no client method builds these URLs and none should.',
169+
+ 'CDN origin, so no client method builds these URLs and none should. On a boot that refuses the Console, '
170+
+ 'the `console-unavailable` responders mount it instead and answer `503` with a `no-store` plain-text body '
171+
+ 'naming the remedy the boot message prints, without host paths: `createConsoleNotBuiltPlugin` (build it, '
172+
+ 'or reinstall `@objectstack/console`) and `createConsoleShaDriftRefusalPlugin` (rebuild the dist at the '
173+
+ 'repo\'s objectui pin). Still the bundle\'s address, answering why there is no bundle to serve.',
162174
},
163175

164176
// ── runtime asset serving (second factory in the same module) ──────

‎packages/cli/src/utils/console.not-built.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
* - `GET /_console/` (and every path under it) answers `503` with the remedy
1414
* the boot warning names — the same sentence from the same picker, with
1515
* the host's absolute paths left out;
16+
* - the same holds when the console package does not resolve at all
17+
* (`consolePath` is `null`), which used to print nothing and mount nothing:
18+
* the answer says the Console is not installed and names the reinstall
19+
* sentence the picker already gives an install with no `dist/`;
1620
* - `GET /` and `GET /_console` redirect to `/_console/`, exactly as they do
1721
* once the Console is built (the control below), so neither route changes
1822
* owner with the build.
@@ -38,6 +42,10 @@ const ORIGIN = 'http://console.example.test';
3842
/** The first line of the not-built answer, which says what is wrong before saying how to fix it. */
3943
const NOT_BUILT_HEADLINE = 'The ObjectStack Console is not built, so this server has no Console to serve at /_console/.';
4044

45+
/** The same line when the console package did not resolve at all. */
46+
const NOT_INSTALLED_HEADLINE =
47+
'The ObjectStack Console is not installed, so this server has no Console to serve at /_console/.';
48+
4149
/** The framework repo's remedy. */
4250
const BUILD_REMEDY = 'pnpm objectui:build';
4351

@@ -176,6 +184,44 @@ describe('an installed CLI whose `@objectstack/console` has no `dist/`', () => {
176184
});
177185
});
178186

187+
describe('an install where `@objectstack/console` does not resolve at all', () => {
188+
let request: Request;
189+
beforeAll(async () => {
190+
request = await serve(createConsoleNotBuiltPlugin(null));
191+
});
192+
193+
it.each(CONSOLE_REQUESTS)('GET %s answers 503 saying the Console is not installed', async (p) => {
194+
const res = await request(p);
195+
expect(res.status).toBe(503);
196+
expect(res.headers.get('content-type')).toMatch(/^text\/plain/);
197+
expect(res.headers.get('cache-control')).toBe('no-store');
198+
const body = await res.text();
199+
expect(body.split('\n')[0]).toBe(NOT_INSTALLED_HEADLINE);
200+
expect(body).toContain('@objectstack/console');
201+
expect(body).not.toContain(BUILD_REMEDY);
202+
});
203+
204+
it('is the boot warning\'s sentence verbatim — it names no host path to leave out', async () => {
205+
const warning = formatConsoleDistMissingWarning(null);
206+
expect(warning.startsWith(WARNING_PREFIX)).toBe(true);
207+
const body = await (await request('/_console/')).text();
208+
expect(body).toContain(warning.slice(WARNING_PREFIX.length));
209+
expect(body).not.toContain(scratch);
210+
});
211+
212+
it('names the same reinstall sentence as an install whose package has no `dist/`', () => {
213+
// One picker: the unresolved arm differs from the installed-without-dist
214+
// arm only in what it says is missing, never in the fix.
215+
const { consoleDir } = installTree();
216+
const tail = (s: string) => s.slice(s.indexOf(' — '));
217+
expect(tail(formatConsoleDistMissingWarning(null))).toBe(tail(formatConsoleDistMissingWarning(consoleDir)));
218+
});
219+
220+
it('redirects `/` and `/_console` to `/_console/`', async () => {
221+
await expectRedirectsToConsole(request);
222+
});
223+
});
224+
179225
describe('controls', () => {
180226
it('a BUILT Console serves its SPA shell at `/_console/` and the same redirects', async () => {
181227
const dist = path.join(scratch, 'built', 'dist');

‎packages/cli/src/utils/console.sha-drift.test.ts‎

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,29 @@
1515
* server does not mount the Console at all, so the stale bundle is
1616
* unreachable rather than silently authoritative. Detection semantics
1717
* (`detectConsoleShaDrift`) are pinned in `test/console-resolve.test.ts`.
18+
*
19+
* Unreachable must not mean silent. A refused boot used to mount nothing, so
20+
* `GET /_console/` and `GET /` answered the router's bare 404, the same answer
21+
* a wrong URL gets, and only the terminal said why. `os serve` now mounts
22+
* `createConsoleShaDriftRefusalPlugin` on that boot — the not-built answer's
23+
* routes (`console.not-built.test.ts`) with the refusal's own text — and the
24+
* last block below pins what it answers, on a REAL Hono app, from a fixture
25+
* tree whose pin and stamp are made up here (never the repo's live pin).
1826
*/
19-
import { describe, it, expect } from 'vitest';
27+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
28+
import fs from 'node:fs';
29+
import os from 'node:os';
30+
import path from 'node:path';
31+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
2032
import {
33+
createConsoleShaDriftRefusalPlugin,
34+
createConsoleStaticPlugin,
2135
decideConsoleMount,
36+
detectConsoleShaDrift,
2237
formatConsoleShaDriftRefusal,
2338
formatConsoleShaDriftWarning,
2439
DRIFT_OVERRIDE_ENV,
40+
type ConsoleShaDrift,
2541
} from './console.js';
2642

2743
/** The exact gap #7752 measured: pin two days ahead of the served bundle. */
@@ -111,3 +127,118 @@ describe('drift messages', () => {
111127
expect(message).toContain(DRIFT_OVERRIDE_ENV);
112128
});
113129
});
130+
131+
describe('what a dev server that refused a drifted Console answers', () => {
132+
const ORIGIN = 'http://console.example.test';
133+
const PIN = 'aaaaaaaaaaaa1111111111111111111111111111';
134+
const STAMP = 'bbbbbbbbbbbb2222222222222222222222222222';
135+
const HEADLINE =
136+
'The ObjectStack Console here was built from a different objectui commit than this checkout pins, ' +
137+
'so this dev server refuses to serve it at /_console/.';
138+
/** The shell, a client-side route and a hashed asset: the refusal covers every path under the mount. */
139+
const CONSOLE_REQUESTS = ['/_console/', '/_console/apps/crm/records/42', '/_console/assets/index-ocmkyCt6.js'];
140+
141+
type Request = (p: string) => Promise<Response>;
142+
let scratch: string;
143+
144+
beforeAll(() => {
145+
scratch = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'os-test-console-drift-refusal-')));
146+
});
147+
148+
afterAll(() => {
149+
fs.rmSync(scratch, { recursive: true, force: true });
150+
});
151+
152+
/** A checkout pinned at `PIN` whose console dist is stamped `stamp` — what `os dev` resolves. */
153+
function pinnedTree(tag: string, stamp: string): { root: string; consoleDir: string } {
154+
const root = path.join(scratch, tag);
155+
const consoleDir = path.join(root, 'packages', 'console');
156+
fs.mkdirSync(path.join(consoleDir, 'dist'), { recursive: true });
157+
fs.writeFileSync(path.join(root, '.objectui-sha'), `${PIN}\n`);
158+
fs.writeFileSync(path.join(consoleDir, 'package.json'), JSON.stringify({ name: '@objectstack/console' }));
159+
fs.writeFileSync(
160+
path.join(consoleDir, 'dist', 'index.html'),
161+
'<!doctype html><html><head></head><body>console shell</body></html>',
162+
);
163+
fs.writeFileSync(path.join(consoleDir, 'dist', '.objectui-sha'), `${stamp}\n`);
164+
return { root, consoleDir };
165+
}
166+
167+
async function serve(plugin: { start: (ctx: any) => Promise<void> }): Promise<Request> {
168+
const server = new HonoHttpServer(0);
169+
await plugin.start({ getServiceAsync: async () => server, logger: { warn: () => {} } });
170+
const app = server.getRawApp();
171+
return async (p) => app.request(`${ORIGIN}${p}`);
172+
}
173+
174+
describe('the build is not the pin', () => {
175+
let root: string;
176+
let drift: ConsoleShaDrift;
177+
let request: Request;
178+
beforeAll(async () => {
179+
const tree = pinnedTree('drifted', STAMP);
180+
root = tree.root;
181+
const found = detectConsoleShaDrift(tree.consoleDir);
182+
expect(found, 'the fixture must be a drift detectConsoleShaDrift can prove').not.toBeNull();
183+
drift = found!;
184+
// The premise: this is the boot `os dev` refuses, not one it serves.
185+
expect(decideConsoleMount({ hasDist: true, drift, isDev: true, env: {} })).toEqual({
186+
mount: false,
187+
refusedForDrift: true,
188+
});
189+
request = await serve(createConsoleShaDriftRefusalPlugin(drift));
190+
});
191+
192+
it.each(CONSOLE_REQUESTS)('GET %s answers 503 naming `pnpm objectui:build`, not the SPA', async (p) => {
193+
const res = await request(p);
194+
expect(res.status).toBe(503);
195+
expect(res.headers.get('content-type')).toMatch(/^text\/plain/);
196+
expect(res.headers.get('cache-control')).toBe('no-store');
197+
const body = await res.text();
198+
expect(body.split('\n')[0]).toBe(HEADLINE);
199+
expect(body).toContain('pnpm objectui:build');
200+
expect(body).toContain(PIN.slice(0, 12));
201+
expect(body).toContain(STAMP.slice(0, 12));
202+
expect(body).toContain(DRIFT_OVERRIDE_ENV);
203+
expect(body).not.toContain('console shell');
204+
});
205+
206+
it('is the terminal refusal\'s text, with only the absolute pin-file path left out', async () => {
207+
const refusal = formatConsoleShaDriftRefusal(drift);
208+
expect(drift.pinFile.startsWith(root)).toBe(true);
209+
// The refusal block names the pin file by its path on this host; the
210+
// HTTP answer says where it is in words. Everything below the block's
211+
// headline, either side of that path, is the same text.
212+
const [before, after] = refusal.slice(refusal.indexOf('pinned')).split(drift.pinFile);
213+
expect(after, 'the refusal no longer names the pin file this test splits on').toBeDefined();
214+
215+
const body = await (await request('/_console/')).text();
216+
expect(body).toContain(before);
217+
expect(body).toContain(after);
218+
expect(body).not.toContain(root);
219+
});
220+
221+
it('redirects `/` and `/_console` to `/_console/`, as a served Console does', async () => {
222+
for (const p of ['/', '/_console']) {
223+
const res = await request(p);
224+
expect(res.status, `GET ${p}`).toBe(302);
225+
expect(res.headers.get('location'), `GET ${p}`).toBe('/_console/');
226+
}
227+
});
228+
});
229+
230+
it('control: a build that matches the pin is no drift, mounts, and serves the SPA shell', async () => {
231+
const { consoleDir } = pinnedTree('matching', PIN);
232+
const drift = detectConsoleShaDrift(consoleDir);
233+
expect(drift).toBeNull();
234+
expect(decideConsoleMount({ hasDist: true, drift, isDev: true, env: {} })).toEqual({
235+
mount: true,
236+
refusedForDrift: false,
237+
});
238+
const request = await serve(createConsoleStaticPlugin(path.join(consoleDir, 'dist')));
239+
const res = await request('/_console/');
240+
expect(res.status).toBe(200);
241+
expect(res.headers.get('content-type')).toMatch(/^text\/html/);
242+
expect(await res.text()).toContain('console shell');
243+
});
244+
});

0 commit comments

Comments
 (0)