@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.
- package/CLAUDE.md +194 -17
- package/package.json +30 -30
- package/src/app-root.ts +22 -1
- package/src/cdp-browser.ts +100 -0
- package/src/cdp-connection.ts +211 -0
- package/src/cdp-e2e-page.ts +209 -0
- package/src/cdp-errors.ts +56 -0
- package/src/cdp-launch.ts +130 -0
- package/src/cmd-dev.ts +28 -2
- package/src/cmd-doctor.ts +1 -1
- package/src/cmd-test.ts +20 -9
- package/src/compile-externals.ts +11 -4
- package/src/db-accept-created.ts +207 -0
- package/src/db-generate.ts +18 -1
- package/src/db-subscribes.ts +81 -0
- package/src/db-ungeneratable.ts +14 -2
- package/src/dev-assets.ts +19 -54
- package/src/dev-notify-retention.ts +69 -0
- package/src/dev-purge.ts +47 -2
- package/src/dev-render.ts +20 -4
- package/src/dev-replicator.ts +19 -1
- package/src/dev-runtime.ts +12 -3
- package/src/dev-services.ts +8 -0
- package/src/e2e-driver.ts +35 -17
- package/src/e2e-page.ts +15 -3
- package/src/error-codes.ts +20 -0
- package/src/icon-assets.ts +74 -0
- package/src/index.ts +46 -4
- package/src/island-harness-script.ts +8 -1
- package/src/island-shot.ts +37 -4
- package/src/island-verdict.ts +16 -4
- package/src/mcp-errors.ts +15 -0
- package/src/messages.ts +5 -1
- package/src/prerender.ts +41 -1
- package/src/pwa-artifacts.ts +230 -0
- package/src/serve.ts +24 -1
- package/src/static-report.ts +46 -3
- package/src/sw-artifacts.ts +162 -0
- package/src/sw-routes.ts +53 -0
- package/src/templates/naming.ts +11 -0
- package/src/templates/scaffold-app.ts +58 -7
- package/src/templates/scaffold-repo.ts +17 -1
- package/src/test-shards.ts +150 -116
- package/src/ts-scan.ts +6 -1
- 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
|
|
2
|
-
//
|
|
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 (
|
|
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(
|
package/src/dev-replicator.ts
CHANGED
|
@@ -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 =
|
|
82
|
+
const entities = replicatedRelations();
|
|
65
83
|
if (entities.length === 0) throw noEntitiesRefusal();
|
|
66
84
|
|
|
67
85
|
const selection = selectChangeFeed(options.env, { entities });
|
package/src/dev-runtime.ts
CHANGED
|
@@ -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
|
|
331
|
-
//
|
|
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({
|
|
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
|
package/src/dev-services.ts
CHANGED
|
@@ -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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* All three are genuinely out of reach of the shipped port, not merely unimplemented:
|
|
25
|
-
* `CdpPageLike` (`packages/scraping/src/cdp-port.ts`) declares twelve methods and none of them is
|
|
26
|
-
* `setOfflineMode`, and a new build id is a fact about the SERVER, which no page port has ever
|
|
27
|
-
* been able to speak for.
|
|
20
|
+
* A member this driver cannot build is a REFUSAL, never a no-op. A fixture that silently did
|
|
21
|
+
* nothing would make the assertion after it read as proof: `offline()` followed by "the fallback
|
|
22
|
+
* rendered" is the app's ONLINE page passing an offline test.
|
|
28
23
|
*/
|
|
29
24
|
const refuse =
|
|
30
25
|
(name: string, needs: string): (() => Promise<void>) =>
|
|
31
26
|
() =>
|
|
32
27
|
Promise.reject(new FixtureUnavailableError({ name, needs }));
|
|
33
28
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
29
|
+
/**
|
|
30
|
+
* `offline()`/`online()` FORWARD, `As of 2026-08-27`. They refused until then on a reason the tree
|
|
31
|
+
* contradicted on the day it was written: this file said `CdpPageLike`
|
|
32
|
+
* (`packages/scraping/src/cdp-port.ts`) "declares twelve methods and none of them is
|
|
33
|
+
* `setOfflineMode`". It declares it at line 71 — optional, guarded, with a coded
|
|
34
|
+
* `X_NOT_IMPLEMENTED` in `cdp-target.ts` for a launcher that lacks it — and `page-over-target.ts`
|
|
35
|
+
* exposes it as `ScrapePage.offline()`. All of that landed in **the same commit as the comment**
|
|
36
|
+
* (#351), so the refusal was never true, and it is the reason issue #390 records a real browser
|
|
37
|
+
* check as out of reach.
|
|
38
|
+
*
|
|
39
|
+
* Optional on `E2eBrowserPage` rather than required, for the reason `CdpPageLike` gives about the
|
|
40
|
+
* same method: this port is the shape of somebody ELSE's object, and a six-line test double must
|
|
41
|
+
* still satisfy it. Absent, the refusal stands — and now it names the method the double is missing
|
|
42
|
+
* rather than a capability the framework does not have.
|
|
43
|
+
*/
|
|
44
|
+
const networkFixtures = (browser: E2eBrowserPage): Pick<E2eFixtures, 'offline' | 'online'> => {
|
|
45
|
+
const setOffline = browser.offline?.bind(browser);
|
|
46
|
+
if (setOffline === undefined) {
|
|
47
|
+
const needs =
|
|
48
|
+
"a page whose driver implements offline(enabled) — @ultimat3/scraping's ScrapePage does; a hand-rolled E2eBrowserPage may not";
|
|
49
|
+
return { offline: refuse('offline', needs), online: refuse('online', needs) };
|
|
50
|
+
}
|
|
51
|
+
return { offline: () => setOffline(true), online: () => setOffline(false) };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** What `e2eTest` hands its body: a real page, the network condition, and one honest refusal. */
|
|
55
|
+
export const e2eFixtures = (page: PageLike, browser: E2eBrowserPage): E2eFixtures => ({
|
|
36
56
|
page,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
),
|
|
41
|
-
online: refuse('online', 'the same CDP method offline() needs, in order to undo it'),
|
|
57
|
+
...networkFixtures(browser),
|
|
58
|
+
// The one that is still genuinely out of reach, and it is not a port gap: a new build id is a
|
|
59
|
+
// fact about the SERVER, which no page port has ever been able to speak for.
|
|
42
60
|
update: refuse(
|
|
43
61
|
'update',
|
|
44
62
|
'a second build served under a new immutable build id, which is a server fact',
|
|
@@ -65,7 +83,7 @@ export function installE2eDriver(options: E2eDriverOptions): () => void {
|
|
|
65
83
|
const page = e2ePage(options);
|
|
66
84
|
defineFixtures({ page: () => page });
|
|
67
85
|
useE2eDriver((name, body: E2eBody) => {
|
|
68
|
-
bunTest(name, () => body(e2eFixtures(page)));
|
|
86
|
+
bunTest(name, () => body(e2eFixtures(page, options.page)));
|
|
69
87
|
});
|
|
70
88
|
return () => {
|
|
71
89
|
// Both halves, because both were installed. Putting the DECLARATION back — rather than
|
package/src/e2e-page.ts
CHANGED
|
@@ -10,15 +10,27 @@ import { e2eLocator } from './e2e-locator';
|
|
|
10
10
|
import type { E2eSelection } from './e2e-selection';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* What this adapter needs of a browser: four members, every one of them on `ScrapePage`.
|
|
14
|
-
* structurally rather than as `ScrapePage` so a test can stand one up in six lines — the
|
|
15
|
-
* bargain `cdp-port.ts` makes about puppeteer, one layer up.
|
|
13
|
+
* What this adapter needs of a browser: four required members, every one of them on `ScrapePage`.
|
|
14
|
+
* Declared structurally rather than as `ScrapePage` so a test can stand one up in six lines — the
|
|
15
|
+
* same bargain `cdp-port.ts` makes about puppeteer, one layer up.
|
|
16
16
|
*/
|
|
17
17
|
export interface E2eBrowserPage {
|
|
18
18
|
url(): string;
|
|
19
19
|
goto(url: string, options?: { readonly timeout?: number | undefined }): Promise<unknown>;
|
|
20
20
|
evaluate(expression: string): Promise<unknown>;
|
|
21
21
|
click(selector: string): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* The browser's own network condition, which `E2eFixtures.offline()`/`online()` forward to.
|
|
24
|
+
* `ScrapePage` has it (`page-over-target.ts`), reaching `CdpPageLike.setOfflineMode` through
|
|
25
|
+
* `cdp-target.ts`'s guard.
|
|
26
|
+
*
|
|
27
|
+
* OPTIONAL for the reason the four above are structural: this port is the shape of somebody
|
|
28
|
+
* ELSE's object, and requiring it would cost every six-line double a type error for a capability
|
|
29
|
+
* a test that never goes offline does not need. Absent, `e2e-driver.ts` keeps refusing by name —
|
|
30
|
+
* a coded refusal, never a silent no-op, because an `offline()` that did nothing would let the
|
|
31
|
+
* app's ONLINE page pass an offline test.
|
|
32
|
+
*/
|
|
33
|
+
offline?(enabled: boolean): Promise<void>;
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
export interface E2ePageOptions {
|
package/src/error-codes.ts
CHANGED
|
@@ -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
|
|
327
|
-
export {
|
|
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)}};})()';
|
package/src/island-shot.ts
CHANGED
|
@@ -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
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
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,
|