@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
package/src/dev-render.ts
CHANGED
|
@@ -14,15 +14,32 @@
|
|
|
14
14
|
// navigation, with a re-parse on top. The static export writes the file (`writeStyles`), so the
|
|
15
15
|
// "second file" cost is one `Bun.write`.
|
|
16
16
|
|
|
17
|
+
// why: Bun ships no path API; an island's file is its route file's directory joined to its `src`.
|
|
18
|
+
import { posix } from 'node:path';
|
|
19
|
+
import { clientScopeOf } from '@ultimat3/auth';
|
|
17
20
|
import type { Ctx } from '@ultimat3/core';
|
|
21
|
+
import { CLIENT_SCOPE_HEADER } from '@ultimat3/core';
|
|
18
22
|
import type { RouteMeta as HttpRouteMeta, Route, RouteParams } from '@ultimat3/http';
|
|
19
23
|
import { asCtx, html, stream } from '@ultimat3/http';
|
|
20
24
|
import { currentLocale } from '@ultimat3/i18n';
|
|
21
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
ClientSyncHead,
|
|
27
|
+
IslandCollector,
|
|
28
|
+
RenderResult,
|
|
29
|
+
RouteData,
|
|
30
|
+
RouteEntry,
|
|
31
|
+
} from '@ultimat3/render';
|
|
22
32
|
import {
|
|
33
|
+
clientBootTags,
|
|
34
|
+
clientPersistTags,
|
|
35
|
+
clientScopeTag,
|
|
36
|
+
clientSyncTags,
|
|
23
37
|
createIslandCollector,
|
|
38
|
+
documentCarriesScope,
|
|
24
39
|
headFromMeta,
|
|
25
40
|
hydrateRuntime,
|
|
41
|
+
islandModuleId,
|
|
42
|
+
islandModuleIds,
|
|
26
43
|
metaContextFor,
|
|
27
44
|
renderHead,
|
|
28
45
|
routeDataFor,
|
|
@@ -39,9 +56,11 @@ import {
|
|
|
39
56
|
ROOT_ELEMENT_ID,
|
|
40
57
|
renderComponent,
|
|
41
58
|
renderSsr,
|
|
59
|
+
ssrHeaders,
|
|
42
60
|
staticHeaders,
|
|
43
61
|
streamResult,
|
|
44
62
|
} from '@ultimat3/render/server';
|
|
63
|
+
import { realtimeIslandFiles } from './island-realtime';
|
|
45
64
|
import { styleBundle } from './style-bundle';
|
|
46
65
|
|
|
47
66
|
/**
|
|
@@ -71,6 +90,18 @@ export interface DocumentOptions {
|
|
|
71
90
|
* decided by `app.config.ts`, which the boot read and the renderer cannot.
|
|
72
91
|
*/
|
|
73
92
|
readonly themeHead?: string;
|
|
93
|
+
/**
|
|
94
|
+
* The page's sync target — `pageSync(…).head` — rendered as render's `clientSyncTags` on every
|
|
95
|
+
* document this process serves. Principal-free, so a shareable document carries it too; absent
|
|
96
|
+
* for a caller that serves no socket at all (the static export).
|
|
97
|
+
*/
|
|
98
|
+
readonly sync?: ClientSyncHead;
|
|
99
|
+
/**
|
|
100
|
+
* The record types the app persists (`entity(…, { persist: true })`), read per render. Rendered
|
|
101
|
+
* as `ultimate-persist` beside the scope tag only — persistence is per principal, so a document
|
|
102
|
+
* with no scope carries none.
|
|
103
|
+
*/
|
|
104
|
+
readonly persisted?: () => readonly string[];
|
|
74
105
|
}
|
|
75
106
|
|
|
76
107
|
export interface DevRenderOptions extends DocumentOptions {
|
|
@@ -94,16 +125,32 @@ export interface DevRouteData extends Record<string, unknown> {
|
|
|
94
125
|
*/
|
|
95
126
|
const lang = (): string => currentLocale();
|
|
96
127
|
|
|
128
|
+
/**
|
|
129
|
+
* `scope` is present only on a PRIVATE document (a gated `ssr` page, every `stream`): the page's
|
|
130
|
+
* client scope (`@ultimat3/auth`'s `clientScopeOf`), which core's `pageClient()` reads to fence its
|
|
131
|
+
* one store per principal. A shareable document (`static`, `isr`, ungated `ssr`) carries NO scope
|
|
132
|
+
* tag — absent means "not rendered for anyone", a different answer from `''`, the anonymous page.
|
|
133
|
+
*/
|
|
97
134
|
const headFor = async (
|
|
98
135
|
entry: RouteEntry,
|
|
99
136
|
ctx: DevRouteData,
|
|
100
137
|
data: RouteData,
|
|
101
138
|
options: DocumentOptions,
|
|
139
|
+
scope?: string,
|
|
102
140
|
): Promise<string> =>
|
|
103
141
|
renderHead(
|
|
104
142
|
headFromMeta(
|
|
105
143
|
await entry.config.meta(metaContextFor(ctx, data)),
|
|
106
144
|
seoRenderers({ path: new URL(ctx.url).pathname }),
|
|
145
|
+
[
|
|
146
|
+
...(options.sync === undefined ? [] : clientSyncTags(options.sync)),
|
|
147
|
+
// The page boot rides the scope tag: its whole job — restoring a principal's persisted
|
|
148
|
+
// records and replaying its queued writes — is per principal, and a shareable document
|
|
149
|
+
// (no scope tag) has neither. Cheaper than walking the page's islands, and exact.
|
|
150
|
+
...(scope === undefined
|
|
151
|
+
? []
|
|
152
|
+
: [clientScopeTag(scope), ...clientPersistTags(options.persisted?.() ?? [])]),
|
|
153
|
+
],
|
|
107
154
|
),
|
|
108
155
|
) +
|
|
109
156
|
(options.themeHead ?? '') +
|
|
@@ -166,6 +213,33 @@ export async function routeBody(
|
|
|
166
213
|
* an island never declares its own timing, and `resolve` is the build's — identity when nothing
|
|
167
214
|
* built any, which fails at the first island by name rather than emitting an unusable entry.
|
|
168
215
|
*/
|
|
216
|
+
/**
|
|
217
|
+
* Realtime's page boot, as one deferred script — or nothing. Two conditions, both exact: the
|
|
218
|
+
* document carries a principal scope (restoring persisted records and replaying queued writes are
|
|
219
|
+
* per principal; a shareable document has neither), AND one of the islands this render emitted
|
|
220
|
+
* reaches `@ultimat3/realtime` (a page whose islands never touch a record has nothing to restore
|
|
221
|
+
* into and no write to replay). After the body, because which islands rendered is a fact the walk
|
|
222
|
+
* just recorded; still before the hydration runtime, so it runs first among the deferred scripts.
|
|
223
|
+
*/
|
|
224
|
+
function bootScript(
|
|
225
|
+
entry: RouteEntry,
|
|
226
|
+
islands: IslandCollector,
|
|
227
|
+
options: DocumentOptions,
|
|
228
|
+
scope: string | undefined,
|
|
229
|
+
): string {
|
|
230
|
+
if (scope === undefined || options.sync === undefined) return '';
|
|
231
|
+
const rendered = new Set(islandModuleIds(islands.directives));
|
|
232
|
+
if (rendered.size === 0) return '';
|
|
233
|
+
// An island's module id is derived from its `src`, written relative to the page that renders it:
|
|
234
|
+
// each realtime island file, spelled from THIS page, is the id its directive would carry.
|
|
235
|
+
const pageDir = posix.dirname(entry.file);
|
|
236
|
+
const reaches = [...realtimeIslandFiles()].some((file) => {
|
|
237
|
+
const src = posix.relative(pageDir, file);
|
|
238
|
+
return rendered.has(islandModuleId(src.startsWith('.') ? src : `./${src}`));
|
|
239
|
+
});
|
|
240
|
+
return reaches ? renderHead(clientBootTags(options.sync)) : '';
|
|
241
|
+
}
|
|
242
|
+
|
|
169
243
|
const collectorFor = (entry: RouteEntry, options: DocumentOptions): IslandCollector =>
|
|
170
244
|
createIslandCollector({
|
|
171
245
|
file: entry.file,
|
|
@@ -199,15 +273,16 @@ async function documentFrom(
|
|
|
199
273
|
ctx: DevRouteData,
|
|
200
274
|
data: RouteData,
|
|
201
275
|
options: DocumentOptions,
|
|
276
|
+
scope?: string,
|
|
202
277
|
): Promise<string> {
|
|
203
278
|
const islands = collectorFor(entry, options);
|
|
204
279
|
const [head, body] = await Promise.all([
|
|
205
|
-
headFor(entry, ctx, data, options),
|
|
280
|
+
headFor(entry, ctx, data, options, scope),
|
|
206
281
|
routeBody(entry, ctx, data, islands),
|
|
207
282
|
]);
|
|
208
283
|
return (
|
|
209
284
|
`<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head>` +
|
|
210
|
-
`<body>${body}${hydrateRuntime(islands.directives)}</body></html>`
|
|
285
|
+
`<body>${body}${bootScript(entry, islands, options, scope)}${hydrateRuntime(islands.directives)}</body></html>`
|
|
211
286
|
);
|
|
212
287
|
}
|
|
213
288
|
|
|
@@ -256,31 +331,55 @@ async function resultFor(
|
|
|
256
331
|
// correct output, no streaming benefit.
|
|
257
332
|
const islands = collectorFor(entry, options);
|
|
258
333
|
const [head, shell] = await Promise.all([
|
|
259
|
-
|
|
334
|
+
// A stream is always `private, no-store` (`streamResult`), so it always carries the scope.
|
|
335
|
+
headFor(entry, request, data, options, clientScopeOf(ctx.actor)),
|
|
260
336
|
routeBody(entry, request, data, islands),
|
|
261
337
|
]);
|
|
262
|
-
return
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
338
|
+
return withScope(
|
|
339
|
+
streamResult(
|
|
340
|
+
{
|
|
341
|
+
head: `<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head><body>`,
|
|
342
|
+
// The runtime rides the first flush, with the shell it boots. A later chunk would leave
|
|
343
|
+
// the window between flush one and the close with inert islands and no listeners on
|
|
344
|
+
// them — which is exactly the first-click-lost failure `interaction` replay exists for.
|
|
345
|
+
shell: `${shell}${bootScript(entry, islands, options, clientScopeOf(ctx.actor))}${hydrateRuntime(islands.directives)}`,
|
|
346
|
+
holes: [],
|
|
347
|
+
},
|
|
348
|
+
{ buildId: options.buildId },
|
|
349
|
+
status,
|
|
350
|
+
),
|
|
351
|
+
clientScopeOf(ctx.actor),
|
|
273
352
|
);
|
|
274
353
|
}
|
|
275
|
-
default:
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
354
|
+
default: {
|
|
355
|
+
// Asked of the headers `renderSsr` is about to send: a gated page is private and carries the
|
|
356
|
+
// scope; an ungated one is `public, s-maxage` and a CDN may hand it to anyone, so it carries
|
|
357
|
+
// none — absent, which core reads as "not rendered for anyone", never as anonymous.
|
|
358
|
+
const scope = documentCarriesScope(ssrHeaders(entry, { buildId: options.buildId }))
|
|
359
|
+
? clientScopeOf(ctx.actor)
|
|
360
|
+
: undefined;
|
|
361
|
+
return withScope(
|
|
362
|
+
await renderSsr(
|
|
363
|
+
{ entry, params: request.params, url, ctx },
|
|
364
|
+
() => documentFrom(entry, request, data, options, scope),
|
|
365
|
+
{ buildId: options.buildId, status },
|
|
366
|
+
),
|
|
367
|
+
scope,
|
|
280
368
|
);
|
|
369
|
+
}
|
|
281
370
|
}
|
|
282
371
|
}
|
|
283
372
|
|
|
373
|
+
/**
|
|
374
|
+
* A private document's scope, as a RESPONSE header too: the service worker partitions its offline
|
|
375
|
+
* pages by principal and never parses HTML, so the meta alone cannot reach it. Exactly the
|
|
376
|
+
* documents that carry the scope tag carry this — a shareable one carries neither.
|
|
377
|
+
*/
|
|
378
|
+
const withScope = (result: RenderResult, scope: string | undefined): RenderResult =>
|
|
379
|
+
scope === undefined
|
|
380
|
+
? result
|
|
381
|
+
: { ...result, headers: { ...result.headers, [CLIENT_SCOPE_HEADER]: scope } };
|
|
382
|
+
|
|
284
383
|
const responseOf = (result: RenderResult): Response =>
|
|
285
384
|
typeof result.body === 'string'
|
|
286
385
|
? html(result.body, { status: result.status, headers: result.headers })
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The route table `x dev` serves, in mount order: the dashboard, the API, the assets a document
|
|
2
|
+
// names, the island and sync-worker scripts, and the app's pages last. Split from `cmd-dev.ts` at its
|
|
3
|
+
// 500-line ceiling; `serve.ts` composes the production table from the same builders.
|
|
4
|
+
|
|
5
|
+
import type { Route } from '@ultimat3/http';
|
|
6
|
+
import { describeRoutes } from '@ultimat3/render';
|
|
7
|
+
import type { Storage } from '@ultimat3/storage';
|
|
8
|
+
import { apiRoutes } from './api-routes';
|
|
9
|
+
import { mountAppMcp } from './app-mcp';
|
|
10
|
+
import { assetRoutes } from './dev-assets';
|
|
11
|
+
import type { DevDashboardInput } from './dev-dashboard';
|
|
12
|
+
import { devDashboardRoutes } from './dev-dashboard';
|
|
13
|
+
import { appRoutes } from './dev-render';
|
|
14
|
+
import { storageRoutes } from './dev-storage';
|
|
15
|
+
import { errorPageStyleSources } from './error-page-csp';
|
|
16
|
+
import type { IslandBundle } from './island-bundle';
|
|
17
|
+
import { islandHarnessRoutes } from './island-harness-route';
|
|
18
|
+
import { islandRoutes } from './island-routes';
|
|
19
|
+
import { loadIslandStates } from './island-states-load';
|
|
20
|
+
import { pageSync } from './page-sync';
|
|
21
|
+
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
22
|
+
import { styleBundle } from './style-bundle';
|
|
23
|
+
import { styleRoutes } from './style-routes';
|
|
24
|
+
import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
25
|
+
import { serviceWorkerRoutes } from './sw-routes';
|
|
26
|
+
import type { ThemeBoot } from './theme-boot';
|
|
27
|
+
import { loadThemeMode, themeBoot } from './theme-boot';
|
|
28
|
+
|
|
29
|
+
export interface DevRouteTableInput {
|
|
30
|
+
readonly root: string;
|
|
31
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
32
|
+
readonly buildId: string;
|
|
33
|
+
readonly storage: Storage;
|
|
34
|
+
readonly dashboard: DevDashboardInput;
|
|
35
|
+
/** A getter: the watcher tick rebuilds the islands, and a captured bundle would serve the first. */
|
|
36
|
+
readonly islands: () => IslandBundle;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DevRouteTable {
|
|
40
|
+
readonly routes: readonly Route[];
|
|
41
|
+
/** The theme boot, whose `cspSource` the web role admits. */
|
|
42
|
+
readonly theme: ThemeBoot;
|
|
43
|
+
/** The app's own error pages' inline styles, admitted the same way. */
|
|
44
|
+
readonly errorStyles: readonly string[];
|
|
45
|
+
/** Where the app's MCP endpoint was mounted, or `undefined`. */
|
|
46
|
+
readonly mcpPath: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function devRouteTable(input: DevRouteTableInput): Promise<DevRouteTable> {
|
|
50
|
+
// Resolved once, before the first route. `undefined` for an app that is not installable: nothing
|
|
51
|
+
// is mounted, and the 0kb baseline is not spent on a `<link>` to a file that does not exist.
|
|
52
|
+
const pwa = await loadPwaArtifacts(input.root);
|
|
53
|
+
const theme = themeBoot(await loadThemeMode(input.root));
|
|
54
|
+
// The same call `serve.ts` makes, so the two boots cannot serve different sync targets.
|
|
55
|
+
const sync = await pageSync(input.root, input.env, input.buildId);
|
|
56
|
+
const errorStyles = await errorPageStyleSources(input.root);
|
|
57
|
+
// Built once at boot and NOT rebuilt with the islands on a watcher tick: a service worker that
|
|
58
|
+
// changes under a page it controls is the update path, and one per keystroke exercises it per save.
|
|
59
|
+
const serviceWorker =
|
|
60
|
+
pwa === undefined
|
|
61
|
+
? undefined
|
|
62
|
+
: serviceWorkerArtifacts({
|
|
63
|
+
pwa,
|
|
64
|
+
buildId: input.buildId,
|
|
65
|
+
routes: describeRoutes(),
|
|
66
|
+
islands: input.islands(),
|
|
67
|
+
styles: styleBundle(),
|
|
68
|
+
scripts: sync.scripts,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
|
|
72
|
+
// call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
|
|
73
|
+
// one of them asked. Warned once here when `expose` is true and nothing can be mounted.
|
|
74
|
+
const mcpMount = await mountAppMcp(input.root);
|
|
75
|
+
const routes: readonly Route[] = [
|
|
76
|
+
...devDashboardRoutes(input.dashboard),
|
|
77
|
+
// The same API table the container serves: a read that answers here and 404s in production
|
|
78
|
+
// is exactly the drift one composition exists to prevent.
|
|
79
|
+
...apiRoutes(),
|
|
80
|
+
...mcpMount.routes,
|
|
81
|
+
// The image pipeline's only HTTP surface: the icons the web manifest declares, and the
|
|
82
|
+
// variants every `srcset` promises. Mounted before the app's own routes so a page route can
|
|
83
|
+
// never shadow `/icons` or `/media`.
|
|
84
|
+
...assetRoutes({
|
|
85
|
+
root: input.root,
|
|
86
|
+
storage: input.storage,
|
|
87
|
+
...(pwa === undefined ? {} : { pwa }),
|
|
88
|
+
}),
|
|
89
|
+
...storageRoutes({ storage: input.storage }),
|
|
90
|
+
// The chunks the documents below name. Mounted before the app's routes for the reason
|
|
91
|
+
// `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
|
|
92
|
+
...islandRoutes(() => input.islands()),
|
|
93
|
+
// And the stylesheet every one of those documents links. Read through the getter for the
|
|
94
|
+
// reason the islands are: a rebuilt island registers CSS, which mints a new URL, and a table
|
|
95
|
+
// captured at boot would answer 404 for the href the document now carries.
|
|
96
|
+
...styleRoutes(() => styleBundle()),
|
|
97
|
+
// `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
|
|
98
|
+
// lives here rather than in a second server because everything it needs is in THIS process:
|
|
99
|
+
// the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
|
|
100
|
+
// may have. The states are read per REQUEST — an author editing a state and re-running the
|
|
101
|
+
// command must not need a restart to see it.
|
|
102
|
+
...islandHarnessRoutes({
|
|
103
|
+
islands: () => input.islands(),
|
|
104
|
+
states: () => loadIslandStates(input.root),
|
|
105
|
+
}),
|
|
106
|
+
...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
|
|
107
|
+
...sync.routes,
|
|
108
|
+
...appRoutes({
|
|
109
|
+
buildId: input.buildId,
|
|
110
|
+
resolveIsland: (file) => input.islands().resolverFor(file),
|
|
111
|
+
sync: sync.head,
|
|
112
|
+
persisted: sync.persisted,
|
|
113
|
+
themeHead: theme.head,
|
|
114
|
+
...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
|
|
115
|
+
}),
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
return { routes, theme, errorStyles, mcpPath: mcpMount.path };
|
|
119
|
+
}
|
package/src/dev-services.ts
CHANGED
|
@@ -40,7 +40,10 @@ const nonEmpty = (value: string | undefined): string | undefined =>
|
|
|
40
40
|
* is a directory delete rather than a container dance.
|
|
41
41
|
*/
|
|
42
42
|
export function resolveServices(root: string, env: Env): DevServices {
|
|
43
|
-
|
|
43
|
+
// `ULTIMATE_STATE_DIR` relocates the whole of `.x/` — the embedded database, the local disk and
|
|
44
|
+
// the dev lock — for one process tree. It is how an e2e run boots the app on a THROWAWAY database
|
|
45
|
+
// (`e2e-app.ts`) instead of resetting the developer's own, beside a running `x dev`.
|
|
46
|
+
const stateDir = nonEmpty(env['ULTIMATE_STATE_DIR']) ?? join(root, '.x');
|
|
44
47
|
const databaseUrl = nonEmpty(env['DATABASE_URL']);
|
|
45
48
|
const natsUrl = nonEmpty(env['NATS_URL']);
|
|
46
49
|
const s3Endpoint = nonEmpty(env['S3_ENDPOINT']);
|
package/src/dev-sync.ts
CHANGED
|
@@ -131,6 +131,8 @@ export interface RunningSync {
|
|
|
131
131
|
readonly url: string;
|
|
132
132
|
/** The node's registry, so the boot can hand it a change feed the database cannot produce. */
|
|
133
133
|
readonly registry: LiveQueryRegistry;
|
|
134
|
+
/** The node's channel hub — fed the same changes, so a declared channel's `records` flow in dev. */
|
|
135
|
+
readonly hub: ChannelHub;
|
|
134
136
|
stop(): Promise<void>;
|
|
135
137
|
}
|
|
136
138
|
|
|
@@ -212,7 +214,7 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
212
214
|
// second copy of `/_x/sync` here is the copy that stays behind when it moves.
|
|
213
215
|
mount: { path: node.path, fetch: node.fetch, websocket: node.websocket },
|
|
214
216
|
stop: () => node.stop(),
|
|
215
|
-
listen: async (appUrl) => await listen(options, node, registry, appUrl),
|
|
217
|
+
listen: async (appUrl) => await listen(options, node, { registry, hub }, appUrl),
|
|
216
218
|
};
|
|
217
219
|
}
|
|
218
220
|
|
|
@@ -241,7 +243,7 @@ function syncPortFrom(requested: number, appUrl: string | null): number {
|
|
|
241
243
|
async function listen(
|
|
242
244
|
options: StartRolesOptions,
|
|
243
245
|
node: SyncNode,
|
|
244
|
-
|
|
246
|
+
feeds: Pick<RunningSync, 'registry' | 'hub'>,
|
|
245
247
|
appUrl: string | null,
|
|
246
248
|
): Promise<RunningSync> {
|
|
247
249
|
const port = syncPortFrom(options.port, appUrl);
|
|
@@ -266,7 +268,7 @@ async function listen(
|
|
|
266
268
|
});
|
|
267
269
|
return {
|
|
268
270
|
url: listener.url,
|
|
269
|
-
|
|
271
|
+
...feeds,
|
|
270
272
|
stop: async () => {
|
|
271
273
|
listener.stop();
|
|
272
274
|
await node.stop();
|
package/src/e2e-app.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The app an e2e suite drives, spawned on a THROWAWAY state directory: its own embedded database,
|
|
2
|
+
// its own disk, its own dev lock, created per call and removed on `stop()`. Never the developer's
|
|
3
|
+
// `.x/pgdata` — resetting that from a test run destroys the data an `x dev` beside it is using.
|
|
4
|
+
// This file is the DATABASE half; spawning, readiness and the restart are `e2e-spawn.ts`'s.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no temp-directory primitive or recursive remove; `tmpdir()` is node:os's alone.
|
|
7
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
8
|
+
// why: Bun exposes no tmpdir() — only node:os answers the platform temp root.
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
// why: Bun exposes no path API — the state dir is joined, not concatenated.
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { finiteCount } from '@ultimat3/core';
|
|
13
|
+
import type { E2eAppMode } from './e2e-spawn';
|
|
14
|
+
import { inherited, refuse, spawnE2eApp, X_BIN } from './e2e-spawn';
|
|
15
|
+
|
|
16
|
+
export type { E2eAppMode } from './e2e-spawn';
|
|
17
|
+
|
|
18
|
+
export interface StartE2eAppOptions {
|
|
19
|
+
/** The app root — the directory holding `app.config.ts`. */
|
|
20
|
+
readonly root: string;
|
|
21
|
+
readonly mode?: E2eAppMode | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* The arguments after `x db seed`, or `false` for no seeding. Default `['--tier', 'dev']`: every
|
|
24
|
+
* dev-tier seed, which is what a developer's own `x dev` starts from.
|
|
25
|
+
*/
|
|
26
|
+
readonly seed?: readonly string[] | false | undefined;
|
|
27
|
+
/** Extra environment for every process — the reset, the seed and the app. */
|
|
28
|
+
readonly env?: Readonly<Record<string, string>> | undefined;
|
|
29
|
+
/** How long the app may take to answer `/readyz`. */
|
|
30
|
+
readonly readyTimeoutMs?: number | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface E2eApp {
|
|
34
|
+
/** `http://localhost:<port>`, no trailing slash. */
|
|
35
|
+
readonly base: string;
|
|
36
|
+
/** The throwaway `.x` this app runs on — the one directory a test may inspect or corrupt. */
|
|
37
|
+
readonly stateDir: string;
|
|
38
|
+
/** Kill the app and delete its state directory. Idempotent. */
|
|
39
|
+
stop(): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Stop the app and start it again on the SAME port and state directory, with `env` added — a
|
|
42
|
+
* deploy. `{ BUILD_ID: 'b2' }` is a new build the open tabs have not seen (`deploy.newBuild()`).
|
|
43
|
+
*/
|
|
44
|
+
restart(env?: Readonly<Record<string, string>>): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_READY_TIMEOUT_MS = 90_000;
|
|
48
|
+
|
|
49
|
+
function x(args: readonly string[], root: string, env: Record<string, string>): void {
|
|
50
|
+
const run = Bun.spawnSync(['bun', X_BIN, ...args], {
|
|
51
|
+
cwd: root,
|
|
52
|
+
env: { ...inherited(), ...env },
|
|
53
|
+
stdout: 'pipe',
|
|
54
|
+
stderr: 'pipe',
|
|
55
|
+
});
|
|
56
|
+
if (run.exitCode !== 0) {
|
|
57
|
+
throw refuse(`x ${args.join(' ')}`, `${run.stdout.toString()}${run.stderr.toString()}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Reset and seed a fresh state directory, then spawn the app on a free port and wait for `/readyz`.
|
|
63
|
+
* The reset runs against the throwaway directory, so it is a first migration, never a data loss.
|
|
64
|
+
*/
|
|
65
|
+
export async function startE2eApp(options: StartE2eAppOptions): Promise<E2eApp> {
|
|
66
|
+
// Screened FIRST, before a directory or a process exists: `waited < NaN` is false, so a NaN budget would never poll and report a dead app.
|
|
67
|
+
const deadline = finiteCount(
|
|
68
|
+
'startE2eApp',
|
|
69
|
+
'readyTimeoutMs',
|
|
70
|
+
options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
|
|
71
|
+
);
|
|
72
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'ultimate-e2e-'));
|
|
73
|
+
const env: Record<string, string> = { ...options.env, ULTIMATE_STATE_DIR: stateDir };
|
|
74
|
+
const cleanup = (): Promise<void> => rm(stateDir, { recursive: true, force: true });
|
|
75
|
+
try {
|
|
76
|
+
x(['db', 'reset'], options.root, env);
|
|
77
|
+
const seed = options.seed ?? ['--tier', 'dev'];
|
|
78
|
+
if (seed !== false) x(['db', 'seed', ...seed], options.root, env);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
await cleanup();
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const spawned = await spawnE2eApp({
|
|
85
|
+
root: options.root,
|
|
86
|
+
mode: options.mode ?? 'dev',
|
|
87
|
+
env,
|
|
88
|
+
readyTimeoutMs: deadline,
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
base: spawned.base,
|
|
92
|
+
stateDir,
|
|
93
|
+
restart: (next) => spawned.restart(next),
|
|
94
|
+
async stop(): Promise<void> {
|
|
95
|
+
await spawned.stop();
|
|
96
|
+
await cleanup();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
} catch (error) {
|
|
100
|
+
await cleanup();
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The browser and the app the e2e step opened, reachable from a test file: `e2eBrowser()` and
|
|
2
|
+
// `e2eApp()`. On `globalThis` under one `Symbol.for` key, because the preload and a test may each
|
|
3
|
+
// hold their own copy of this module — the page-client handle's reason, one runtime over.
|
|
4
|
+
|
|
5
|
+
import type { E2eBrowser } from './cdp-browser';
|
|
6
|
+
import { CdpBrowserMissingError } from './cdp-errors';
|
|
7
|
+
import { CHROME_CANDIDATES } from './cdp-launch';
|
|
8
|
+
import type { E2eApp } from './e2e-app';
|
|
9
|
+
|
|
10
|
+
/** Set by the e2e step to the app root; the preload spawns that app and opens a browser. */
|
|
11
|
+
export const E2E_ROOT_ENV = 'ULTIMATE_E2E_ROOT';
|
|
12
|
+
|
|
13
|
+
interface E2eRun {
|
|
14
|
+
browser: E2eBrowser;
|
|
15
|
+
readonly app: E2eApp;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const KEY = Symbol.for('ultimate.e2e.run');
|
|
19
|
+
|
|
20
|
+
export function publishE2eRun(run: E2eRun): void {
|
|
21
|
+
Object.defineProperty(globalThis, KEY, { value: run, configurable: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const current = (): E2eRun | undefined => Reflect.get(globalThis, KEY) as E2eRun | undefined;
|
|
25
|
+
|
|
26
|
+
/** Swap in a relaunched browser — the app stays; only the dead browser is replaced. */
|
|
27
|
+
export function republishE2eBrowser(browser: E2eBrowser): void {
|
|
28
|
+
const run = current();
|
|
29
|
+
if (run !== undefined) run.browser = browser;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const missing = (): CdpBrowserMissingError =>
|
|
33
|
+
new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The run's browser: `page`, and `session` for a second tab, an init script, the socket and request
|
|
37
|
+
* log and the offline switch for every worker. Refuses by name outside an e2e run that found one.
|
|
38
|
+
*/
|
|
39
|
+
export function e2eBrowser(): E2eBrowser {
|
|
40
|
+
const run = current();
|
|
41
|
+
if (run === undefined) throw missing();
|
|
42
|
+
return run.browser;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The app the run spawned: `base`, `stateDir`, and `restart({ BUILD_ID })` — a deploy. */
|
|
46
|
+
export function e2eApp(): E2eApp {
|
|
47
|
+
const run = current();
|
|
48
|
+
if (run === undefined) throw missing();
|
|
49
|
+
return run.app;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The spawned app's origin, or `undefined` outside an e2e run. */
|
|
53
|
+
export function e2eBaseUrl(): string | undefined {
|
|
54
|
+
return current()?.app.base;
|
|
55
|
+
}
|
package/src/e2e-driver.ts
CHANGED
|
@@ -14,7 +14,14 @@ import {
|
|
|
14
14
|
import type { E2eBrowserPage, E2ePageOptions } from './e2e-page';
|
|
15
15
|
import { e2ePage } from './e2e-page';
|
|
16
16
|
|
|
17
|
-
export
|
|
17
|
+
export interface E2eDriverOptions extends E2ePageOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Switch the running app to a new build — the SERVER half no page port can speak for. Given, it
|
|
20
|
+
* becomes the `deploy` fixture's `newBuild()` and `e2eTest`'s `update()`; absent, both refuse by
|
|
21
|
+
* name. The gate's e2e preload passes the spawned app's `restart({ BUILD_ID })`.
|
|
22
|
+
*/
|
|
23
|
+
readonly newBuild?: (() => Promise<void>) | undefined;
|
|
24
|
+
}
|
|
18
25
|
|
|
19
26
|
/**
|
|
20
27
|
* A member this driver cannot build is a REFUSAL, never a no-op. A fixture that silently did
|
|
@@ -52,15 +59,21 @@ const networkFixtures = (browser: E2eBrowserPage): Pick<E2eFixtures, 'offline' |
|
|
|
52
59
|
};
|
|
53
60
|
|
|
54
61
|
/** What `e2eTest` hands its body: a real page, the network condition, and one honest refusal. */
|
|
55
|
-
export const e2eFixtures = (
|
|
62
|
+
export const e2eFixtures = (
|
|
63
|
+
page: PageLike,
|
|
64
|
+
browser: E2eBrowserPage,
|
|
65
|
+
newBuild?: () => Promise<void>,
|
|
66
|
+
): E2eFixtures => ({
|
|
56
67
|
page,
|
|
57
68
|
...networkFixtures(browser),
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
update:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
// A new build id is a fact about the SERVER, which no page port can speak for — so it is
|
|
70
|
+
// forwarded when whoever spawned the app can restart it, and refused by name otherwise.
|
|
71
|
+
update:
|
|
72
|
+
newBuild ??
|
|
73
|
+
refuse(
|
|
74
|
+
'update',
|
|
75
|
+
'a second build served under a new immutable build id, which is a server fact',
|
|
76
|
+
),
|
|
64
77
|
});
|
|
65
78
|
|
|
66
79
|
/**
|
|
@@ -73,7 +86,7 @@ export const e2eFixtures = (page: PageLike, browser: E2eBrowserPage): E2eFixture
|
|
|
73
86
|
* `test.skip` — which the gate now reports as a SKIPPED step rather than the green check it
|
|
74
87
|
* printed until #434, and which a repo whose `x.verify.json` names `e2e` gets red for.
|
|
75
88
|
*
|
|
76
|
-
* `budget
|
|
89
|
+
* `budget` and `signIn` are deliberately NOT registered here, and `deploy` only with `newBuild`. Each needs something a
|
|
77
90
|
* page cannot supply — byte counts off a built `dist/`, an app's own sign-in route, a second build
|
|
78
91
|
* — so each keeps refusing with `X_TEST_FIXTURE_UNAVAILABLE` naming what it waits for.
|
|
79
92
|
*
|
|
@@ -82,16 +95,23 @@ export const e2eFixtures = (page: PageLike, browser: E2eBrowserPage): E2eFixture
|
|
|
82
95
|
*/
|
|
83
96
|
export function installE2eDriver(options: E2eDriverOptions): () => void {
|
|
84
97
|
const page = e2ePage(options);
|
|
85
|
-
|
|
98
|
+
const newBuild = options.newBuild;
|
|
99
|
+
defineFixtures({
|
|
100
|
+
page: () => page,
|
|
101
|
+
...(newBuild === undefined ? {} : { deploy: () => ({ newBuild }) }),
|
|
102
|
+
});
|
|
86
103
|
useE2eDriver((name, body: E2eBody) => {
|
|
87
|
-
bunTest(name, () => body(e2eFixtures(page, options.page)));
|
|
104
|
+
bunTest(name, () => body(e2eFixtures(page, options.page, newBuild)));
|
|
88
105
|
});
|
|
89
106
|
return () => {
|
|
90
107
|
// Both halves, because both were installed. Putting the DECLARATION back — rather than
|
|
91
108
|
// deleting the key — is what keeps a later file's `{ page }` failing as
|
|
92
109
|
// `X_TEST_FIXTURE_UNAVAILABLE` (a driver is missing) instead of `X_TEST_FIXTURE_UNKNOWN`
|
|
93
110
|
// (register it), which is the wrong instruction for a name the framework declares.
|
|
94
|
-
defineFixtures({
|
|
111
|
+
defineFixtures({
|
|
112
|
+
page: unavailableFixture('page'),
|
|
113
|
+
...(newBuild === undefined ? {} : { deploy: unavailableFixture('deploy') }),
|
|
114
|
+
});
|
|
95
115
|
resetE2eDriver();
|
|
96
116
|
};
|
|
97
117
|
}
|
package/src/e2e-errors.ts
CHANGED
|
@@ -101,3 +101,17 @@ export class E2eServiceWorkerAbsentError extends UltimateError {
|
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The app an e2e run spawns (`e2e-app.ts`) did not come up: its reset, its seed, or its boot. The
|
|
107
|
+
* cause carries that process's own output, rendered, because it is the only place the reason is.
|
|
108
|
+
*/
|
|
109
|
+
export class E2eAppFailedError extends UltimateError {
|
|
110
|
+
constructor(input: { readonly step: string; readonly output: string }) {
|
|
111
|
+
super({
|
|
112
|
+
code: 'X_E2E_APP_FAILED',
|
|
113
|
+
cause: `${renderCauseValue(input.step)} failed for the e2e app: ${renderCauseValue(input.output)}`,
|
|
114
|
+
fix: 'x dev --json # boot the same app by hand and read why it would not start; the e2e run used a throwaway ULTIMATE_STATE_DIR, so your own .x is untouched',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/e2e-page.ts
CHANGED
|
@@ -67,11 +67,14 @@ const TITLE = '(() => JSON.stringify({ title: document.title }))()';
|
|
|
67
67
|
* The cost, stated rather than hidden: this is a SECOND request to the same route, so what it
|
|
68
68
|
* measures is that route's streaming behaviour and not the byte-for-byte first chunk the open
|
|
69
69
|
* document received. It runs in the page, so it carries the page's cookies and its origin — a
|
|
70
|
-
* `fetch` from the test process would carry neither.
|
|
70
|
+
* `fetch` from the test process would carry neither. The reader is CANCELLED after that chunk: a
|
|
71
|
+
* streamed response nobody pulls stays open until its last hole fills, holding one of the page's
|
|
72
|
+
* six connections to its origin for the rest of the test.
|
|
71
73
|
*/
|
|
72
74
|
const firstFlushExpression = (url: string): string =>
|
|
73
75
|
`(() => fetch(${JSON.stringify(url)}, { credentials: 'same-origin' })
|
|
74
|
-
.then((response) => response.body.getReader().read()
|
|
76
|
+
.then((response) => { const reader = response.body.getReader(); return reader.read()
|
|
77
|
+
.then((chunk) => { reader.cancel().catch(() => {}); return chunk; }); })
|
|
75
78
|
.then((chunk) => JSON.stringify({ html: new TextDecoder().decode(chunk.value || new Uint8Array()) })))()`;
|
|
76
79
|
|
|
77
80
|
/**
|