@ultimat3/cli 17.0.0 → 19.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 (45) hide show
  1. package/CLAUDE.md +194 -17
  2. package/package.json +30 -30
  3. package/src/app-root.ts +22 -1
  4. package/src/cdp-browser.ts +100 -0
  5. package/src/cdp-connection.ts +211 -0
  6. package/src/cdp-e2e-page.ts +209 -0
  7. package/src/cdp-errors.ts +56 -0
  8. package/src/cdp-launch.ts +130 -0
  9. package/src/cmd-dev.ts +28 -2
  10. package/src/cmd-doctor.ts +1 -1
  11. package/src/cmd-test.ts +20 -9
  12. package/src/compile-externals.ts +11 -4
  13. package/src/db-accept-created.ts +207 -0
  14. package/src/db-generate.ts +18 -1
  15. package/src/db-subscribes.ts +81 -0
  16. package/src/db-ungeneratable.ts +14 -2
  17. package/src/dev-assets.ts +19 -54
  18. package/src/dev-notify-retention.ts +69 -0
  19. package/src/dev-purge.ts +47 -2
  20. package/src/dev-render.ts +20 -4
  21. package/src/dev-replicator.ts +19 -1
  22. package/src/dev-runtime.ts +12 -3
  23. package/src/dev-services.ts +8 -0
  24. package/src/e2e-driver.ts +35 -17
  25. package/src/e2e-page.ts +15 -3
  26. package/src/error-codes.ts +20 -0
  27. package/src/icon-assets.ts +74 -0
  28. package/src/index.ts +46 -4
  29. package/src/island-harness-script.ts +8 -1
  30. package/src/island-shot.ts +37 -4
  31. package/src/island-verdict.ts +16 -4
  32. package/src/mcp-errors.ts +15 -0
  33. package/src/messages.ts +5 -1
  34. package/src/prerender.ts +41 -1
  35. package/src/pwa-artifacts.ts +230 -0
  36. package/src/serve.ts +24 -1
  37. package/src/static-report.ts +46 -3
  38. package/src/sw-artifacts.ts +162 -0
  39. package/src/sw-routes.ts +53 -0
  40. package/src/templates/naming.ts +11 -0
  41. package/src/templates/scaffold-app.ts +58 -7
  42. package/src/templates/scaffold-repo.ts +17 -1
  43. package/src/test-shards.ts +150 -116
  44. package/src/ts-scan.ts +6 -1
  45. package/src/verify-test-run.ts +31 -46
package/src/dev-purge.ts CHANGED
@@ -1,5 +1,10 @@
1
- // The retention sweep this boot owns: the three framework tables that grow with traffic, the
2
- // `purge()` job that empties them and the `task` that fires it hourly.
1
+ // The retention sweep this boot owns: the framework tables that grow with traffic, the `purge()`
2
+ // job that empties them and the `task` that fires it hourly.
3
+ //
4
+ // FIVE TARGETS over six tables, `As of 2026-08-27` — `x_idempotency`, `x_rate_limit`, the two
5
+ // `x_auth` tables under one target, `x_notify_deliveries` and `x_notify_inbox`. Counted nowhere in
6
+ // prose but here: this header said "three" for two releases after the notify tables joined the
7
+ // boot's DDL, which is how `x_notify_inbox` became the one framework table nothing swept.
3
8
  //
4
9
  // WHY here and not in the packages that own the tables: `postgresIdempotencyStore` (tier 3),
5
10
  // `postgresRateLimitStore` (tier 2) and `postgresAuthLimiter` (tier 2) cannot see each other and
@@ -17,6 +22,9 @@ import { purgeAuthLimits } from '@ultimat3/auth';
17
22
  import type { PostgresRateLimitStore } from '@ultimat3/http';
18
23
  import type { JobHandle, PurgeInput, PurgeTarget } from '@ultimat3/jobs';
19
24
  import { DEFAULT_PURGE_CRON, getJob, getTask, purge, task } from '@ultimat3/jobs';
25
+ import { purgeNotifyDeliveries, purgeNotifyInbox } from '@ultimat3/notify';
26
+ import type { InboxRetention } from './dev-notify-retention';
27
+ import { NO_INBOX_RETENTION } from './dev-notify-retention';
20
28
 
21
29
  /** The durable queue key. Pinned, like every framework-owned job name — rows carry it. */
22
30
  export const PURGE_JOB_NAME = 'x.purge';
@@ -36,6 +44,42 @@ export const PURGE_TASK_NAME = 'x.purge.hourly';
36
44
  export interface RetentionStores {
37
45
  readonly idempotency: PostgresIdempotencyStore;
38
46
  readonly rateLimit: PostgresRateLimitStore;
47
+ /**
48
+ * The app's own answer to "how long is an unread message kept", loaded from `app.config.ts` by
49
+ * `loadInboxRetention`. Absent windows mean the inbox is never swept, which is the default and a
50
+ * decision rather than an oversight — see `NotifyConfig` in `@ultimat3/core`.
51
+ */
52
+ readonly inboxRetention?: InboxRetention | undefined;
53
+ }
54
+
55
+ /**
56
+ * The two notify tables, and neither store is in `RetentionStores` — deliberately, and for the
57
+ * reason `authTarget` is not either. `setNotifyStores` is an APP's boot line and runs when the
58
+ * app's modules import, which is AFTER this install; `framework-schema.ts` says so where it
59
+ * applies the DDL "whether or not this boot calls `setNotifyStores`". So the sweep can only ask,
60
+ * per attempt, what is installed now — `purgeNotifyInbox`/`purgeNotifyDeliveries` answer 0 for a
61
+ * memory store or none at all, which is a boot that made a decision, not a failure.
62
+ */
63
+ function notifyTargets(retention: InboxRetention): readonly PurgeTarget[] {
64
+ return [
65
+ {
66
+ name: 'x_notify_deliveries',
67
+ // The job's clock, exactly as `x_rate_limit` below: `at` is written by whichever process took
68
+ // the delivery, so a cutoff computed inside Postgres measures the offset between two clocks
69
+ // rather than the age of the row. The WINDOW is the ledger's own, never named here — one
70
+ // number, beside the statement that reads it.
71
+ purgeExpired: (nowMs: number): Promise<number> => purgeNotifyDeliveries(nowMs),
72
+ },
73
+ {
74
+ name: 'x_notify_inbox',
75
+ purgeExpired: (nowMs: number): Promise<number> =>
76
+ purgeNotifyInbox({
77
+ read: retention.readMs === undefined ? undefined : new Date(nowMs - retention.readMs),
78
+ unread:
79
+ retention.unreadMs === undefined ? undefined : new Date(nowMs - retention.unreadMs),
80
+ }),
81
+ },
82
+ ];
39
83
  }
40
84
 
41
85
  /**
@@ -76,6 +120,7 @@ function retentionTargets(stores: RetentionStores): readonly PurgeTarget[] {
76
120
  purgeExpired: (nowMs: number): Promise<number> => stores.rateLimit.purgeExpired(nowMs),
77
121
  },
78
122
  authTarget,
123
+ ...notifyTargets(stores.inboxRetention ?? NO_INBOX_RETENTION),
79
124
  ];
80
125
  }
81
126
 
package/src/dev-render.ts CHANGED
@@ -45,6 +45,17 @@ export type IslandResolver = (routeFile: string) => (src: string) => string;
45
45
 
46
46
  export interface DocumentOptions {
47
47
  readonly resolveIsland?: IslandResolver;
48
+ /**
49
+ * `<link rel="manifest">`, both `theme-color` metas and the apple-touch links — `PwaArtifacts.head`
50
+ * from `pwa-artifacts.ts`, or absent when the app is not installable.
51
+ *
52
+ * A document-level string rather than something a route's `meta()` returns: it is the same three
53
+ * elements on every page of the app, an installable app is one whose EVERY page carries them
54
+ * (a browser offers the install on whichever page the visitor landed on), and `headFromMeta`
55
+ * projects per-route SEO. Passed through `DocumentOptions` for `resolveIsland`'s reason — the
56
+ * boot knows it, the renderer cannot ask.
57
+ */
58
+ readonly pwaHead?: string;
48
59
  }
49
60
 
50
61
  export interface DevRenderOptions extends DocumentOptions {
@@ -68,13 +79,18 @@ export interface DevRouteData extends Record<string, unknown> {
68
79
  */
69
80
  const lang = (): string => currentLocale();
70
81
 
71
- const headFor = async (entry: RouteEntry, ctx: DevRouteData, data: RouteData): Promise<string> =>
82
+ const headFor = async (
83
+ entry: RouteEntry,
84
+ ctx: DevRouteData,
85
+ data: RouteData,
86
+ options: DocumentOptions,
87
+ ): Promise<string> =>
72
88
  renderHead(
73
89
  headFromMeta(
74
90
  await entry.config.meta(metaContextFor(ctx, data)),
75
91
  seoRenderers({ path: new URL(ctx.url).pathname }),
76
92
  ),
77
- );
93
+ ) + (options.pwaHead ?? '');
78
94
 
79
95
  /** `<style>` for the surface's own stylesheets, or nothing at all when the surface imports none. */
80
96
  const styleTag = (entry: RouteEntry): string => {
@@ -155,7 +171,7 @@ async function documentFrom(
155
171
  ): Promise<string> {
156
172
  const islands = collectorFor(entry, options);
157
173
  const [head, body] = await Promise.all([
158
- headFor(entry, ctx, data),
174
+ headFor(entry, ctx, data, options),
159
175
  routeBody(entry, ctx, data, islands),
160
176
  ]);
161
177
  return (
@@ -203,7 +219,7 @@ async function resultFor(
203
219
  // correct output, no streaming benefit.
204
220
  const islands = collectorFor(entry, options);
205
221
  const [head, shell] = await Promise.all([
206
- headFor(entry, request, data),
222
+ headFor(entry, request, data, options),
207
223
  routeBody(entry, request, data, islands),
208
224
  ]);
209
225
  return streamResult(
@@ -41,6 +41,24 @@ const embeddedRefusal = (): BadFlagError =>
41
41
  fix: 'DATABASE_URL=postgres://user:password@localhost:5432/app x dev --role replicator',
42
42
  });
43
43
 
44
+ /**
45
+ * The relations the feed decodes: every registered entity's PHYSICAL TABLE, never its name.
46
+ *
47
+ * Both readers of this list are catalog readers. `PgReplicationStream` keeps a change only when
48
+ * `#entities.has(relation.name)`, and a pgoutput Relation message names the table; `warnPartialIdentity`
49
+ * matches the same list against `pg_class.relname`. An entity NAME is the framework's own registry
50
+ * key — what a cache tag, a policy and `x entities describe` are keyed by — and `entity('user',
51
+ * { table: 'users' })` makes the two different strings. Passing the name meant a renamed table
52
+ * matched nothing on either side: every change SKIPPED, and a replica-identity warning that could
53
+ * never fire. Latent wherever the two agree, which is every entity in `examples/dummy`.
54
+ *
55
+ * Deduplicated and sorted: two entities may share one table, and the same registry must hand the
56
+ * feed the same list whatever order it was registered in.
57
+ */
58
+ export function replicatedRelations(): readonly string[] {
59
+ return [...new Set(describeEntities().map((entity) => entity.table))].sort();
60
+ }
61
+
44
62
  /**
45
63
  * An entity list is the feed's filter, so an empty one is a replicator that decodes every change
46
64
  * and forwards none. Refused here rather than inside the feed: this is the layer that knows the
@@ -61,7 +79,7 @@ const noEntitiesRefusal = (): BadFlagError =>
61
79
  */
62
80
  export async function startReplicator(options: StartReplicatorOptions): Promise<RunningReplicator> {
63
81
  if (options.services.db.mode === 'embedded') throw embeddedRefusal();
64
- const entities = describeEntities().map((entity) => entity.name);
82
+ const entities = replicatedRelations();
65
83
  if (entities.length === 0) throw noEntitiesRefusal();
66
84
 
67
85
  const selection = selectChangeFeed(options.env, { entities });
@@ -31,6 +31,7 @@ import { selectTransport } from '@ultimat3/realtime/server';
31
31
  import type { Storage } from '@ultimat3/storage';
32
32
  import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
33
33
  import { loadCacheTiers, startCacheTiers } from './dev-cache';
34
+ import { loadInboxRetention } from './dev-notify-retention';
34
35
  import { installRetentionSweep } from './dev-purge';
35
36
  import type { DevDbClient } from './dev-queue';
36
37
  import { pgExecutorFor, startQueue } from './dev-queue';
@@ -327,11 +328,19 @@ export async function startServices(
327
328
  postgresAuthLimiter({ executor, clock: systemClock, policy }),
328
329
  );
329
330
  started.push(() => resetAuthLimiters());
330
- // The hourly sweep over the three framework tables this boot is responsible for. Every one of
331
- // them ships a `purgeExpired()` that nothing called, so every row written was a row kept —
331
+ // The hourly sweep over the framework tables this boot is responsible for. Every one of them
332
+ // ships a `purgeExpired()` that nothing called, so every row written was a row kept —
332
333
  // `x_rate_limit` takes one upsert per request the web role serves, assets included.
334
+ //
335
+ // `inboxRetention` is READ HERE and not defaulted in `dev-purge.ts`: the two windows are the
336
+ // app's, `startServices` holds no `AppConfig`, and a loader that silently answered "never
337
+ // sweep" from inside the sweep would be indistinguishable from an app that chose it.
333
338
  started.push(
334
- installRetentionSweep({ idempotency: queue.idempotency, rateLimit: rateLimitStore }),
339
+ installRetentionSweep({
340
+ idempotency: queue.idempotency,
341
+ rateLimit: rateLimitStore,
342
+ inboxRetention: await loadInboxRetention(services.root),
343
+ }),
335
344
  );
336
345
  // One readiness check per resource this boot OWNS, released with it. Nothing in the tree
337
346
  // registered one, so `/readyz` was `markReady()` alone — "this process bound a socket" — and
@@ -21,6 +21,13 @@ export interface DevServices {
21
21
  readonly events: ServiceBinding;
22
22
  readonly storage: ServiceBinding;
23
23
  readonly stateDir: string;
24
+ /**
25
+ * The app directory itself, carried rather than re-derived from `stateDir`. A boot that needs to
26
+ * read the app's own `app.config.ts` — `loadInboxRetention` does — otherwise has to undo the
27
+ * `join(root, '.x')` above, and a `dirname` that silently disagrees with this file's join is a
28
+ * path bug nothing would catch.
29
+ */
30
+ readonly root: string;
24
31
  }
25
32
 
26
33
  export type Env = Readonly<Record<string, string | undefined>>;
@@ -44,6 +51,7 @@ export function resolveServices(root: string, env: Env): DevServices {
44
51
  mkdirSync(stateDir, { recursive: true });
45
52
  }
46
53
  return {
54
+ root,
47
55
  stateDir,
48
56
  db:
49
57
  databaseUrl === undefined
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
- * The three `E2eFixtures` members this driver cannot build, and why each is a REFUSAL rather than
21
- * a no-op. A fixture that silently did nothing would make the assertion after it read as proof:
22
- * `offline()` followed by "the fallback rendered" is the app's ONLINE page passing an offline test.
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
- /** What `e2eTest` hands its body: a real page, and three members that say what they are missing. */
35
- export const e2eFixtures = (page: PageLike): E2eFixtures => ({
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
- offline: refuse(
38
- 'offline',
39
- "a CDP method for the browser's own network state — the shipped CdpPageLike has no setOfflineMode",
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`. Declared
14
- * structurally rather than as `ScrapePage` so a test can stand one up in six lines — the same
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 {
@@ -108,6 +108,14 @@ export const CLI_OWNED_ERROR_CODES = [
108
108
  // code, because the only remedy available for all of them is a line in the migration file, and
109
109
  // where that file lives is this package's fact.
110
110
  'X_MIGRATION_UNGENERATABLE',
111
+ // The third `subscribes:` condition, and the only one neither @ultimat3/query nor
112
+ // @ultimat3/db can ask. Query owns X_QUERY_SUBSCRIBES_INVALID (a declaration that cannot
113
+ // be acted on) and X_QUERY_SUBSCRIBES_DRIFT (it disagrees with the resolved shape); db
114
+ // keeps only the declared names an entity's table matches and DROPS the rest, because it
115
+ // has no way to tell a typo from a table another migration owns. Neither package holds
116
+ // both registries, so a name matching nothing was granted REPLICA IDENTITY FULL on
117
+ // nothing while its author read the declaration as granted (#357).
118
+ 'X_QUERY_SUBSCRIBES_UNKNOWN',
111
119
  'X_DB_MIGRATE_FAILED',
112
120
  'X_DB_BRANCH_FAILED',
113
121
  'X_DB_STUDIO_FAILED',
@@ -158,6 +166,13 @@ export const CLI_OWNED_ERROR_CODES = [
158
166
  'X_E2E_LOCATOR_EMPTY',
159
167
  'X_E2E_LOCATOR_AMBIGUOUS',
160
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',
161
176
  'X_GH_UNAVAILABLE',
162
177
  'X_GH_NOT_AUTHENTICATED',
163
178
  'X_GH_COMMAND_FAILED',
@@ -258,6 +273,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
258
273
  X_DB_SCHEMA_UNMIGRATED: 'an entity declaration no migration recorded',
259
274
  X_DB_SCHEMA_UNDECLARED: 'a migration records schema no entity declares',
260
275
  X_MIGRATION_UNGENERATABLE: 'this migration holds SQL no declaration carries and does not say so',
276
+ X_QUERY_SUBSCRIBES_UNKNOWN: 'a live query subscribes to a table no entity declares',
261
277
  X_DB_MIGRATE_FAILED: 'x db migrate failed',
262
278
  X_DB_BRANCH_FAILED: 'an x db branch step failed',
263
279
  X_DB_STUDIO_FAILED: 'x db studio failed',
@@ -285,6 +301,10 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
285
301
  X_E2E_LOCATOR_EMPTY: 'an e2e locator matched no element',
286
302
  X_E2E_LOCATOR_AMBIGUOUS: 'an e2e locator matched more than one element and was asked to click',
287
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',
288
308
  X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
289
309
  X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
290
310
  X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
@@ -0,0 +1,74 @@
1
+ // Single responsibility: the one source image every generated icon derives from, where the matrix
2
+ // is served, and the renderer that turns one into the other. Its own module so the two things that
3
+ // need it — `dev-assets.ts`, which SERVES the matrix, and `pwa-artifacts.ts`, which NAMES it in the
4
+ // web manifest — can share it without importing each other.
5
+
6
+ // why: Bun exposes no path-join primitive, and `ICON_SOURCE` is app-root-relative, so resolving it
7
+ // against the root is string work no `Bun.file` overload does.
8
+ import { join } from 'node:path';
9
+ import type { IconPlan } from '@ultimat3/pwa';
10
+ import { BuiltinImagePipeline, PwaIconMissingError, planIcons } from '@ultimat3/pwa';
11
+
12
+ /**
13
+ * The one source image every generated icon derives from. `x new` scaffolds it, `x doctor` checks
14
+ * it and this file reads it — one constant, because a second spelling is an app that passes the
15
+ * diagnostic and still serves no icons. PNG, not SVG: core's pipeline decodes PNG and JPEG only.
16
+ */
17
+ export const ICON_SOURCE = 'apps/web/site/icon.png';
18
+
19
+ /** Where `planIcons` writes, and therefore the paths the generated web manifest names. */
20
+ export const ICON_BASE_PATH = '/icons';
21
+
22
+ /**
23
+ * The matrix's whole plan, off one constant pair. One call, so the icons `/icons/*` serves and the
24
+ * icons `manifest.webmanifest` names can never be two different lists.
25
+ */
26
+ export const iconPlan = (): IconPlan =>
27
+ planIcons({ sourceIcon: ICON_SOURCE, outDir: ICON_BASE_PATH });
28
+
29
+ /**
30
+ * Whether the app committed the one file the whole matrix derives from. Read where the answer
31
+ * changes what is EMITTED — a manifest naming twelve icons an app has no source for is twelve 404s
32
+ * in an install prompt, which is the promise-nothing-keeps shape this module's callers exist to
33
+ * close. `x doctor` owns the diagnostic and reports the same condition with `X_PWA_ICON_MISSING`.
34
+ */
35
+ export const hasSourceIcon = (root: string): Promise<boolean> =>
36
+ Bun.file(join(root, ICON_SOURCE)).exists();
37
+
38
+ /**
39
+ * Rendered once per process, not per request: the fourteen matrix entries are pure functions of
40
+ * one source file, and re-encoding a 512px PNG on every hit would be work no caller can observe.
41
+ */
42
+ export function iconRenderer(root: string): (plan: IconPlan, path: string) => Promise<Uint8Array> {
43
+ const pipeline = new BuiltinImagePipeline();
44
+ const rendered = new Map<string, Promise<Uint8Array>>();
45
+ const sourceBytes = async (): Promise<Uint8Array> => {
46
+ const file = Bun.file(join(root, ICON_SOURCE));
47
+ if (!(await file.exists())) {
48
+ throw new PwaIconMissingError(
49
+ `${ICON_SOURCE} does not exist, so every icon the web manifest declares is unbacked and ` +
50
+ 'the app is not installable',
51
+ // The same edit `x doctor` reports for the same condition, in `@ultimat3/pwa`'s own words.
52
+ // `x new` was here and takes an app name, so it could never run inside the broken app.
53
+ `add a 1024x1024 square PNG at ${ICON_SOURCE}`,
54
+ );
55
+ }
56
+ return file.bytes();
57
+ };
58
+ return async (plan, path) => {
59
+ const entry = plan.entries.find((candidate) => candidate.outputPath === path);
60
+ if (entry === undefined) {
61
+ throw new PwaIconMissingError(
62
+ `${path} is not in the icon matrix, so no transform describes it`,
63
+ `request one of ${plan.entries.map((one) => one.outputPath).join(', ')}`,
64
+ );
65
+ }
66
+ const existing = rendered.get(path);
67
+ if (existing !== undefined) return existing;
68
+ const bytes = sourceBytes().then((source) => pipeline.resize(source, entry.transform));
69
+ rendered.set(path, bytes);
70
+ // A failed render must not be remembered — the next request comes after the source was added.
71
+ bytes.catch(() => rendered.delete(path));
72
+ return bytes;
73
+ };
74
+ }
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,
@@ -72,6 +99,7 @@ export { testCommand } from './cmd-test';
72
99
  export { runVerify, VERIFY_STEPS, verifyCommand, verifyStepNames } from './cmd-verify';
73
100
  export type { CliCommand, CommandContext } from './command';
74
101
  export { failed, ok } from './command';
102
+ export { acceptCreatedTables, createdTables } from './db-accept-created';
75
103
  export type { BranchRow, BranchSubcommand } from './db-branch';
76
104
  export {
77
105
  BRANCH_SUBCOMMANDS,
@@ -83,11 +111,11 @@ export {
83
111
  } from './db-branch';
84
112
  export type { GeneratedFiles, GenerateMigrationOptions, GenerateOutcome } from './db-generate';
85
113
  export { generateAppMigration, migrationSql } from './db-generate';
114
+ export type { SubscribingQuery } from './db-subscribes';
115
+ export { QuerySubscribesUnknownError, replicaIdentityTables } from './db-subscribes';
86
116
  export type { AssetRoutesOptions } from './dev-assets';
87
117
  export {
88
118
  assetRoutes,
89
- ICON_BASE_PATH,
90
- ICON_SOURCE,
91
119
  MEDIA_BASE_PATH,
92
120
  } from './dev-assets';
93
121
  export type { DevDashboardInput, DevStatus } from './dev-dashboard';
@@ -234,6 +262,13 @@ export {
234
262
  } from './framework-schema';
235
263
  export type { Guard } from './guards';
236
264
  export { findingProblem, GUARD_DIR, guardFindings, guardPaths } from './guards';
265
+ export {
266
+ hasSourceIcon,
267
+ ICON_BASE_PATH,
268
+ ICON_SOURCE,
269
+ iconPlan,
270
+ iconRenderer,
271
+ } from './icon-assets';
237
272
  // The island bundler, and only its entry point. An island is the one module Ultimate ships to a
238
273
  // browser, so an app has to be able to build one to TEST one — `mountIsland` from
239
274
  // `@ultimat3/testing` takes this function as its `build` parameter (issue #260). `discoverIslands`,
@@ -274,6 +309,13 @@ export type { CommandSpec, FlagSpec, ParsedArgs } from './parse';
274
309
  export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from './parse';
275
310
  export type { PrerenderedPage, PrerenderOptions, PrerenderReport } from './prerender';
276
311
  export { DEFAULT_ORIGIN, isPrerenderable, prerenderSite } from './prerender';
312
+ export type { PwaArtifacts } from './pwa-artifacts';
313
+ export {
314
+ loadPwaArtifacts,
315
+ pwaManifestRoute,
316
+ WEB_MANIFEST_PATH,
317
+ writePwaIcons,
318
+ } from './pwa-artifacts';
277
319
  export { COMMANDS, cliVersion, commandFor, SPECS } from './registry';
278
320
  export type { SchemaDifference, SchemaDirection, SchemaPart } from './schema-diff';
279
321
  export { diffDeclaredSchema } from './schema-diff';
@@ -323,8 +365,8 @@ export type { TestCounts } from './test-counts';
323
365
  export { countsOf } from './test-counts';
324
366
  export type { TestFile } from './test-select';
325
367
  export { belongsToType, discoverTests, sampleFiles } from './test-select';
326
- export type { ReproduceOptions, RunShardsOptions, Shard } from './test-shards';
327
- export { planShards, reproduceFor, runShards, shardArgs } from './test-shards';
368
+ export type { ReproduceOptions, RunShardsOptions } from './test-shards';
369
+ export { filesIn, reproduceFor, runShards, testArgs } from './test-shards';
328
370
  export { availableCpus, defaultWorkers, WORKER_CEILING } from './test-workers';
329
371
  export type {
330
372
  CodeFixSite,
@@ -144,4 +144,11 @@ export const readinessProbe = (selector: string): string =>
144
144
  // Children OR text: a component that renders one text node has painted, and one that mounted
145
145
  // and rendered nothing is the silence a non-zero box would otherwise read as success.
146
146
  'filled:box?(box.children.length>0||(box.textContent||"").trim().length>0):false,' +
147
- 'box:{x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)}};})()';
147
+ 'box:{x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)},' +
148
+ // The box is VIEWPORT coordinates — what `getBoundingClientRect()` answers — and a capture clip
149
+ // is PAGE coordinates. They agree only while the page is at the origin, which is the one case a
150
+ // harness happens to be in and is not a rule anything enforces: a state whose component sits
151
+ // below the fold scrolls, and a clip taken from the raw rect then crops the wrong band with
152
+ // nothing to report it. The offset is returned rather than added here so `box` keeps meaning
153
+ // exactly what the verdict already publishes.
154
+ 'scroll:{x:Math.round(window.scrollX||0),y:Math.round(window.scrollY||0)}};})()';
@@ -6,7 +6,7 @@
6
6
  // why: no Bun native joins a path; `Bun.write` and `Bun.file` both take one already joined.
7
7
  import { join } from 'node:path';
8
8
  import { finiteCount } from '@ultimat3/core';
9
- import type { ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
9
+ import type { CaptureClip, ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
10
10
  import { systemScrapeClock } from '@ultimat3/scraping';
11
11
  import type { IslandShotTarget, IslandStatesManifest, IslandViewport } from '@ultimat3/testing';
12
12
  import { islandShotTargets, islandStatesFile } from '@ultimat3/testing';
@@ -139,6 +139,21 @@ export function photographFault(
139
139
  return undefined;
140
140
  }
141
141
 
142
+ /**
143
+ * The capture rectangle for a readiness answer, in PAGE coordinates.
144
+ *
145
+ * `seen` is non-null and its box has area by the time this is reached — `photographFault` refuses
146
+ * both above, and it refuses them BEFORE the shutter for exactly this reason: a zero-area clip is
147
+ * `X_SCRAPE_CAPTURE_CLIP_EMPTY` from the port, which is a worse report of the same fault than
148
+ * "rendered nothing". The `?? 0` pair is the parser's floor and not a second opinion.
149
+ */
150
+ const clipFor = (seen: IslandReadiness | null): CaptureClip => ({
151
+ x: (seen?.box.x ?? 0) + (seen?.scroll.x ?? 0),
152
+ y: (seen?.box.y ?? 0) + (seen?.scroll.y ?? 0),
153
+ width: seen?.box.width ?? 0,
154
+ height: seen?.box.height ?? 0,
155
+ });
156
+
142
157
  const hostFix = (target: IslandShotTarget): string =>
143
158
  `in ${islandStatesFile(target.island)} set island to a path that exports mount(el, props)`;
144
159
 
@@ -171,6 +186,17 @@ async function captureOne(
171
186
  timeoutMs: options.timeoutMs,
172
187
  });
173
188
  const page = session.page;
189
+ // BEFORE the navigation, so the first paint already has it: `prefers-color-scheme` is a live
190
+ // media query, and the theme a component resolves on mount is the one it will keep.
191
+ //
192
+ // This is the INPUT and the harness's `data-theme` attribute is the OUTCOME, and both are set
193
+ // deliberately. The attribute is right for a component that READS a theme it does not own; the
194
+ // preference is the only thing that reaches one that RESOLVES its own. `examples/dummy`'s
195
+ // settings island is the second kind — its state's `theme` prop is `'system'`, so on mount it
196
+ // DELETES the attribute the harness set, both pictures fall through to `:root`, and the two
197
+ // came back byte-identical with the same md5 (issue #338). Re-setting the attribute after
198
+ // readiness is not the repair: it photographs a state the component would never reach.
199
+ await page.colorScheme(target.theme);
174
200
  await page.goto(url, { timeout: options.timeoutMs });
175
201
  const expression = readinessProbe(target.target ?? '[data-x-island]');
176
202
  const probe = (): Promise<IslandReadiness | null> =>
@@ -202,9 +228,16 @@ async function captureOne(
202
228
  ...fault,
203
229
  });
204
230
  }
205
- // Never `fullPage`: the frame is the state's own declared viewport, and a full-page capture
206
- // would grow with whatever the component scrolled.
207
- const bytes = await page.screenshot({ fullPage: false });
231
+ // The COMPONENT, not the viewport it happens to sit in — the crop this feature was designed
232
+ // around, and which nothing passed until 2026-08-26 (issue #338). The rectangle is the
233
+ // readiness probe's own box, which is the crop target the manifest declared, translated from
234
+ // the DOM's viewport coordinates into the page coordinates a capture clip is in.
235
+ //
236
+ // The clip ALONE. `fullPage: false` beside it is accepted — `assertCaptureFraming` refuses only
237
+ // `=== true`, and `cdp-target.ts` sends `{ clip }` and nothing else either way, so all four
238
+ // pictures really were written with the pair — but it is a field that says nothing: the two
239
+ // are exclusive, and spelling out the default of the one you did not ask for reads as a choice.
240
+ const bytes = await page.screenshot({ clip: clipFor(seen) });
208
241
  if (bytes.byteLength < floor) {
209
242
  throw new IslandUnphotographableError({
210
243
  island: target.island,