@ultimat3/cli 18.0.0 → 19.1.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 +105 -11
- package/README.md +24 -0
- package/package.json +29 -29
- package/src/app-mcp.ts +152 -0
- package/src/app-root.ts +10 -1
- package/src/app-runtime.ts +39 -0
- package/src/bin.ts +18 -0
- package/src/budgets.ts +1 -1
- package/src/cdp-browser.ts +100 -0
- package/src/cdp-connection.ts +211 -0
- package/src/cdp-e2e-page.ts +209 -0
- package/src/cdp-errors.ts +56 -0
- package/src/cdp-launch.ts +130 -0
- package/src/cmd-dev-fixture.ts +147 -0
- package/src/cmd-dev.ts +41 -3
- package/src/compile-externals.ts +11 -4
- package/src/dev-live-feed.ts +61 -0
- package/src/dev-roles.ts +20 -0
- package/src/dev-sync.ts +5 -1
- package/src/e2e-driver.ts +35 -17
- package/src/e2e-page.ts +15 -3
- package/src/error-codes.ts +11 -0
- package/src/index.ts +27 -0
- package/src/local-cli.ts +71 -0
- package/src/mcp-errors.ts +9 -0
- package/src/measurement-actor.ts +26 -0
- package/src/messages.ts +5 -0
- package/src/prerender.ts +42 -5
- package/src/pwa-artifacts.ts +44 -2
- package/src/serve.ts +38 -4
- package/src/source-files.ts +14 -2
- package/src/static-report.ts +46 -3
- package/src/sw-artifacts.ts +162 -0
- package/src/sw-routes.ts +53 -0
- package/src/templates/entity.ts +7 -0
- package/src/templates/scaffold-app.ts +58 -7
- package/src/templates/scaffold-repo.ts +4 -1
- package/src/ts-scan.ts +8 -111
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// The app `x dev` boots in its tests, and the one place it is declared: every assertion that
|
|
2
|
+
// needs a booted app lives in `cmd-dev.test.ts`, because a process has ONE lifecycle — the second
|
|
3
|
+
// in-process boot is refused (X_LIFECYCLE_DRAINED) — and the coverage gate runs a package in one
|
|
4
|
+
// process. So the fixture is a module of its own, and that file keeps its line budget for tests.
|
|
5
|
+
//
|
|
6
|
+
// What the app declares, and what each declaration is here to prove:
|
|
7
|
+
// app.config.ts the root marker a real `x dev` cannot start without; `ai.mcp` by default
|
|
8
|
+
// apps/web/mcp.ts the app's own MCP endpoint, mounted by the web role
|
|
9
|
+
// apps/web/runtime.ts the app's middleware, reaching a development process
|
|
10
|
+
// apps/web/app/notes/* a memory-backed entity and a live query, fed by the in-process bridge
|
|
11
|
+
// apps/web/app/posts/* an action, a policy and a query, mounted as HTTP routes
|
|
12
|
+
// apps/web/site/pricing/* a static page with its own stylesheet, under the CSP `x dev` sends
|
|
13
|
+
import { resetRegistry as resetActions } from '@ultimat3/action';
|
|
14
|
+
import { clearRegistry as clearEntities } from '@ultimat3/entity';
|
|
15
|
+
import { resetJobs, resetTasks } from '@ultimat3/jobs';
|
|
16
|
+
import { clearPermissions, clearRoles } from '@ultimat3/policy';
|
|
17
|
+
import { resetRegistry as resetQueries } from '@ultimat3/query';
|
|
18
|
+
import type { Frame } from '@ultimat3/realtime';
|
|
19
|
+
import { decode } from '@ultimat3/realtime';
|
|
20
|
+
import type { WsLike } from '@ultimat3/realtime/server';
|
|
21
|
+
import { clearRoutes } from '@ultimat3/render';
|
|
22
|
+
import { resetAppLoad } from './app-load';
|
|
23
|
+
|
|
24
|
+
export const DEV_FIXTURE_FILES: Readonly<Record<string, string>> = {
|
|
25
|
+
'package.json': JSON.stringify({ name: 'dev-fixture', version: '1.4.0' }),
|
|
26
|
+
|
|
27
|
+
// The root marker a real `x dev` cannot start without, and where `ai.mcp` is declared — by
|
|
28
|
+
// default `{ expose: true, path: '/mcp' }`, which is what the MCP mount reads.
|
|
29
|
+
'app.config.ts': `import { defineConfig } from '@ultimat3/core';
|
|
30
|
+
export const config = defineConfig({ name: 'dev-fixture' });
|
|
31
|
+
`,
|
|
32
|
+
|
|
33
|
+
// The app's own MCP endpoint, in the contract `app-mcp.ts` reads. `resolveToken` answering
|
|
34
|
+
// `null` rejects every bearer, which is enough to prove the ROUTE is mounted (401, not 404).
|
|
35
|
+
'apps/web/mcp.ts': `import { defineAppMcp } from '@ultimat3/mcp';
|
|
36
|
+
export const mcp = defineAppMcp({ include: 'exposed', resolveToken: () => null });
|
|
37
|
+
`,
|
|
38
|
+
|
|
39
|
+
// The app's own middleware, in the contract `app-runtime.ts` reads: a header on every response
|
|
40
|
+
// is the cheapest proof that the chain `x dev` composed is the app's and not only the replica's.
|
|
41
|
+
'apps/web/runtime.ts': `const stamp = async (request, ctx, next) => {
|
|
42
|
+
const response = await next(request, ctx);
|
|
43
|
+
const headers = new Headers(response.headers);
|
|
44
|
+
headers.set('x-dev-runtime', 'app');
|
|
45
|
+
return new Response(response.body, { status: response.status, headers });
|
|
46
|
+
};
|
|
47
|
+
export const runtime = { middleware: [stamp] };
|
|
48
|
+
`,
|
|
49
|
+
|
|
50
|
+
// A memory-backed entity, so the fixture needs no migration: the row observer sits on
|
|
51
|
+
// `database()`'s repo wrapper, the same seam a Postgres-backed repo writes through.
|
|
52
|
+
'apps/web/app/notes/entity.ts': `import { database, entity, memoryDriver, text, uuid } from '@ultimat3/entity';
|
|
53
|
+
export const notes = entity('notes', { columns: { id: uuid().primaryKey(), title: text() } });
|
|
54
|
+
export const db = database({ notes }, { driver: memoryDriver() });
|
|
55
|
+
`,
|
|
56
|
+
'apps/web/app/notes/live.ts': `import { allow } from '@ultimat3/policy';
|
|
57
|
+
import { from, query, t } from '@ultimat3/query';
|
|
58
|
+
import { db } from './entity';
|
|
59
|
+
export const liveNotes = query({
|
|
60
|
+
input: t.object({}),
|
|
61
|
+
policy: allow('public'),
|
|
62
|
+
live: true,
|
|
63
|
+
subscribes: ['notes'],
|
|
64
|
+
sql: () =>
|
|
65
|
+
from<{ id: string; title: string }>('notes', () => db.notes.where({}).all())
|
|
66
|
+
.orderBy('id')
|
|
67
|
+
.limit(50),
|
|
68
|
+
});
|
|
69
|
+
`,
|
|
70
|
+
|
|
71
|
+
'apps/web/app/posts/policy.ts': `import { allow, can, definePermissions, defineRoles } from '@ultimat3/policy';
|
|
72
|
+
export const permissions = definePermissions(['post:publish'] as const);
|
|
73
|
+
export const roles = defineRoles({
|
|
74
|
+
author: { grants: ['post:publish'] },
|
|
75
|
+
reader: { grants: [] },
|
|
76
|
+
});
|
|
77
|
+
export const canPostWrite = can('post:publish');
|
|
78
|
+
export const anyone = allow();
|
|
79
|
+
`,
|
|
80
|
+
|
|
81
|
+
'apps/web/app/posts/actions.ts': `import { action, t } from '@ultimat3/action';
|
|
82
|
+
import { anyone, canPostWrite } from './policy';
|
|
83
|
+
|
|
84
|
+
export const publishPost = action({
|
|
85
|
+
input: t.object({ id: t.uuid }),
|
|
86
|
+
output: t.object({ id: t.uuid }),
|
|
87
|
+
policy: canPostWrite,
|
|
88
|
+
async handle({ input }) {
|
|
89
|
+
return { id: input.id };
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export const echoPost = action({
|
|
94
|
+
input: t.object({ word: t.string }),
|
|
95
|
+
output: t.object({ word: t.string }),
|
|
96
|
+
policy: anyone,
|
|
97
|
+
async handle({ input }) {
|
|
98
|
+
return { word: input.word };
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
`,
|
|
102
|
+
|
|
103
|
+
// A stylesheet the page imports, because that import is what registers it — and the document's
|
|
104
|
+
// inline `<style>` is what the CSP has to name. Without one this file served no styled page and
|
|
105
|
+
// could not have caught the policy that blanked every deployed app.
|
|
106
|
+
'apps/web/site/pricing/page.module.scss': `.price { color: #123456; }
|
|
107
|
+
`,
|
|
108
|
+
|
|
109
|
+
'apps/web/site/pricing/page.tsx': `import { defineRoute } from '@ultimat3/render';
|
|
110
|
+
import './page.module.scss';
|
|
111
|
+
|
|
112
|
+
export const config = defineRoute({
|
|
113
|
+
render: 'static',
|
|
114
|
+
offline: 'precache',
|
|
115
|
+
hydrate: 'never',
|
|
116
|
+
budget: { js: '0kb' },
|
|
117
|
+
meta: () => ({ title: 'Pricing', description: 'What it costs' }),
|
|
118
|
+
});
|
|
119
|
+
`,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const resetRegistries = (): void => {
|
|
123
|
+
resetActions();
|
|
124
|
+
resetQueries();
|
|
125
|
+
clearEntities();
|
|
126
|
+
clearRoutes();
|
|
127
|
+
resetJobs();
|
|
128
|
+
resetTasks();
|
|
129
|
+
clearPermissions();
|
|
130
|
+
clearRoles();
|
|
131
|
+
resetAppLoad();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** A `WsLike` that keeps every frame the node sends, decoded — what a live assertion reads. */
|
|
135
|
+
export class FakeWs implements WsLike {
|
|
136
|
+
readonly frames: Frame[] = [];
|
|
137
|
+
send(data: string): number {
|
|
138
|
+
this.frames.push(decode(data));
|
|
139
|
+
return data.length;
|
|
140
|
+
}
|
|
141
|
+
close(): void {}
|
|
142
|
+
subscribe(): void {}
|
|
143
|
+
unsubscribe(): void {}
|
|
144
|
+
getBufferedAmount(): number {
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/cmd-dev.ts
CHANGED
|
@@ -14,15 +14,19 @@ import type { OverlayNotice, RequestContext, Route } from '@ultimat3/http';
|
|
|
14
14
|
import { asCtx } from '@ultimat3/http';
|
|
15
15
|
import type { Manifest } from '@ultimat3/manifest';
|
|
16
16
|
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
17
|
+
import { describeRoutes } from '@ultimat3/render';
|
|
17
18
|
import { apiRoutes } from './api-routes';
|
|
18
19
|
import { loadSignInPath } from './app-auth';
|
|
19
20
|
import { loadApp } from './app-load';
|
|
20
21
|
import { appManifest } from './app-manifest';
|
|
22
|
+
import { mountAppMcp } from './app-mcp';
|
|
21
23
|
import { requireAppRoot } from './app-root';
|
|
24
|
+
import { loadAppRuntime } from './app-runtime';
|
|
22
25
|
import type { CliCommand, CommandContext } from './command';
|
|
23
26
|
import { assetRoutes } from './dev-assets';
|
|
24
27
|
import type { DevDashboardInput, DevStatus } from './dev-dashboard';
|
|
25
28
|
import { devDashboardRoutes, devPanels } from './dev-dashboard';
|
|
29
|
+
import { liveFeedLabel } from './dev-live-feed';
|
|
26
30
|
import { clearLock, preflight, writeLock } from './dev-lock';
|
|
27
31
|
import { createStatementLedger } from './dev-n-plus-one';
|
|
28
32
|
import { appRoutes } from './dev-render';
|
|
@@ -49,6 +53,8 @@ import { flagString } from './parse';
|
|
|
49
53
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
50
54
|
import { metricsPortFor } from './serve';
|
|
51
55
|
import { loopFacts, loopFinding, loopNotice } from './statement-loop';
|
|
56
|
+
import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
57
|
+
import { serviceWorkerRoutes } from './sw-routes';
|
|
52
58
|
|
|
53
59
|
const DEFAULT_PORT = 3000;
|
|
54
60
|
|
|
@@ -68,6 +74,8 @@ export interface DevServer {
|
|
|
68
74
|
readonly runtime: RunningServices;
|
|
69
75
|
/** Panel keys `/_x` mounted, in tab order. Reported so `--json` names what is reachable. */
|
|
70
76
|
readonly panels: readonly string[];
|
|
77
|
+
/** `POST <path>` of the app's own MCP endpoint, or `null` when nothing was mounted. */
|
|
78
|
+
readonly mcp: string | null;
|
|
71
79
|
stop(): Promise<void>;
|
|
72
80
|
}
|
|
73
81
|
|
|
@@ -176,12 +184,25 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
176
184
|
// name it. `undefined` for an app that is not installable, and then nothing is mounted and no
|
|
177
185
|
// document changes — the 0kb baseline is not spent on a `<link>` to a file that does not exist.
|
|
178
186
|
const pwa = await loadPwaArtifacts(options.root);
|
|
187
|
+
// Built once at boot, from this process's own route table and island bundle. `x dev` rebuilds
|
|
188
|
+
// islands on the watcher tick and the worker is NOT rebuilt with them, deliberately: a service
|
|
189
|
+
// worker that changes under a page it already controls is the update path, and re-emitting one
|
|
190
|
+
// per keystroke would exercise it on every save.
|
|
191
|
+
const serviceWorker =
|
|
192
|
+
pwa === undefined
|
|
193
|
+
? undefined
|
|
194
|
+
: serviceWorkerArtifacts({ pwa, buildId, routes: describeRoutes(), islands: state.islands });
|
|
179
195
|
|
|
196
|
+
// The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
|
|
197
|
+
// call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
|
|
198
|
+
// one of them asked. Warned once here when `expose` is true and nothing can be mounted.
|
|
199
|
+
const mcpMount = await mountAppMcp(options.root);
|
|
180
200
|
const routes: readonly Route[] = [
|
|
181
201
|
...devDashboardRoutes(dashboard),
|
|
182
202
|
// The same API table the container serves: a read that answers here and 404s in production
|
|
183
203
|
// is exactly the drift one composition exists to prevent.
|
|
184
204
|
...apiRoutes(),
|
|
205
|
+
...mcpMount.routes,
|
|
185
206
|
// The image pipeline's only HTTP surface: the icons the web manifest declares, and the
|
|
186
207
|
// variants every `srcset` promises. Mounted before the app's own routes so a page route can
|
|
187
208
|
// never shadow `/icons` or `/media`.
|
|
@@ -203,14 +224,22 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
203
224
|
islands: () => state.islands,
|
|
204
225
|
states: () => loadIslandStates(options.root),
|
|
205
226
|
}),
|
|
227
|
+
...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
|
|
206
228
|
...appRoutes({
|
|
207
229
|
buildId,
|
|
208
230
|
resolveIsland: (file) => state.islands.resolverFor(file),
|
|
209
|
-
...(pwa === undefined ? {} : { pwaHead: pwa.head }),
|
|
231
|
+
...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
|
|
210
232
|
}),
|
|
211
233
|
];
|
|
212
234
|
|
|
213
|
-
|
|
235
|
+
// The app's `apps/<app>/runtime.ts`, composed exactly as `runRole` composes a caller's
|
|
236
|
+
// `runtime`: the replica scope in front, the app's own middleware behind it. Before this the
|
|
237
|
+
// first argument was `undefined` here and an app's middleware reached no development process.
|
|
238
|
+
const replicaOverride = replicaOverrides(
|
|
239
|
+
await loadAppRuntime(options.root),
|
|
240
|
+
services.db,
|
|
241
|
+
options.env,
|
|
242
|
+
);
|
|
214
243
|
const running = await startRoles({
|
|
215
244
|
roles: options.roles ?? DEV_ROLES,
|
|
216
245
|
port: options.port,
|
|
@@ -266,6 +295,7 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
266
295
|
url: running.url ?? `http://localhost:${options.port}`,
|
|
267
296
|
services,
|
|
268
297
|
roles: running.roles,
|
|
298
|
+
mcp: mcpMount.path,
|
|
269
299
|
get buildId(): string {
|
|
270
300
|
return state.manifest.buildId;
|
|
271
301
|
},
|
|
@@ -368,7 +398,7 @@ export const devCommand: CliCommand = {
|
|
|
368
398
|
panels: server.panels.length,
|
|
369
399
|
// Rendered text, so the mail and CDN halves come from the catalog; `data` below carries the
|
|
370
400
|
// status values a script parses, which is why the two are different calls and not one.
|
|
371
|
-
services: `${describeServices(server.services)} ${mailLabel(server.runtime)} ${cdnLabel(server.runtime)}`,
|
|
401
|
+
services: `${describeServices(server.services)} ${mailLabel(server.runtime)} ${cdnLabel(server.runtime)} ${liveFeedLabel(server.running.liveFeed)}`,
|
|
372
402
|
}),
|
|
373
403
|
findings: server.findings,
|
|
374
404
|
// Every fact `lines` prints is a fact `--json` carries, `manifest` included — or the two
|
|
@@ -393,10 +423,15 @@ export const devCommand: CliCommand = {
|
|
|
393
423
|
// on one database is the one topology mistake that cannot be seen from the outside, so the
|
|
394
424
|
// slot is a scriptable fact rather than a line in a log.
|
|
395
425
|
replicationSlot: server.running.replicator?.slot ?? null,
|
|
426
|
+
// Which change feed the sync node has: `in-process` under the embedded database, where
|
|
427
|
+
// this process's own writes reach subscribers; `replication` with a real one; `none`
|
|
428
|
+
// when no sync role runs here. The label on the ready line is this same fact.
|
|
429
|
+
liveFeed: server.running.liveFeed,
|
|
396
430
|
buildId: server.buildId,
|
|
397
431
|
manifest: join(root, MANIFEST_FILENAME),
|
|
398
432
|
introspect: `${server.url}/_x`,
|
|
399
433
|
panels: [...server.panels],
|
|
434
|
+
mcp: server.mcp,
|
|
400
435
|
},
|
|
401
436
|
lines: [
|
|
402
437
|
// A hard kill leaves the lock behind; clearing it is normal and worth one line, never a
|
|
@@ -406,6 +441,9 @@ export const devCommand: CliCommand = {
|
|
|
406
441
|
msg('cli.dev.panels', { panels: server.panels.join(', ') }),
|
|
407
442
|
msg('cli.dev.manifest', { path: join(root, MANIFEST_FILENAME) }),
|
|
408
443
|
msg('cli.dev.introspect', { url: `${server.url}/_x` }),
|
|
444
|
+
// Only when something was mounted: the unmounted case has already said why, once, as a
|
|
445
|
+
// warning with a fix, and a summary line reading `mcp none` would be a second copy of it.
|
|
446
|
+
...(server.mcp === null ? [] : [msg('cli.dev.mcp', { path: server.mcp })]),
|
|
409
447
|
],
|
|
410
448
|
};
|
|
411
449
|
await writeLock(services.stateDir, {
|
package/src/compile-externals.ts
CHANGED
|
@@ -11,10 +11,17 @@
|
|
|
11
11
|
* anyway, and that is the whole failure: Bun 1.3 refuses the build with
|
|
12
12
|
* `Could not resolve: "@babel/preset-typescript/package.json"`, while Bun 1.4 bundles the
|
|
13
13
|
* unresolvable `require` as a runtime throw — so one tree compiled on a laptop and did not in CI.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
*
|
|
15
|
+
* **This list is what closed that, and the version pin never was.** The paragraph here said the
|
|
16
|
+
* skew was closed `As of 2026-08-20` by CI moving to `1.4.x`, "so every builder now takes the
|
|
17
|
+
* second branch", and added that the external stays regardless. Read together those are two fixes
|
|
18
|
+
* for one bug, and only the second is a fix: pinning the whole repository to the Bun that TOLERATES
|
|
19
|
+
* an unresolvable `require` leaves the `--compile` graph still reaching one, so the next Bun that
|
|
20
|
+
* tightens resolution breaks the build again. Marking the specifier external means the graph never
|
|
21
|
+
* reaches it, on either Bun — measured on 2026-08-27, when the 1.4 pin was trialled in reverse:
|
|
22
|
+
* `docker build -f docker/Dockerfile` is green on `oven/bun:1.3-slim` and the image answers
|
|
23
|
+
* `--version`. So this file does not depend on the series above it, and a future move of that pin
|
|
24
|
+
* costs it nothing.
|
|
18
25
|
*
|
|
19
26
|
* Marking the dead specifier external rather than the two live ones: `serve.ts` calls
|
|
20
27
|
* `buildIslands` on every boot, unconditionally, so a binary with `@babel/core` external is a
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Where a `sync` node's changes come from when this process boots one. Production decodes the
|
|
2
|
+
// write-ahead log (`x dev --role replicator`, `PgLogicalReplicationFeed`, a real `DATABASE_URL`).
|
|
3
|
+
// The embedded database has no walsender, and until 2026-09-05 nothing stood in for it: a live
|
|
4
|
+
// subscription under `x dev` took its snapshot and then heard nothing, so every `--live` query in
|
|
5
|
+
// every scaffolded app was dead in development — which is where an author first tries one.
|
|
6
|
+
//
|
|
7
|
+
// The bridge is `@ultimat3/testing`'s `startLiveReplicator`, the same in-process row observer the
|
|
8
|
+
// framework's own live tests run on: a repository write in THIS process becomes a `ChangeEvent`
|
|
9
|
+
// shaped exactly as the WAL decoder shapes one, fanned into the node's registry. Its honest bound
|
|
10
|
+
// is stated there and repeated here — a write another process makes is invisible — and `x dev` is
|
|
11
|
+
// the one boot where that bound holds by construction: every role runs in this one process.
|
|
12
|
+
|
|
13
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
14
|
+
import type { LiveReplicator } from '@ultimat3/testing';
|
|
15
|
+
import { startLiveReplicator } from '@ultimat3/testing';
|
|
16
|
+
import type { ServiceMode } from './dev-services';
|
|
17
|
+
import type { RunningSync } from './dev-sync';
|
|
18
|
+
|
|
19
|
+
/** What feeds the sync node, said out loud on the boot line and in `--json`. */
|
|
20
|
+
export type LiveFeed = 'in-process' | 'replication' | 'none';
|
|
21
|
+
|
|
22
|
+
export interface RunningLiveFeed {
|
|
23
|
+
readonly feed: LiveFeed;
|
|
24
|
+
/** The installed bridge, so a test can await `settled()`; `null` for the other two feeds. */
|
|
25
|
+
readonly bridge: LiveReplicator | null;
|
|
26
|
+
stop(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LiveFeedInput {
|
|
30
|
+
/** The sync node this process booted, or `null` when the role was not selected. */
|
|
31
|
+
readonly sync: RunningSync | null;
|
|
32
|
+
/** The database's binding: `embedded` is PGlite, which has no log to decode. */
|
|
33
|
+
readonly dbMode: ServiceMode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The label the `x dev` boot line carries beside `db=`, `events=` and `storage=`. */
|
|
37
|
+
export const liveFeedLabel = (feed: LiveFeed): string => `live=${feed}`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `embedded` → the in-process bridge; anything else → replication, which is the WAL decoder's
|
|
41
|
+
* job whether the replicator role runs in this process or another. Never both: with a real
|
|
42
|
+
* database the decoder already delivers this process's own writes, and a bridge beside it would
|
|
43
|
+
* deliver every one of them twice. No sync node, no feed to speak of.
|
|
44
|
+
*/
|
|
45
|
+
export async function startLiveFeed(input: LiveFeedInput): Promise<RunningLiveFeed> {
|
|
46
|
+
if (input.sync === null) return { feed: 'none', bridge: null, stop: () => undefined };
|
|
47
|
+
if (input.dbMode !== 'embedded') {
|
|
48
|
+
return { feed: 'replication', bridge: null, stop: () => undefined };
|
|
49
|
+
}
|
|
50
|
+
const bridge = await startLiveReplicator({
|
|
51
|
+
registry: input.sync.registry,
|
|
52
|
+
// Logged, never thrown: one change nobody could fan out must not take the dev server down.
|
|
53
|
+
onError: (error) =>
|
|
54
|
+
logger.warn('live.bridge_delivery_failed', { error: renderThrowable(error) }),
|
|
55
|
+
});
|
|
56
|
+
logger.info('live feed in-process', {
|
|
57
|
+
detail:
|
|
58
|
+
'the embedded database has no walsender; repository writes in this process reach subscribers',
|
|
59
|
+
});
|
|
60
|
+
return { feed: 'in-process', bridge, stop: () => bridge.stop() };
|
|
61
|
+
}
|
package/src/dev-roles.ts
CHANGED
|
@@ -26,7 +26,11 @@ import {
|
|
|
26
26
|
jobDriver,
|
|
27
27
|
pgSchedulerState,
|
|
28
28
|
} from '@ultimat3/jobs';
|
|
29
|
+
import type { LiveQueryRegistry } from '@ultimat3/realtime/server';
|
|
30
|
+
import type { LiveReplicator } from '@ultimat3/testing';
|
|
29
31
|
import { devHooks } from './dev-hooks';
|
|
32
|
+
import type { LiveFeed } from './dev-live-feed';
|
|
33
|
+
import { startLiveFeed } from './dev-live-feed';
|
|
30
34
|
import { pgExecutorFor } from './dev-queue';
|
|
31
35
|
import type { RunningReplicator } from './dev-replicator';
|
|
32
36
|
import { startReplicator } from './dev-replicator';
|
|
@@ -130,6 +134,12 @@ export interface RunningRoles {
|
|
|
130
134
|
readonly scheduler: Scheduler | null;
|
|
131
135
|
/** The slot and feed this process holds; null when the replicator was not selected. */
|
|
132
136
|
readonly replicator: RunningReplicator | null;
|
|
137
|
+
/** What feeds the sync node: this process's own writes, the WAL decoder, or nothing. */
|
|
138
|
+
readonly liveFeed: LiveFeed;
|
|
139
|
+
/** The in-process bridge when `liveFeed` is `in-process`, so a test can await `settled()`. */
|
|
140
|
+
readonly liveBridge: LiveReplicator | null;
|
|
141
|
+
/** The sync node's registry, so a test can hold a real subscription; null without the role. */
|
|
142
|
+
readonly liveRegistry: LiveQueryRegistry | null;
|
|
133
143
|
stop(): Promise<void>;
|
|
134
144
|
}
|
|
135
145
|
|
|
@@ -426,6 +436,12 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
426
436
|
: null;
|
|
427
437
|
if (replicator !== null) started.push(() => replicator.stop());
|
|
428
438
|
|
|
439
|
+
// What feeds the sync node this process booted. The embedded database has no log to decode,
|
|
440
|
+
// so under it the node is fed by this process's own repository writes (`dev-live-feed.ts`);
|
|
441
|
+
// with a real database the WAL decoder above is the feed, here or in another process.
|
|
442
|
+
const live = await startLiveFeed({ sync, dbMode: options.runtime.services.db.mode });
|
|
443
|
+
started.push(async () => live.stop());
|
|
444
|
+
|
|
429
445
|
return {
|
|
430
446
|
roles: selected,
|
|
431
447
|
url: server === null ? null : server.url(),
|
|
@@ -435,8 +451,12 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
435
451
|
worker,
|
|
436
452
|
scheduler,
|
|
437
453
|
replicator,
|
|
454
|
+
liveFeed: live.feed,
|
|
455
|
+
liveBridge: live.bridge,
|
|
456
|
+
liveRegistry: sync?.registry ?? null,
|
|
438
457
|
async stop() {
|
|
439
458
|
// Reverse boot order, so the slot is released before the bus it published to closes.
|
|
459
|
+
live.stop();
|
|
440
460
|
await replicator?.stop();
|
|
441
461
|
await scheduler?.stop();
|
|
442
462
|
// Before the worker, so nothing publishes into a queue whose consumer has already gone —
|
package/src/dev-sync.ts
CHANGED
|
@@ -96,6 +96,8 @@ export function syncPortFor(port: number): number {
|
|
|
96
96
|
/** What `startRoles` holds on to: where the node listens, and how to take it down. */
|
|
97
97
|
export interface RunningSync {
|
|
98
98
|
readonly url: string;
|
|
99
|
+
/** The node's registry, so the boot can hand it a change feed the database cannot produce. */
|
|
100
|
+
readonly registry: LiveQueryRegistry;
|
|
99
101
|
stop(): Promise<void>;
|
|
100
102
|
}
|
|
101
103
|
|
|
@@ -154,9 +156,10 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
|
|
|
154
156
|
// override is how a deployment states a window its credential already declares (a token's
|
|
155
157
|
// `exp`), or resolves identity from a header the adapter deliberately does not retain.
|
|
156
158
|
const authenticate = options.overrides?.syncAuthenticate ?? syncAuthenticator(options.buildId);
|
|
159
|
+
const registry = registerLiveQueries(options);
|
|
157
160
|
const node = createSyncNode({
|
|
158
161
|
hub,
|
|
159
|
-
registry
|
|
162
|
+
registry,
|
|
160
163
|
transport: options.runtime.transport,
|
|
161
164
|
buildId: options.buildId,
|
|
162
165
|
sockets,
|
|
@@ -177,6 +180,7 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
|
|
|
177
180
|
const listener = listenSyncNode(node, { port });
|
|
178
181
|
return {
|
|
179
182
|
url: listener.url,
|
|
183
|
+
registry,
|
|
180
184
|
stop: async () => {
|
|
181
185
|
listener.stop();
|
|
182
186
|
await node.stop();
|
package/src/e2e-driver.ts
CHANGED
|
@@ -11,34 +11,52 @@ import {
|
|
|
11
11
|
unavailableFixture,
|
|
12
12
|
useE2eDriver,
|
|
13
13
|
} from '@ultimat3/testing';
|
|
14
|
-
import type { E2ePageOptions } from './e2e-page';
|
|
14
|
+
import type { E2eBrowserPage, E2ePageOptions } from './e2e-page';
|
|
15
15
|
import { e2ePage } from './e2e-page';
|
|
16
16
|
|
|
17
17
|
export type E2eDriverOptions = E2ePageOptions;
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* All three are genuinely out of reach of the shipped port, not merely unimplemented:
|
|
25
|
-
* `CdpPageLike` (`packages/scraping/src/cdp-port.ts`) declares twelve methods and none of them is
|
|
26
|
-
* `setOfflineMode`, and a new build id is a fact about the SERVER, which no page port has ever
|
|
27
|
-
* been able to speak for.
|
|
20
|
+
* A member this driver cannot build is a REFUSAL, never a no-op. A fixture that silently did
|
|
21
|
+
* nothing would make the assertion after it read as proof: `offline()` followed by "the fallback
|
|
22
|
+
* rendered" is the app's ONLINE page passing an offline test.
|
|
28
23
|
*/
|
|
29
24
|
const refuse =
|
|
30
25
|
(name: string, needs: string): (() => Promise<void>) =>
|
|
31
26
|
() =>
|
|
32
27
|
Promise.reject(new FixtureUnavailableError({ name, needs }));
|
|
33
28
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
29
|
+
/**
|
|
30
|
+
* `offline()`/`online()` FORWARD, `As of 2026-08-27`. They refused until then on a reason the tree
|
|
31
|
+
* contradicted on the day it was written: this file said `CdpPageLike`
|
|
32
|
+
* (`packages/scraping/src/cdp-port.ts`) "declares twelve methods and none of them is
|
|
33
|
+
* `setOfflineMode`". It declares it at line 71 — optional, guarded, with a coded
|
|
34
|
+
* `X_NOT_IMPLEMENTED` in `cdp-target.ts` for a launcher that lacks it — and `page-over-target.ts`
|
|
35
|
+
* exposes it as `ScrapePage.offline()`. All of that landed in **the same commit as the comment**
|
|
36
|
+
* (#351), so the refusal was never true, and it is the reason issue #390 records a real browser
|
|
37
|
+
* check as out of reach.
|
|
38
|
+
*
|
|
39
|
+
* Optional on `E2eBrowserPage` rather than required, for the reason `CdpPageLike` gives about the
|
|
40
|
+
* same method: this port is the shape of somebody ELSE's object, and a six-line test double must
|
|
41
|
+
* still satisfy it. Absent, the refusal stands — and now it names the method the double is missing
|
|
42
|
+
* rather than a capability the framework does not have.
|
|
43
|
+
*/
|
|
44
|
+
const networkFixtures = (browser: E2eBrowserPage): Pick<E2eFixtures, 'offline' | 'online'> => {
|
|
45
|
+
const setOffline = browser.offline?.bind(browser);
|
|
46
|
+
if (setOffline === undefined) {
|
|
47
|
+
const needs =
|
|
48
|
+
"a page whose driver implements offline(enabled) — @ultimat3/scraping's ScrapePage does; a hand-rolled E2eBrowserPage may not";
|
|
49
|
+
return { offline: refuse('offline', needs), online: refuse('online', needs) };
|
|
50
|
+
}
|
|
51
|
+
return { offline: () => setOffline(true), online: () => setOffline(false) };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** What `e2eTest` hands its body: a real page, the network condition, and one honest refusal. */
|
|
55
|
+
export const e2eFixtures = (page: PageLike, browser: E2eBrowserPage): E2eFixtures => ({
|
|
36
56
|
page,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
),
|
|
41
|
-
online: refuse('online', 'the same CDP method offline() needs, in order to undo it'),
|
|
57
|
+
...networkFixtures(browser),
|
|
58
|
+
// The one that is still genuinely out of reach, and it is not a port gap: a new build id is a
|
|
59
|
+
// fact about the SERVER, which no page port has ever been able to speak for.
|
|
42
60
|
update: refuse(
|
|
43
61
|
'update',
|
|
44
62
|
'a second build served under a new immutable build id, which is a server fact',
|
|
@@ -65,7 +83,7 @@ export function installE2eDriver(options: E2eDriverOptions): () => void {
|
|
|
65
83
|
const page = e2ePage(options);
|
|
66
84
|
defineFixtures({ page: () => page });
|
|
67
85
|
useE2eDriver((name, body: E2eBody) => {
|
|
68
|
-
bunTest(name, () => body(e2eFixtures(page)));
|
|
86
|
+
bunTest(name, () => body(e2eFixtures(page, options.page)));
|
|
69
87
|
});
|
|
70
88
|
return () => {
|
|
71
89
|
// Both halves, because both were installed. Putting the DECLARATION back — rather than
|
package/src/e2e-page.ts
CHANGED
|
@@ -10,15 +10,27 @@ import { e2eLocator } from './e2e-locator';
|
|
|
10
10
|
import type { E2eSelection } from './e2e-selection';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* What this adapter needs of a browser: four members, every one of them on `ScrapePage`.
|
|
14
|
-
* structurally rather than as `ScrapePage` so a test can stand one up in six lines — the
|
|
15
|
-
* bargain `cdp-port.ts` makes about puppeteer, one layer up.
|
|
13
|
+
* What this adapter needs of a browser: four required members, every one of them on `ScrapePage`.
|
|
14
|
+
* Declared structurally rather than as `ScrapePage` so a test can stand one up in six lines — the
|
|
15
|
+
* same bargain `cdp-port.ts` makes about puppeteer, one layer up.
|
|
16
16
|
*/
|
|
17
17
|
export interface E2eBrowserPage {
|
|
18
18
|
url(): string;
|
|
19
19
|
goto(url: string, options?: { readonly timeout?: number | undefined }): Promise<unknown>;
|
|
20
20
|
evaluate(expression: string): Promise<unknown>;
|
|
21
21
|
click(selector: string): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* The browser's own network condition, which `E2eFixtures.offline()`/`online()` forward to.
|
|
24
|
+
* `ScrapePage` has it (`page-over-target.ts`), reaching `CdpPageLike.setOfflineMode` through
|
|
25
|
+
* `cdp-target.ts`'s guard.
|
|
26
|
+
*
|
|
27
|
+
* OPTIONAL for the reason the four above are structural: this port is the shape of somebody
|
|
28
|
+
* ELSE's object, and requiring it would cost every six-line double a type error for a capability
|
|
29
|
+
* a test that never goes offline does not need. Absent, `e2e-driver.ts` keeps refusing by name —
|
|
30
|
+
* a coded refusal, never a silent no-op, because an `offline()` that did nothing would let the
|
|
31
|
+
* app's ONLINE page pass an offline test.
|
|
32
|
+
*/
|
|
33
|
+
offline?(enabled: boolean): Promise<void>;
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
export interface E2ePageOptions {
|
package/src/error-codes.ts
CHANGED
|
@@ -166,6 +166,13 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
166
166
|
'X_E2E_LOCATOR_EMPTY',
|
|
167
167
|
'X_E2E_LOCATOR_AMBIGUOUS',
|
|
168
168
|
'X_E2E_SERVICE_WORKER_ABSENT',
|
|
169
|
+
// The raw-CDP browser under that driver — `cdp-launch.ts`, `cdp-connection.ts`,
|
|
170
|
+
// `cdp-e2e-page.ts`, `cdp-browser.ts`. Four codes and not one, because the four repairs differ:
|
|
171
|
+
// install a browser, read the browser's own stderr, look at the page, raise a deadline.
|
|
172
|
+
'X_CDP_BROWSER_MISSING',
|
|
173
|
+
'X_CDP_LAUNCH_FAILED',
|
|
174
|
+
'X_CDP_CALL_FAILED',
|
|
175
|
+
'X_CDP_TIMEOUT',
|
|
169
176
|
'X_GH_UNAVAILABLE',
|
|
170
177
|
'X_GH_NOT_AUTHENTICATED',
|
|
171
178
|
'X_GH_COMMAND_FAILED',
|
|
@@ -294,6 +301,10 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
294
301
|
X_E2E_LOCATOR_EMPTY: 'an e2e locator matched no element',
|
|
295
302
|
X_E2E_LOCATOR_AMBIGUOUS: 'an e2e locator matched more than one element and was asked to click',
|
|
296
303
|
X_E2E_SERVICE_WORKER_ABSENT: 'no service worker took control of the page within the budget',
|
|
304
|
+
X_CDP_BROWSER_MISSING: 'no Chrome or Chromium is installed for the e2e driver to launch',
|
|
305
|
+
X_CDP_LAUNCH_FAILED: 'the browser started and never announced a DevTools endpoint',
|
|
306
|
+
X_CDP_CALL_FAILED: 'the browser refused a DevTools call',
|
|
307
|
+
X_CDP_TIMEOUT: 'a DevTools call did not answer inside its deadline',
|
|
297
308
|
X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
|
|
298
309
|
X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
|
|
299
310
|
X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,33 @@ export type { BoundaryCut, BoundarySplit } from './boundary-cuts';
|
|
|
25
25
|
export { planBoundaryCuts } from './boundary-cuts';
|
|
26
26
|
export type { BuildStats, RouteStats } from './budgets';
|
|
27
27
|
export { BUILD_STATS_FILE, checkBudgets, readBuildStats } from './budgets';
|
|
28
|
+
// The raw-CDP browser the driver above runs on. `openE2eBrowserIfAvailable()` is what an app's
|
|
29
|
+
// test preload calls: it answers `undefined` on a machine with no Chrome, so the browser-backed
|
|
30
|
+
// suite SKIPS rather than turning a gate red for a reason unrelated to the change.
|
|
31
|
+
export type { E2eBrowser, OpenE2eBrowserOptions } from './cdp-browser';
|
|
32
|
+
export {
|
|
33
|
+
DEFAULT_CDP_TIMEOUT_MS,
|
|
34
|
+
openE2eBrowser,
|
|
35
|
+
openE2eBrowserIfAvailable,
|
|
36
|
+
} from './cdp-browser';
|
|
37
|
+
export type { CdpConnection, CdpConnectionOptions, CdpResult } from './cdp-connection';
|
|
38
|
+
export { cdpConnect } from './cdp-connection';
|
|
39
|
+
export type { CdpE2ePageOptions } from './cdp-e2e-page';
|
|
40
|
+
export { cdpE2ePage } from './cdp-e2e-page';
|
|
41
|
+
export {
|
|
42
|
+
CdpBrowserMissingError,
|
|
43
|
+
CdpCallFailedError,
|
|
44
|
+
CdpLaunchFailedError,
|
|
45
|
+
CdpTimeoutError,
|
|
46
|
+
} from './cdp-errors';
|
|
47
|
+
export type { LaunchedBrowser, LaunchOptions } from './cdp-launch';
|
|
48
|
+
export {
|
|
49
|
+
CHROME_CANDIDATES,
|
|
50
|
+
CHROME_PATH_ENV,
|
|
51
|
+
findChrome,
|
|
52
|
+
launchChrome,
|
|
53
|
+
launchFoundChrome,
|
|
54
|
+
} from './cdp-launch';
|
|
28
55
|
export type { BuildTarget } from './cmd-build';
|
|
29
56
|
export {
|
|
30
57
|
argsFor,
|