@ultimat3/cli 20.2.1 → 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.
Files changed (49) hide show
  1. package/CLAUDE.md +69 -1
  2. package/package.json +30 -30
  3. package/src/app-env.ts +2 -2
  4. package/src/budgets.ts +45 -12
  5. package/src/build-errors.ts +54 -0
  6. package/src/cdp-browser.ts +21 -27
  7. package/src/cdp-connection.ts +66 -30
  8. package/src/cdp-e2e-page.ts +84 -113
  9. package/src/cdp-e2e-session.ts +199 -0
  10. package/src/cdp-launch.ts +95 -41
  11. package/src/cdp-offline-script.ts +73 -0
  12. package/src/cdp-pipe.ts +77 -0
  13. package/src/cmd-deploy.ts +7 -0
  14. package/src/cmd-dev.ts +23 -86
  15. package/src/dev-live-feed.ts +2 -0
  16. package/src/dev-render.ts +119 -20
  17. package/src/dev-route-table.ts +119 -0
  18. package/src/dev-services.ts +4 -1
  19. package/src/dev-sync.ts +5 -3
  20. package/src/e2e-app.ts +103 -0
  21. package/src/e2e-browser-handle.ts +55 -0
  22. package/src/e2e-driver.ts +32 -12
  23. package/src/e2e-errors.ts +14 -0
  24. package/src/e2e-page.ts +5 -2
  25. package/src/e2e-preload.ts +64 -0
  26. package/src/e2e-probe.ts +23 -0
  27. package/src/e2e-spawn.ts +169 -0
  28. package/src/error-codes.ts +7 -0
  29. package/src/error-unthrown.ts +130 -0
  30. package/src/errors.ts +8 -29
  31. package/src/index.ts +18 -4
  32. package/src/island-bundle.ts +33 -11
  33. package/src/island-realtime.ts +91 -0
  34. package/src/island-verdict.ts +1 -1
  35. package/src/live-routes.ts +82 -42
  36. package/src/mcp-errors.ts +3 -0
  37. package/src/page-sync.ts +54 -0
  38. package/src/realtime-browser-probe-fixture.ts +2 -2
  39. package/src/serve.ts +9 -0
  40. package/src/sw-artifacts.ts +13 -3
  41. package/src/sync-url.ts +31 -0
  42. package/src/templates/resource-form-island.ts +30 -21
  43. package/src/templates/route.ts +3 -0
  44. package/src/templates/scaffold-container.ts +18 -3
  45. package/src/templates/scaffold-env.ts +6 -0
  46. package/src/verify-e2e.ts +38 -0
  47. package/src/verify-run.ts +105 -50
  48. package/src/verify-tests.ts +21 -4
  49. package/src/worker-bundle.ts +192 -0
package/src/verify-run.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
15
15
 
16
16
  /**
17
- * Run every step in order, never bailing early: an agent fixing three things at once needs all
17
+ * Run every step, never bailing early: an agent fixing three things at once needs all
18
18
  * three findings from one run, not one per round-trip.
19
19
  *
20
20
  * `ctx.only` narrows the list to one step. The narrowing lives HERE rather than in `cmd-verify.ts`
@@ -26,61 +26,43 @@ export async function runVerify(
26
26
  ctx: VerifyContext,
27
27
  ): Promise<CommandResult> {
28
28
  const floor = await readVerifyFloor(ctx.root);
29
- const results: StepResult[] = [];
30
29
  const selected = ctx.only === undefined ? steps : steps.filter((step) => step.name === ctx.only);
30
+ const byName = new Map<string, StepResult>();
31
+ const began = performance.now();
32
+ // The static steps wait for the serial suites and then run BESIDE them — only when `live` is in
33
+ // the list, so a one-step run (`--only`) and a list with no serial suite keep today's order.
34
+ const overlapping = selected.some((step) => step.name === SERIAL_SUITES[0]);
35
+ const beside = overlapping ? selected.filter((step) => BESIDE_SERIAL_SUITES.has(step.name)) : [];
36
+ let pending: Promise<void> | undefined;
37
+ const join = async (): Promise<void> => {
38
+ await pending;
39
+ pending = undefined;
40
+ };
31
41
  for (const step of selected) {
32
- const applies = step.applies === undefined ? true : await step.applies(ctx);
33
- if (!applies) {
34
- // A skip this repo already ruled out is not a skip. The step ran here before — the floor is
35
- // that claim, committed — so "nothing to check" now means the suite was deleted, and the
36
- // gate says so on the step's own line rather than counting one more thing not to worry
37
- // about. Recorded as failed and NOT as skipped, so every reader of a step table sees it:
38
- // the summary, `data.failed`, and the reference-app gate's own red list.
39
- const required = floorRequires(floor, step.name);
40
- results.push({
41
- name: step.name,
42
- ok: !required,
43
- durationMs: 0,
44
- skipped: !required,
45
- findings: required ? [vanishedSuiteFinding(step.name)] : [],
46
- });
47
- continue;
42
+ if (beside.includes(step)) continue;
43
+ if (step.name === SERIAL_SUITES[0]) {
44
+ pending = Promise.all(
45
+ beside.map(async (other) => {
46
+ byName.set(other.name, await runStep(other, ctx, floor));
47
+ }),
48
+ ).then(() => undefined);
49
+ } else if (!SERIAL_SUITES.includes(step.name)) {
50
+ await join();
48
51
  }
49
- const started = performance.now();
50
- const outcome = await step.run(ctx).catch(
51
- (error: unknown): StepOutcome => ({
52
- ok: false,
53
- findings: [findingOf(error, step.name)],
54
- }),
55
- );
56
- // A suite that executed nothing did not run, whatever its exit code says: `bun test` exits 0
57
- // over an all-skipped file, so the counts are the only channel that can tell the two apart.
58
- // ONE definition of "nothing ran", read twice, because the floor decides which of the two
59
- // things it means — exactly as it already does for a step whose `applies` said no.
60
- const tests = outcome.tests;
61
- const nothingRan = tests !== undefined && tests.ran === 0;
62
- const required = floorRequires(floor, step.name);
63
- // A step the floor requires whose suite executed nothing is the same vanished suite as a step
64
- // with no files at all — the run just had to finish before it could be seen. Appended to the
65
- // step's own findings so `data.failed`, the counts and every gate reading this table carry it.
66
- const vanished = nothingRan && required ? [skippedSuiteFinding(step.name, tests.skipped)] : [];
67
- results.push({
68
- name: step.name,
69
- ok: outcome.ok && vanished.length === 0,
70
- durationMs: Math.round(performance.now() - started),
71
- // Without a floor to require it, a suite that ran nothing is a SKIP and not a pass (#434):
72
- // the `e2e` step printed `✓ e2e 46ms` over its one skipped test, which is the one thing a
73
- // step table may never do — a reader cannot tell a lane that ran from a lane that did not.
74
- skipped: nothingRan && !required,
75
- findings: [...outcome.findings, ...vanished],
76
- ...(outcome.output === undefined ? {} : { output: outcome.output }),
77
- ...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
78
- ...(tests === undefined ? {} : { tests }),
79
- });
52
+ byName.set(step.name, await runStep(step, ctx, floor));
80
53
  }
54
+ await join();
55
+ // Reported in the declared order, whatever order the steps finished in: the table, `--json` and
56
+ // every gate parsing either read the same sequence they always did.
57
+ const results = selected.flatMap((step) => {
58
+ const result = byName.get(step.name);
59
+ return result === undefined ? [] : [result];
60
+ });
81
61
  const failedSteps = results.filter((step) => !step.ok).map((step) => step.name);
82
62
  const skippedSteps = results.filter((step) => step.skipped === true).map((step) => step.name);
83
- const totalMs = results.reduce((sum, step) => sum + step.durationMs, 0);
63
+ // WALL time, not the sum of step times: with steps overlapping, the sum overstates what a run
64
+ // costs, and the wall clock is the number a CI job waits on.
65
+ const totalMs = Math.round(performance.now() - began);
84
66
  const summary = verifySummary({
85
67
  results,
86
68
  failed: failedSteps,
@@ -139,6 +121,79 @@ function verifySummary(input: {
139
121
  return msg(clean ? 'cli.verify.fail' : 'cli.verify.failSkipped', params);
140
122
  }
141
123
 
124
+ /**
125
+ * `x verify`'s wall time was the SUM of 20 serial steps (#14, the DX ledger): measured locally, 395s,
126
+ * of which `lint`, `boundaries`, `filesize`, `package-shape` and `errors` were 127s spent while
127
+ * nothing else ran. They read the tree and write nothing a later step reads (`lint` is biome over
128
+ * files; the rest are in-process scans), so they run BESIDE the serial suites — `live` and `e2e`
129
+ * are one worker each, Postgres- and browser-bound, and mostly waiting. `typecheck` stays first
130
+ * and alone: `tsc -b` writes `.tsbuildinfo` and `dist/`, and `unit` saturates every core.
131
+ */
132
+ export const BESIDE_SERIAL_SUITES: ReadonlySet<string> = new Set([
133
+ 'lint',
134
+ 'boundaries',
135
+ 'filesize',
136
+ 'package-shape',
137
+ 'errors',
138
+ ]);
139
+
140
+ /** The consecutive run of steps the static group overlaps, in the order `VERIFY_STEP_NAMES` holds. */
141
+ export const SERIAL_SUITES: readonly string[] = ['live', 'job', 'e2e', 'eval'];
142
+
143
+ async function runStep(
144
+ step: VerifyStep,
145
+ ctx: VerifyContext,
146
+ floor: Awaited<ReturnType<typeof readVerifyFloor>>,
147
+ ): Promise<StepResult> {
148
+ const applies = step.applies === undefined ? true : await step.applies(ctx);
149
+ if (!applies) {
150
+ // A skip this repo already ruled out is not a skip. The step ran here before — the floor is
151
+ // that claim, committed — so "nothing to check" now means the suite was deleted, and the
152
+ // gate says so on the step's own line rather than counting one more thing not to worry
153
+ // about. Recorded as failed and NOT as skipped, so every reader of a step table sees it:
154
+ // the summary, `data.failed`, and the reference-app gate's own red list.
155
+ const required = floorRequires(floor, step.name);
156
+ return {
157
+ name: step.name,
158
+ ok: !required,
159
+ durationMs: 0,
160
+ skipped: !required,
161
+ findings: required ? [vanishedSuiteFinding(step.name)] : [],
162
+ };
163
+ }
164
+ const started = performance.now();
165
+ const outcome = await step.run(ctx).catch(
166
+ (error: unknown): StepOutcome => ({
167
+ ok: false,
168
+ findings: [findingOf(error, step.name)],
169
+ }),
170
+ );
171
+ // A suite that executed nothing did not run, whatever its exit code says: `bun test` exits 0
172
+ // over an all-skipped file, so the counts are the only channel that can tell the two apart.
173
+ // ONE definition of "nothing ran", read twice, because the floor decides which of the two
174
+ // things it means — exactly as it already does for a step whose `applies` said no.
175
+ const tests = outcome.tests;
176
+ const nothingRan = tests !== undefined && tests.ran === 0;
177
+ const required = floorRequires(floor, step.name);
178
+ // A step the floor requires whose suite executed nothing is the same vanished suite as a step
179
+ // with no files at all — the run just had to finish before it could be seen. Appended to the
180
+ // step's own findings so `data.failed`, the counts and every gate reading this table carry it.
181
+ const vanished = nothingRan && required ? [skippedSuiteFinding(step.name, tests.skipped)] : [];
182
+ return {
183
+ name: step.name,
184
+ ok: outcome.ok && vanished.length === 0,
185
+ durationMs: Math.round(performance.now() - started),
186
+ // Without a floor to require it, a suite that ran nothing is a SKIP and not a pass (#434):
187
+ // the `e2e` step printed `✓ e2e 46ms` over its one skipped test, which is the one thing a
188
+ // step table may never do — a reader cannot tell a lane that ran from a lane that did not.
189
+ skipped: nothingRan && !required,
190
+ findings: [...outcome.findings, ...vanished],
191
+ ...(outcome.output === undefined ? {} : { output: outcome.output }),
192
+ ...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
193
+ ...(tests === undefined ? {} : { tests }),
194
+ };
195
+ }
196
+
142
197
  function findingOf(error: unknown, step: string): Finding {
143
198
  // A step may throw anything, including an Error that fights being read: `instanceof` runs a
144
199
  // Proxy's `getPrototypeOf` trap and `.message` runs a getter, so a hostile throw would take the
@@ -19,6 +19,7 @@ import { testEnvOverrides } from './test-dotenv';
19
19
  import type { TestFile } from './test-select';
20
20
  import { discoverTests } from './test-select';
21
21
  import { defaultWorkers } from './test-workers';
22
+ import { withE2eApp } from './verify-e2e';
22
23
  import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
23
24
  import { fromExec, fromFindings } from './verify-step';
24
25
  import { runParallel } from './verify-test-run';
@@ -162,6 +163,12 @@ const ignoreFlags = (patterns: readonly string[]): readonly string[] =>
162
163
  export const typeFiltersOf = (type: Exclude<TestType, 'unit'>): readonly string[] =>
163
164
  OWNERSHIP.filter(([owner]) => owner === type).map(([, filter]) => filter);
164
165
 
166
+ /**
167
+ * An e2e test's own budget: a first navigation waits on `x dev` compiling the route and its island,
168
+ * which bun's default 5 s does not cover — a timeout there reads as an app bug that is not one.
169
+ */
170
+ export const E2E_TEST_TIMEOUT_MS = 60_000;
171
+
165
172
  /** Unit is everything the typed suites do not claim, so no test can fall between two steps. */
166
173
  export const testStepCommand = (type: TestType): readonly string[] =>
167
174
  type === 'unit'
@@ -173,6 +180,7 @@ export const testStepCommand = (type: TestType): readonly string[] =>
173
180
  : [
174
181
  'bun',
175
182
  'test',
183
+ ...(type === 'e2e' ? [`--timeout=${String(E2E_TEST_TIMEOUT_MS)}`] : []),
176
184
  ...ignoreFlags([...NEVER_A_TEST, ...disownedBy(type)]),
177
185
  ...typeFiltersOf(type),
178
186
  ];
@@ -203,10 +211,19 @@ export const resetTestDiscovery = (): void => discovered.clear();
203
211
  const runSerial = async (ctx: VerifyContext, type: TestType): Promise<StepOutcome> => {
204
212
  const command = testStepCommand(type);
205
213
  const envOverrides = testEnvOverrides(ctx.root, ctx.env ?? Bun.env);
206
- const result = await ctx.runner(command, {
207
- cwd: ctx.root,
208
- ...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
209
- });
214
+ const exec = (e2e: { command: readonly string[]; env: typeof envOverrides }) =>
215
+ ctx.runner(e2e.command, {
216
+ cwd: ctx.root,
217
+ ...(Object.keys(e2e.env).length === 0 ? {} : { env: e2e.env }),
218
+ });
219
+ // The e2e step drives a real browser against the app it spawns, when this machine has one.
220
+ const result =
221
+ type === 'e2e'
222
+ ? await withE2eApp(
223
+ { root: ctx.root, isApp: isApp(ctx.root), command, env: envOverrides },
224
+ exec,
225
+ )
226
+ : await exec({ command, env: envOverrides });
210
227
  return {
211
228
  ...fromExec(result, {
212
229
  code: 'X_TEST_FAILED',
@@ -0,0 +1,192 @@
1
+ // The page's framework scripts (plan 101), each built as its own classic-script browser bundle,
2
+ // addressed by a hash of its source graph, served `immutable` by the same route in `x dev` and the
3
+ // container: the ONE sync worker (`@ultimat3/realtime/sync-worker`, slice 11) and the ONE page boot
4
+ // (`@ultimat3/realtime/boot` — the disk restore and the outbox, once per page instead of once per
5
+ // island). A new deploy is a new URL, so an old tab keeps what it started with.
6
+
7
+ // why: Bun ships no path API; the entry is resolved to a file and named in the cause.
8
+ import { dirname, join, relative } from 'node:path';
9
+ import type { Route, UltimateRequest } from '@ultimat3/http';
10
+ import { applyCacheHeaders, json } from '@ultimat3/http';
11
+ import { FrameworkScriptBuildFailedError, type FrameworkScriptKind } from './errors';
12
+ import { describeBuildError, graphHash, stripDebugId } from './island-bundle';
13
+
14
+ /** Under the dev namespace `/_x` — where the socket it opens (`/_x/sync`) already lives. */
15
+ export const SYNC_WORKER_BASE_PATH = '/_x/sync-worker';
16
+
17
+ /** What an app resolves: realtime's worker entry, from the APP's install, never the CLI's own. */
18
+ export const SYNC_WORKER_SPECIFIER = '@ultimat3/realtime/sync-worker';
19
+
20
+ /** Under the dev namespace too; one per document that carries a principal scope. */
21
+ export const PAGE_BOOT_BASE_PATH = '/_x/page-boot';
22
+
23
+ /** Realtime's page boot, from the APP's install. */
24
+ export const PAGE_BOOT_SPECIFIER = '@ultimat3/realtime/boot';
25
+
26
+ /** One framework script: the sync worker or the page boot. */
27
+ export interface FrameworkScript {
28
+ /** `<base>/<hash>.js` — what the document names. */
29
+ readonly url: string;
30
+ readonly code: string;
31
+ readonly bytes: number;
32
+ }
33
+
34
+ /** The worker, by the name every caller already uses. */
35
+ export type SyncWorkerChunk = FrameworkScript;
36
+
37
+ export interface BuildSyncWorkerOptions {
38
+ /** An absolute entry path. Absent resolves `SYNC_WORKER_SPECIFIER` from `root`. */
39
+ readonly entry?: string;
40
+ }
41
+
42
+ /**
43
+ * `undefined` when the app cannot resolve realtime's worker: an app with no realtime has no
44
+ * socket to share, and the tab-side host falls back to an in-page engine by design (slice 11's
45
+ * transparent fallback) — absence is an answer, not a failure.
46
+ */
47
+ export function buildSyncWorker(
48
+ root: string,
49
+ options: BuildSyncWorkerOptions = {},
50
+ ): Promise<FrameworkScript | undefined> {
51
+ return buildFrameworkScript(
52
+ root,
53
+ 'sync worker',
54
+ SYNC_WORKER_SPECIFIER,
55
+ SYNC_WORKER_BASE_PATH,
56
+ options.entry,
57
+ );
58
+ }
59
+
60
+ /**
61
+ * The page boot, or `undefined` for an app with no realtime (nothing to restore, no outbox). A
62
+ * document then carries no boot script, and `pageRealtime().booted` answers at once.
63
+ */
64
+ export function buildPageBoot(
65
+ root: string,
66
+ options: BuildSyncWorkerOptions = {},
67
+ ): Promise<FrameworkScript | undefined> {
68
+ return buildFrameworkScript(
69
+ root,
70
+ 'page boot',
71
+ PAGE_BOOT_SPECIFIER,
72
+ PAGE_BOOT_BASE_PATH,
73
+ options.entry,
74
+ );
75
+ }
76
+
77
+ async function buildFrameworkScript(
78
+ root: string,
79
+ what: FrameworkScriptKind,
80
+ specifier: string,
81
+ basePath: string,
82
+ explicit: string | undefined,
83
+ ): Promise<FrameworkScript | undefined> {
84
+ const entry = explicit ?? resolveEntry(root, specifier);
85
+ if (entry === undefined) return undefined;
86
+ const label = relative(root, entry);
87
+ let built: Awaited<ReturnType<typeof Bun.build>>;
88
+ try {
89
+ built = await Bun.build({
90
+ entrypoints: [entry],
91
+ target: 'browser',
92
+ // A CLASSIC script: `new SharedWorker(url, { name })` with no `type: 'module'` runs it, and a
93
+ // `<script defer>` runs it before any island module that follows it in the document.
94
+ format: 'iife',
95
+ splitting: false,
96
+ minify: true,
97
+ // `island-bundle.ts`'s reasons, verbatim: a chunk is only ever built to be shipped, and the
98
+ // map's `sourcesContent` is the one stable identity a minified bundle has.
99
+ define: { 'process.env.NODE_ENV': '"production"' },
100
+ sourcemap: 'external',
101
+ });
102
+ } catch (error) {
103
+ throw new FrameworkScriptBuildFailedError({
104
+ what,
105
+ entry: label,
106
+ logs: describeBuildError(error),
107
+ });
108
+ }
109
+ const output = built.outputs.find((artifact) => artifact.kind === 'entry-point');
110
+ const map = built.outputs.find((artifact) => artifact.kind === 'sourcemap');
111
+ if (!built.success || output === undefined || map === undefined) {
112
+ throw new FrameworkScriptBuildFailedError({
113
+ what,
114
+ entry: label,
115
+ logs: built.logs.map((log) => String(log)).join('; '),
116
+ });
117
+ }
118
+ const code = stripDebugId(await output.text());
119
+ return {
120
+ url: `${basePath}/${graphHash(specifier, await map.text())}.js`,
121
+ code,
122
+ bytes: new TextEncoder().encode(code).byteLength,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * The app's realtime, from the root or — in a workspace app, where `@ultimat3/realtime` is a
128
+ * dependency of `apps/<app>` and not of the root (`examples/dummy`) — from the first app that has
129
+ * it: where its islands resolve it, which is the copy the scripts must match. Resolving from the
130
+ * root alone built no worker and no boot for such an app, silently.
131
+ */
132
+ function resolveEntry(root: string, specifier: string): string | undefined {
133
+ for (const dir of [root, ...appDirs(root)]) {
134
+ try {
135
+ return Bun.resolveSync(specifier, dir);
136
+ } catch {
137
+ // Not installed here, or a realtime that predates the export: try the next app.
138
+ }
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ function appDirs(root: string): readonly string[] {
144
+ const glob = new Bun.Glob('apps/*/package.json');
145
+ return [...glob.scanSync({ cwd: root, onlyFiles: true })]
146
+ .sort()
147
+ .map((file) => join(root, dirname(file)));
148
+ }
149
+
150
+ /** The script a build produced, read per request so `x dev` and the container share one route. */
151
+ export type SyncWorkerSource = () => FrameworkScript | undefined;
152
+
153
+ export function syncWorkerRoutes(source: SyncWorkerSource): readonly Route[] {
154
+ return [scriptRoute(SYNC_WORKER_BASE_PATH, 'assets.sync-worker', 'sync worker', source)];
155
+ }
156
+
157
+ export function pageBootRoutes(source: SyncWorkerSource): readonly Route[] {
158
+ return [scriptRoute(PAGE_BOOT_BASE_PATH, 'assets.page-boot', 'page boot', source)];
159
+ }
160
+
161
+ function scriptRoute(
162
+ basePath: string,
163
+ name: string,
164
+ what: string,
165
+ source: SyncWorkerSource,
166
+ ): Route {
167
+ return {
168
+ method: 'GET',
169
+ path: `${basePath}/:file`,
170
+ meta: { name, auth: 'public', tags: ['assets'] },
171
+ handler: (request: UltimateRequest): Response => {
172
+ const script = source();
173
+ if (script === undefined || script.url !== request.pathname) {
174
+ return json(
175
+ {
176
+ ok: false,
177
+ error: {
178
+ code: 'X_ROUTE_NOT_FOUND',
179
+ cause: `no ${what} is built at ${request.pathname} — the document that named it was rendered against another build`,
180
+ fix: `reload the page — this process serves only the ${what} it built`,
181
+ },
182
+ },
183
+ { status: 404 },
184
+ );
185
+ }
186
+ return applyCacheHeaders(
187
+ new Response(script.code, { headers: { 'content-type': 'text/javascript' } }),
188
+ { mode: 'immutable' },
189
+ );
190
+ },
191
+ };
192
+ }