@ultimat3/cli 20.2.0 → 21.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +70 -1
- package/package.json +30 -30
- package/src/app-env.ts +2 -2
- package/src/budgets.ts +45 -12
- package/src/build-errors.ts +54 -0
- package/src/cdp-browser.ts +21 -27
- package/src/cdp-connection.ts +66 -30
- package/src/cdp-e2e-page.ts +84 -113
- package/src/cdp-e2e-session.ts +199 -0
- package/src/cdp-launch.ts +95 -41
- package/src/cdp-offline-script.ts +73 -0
- package/src/cdp-pipe.ts +77 -0
- package/src/cmd-deploy.ts +7 -0
- package/src/cmd-dev.ts +23 -86
- package/src/cmd-shot.ts +30 -4
- package/src/dev-live-feed.ts +2 -0
- package/src/dev-render.ts +119 -20
- package/src/dev-route-table.ts +119 -0
- package/src/dev-services.ts +4 -1
- package/src/dev-sync.ts +5 -3
- package/src/e2e-app.ts +103 -0
- package/src/e2e-browser-handle.ts +55 -0
- package/src/e2e-driver.ts +32 -12
- package/src/e2e-errors.ts +14 -0
- package/src/e2e-page.ts +5 -2
- package/src/e2e-preload.ts +64 -0
- package/src/e2e-probe.ts +23 -0
- package/src/e2e-spawn.ts +169 -0
- package/src/error-codes.ts +7 -0
- package/src/error-unthrown.ts +130 -0
- package/src/errors.ts +8 -29
- package/src/index.ts +18 -4
- package/src/island-bundle.ts +38 -11
- package/src/island-realtime.ts +91 -0
- package/src/island-solid-dedupe.ts +108 -0
- package/src/island-verdict.ts +1 -1
- package/src/live-routes.ts +82 -42
- package/src/mcp-errors.ts +3 -0
- package/src/page-sync.ts +54 -0
- package/src/realtime-browser-probe-fixture.ts +2 -2
- package/src/serve.ts +9 -0
- package/src/shot-theme.ts +52 -0
- package/src/sw-artifacts.ts +13 -3
- package/src/sync-url.ts +31 -0
- package/src/templates/resource-form-island.ts +30 -21
- package/src/templates/route.ts +3 -0
- package/src/templates/scaffold-container.ts +18 -3
- package/src/templates/scaffold-dashboard-shared.ts +8 -5
- package/src/templates/scaffold-env.ts +6 -0
- package/src/verify-e2e.ts +38 -0
- package/src/verify-run.ts +105 -50
- package/src/verify-tests.ts +21 -4
- package/src/worker-bundle.ts +192 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// One responsibility: `navigator.onLine` reads `false` from a document's FIRST script while the
|
|
2
|
+
// session's offline switch is thrown, and reads `true` again — with the `online` event a page
|
|
3
|
+
// reconnects on — when it goes back. `Network.emulateNetworkConditions` cuts the NETWORK for a
|
|
4
|
+
// document created under the switch but, measured on Chrome 150, never tells it so: a reload under
|
|
5
|
+
// the switch read `navigator.onLine === true` at its first script every time, COOP or not, still
|
|
6
|
+
// read `true` a second later, and got no `online` event when the switch went back.
|
|
7
|
+
// `Network.overrideNetworkState` changed nothing. The dummy's page boot asks at its first script
|
|
8
|
+
// and replayed its outbox with a real POST under the cut (`offline-like.e2e.test.ts`, two attempts).
|
|
9
|
+
//
|
|
10
|
+
// The override is an OWN property on `navigator`, so the real getter on `Navigator.prototype` is one
|
|
11
|
+
// `delete` away. A document Chrome DID tell (it saw `offline`) gets Chrome's own `online` event on
|
|
12
|
+
// restore; one it never told gets one from `RESTORE_ONLINE`, so a page reconnects exactly once.
|
|
13
|
+
|
|
14
|
+
import type { CdpResult } from './cdp-connection';
|
|
15
|
+
|
|
16
|
+
type Send = (
|
|
17
|
+
method: string,
|
|
18
|
+
params: Record<string, unknown>,
|
|
19
|
+
session: string,
|
|
20
|
+
) => Promise<CdpResult>;
|
|
21
|
+
|
|
22
|
+
/** Runs before the page's own scripts, in every document a page session creates while cut. */
|
|
23
|
+
export const OFFLINE_FIRST_SCRIPT = `(() => {
|
|
24
|
+
let told = false;
|
|
25
|
+
Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => false });
|
|
26
|
+
addEventListener('offline', () => { told = true; });
|
|
27
|
+
addEventListener('online', () => { delete navigator.onLine; }, { once: true });
|
|
28
|
+
Object.defineProperty(window, '__xRestoreOnLine', { configurable: true, value: () => {
|
|
29
|
+
if (!Object.getOwnPropertyDescriptor(navigator, 'onLine')) return;
|
|
30
|
+
delete navigator.onLine;
|
|
31
|
+
if (!told) dispatchEvent(new Event('online'));
|
|
32
|
+
} });
|
|
33
|
+
})();`;
|
|
34
|
+
|
|
35
|
+
/** Evaluated in every open page when the switch goes back: a no-op in a document never cut. */
|
|
36
|
+
export const RESTORE_ONLINE = `window.__xRestoreOnLine?.()`;
|
|
37
|
+
|
|
38
|
+
export interface OfflineScripts {
|
|
39
|
+
/** Register the script on one page session; answers once the browser has it. */
|
|
40
|
+
add(session: string): Promise<unknown>;
|
|
41
|
+
/** Unregister it from one page session, if it holds it, and restore the open document. */
|
|
42
|
+
remove(session: string): Promise<unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const field = (from: unknown, key: string): string | undefined => {
|
|
46
|
+
const value =
|
|
47
|
+
typeof from === 'object' && from !== null ? (from as Record<string, unknown>)[key] : undefined;
|
|
48
|
+
return typeof value === 'string' ? value : undefined;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Per-session registration, keyed by the identifier the browser hands back. */
|
|
52
|
+
export function offlineScripts(send: Send): OfflineScripts {
|
|
53
|
+
const held = new Map<string, string>();
|
|
54
|
+
return {
|
|
55
|
+
async add(session) {
|
|
56
|
+
const answer = await send(
|
|
57
|
+
'Page.addScriptToEvaluateOnNewDocument',
|
|
58
|
+
{ source: OFFLINE_FIRST_SCRIPT },
|
|
59
|
+
session,
|
|
60
|
+
);
|
|
61
|
+
const identifier = field(answer.result, 'identifier');
|
|
62
|
+
if (identifier !== undefined) held.set(session, identifier);
|
|
63
|
+
return answer;
|
|
64
|
+
},
|
|
65
|
+
async remove(session) {
|
|
66
|
+
const identifier = held.get(session);
|
|
67
|
+
if (identifier === undefined) return undefined;
|
|
68
|
+
held.delete(session);
|
|
69
|
+
await send('Page.removeScriptToEvaluateOnNewDocument', { identifier }, session);
|
|
70
|
+
return send('Runtime.evaluate', { expression: RESTORE_ONLINE }, session);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/cdp-pipe.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// One responsibility: CDP over the pipe `--remote-debugging-pipe` opens — Chrome reads commands on
|
|
2
|
+
// its fd 3 and writes replies and events on its fd 4, each message one JSON text ended by a NUL
|
|
3
|
+
// byte. This is the e2e driver's wire; the WebSocket in `cdp-connection.ts` is for a remote browser.
|
|
4
|
+
//
|
|
5
|
+
// Why a pipe rather than the WebSocket Chrome also offers: Bun 1.4.0's WebSocket client handed
|
|
6
|
+
// `onmessage` text spliced from several frames under the dummy's `offline-feed` load — 64
|
|
7
|
+
// unparseable frames in one run, one of them a `Runtime.evaluate` reply that then waited out its
|
|
8
|
+
// 30 s deadline. A frame nobody can parse has no `id`, so no layer above can even tell which call
|
|
9
|
+
// it lost. A pipe is bytes and a delimiter, read here, and nothing in between.
|
|
10
|
+
|
|
11
|
+
import type { CdpTransport } from './cdp-connection';
|
|
12
|
+
|
|
13
|
+
/** The two ends a transport needs: a sink for whole messages, and the byte stream Chrome writes. */
|
|
14
|
+
export interface PipeEnds {
|
|
15
|
+
/** Write these bytes to Chrome's fd 3, in order. */
|
|
16
|
+
readonly write: (bytes: Uint8Array) => void;
|
|
17
|
+
/** Chrome's fd 4. */
|
|
18
|
+
readonly read: ReadableStream<Uint8Array>;
|
|
19
|
+
/** Release the write end — Chrome treats its fd 3 closing as the client going away. */
|
|
20
|
+
readonly end: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const NUL = 0;
|
|
24
|
+
|
|
25
|
+
/** Concatenate two byte arrays; the reader only ever holds the unterminated tail. */
|
|
26
|
+
const join = (a: Uint8Array, b: Uint8Array): Uint8Array => {
|
|
27
|
+
if (a.length === 0) return b;
|
|
28
|
+
const out = new Uint8Array(a.length + b.length);
|
|
29
|
+
out.set(a);
|
|
30
|
+
out.set(b, a.length);
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function pipeTransport(ends: PipeEnds): CdpTransport {
|
|
35
|
+
const encoder = new TextEncoder();
|
|
36
|
+
let closed = false;
|
|
37
|
+
const reader = ends.read.getReader();
|
|
38
|
+
return {
|
|
39
|
+
send(text: string): void {
|
|
40
|
+
if (closed) return;
|
|
41
|
+
const body = encoder.encode(text);
|
|
42
|
+
const framed = new Uint8Array(body.length + 1);
|
|
43
|
+
framed.set(body);
|
|
44
|
+
framed[body.length] = NUL;
|
|
45
|
+
ends.write(framed);
|
|
46
|
+
},
|
|
47
|
+
close(): void {
|
|
48
|
+
if (closed) return;
|
|
49
|
+
closed = true;
|
|
50
|
+
ends.end();
|
|
51
|
+
void reader.cancel().catch(() => undefined);
|
|
52
|
+
},
|
|
53
|
+
listen(handlers): void {
|
|
54
|
+
void (async () => {
|
|
55
|
+
// Split on BYTES, decoded per message: a multi-byte character may straddle two reads, and
|
|
56
|
+
// decoding each read on its own would corrupt it — the failure this file exists to end.
|
|
57
|
+
const decoder = new TextDecoder();
|
|
58
|
+
let tail: Uint8Array = new Uint8Array(0);
|
|
59
|
+
try {
|
|
60
|
+
for (;;) {
|
|
61
|
+
const { value, done } = await reader.read();
|
|
62
|
+
if (done) break;
|
|
63
|
+
tail = join(tail, value);
|
|
64
|
+
for (let at = tail.indexOf(NUL); at !== -1; at = tail.indexOf(NUL)) {
|
|
65
|
+
handlers.message(decoder.decode(tail.subarray(0, at)));
|
|
66
|
+
tail = tail.subarray(at + 1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// A read that fails is a pipe that is gone — reported as the close it is, below.
|
|
71
|
+
}
|
|
72
|
+
closed = true;
|
|
73
|
+
handlers.closed('the browser closed the CDP pipe');
|
|
74
|
+
})();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/cmd-deploy.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { msg } from './messages';
|
|
|
11
11
|
import type { CommandResult, JsonValue } from './output';
|
|
12
12
|
import { flagBool, flagString } from './parse';
|
|
13
13
|
import { quoteArg } from './shell-quote';
|
|
14
|
+
import { PROD_ENV_FILE } from './templates/scaffold-container';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Ordered, and the order is the design. `migrate` GATES — it runs to completion before anything
|
|
@@ -146,6 +147,12 @@ export function planDeploy(image: string, method: DeployMethod, root: string): D
|
|
|
146
147
|
command: [
|
|
147
148
|
'docker',
|
|
148
149
|
'compose',
|
|
150
|
+
// Compose interpolates `${SYNC_URL:?…}` and `${POSTGRES_PASSWORD:?…}` from the shell and
|
|
151
|
+
// `--env-file` only — never from a service's `env_file:`. Without this an operator who put
|
|
152
|
+
// them in `.env.production`, the one file the compose file tells them to fill, had every
|
|
153
|
+
// step die on a parse error. Global flag, so it precedes `-f`; the shell still wins over it.
|
|
154
|
+
'--env-file',
|
|
155
|
+
join(root, PROD_ENV_FILE),
|
|
149
156
|
'-f',
|
|
150
157
|
join(root, 'docker', 'docker-compose.prod.yml'),
|
|
151
158
|
ONE_SHOT_ROLES.includes(role) ? 'run' : 'up',
|
package/src/cmd-dev.ts
CHANGED
|
@@ -9,58 +9,43 @@ import { devShellStyle } from '@ultimat3/admin/dev';
|
|
|
9
9
|
import type { Role } from '@ultimat3/core';
|
|
10
10
|
import { configureTelemetry, METRICS_PATH, noopExporter } from '@ultimat3/core';
|
|
11
11
|
import { setStatementObserver } from '@ultimat3/db';
|
|
12
|
-
import type { OverlayNotice, RequestContext
|
|
12
|
+
import type { OverlayNotice, RequestContext } from '@ultimat3/http';
|
|
13
13
|
import { asCtx } from '@ultimat3/http';
|
|
14
14
|
import type { Manifest } from '@ultimat3/manifest';
|
|
15
15
|
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
16
|
-
import { describeRoutes } from '@ultimat3/render';
|
|
17
|
-
import { apiRoutes } from './api-routes';
|
|
18
16
|
import { loadSignInPath } from './app-auth';
|
|
19
17
|
import { appManifest } from './app-manifest';
|
|
20
|
-
import { mountAppMcp } from './app-mcp';
|
|
21
18
|
import { requireAppRoot } from './app-root';
|
|
22
19
|
import { loadAppRuntime } from './app-runtime';
|
|
23
20
|
import type { CliCommand, CommandContext } from './command';
|
|
24
|
-
import { assetRoutes } from './dev-assets';
|
|
25
21
|
import type { DevDashboardInput, DevStatus } from './dev-dashboard';
|
|
26
|
-
import {
|
|
22
|
+
import { devPanels } from './dev-dashboard';
|
|
27
23
|
import { declareDevEnvironment } from './dev-environment';
|
|
28
24
|
import { liveFeedLabel } from './dev-live-feed';
|
|
29
25
|
import { clearLock, preflight, writeLock } from './dev-lock';
|
|
30
26
|
import { createStatementLedger } from './dev-n-plus-one';
|
|
31
27
|
import { coalesceReloads } from './dev-reload';
|
|
32
|
-
import { appRoutes } from './dev-render';
|
|
33
28
|
import { replicaOverrides } from './dev-replica';
|
|
34
29
|
import type { RunningRoles } from './dev-roles';
|
|
35
30
|
import { DEV_BINDING, DEV_ROLES, selectRoles, startRoles } from './dev-roles';
|
|
31
|
+
import { devRouteTable } from './dev-route-table';
|
|
36
32
|
import type { RunningServices } from './dev-runtime';
|
|
37
33
|
import { cdnLabel, describeCdn, describeMail, mailLabel, startServices } from './dev-runtime';
|
|
38
34
|
import type { DevServices } from './dev-services';
|
|
39
35
|
import { describeServices, reportedUrls, resolveServices } from './dev-services';
|
|
40
|
-
import { storageRoutes } from './dev-storage';
|
|
41
36
|
import { createTraceRecorder } from './dev-traces';
|
|
42
37
|
import { watchTree } from './dev-watch-tree';
|
|
43
|
-
import { errorPageStyleSources } from './error-page-csp';
|
|
44
38
|
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
45
39
|
import { holdUntilShutdown } from './hold';
|
|
46
40
|
import type { IslandBundle } from './island-bundle';
|
|
47
41
|
import { buildIslands } from './island-bundle';
|
|
48
42
|
import { FRAME_STYLE } from './island-harness';
|
|
49
|
-
import { islandHarnessRoutes } from './island-harness-route';
|
|
50
|
-
import { islandRoutes } from './island-routes';
|
|
51
|
-
import { loadIslandStates } from './island-states-load';
|
|
52
43
|
import { msg } from './messages';
|
|
53
44
|
import type { CommandResult, Finding } from './output';
|
|
54
45
|
import { findingFrom } from './output';
|
|
55
46
|
import { flagString } from './parse';
|
|
56
|
-
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
57
47
|
import { metricsPortFor } from './serve';
|
|
58
48
|
import { loopFacts, loopFinding, loopNotice } from './statement-loop';
|
|
59
|
-
import { styleBundle } from './style-bundle';
|
|
60
|
-
import { styleRoutes } from './style-routes';
|
|
61
|
-
import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
62
|
-
import { serviceWorkerRoutes } from './sw-routes';
|
|
63
|
-
import { loadThemeMode, themeBoot } from './theme-boot';
|
|
64
49
|
|
|
65
50
|
const DEFAULT_PORT = 3000;
|
|
66
51
|
|
|
@@ -68,7 +53,10 @@ export interface DevServer {
|
|
|
68
53
|
readonly url: string;
|
|
69
54
|
readonly services: DevServices;
|
|
70
55
|
readonly roles: readonly Role[];
|
|
71
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* `BUILD_ID` when stamped — the id every response carries. Otherwise the manifest as it stands
|
|
58
|
+
* now, so a reload that registers a new route moves it.
|
|
59
|
+
*/
|
|
72
60
|
readonly buildId: string;
|
|
73
61
|
/**
|
|
74
62
|
* Modules that would not import, primitives that would not register, reloads that would not
|
|
@@ -164,7 +152,12 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
164
152
|
// boot on purpose: the header is handed to the HTTP config and the render modes once, and a
|
|
165
153
|
// reload cannot re-pin it — `state.manifest.buildId` is what `/_x` and `--json` report, so a
|
|
166
154
|
// divergence between the two is visible rather than silent, and a restart closes it.
|
|
167
|
-
|
|
155
|
+
// `BUILD_ID` wins when set — `serve.ts`'s rule, so an e2e `deploy.newBuild()` can restart `x dev`
|
|
156
|
+
// as a new build with the same sources.
|
|
157
|
+
// Stamped, it is ALSO what `server.buildId` answers below: the process serves no other build.
|
|
158
|
+
const rawStamp = options.env['BUILD_ID'];
|
|
159
|
+
const stamped = rawStamp !== undefined && rawStamp !== '' ? rawStamp : undefined;
|
|
160
|
+
const buildId = stamped ?? state.manifest.buildId;
|
|
168
161
|
|
|
169
162
|
let server: DevServer;
|
|
170
163
|
// Read at request time, never captured at boot: `/_x/services` must report the reload counter
|
|
@@ -185,70 +178,14 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
185
178
|
};
|
|
186
179
|
const panels = devPanels(dashboard).map((panel) => panel.key);
|
|
187
180
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
// worker that changes under a page it already controls is the update path, and re-emitting one
|
|
197
|
-
// per keystroke would exercise it on every save.
|
|
198
|
-
const serviceWorker =
|
|
199
|
-
pwa === undefined
|
|
200
|
-
? undefined
|
|
201
|
-
: serviceWorkerArtifacts({
|
|
202
|
-
pwa,
|
|
203
|
-
buildId,
|
|
204
|
-
routes: describeRoutes(),
|
|
205
|
-
islands: state.islands,
|
|
206
|
-
styles: styleBundle(),
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
// The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
|
|
210
|
-
// call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
|
|
211
|
-
// one of them asked. Warned once here when `expose` is true and nothing can be mounted.
|
|
212
|
-
const mcpMount = await mountAppMcp(options.root);
|
|
213
|
-
const routes: readonly Route[] = [
|
|
214
|
-
...devDashboardRoutes(dashboard),
|
|
215
|
-
// The same API table the container serves: a read that answers here and 404s in production
|
|
216
|
-
// is exactly the drift one composition exists to prevent.
|
|
217
|
-
...apiRoutes(),
|
|
218
|
-
...mcpMount.routes,
|
|
219
|
-
// The image pipeline's only HTTP surface: the icons the web manifest declares, and the
|
|
220
|
-
// variants every `srcset` promises. Mounted before the app's own routes so a page route can
|
|
221
|
-
// never shadow `/icons` or `/media`.
|
|
222
|
-
...assetRoutes({
|
|
223
|
-
root: options.root,
|
|
224
|
-
storage: runtime.storage,
|
|
225
|
-
...(pwa === undefined ? {} : { pwa }),
|
|
226
|
-
}),
|
|
227
|
-
...storageRoutes({ storage: runtime.storage }),
|
|
228
|
-
// The chunks the documents below name. Mounted before the app's routes for the reason
|
|
229
|
-
// `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
|
|
230
|
-
...islandRoutes(() => state.islands),
|
|
231
|
-
// And the stylesheet every one of those documents links. Read through the getter for the
|
|
232
|
-
// reason the islands are: a rebuilt island registers CSS, which mints a new URL, and a table
|
|
233
|
-
// captured at boot would answer 404 for the href the document now carries.
|
|
234
|
-
...styleRoutes(() => styleBundle()),
|
|
235
|
-
// `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
|
|
236
|
-
// lives here rather than in a second server because everything it needs is in THIS process:
|
|
237
|
-
// the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
|
|
238
|
-
// may have. The states are read per REQUEST — an author editing a state and re-running the
|
|
239
|
-
// command must not need a restart to see it.
|
|
240
|
-
...islandHarnessRoutes({
|
|
241
|
-
islands: () => state.islands,
|
|
242
|
-
states: () => loadIslandStates(options.root),
|
|
243
|
-
}),
|
|
244
|
-
...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
|
|
245
|
-
...appRoutes({
|
|
246
|
-
buildId,
|
|
247
|
-
resolveIsland: (file) => state.islands.resolverFor(file),
|
|
248
|
-
themeHead: theme.head,
|
|
249
|
-
...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
|
|
250
|
-
}),
|
|
251
|
-
];
|
|
181
|
+
const { routes, theme, errorStyles, mcpPath } = await devRouteTable({
|
|
182
|
+
root: options.root,
|
|
183
|
+
env: options.env,
|
|
184
|
+
buildId,
|
|
185
|
+
storage: runtime.storage,
|
|
186
|
+
dashboard,
|
|
187
|
+
islands: () => state.islands,
|
|
188
|
+
});
|
|
252
189
|
|
|
253
190
|
// The app's `apps/<app>/runtime.ts`, composed exactly as `runRole` composes a caller's
|
|
254
191
|
// `runtime`: the replica scope in front, the app's own middleware behind it. Before this the
|
|
@@ -320,9 +257,9 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
320
257
|
url: running.url ?? `http://localhost:${options.port}`,
|
|
321
258
|
services,
|
|
322
259
|
roles: running.roles,
|
|
323
|
-
mcp:
|
|
260
|
+
mcp: mcpPath,
|
|
324
261
|
get buildId(): string {
|
|
325
|
-
return state.manifest.buildId;
|
|
262
|
+
return stamped ?? state.manifest.buildId;
|
|
326
263
|
},
|
|
327
264
|
// A getter, not a snapshot: `/_x` and `--json` must show the reload that just failed and the
|
|
328
265
|
// loop the last request tripped, not the findings as they were when the route table was built.
|
package/src/cmd-shot.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { shotBrowserChoice } from './shot-browser';
|
|
|
32
32
|
import type { BootDevServer, ShotServer } from './shot-server';
|
|
33
33
|
import { allowHostsFrom, devServerFor, SHOT_DIR } from './shot-server';
|
|
34
34
|
import { SETTLE_POLL_MS, settleIslands } from './shot-settle';
|
|
35
|
+
import { readThemeFlag, themeChoiceExpression } from './shot-theme';
|
|
35
36
|
import type { IslandCount, ShotArtifacts } from './shot-verdict';
|
|
36
37
|
import {
|
|
37
38
|
buildVerdict,
|
|
@@ -191,9 +192,13 @@ export interface ShotRun {
|
|
|
191
192
|
*/
|
|
192
193
|
readonly extraHosts?: string | undefined;
|
|
193
194
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
195
|
+
* The theme the picture is of. Two things happen BEFORE navigation, because the boot script runs
|
|
196
|
+
* inline and nothing after `goto` can reach it: `prefers-color-scheme` is emulated so the boot's
|
|
197
|
+
* "system" branch answers the same on every box, AND the scheme is stored as the visitor's
|
|
198
|
+
* choice under `THEME_STORAGE_KEY` (`shot-theme.ts`), because an app with `theme.defaultMode`
|
|
199
|
+
* set answers that before the OS and only a stored choice beats it (issue #489). Absent means
|
|
200
|
+
* neither: the box's own preference and the app's own default — what `x shot` has always done,
|
|
201
|
+
* and the point of `defaultMode` — and `ui.shot` names one explicitly for exactly that reason.
|
|
197
202
|
*/
|
|
198
203
|
readonly colorScheme?: ColorScheme | undefined;
|
|
199
204
|
readonly now?: (() => Date) | undefined;
|
|
@@ -234,7 +239,11 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
|
|
|
234
239
|
timeoutMs: options.timeoutMs,
|
|
235
240
|
});
|
|
236
241
|
const page = session.page;
|
|
237
|
-
if (options.colorScheme !== undefined)
|
|
242
|
+
if (options.colorScheme !== undefined) {
|
|
243
|
+
await page.colorScheme(options.colorScheme);
|
|
244
|
+
const choice = themeChoiceExpression(options.colorScheme);
|
|
245
|
+
if (choice !== undefined) await page.prepare(choice);
|
|
246
|
+
}
|
|
238
247
|
await page.goto(requestedUrl, { timeout: options.timeoutMs });
|
|
239
248
|
if (options.settleMs > 0) await Bun.sleep(options.settleMs);
|
|
240
249
|
// The probe may legitimately answer nothing — a page that refuses evaluation, a driver with no
|
|
@@ -322,6 +331,11 @@ export const shotCommand: CliCommand = {
|
|
|
322
331
|
summary: 'attach to a browser somebody else is running (a provider session, a sidecar)',
|
|
323
332
|
},
|
|
324
333
|
{ name: 'allow-hosts', type: 'string', summary: 'extra hosts the page may request' },
|
|
334
|
+
{
|
|
335
|
+
name: 'theme',
|
|
336
|
+
type: 'string',
|
|
337
|
+
summary: "light or dark, stored as the visitor's choice; absent is the app's own default",
|
|
338
|
+
},
|
|
325
339
|
// A FLAG on `x shot` and never a second command: photographing a route and photographing a
|
|
326
340
|
// component are one job with two subjects, and a parallel command would be the second path
|
|
327
341
|
// axiom 1 refuses.
|
|
@@ -352,6 +366,7 @@ export const shotCommand: CliCommand = {
|
|
|
352
366
|
// report, which is the rule `x routes` and `x mcp` already follow.
|
|
353
367
|
const island = flagString(ctx.args, 'island');
|
|
354
368
|
const state = flagString(ctx.args, 'state');
|
|
369
|
+
const theme = readThemeFlag(flagString(ctx.args, 'theme'));
|
|
355
370
|
const positional = ctx.args.positionals[0];
|
|
356
371
|
const sweep = flagBool(ctx.args, 'all-islands');
|
|
357
372
|
// Every ambiguous pair refused BY NAME, before a value is read: a reader who typed two
|
|
@@ -364,6 +379,16 @@ export const shotCommand: CliCommand = {
|
|
|
364
379
|
refuseRouteWithIsland(positional, island);
|
|
365
380
|
}
|
|
366
381
|
const component = sweep || (island !== undefined && island !== '');
|
|
382
|
+
// An island is photographed in BOTH themes by the harness, which owns its `data-theme` and
|
|
383
|
+
// carries no boot script — so a theme asked for beside one is a request nothing could honour.
|
|
384
|
+
if (component && theme !== undefined) {
|
|
385
|
+
throw new BadFlagError({
|
|
386
|
+
flag: 'theme',
|
|
387
|
+
command: 'shot',
|
|
388
|
+
reason: 'photographs a route; an island is photographed in both themes',
|
|
389
|
+
fix: 'x shot / --theme light --json',
|
|
390
|
+
});
|
|
391
|
+
}
|
|
367
392
|
const route = component ? '' : readRoute(positional);
|
|
368
393
|
const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
|
|
369
394
|
const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
|
|
@@ -415,6 +440,7 @@ export const shotCommand: CliCommand = {
|
|
|
415
440
|
timeoutMs,
|
|
416
441
|
fullPage: flagBool(ctx.args, 'full'),
|
|
417
442
|
extraHosts: flagString(ctx.args, 'allow-hosts'),
|
|
443
|
+
...(theme === undefined ? {} : { colorScheme: theme }),
|
|
418
444
|
}),
|
|
419
445
|
);
|
|
420
446
|
},
|
package/src/dev-live-feed.ts
CHANGED
|
@@ -49,6 +49,8 @@ export async function startLiveFeed(input: LiveFeedInput): Promise<RunningLiveFe
|
|
|
49
49
|
}
|
|
50
50
|
const bridge = await startLiveReplicator({
|
|
51
51
|
registry: input.sync.registry,
|
|
52
|
+
// The same changes the channels read — a real node's change subscription feeds both.
|
|
53
|
+
channels: input.sync.hub,
|
|
52
54
|
// Logged, never thrown: one change nobody could fan out must not take the dev server down.
|
|
53
55
|
onError: (error) =>
|
|
54
56
|
logger.warn('live.bridge_delivery_failed', { error: renderThrowable(error) }),
|