@ultimat3/cli 6.0.0 → 7.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 +50 -4
- package/package.json +25 -24
- package/src/affected.ts +320 -0
- package/src/browser-launcher.ts +109 -0
- package/src/ci-log.ts +0 -0
- package/src/ci-runs.ts +179 -0
- package/src/cmd-affected.ts +109 -0
- package/src/cmd-build.ts +29 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-pr.ts +308 -0
- package/src/cmd-shot.ts +320 -0
- package/src/cmd-test.ts +96 -7
- package/src/error-codes.ts +16 -0
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/index.ts +37 -0
- package/src/island-bundle.ts +62 -3
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/mcp-errors.ts +9 -0
- package/src/messages.ts +64 -0
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/registry.ts +8 -0
- package/src/shot-verdict.ts +337 -0
- package/src/static-report.ts +219 -0
- package/src/templates/index.ts +1 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +129 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +20 -41
- package/src/templates/route.ts +14 -1
- package/src/templates/scaffold-app.ts +17 -3
- package/src/templates/scaffold-db-package.ts +32 -1
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +11 -1
- package/src/workspace-graph.ts +241 -0
package/src/prerender.ts
CHANGED
|
@@ -14,13 +14,19 @@ import { measureDocumentJs, writeBuildStats } from './budgets';
|
|
|
14
14
|
import { routeDocument } from './dev-render';
|
|
15
15
|
import type { IslandBundle } from './island-bundle';
|
|
16
16
|
import { buildIslands, writeIslands } from './island-bundle';
|
|
17
|
+
import type { SkippedRoute } from './static-report';
|
|
18
|
+
import { skippedRoute, skipReasonFor, writeStaticReport } from './static-report';
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
|
-
* `static` only. `isr` revalidates and `ssr`/`stream
|
|
20
|
-
*
|
|
21
|
-
*
|
|
21
|
+
* `static` only. `isr` revalidates and `ssr`/`stream` need a process, so writing any of them to
|
|
22
|
+
* disk would publish a page whose staleness nothing can correct — and the route already declared
|
|
23
|
+
* which of the four it is.
|
|
24
|
+
*
|
|
25
|
+
* DERIVED from `skipReasonFor`, never a second `=== 'static'`: the answer and the reason reported
|
|
26
|
+
* beside it are one decision, and two copies of it is how a route came to be dropped silently.
|
|
22
27
|
*/
|
|
23
|
-
export const isPrerenderable = (entry: RouteEntry): boolean =>
|
|
28
|
+
export const isPrerenderable = (entry: RouteEntry): boolean =>
|
|
29
|
+
skipReasonFor({ surface: entry.surface, render: entry.config.render }) === null;
|
|
24
30
|
|
|
25
31
|
export interface PrerenderOptions {
|
|
26
32
|
readonly root: string;
|
|
@@ -30,6 +36,8 @@ export interface PrerenderOptions {
|
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export interface PrerenderedPage {
|
|
39
|
+
/** The DECLARED route, `/blog/:slug` — one route can write many pages, and the report groups them. */
|
|
40
|
+
readonly route: string;
|
|
33
41
|
readonly path: string;
|
|
34
42
|
/** Relative to `out`, POSIX, as `renderStatic` computed it. */
|
|
35
43
|
readonly file: string;
|
|
@@ -47,8 +55,13 @@ export interface PrerenderReport {
|
|
|
47
55
|
readonly out: string;
|
|
48
56
|
readonly buildId: string;
|
|
49
57
|
readonly pages: readonly PrerenderedPage[];
|
|
50
|
-
/**
|
|
51
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Every declared route that wrote no file, WITH the cause. A bare path list was the whole of
|
|
60
|
+
* #242: `.x/static/` held a partial site, the report said only which paths were missing, and a
|
|
61
|
+
* screenshot tool pointed at the directory filed "the island did not mount" against a route that
|
|
62
|
+
* had never been in the artifact. The reason is what tells an author whether an edit exists.
|
|
63
|
+
*/
|
|
64
|
+
readonly skipped: readonly SkippedRoute[];
|
|
52
65
|
/**
|
|
53
66
|
* Routes whose budget this build could not weigh, with the reason. `X_BUDGET_UNMEASURED` is what
|
|
54
67
|
* the gate then reports for each; this is the half that says WHY, which a per-route finding read
|
|
@@ -57,6 +70,8 @@ export interface PrerenderReport {
|
|
|
57
70
|
readonly unmeasured: readonly UnmeasuredRoute[];
|
|
58
71
|
/** Where the measured stats landed, for the `budgets` gate step to read. */
|
|
59
72
|
readonly stats: string;
|
|
73
|
+
/** Where the emitted/skipped inventory landed, for `x build --target static` to read back. */
|
|
74
|
+
readonly report: string;
|
|
60
75
|
/** Client entries emitted, one chunk each. Reported so "which JS shipped?" needs no unzip. */
|
|
61
76
|
readonly islands: readonly string[];
|
|
62
77
|
}
|
|
@@ -104,7 +119,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
104
119
|
const buildId = (await appManifest(options.root)).manifest.buildId;
|
|
105
120
|
const origin = options.origin ?? DEFAULT_ORIGIN;
|
|
106
121
|
const pages: PrerenderedPage[] = [];
|
|
107
|
-
const skipped:
|
|
122
|
+
const skipped: SkippedRoute[] = [];
|
|
108
123
|
const routes: RouteStats[] = [];
|
|
109
124
|
const unmeasured: UnmeasuredRoute[] = [];
|
|
110
125
|
|
|
@@ -115,8 +130,10 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
115
130
|
await writeIslands(islands, options.out);
|
|
116
131
|
|
|
117
132
|
for (const entry of routeEntries()) {
|
|
118
|
-
|
|
119
|
-
|
|
133
|
+
const facts = { surface: entry.surface, render: entry.config.render, route: entry.path };
|
|
134
|
+
const reason = skipReasonFor(facts);
|
|
135
|
+
if (reason !== null) {
|
|
136
|
+
skipped.push(skippedRoute(facts, reason));
|
|
120
137
|
if (!declaresBudget(entry)) continue;
|
|
121
138
|
// Non-fatal, and that is deliberate: an ssr page's `load` may want a request, a session or a
|
|
122
139
|
// database this build does not have, and a `x build --target static` that started failing on
|
|
@@ -152,10 +169,24 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
152
169
|
),
|
|
153
170
|
{ buildId },
|
|
154
171
|
);
|
|
172
|
+
// `enumeratePrerender` answers `[]` for a dynamic route with no `prerender()`, so a
|
|
173
|
+
// `render: 'static'` route with a param writes nothing and used to be reported NOWHERE — past
|
|
174
|
+
// the skip branch by its mode, absent from `pages` by its zero artifacts. A route in neither
|
|
175
|
+
// list is the defect this report exists to close, wearing its other shape.
|
|
176
|
+
if (artifacts.length === 0) {
|
|
177
|
+
skipped.push(skippedRoute(facts, 'no-prerender-paths'));
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
155
180
|
for (const artifact of artifacts) {
|
|
156
181
|
const file = join(options.out, artifact.outputPath);
|
|
157
182
|
const bytes = await Bun.write(file, artifact.html);
|
|
158
|
-
pages.push({
|
|
183
|
+
pages.push({
|
|
184
|
+
route: entry.path,
|
|
185
|
+
path: artifact.path,
|
|
186
|
+
file: artifact.outputPath,
|
|
187
|
+
hash: artifact.hash,
|
|
188
|
+
bytes,
|
|
189
|
+
});
|
|
159
190
|
// Measured from the document that was just written, so the `budgets` step compares a
|
|
160
191
|
// declared budget against bytes that exist on disk rather than against a graph's estimate.
|
|
161
192
|
const measured = await measureDocumentJs(artifact.html, options.out);
|
|
@@ -168,6 +199,16 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
168
199
|
}
|
|
169
200
|
}
|
|
170
201
|
const stats = await writeBuildStats(options.root, { routes });
|
|
202
|
+
// Written LAST and by the same call that writes the stats, so an app whose `prerender.ts` does
|
|
203
|
+
// not reach `prerenderSite` produces neither — and `x verify`'s `budgets` step already reds that
|
|
204
|
+
// app with `X_BUDGET_UNMEASURED`, which is why this side needs no second code of its own.
|
|
205
|
+
const report = await writeStaticReport(options.root, {
|
|
206
|
+
target: 'static',
|
|
207
|
+
out: options.out,
|
|
208
|
+
buildId,
|
|
209
|
+
emitted: pages.map((page) => ({ route: page.route, path: page.path, file: page.file })),
|
|
210
|
+
skipped,
|
|
211
|
+
});
|
|
171
212
|
return {
|
|
172
213
|
out: options.out,
|
|
173
214
|
buildId,
|
|
@@ -175,6 +216,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
175
216
|
skipped,
|
|
176
217
|
unmeasured,
|
|
177
218
|
stats,
|
|
219
|
+
report,
|
|
178
220
|
islands: islands.chunks.map((chunk) => chunk.file),
|
|
179
221
|
};
|
|
180
222
|
}
|
package/src/registry.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// The command registry: the one list the parser, the help catalogue and the dispatcher all read.
|
|
2
2
|
// A command that is not here does not exist — there is no second place to register one.
|
|
3
3
|
|
|
4
|
+
import { affectedCommand } from './cmd-affected';
|
|
4
5
|
import { buildCommand } from './cmd-build';
|
|
6
|
+
import { ciCommand } from './cmd-ci';
|
|
5
7
|
import { dbCommand } from './cmd-db';
|
|
6
8
|
import { deployCommand } from './cmd-deploy';
|
|
7
9
|
import { devCommand } from './cmd-dev';
|
|
@@ -19,9 +21,11 @@ import { mcpCommand } from './cmd-mcp';
|
|
|
19
21
|
import { newCommand } from './cmd-new';
|
|
20
22
|
import { plannedCommands } from './cmd-planned';
|
|
21
23
|
import { policyCommand } from './cmd-policy';
|
|
24
|
+
import { prCommand } from './cmd-pr';
|
|
22
25
|
import { actionsCommand, entitiesCommand, queriesCommand } from './cmd-registries';
|
|
23
26
|
import { routesCommand } from './cmd-routes';
|
|
24
27
|
import { secretsCommand } from './cmd-secrets';
|
|
28
|
+
import { shotCommand } from './cmd-shot';
|
|
25
29
|
import { tasksCommand } from './cmd-tasks';
|
|
26
30
|
import { testCommand } from './cmd-test';
|
|
27
31
|
import { verifyCommand } from './cmd-verify';
|
|
@@ -69,6 +73,10 @@ const CORE: readonly CliCommand[] = [
|
|
|
69
73
|
errorsCommand,
|
|
70
74
|
docsCommand,
|
|
71
75
|
fixCommand,
|
|
76
|
+
affectedCommand,
|
|
77
|
+
shotCommand,
|
|
78
|
+
prCommand,
|
|
79
|
+
ciCommand,
|
|
72
80
|
];
|
|
73
81
|
|
|
74
82
|
/**
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// What a shot CLAIMS, and what it refuses to claim: the verdict `x shot` writes beside the
|
|
2
|
+
// picture, built from plain values so every rule here is testable with no browser, no dev server
|
|
3
|
+
// and no `ParsedArgs` — the `cmd-jobs.ts` / `jobs-report.ts` split, repeated.
|
|
4
|
+
|
|
5
|
+
import { probeImage } from '@ultimat3/core';
|
|
6
|
+
import { ISLAND_FAILED_ATTRIBUTE, ISLAND_MOUNTED_ATTRIBUTE } from '@ultimat3/render';
|
|
7
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
+
import { t, validate } from '@ultimat3/schema';
|
|
9
|
+
import type { ConsoleLine, NetworkEntry, PageError } from '@ultimat3/scraping';
|
|
10
|
+
import { msg } from './messages';
|
|
11
|
+
import type { JsonValue } from './output';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The one expression that counts islands, and it counts TWO different facts because they are two
|
|
15
|
+
* different facts. `data-x-island` is emitted by `emitIslandAttributes` for every island on the
|
|
16
|
+
* page including `hydrate: 'never'`, so its presence means "the server rendered an island here"
|
|
17
|
+
* and nothing about the client. `el.__x` is the boot promise `hydrateRuntime`'s prelude assigns
|
|
18
|
+
* (`el.__x=import(e).then(…)`), so it means "the runtime asked for this island's chunk" — still
|
|
19
|
+
* not "mount() resolved", which nothing in the DOM records today.
|
|
20
|
+
*
|
|
21
|
+
* An expression, never a closure: `CdpPageLike.evaluate` takes the string form only.
|
|
22
|
+
*/
|
|
23
|
+
/** Every key this command renders. `msg()` answers `⟦key⟧` for a miss, which no build can see. */
|
|
24
|
+
export const SHOT_MESSAGE_KEYS = [
|
|
25
|
+
'cli.shot.ok',
|
|
26
|
+
'cli.shot.errors',
|
|
27
|
+
'cli.shot.redirected',
|
|
28
|
+
'cli.shot.picture',
|
|
29
|
+
'cli.shot.verdict',
|
|
30
|
+
'cli.shot.server.booted',
|
|
31
|
+
'cli.shot.server.reused',
|
|
32
|
+
'cli.shot.canvas',
|
|
33
|
+
'cli.shot.canvasUnreadable',
|
|
34
|
+
'cli.shot.islands',
|
|
35
|
+
'cli.shot.islandsUnknown',
|
|
36
|
+
'cli.shot.network',
|
|
37
|
+
'cli.shot.console',
|
|
38
|
+
'cli.shot.threw',
|
|
39
|
+
'cli.shot.pageError',
|
|
40
|
+
'cli.shot.blind.status',
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Four facts, not one, because they are genuinely different and conflating them is what makes a
|
|
45
|
+
* screenshot tool lie. `declared` is server-rendered and says nothing about the client.
|
|
46
|
+
* `booted` means the runtime CALLED `import()` — set before `mount()` settles, and legitimately
|
|
47
|
+
* absent for a `visible` or `interaction` island nothing has scrolled to or clicked. `mounted` and
|
|
48
|
+
* `failed` are the outcome, marked by the prelude itself when the mount promise settles.
|
|
49
|
+
*
|
|
50
|
+
* The attribute names are INTERPOLATED from `@ultimat3/render`'s own exports rather than typed
|
|
51
|
+
* here: this probe and the runtime that writes the markers have to agree, and a second spelling of
|
|
52
|
+
* `data-x-mounted` would read as "no island mounted" — a clean-looking answer that is wrong.
|
|
53
|
+
*/
|
|
54
|
+
export const ISLAND_PROBE =
|
|
55
|
+
'(function(){var els=document.querySelectorAll("[data-x-island]");var by={};var booted=0;' +
|
|
56
|
+
'var mounted=0;var failed=0;var failures=[];' +
|
|
57
|
+
'for(var i=0;i<els.length;i+=1){var el=els[i];' +
|
|
58
|
+
'var s=el.getAttribute("data-x-hydrate")||"unknown";by[s]=(by[s]||0)+1;' +
|
|
59
|
+
'if(el.__x!==undefined)booted+=1;' +
|
|
60
|
+
`if(el.hasAttribute("${ISLAND_MOUNTED_ATTRIBUTE}"))mounted+=1;` +
|
|
61
|
+
`if(el.hasAttribute("${ISLAND_FAILED_ATTRIBUTE}")){failed+=1;failures.push({` +
|
|
62
|
+
'island:el.getAttribute("data-x-island")||"",' +
|
|
63
|
+
`message:el.getAttribute("${ISLAND_FAILED_ATTRIBUTE}")||''});}}` +
|
|
64
|
+
'return{declared:els.length,booted:booted,mounted:mounted,failed:failed,' +
|
|
65
|
+
'byStrategy:by,failures:failures};})()';
|
|
66
|
+
|
|
67
|
+
export interface IslandCount {
|
|
68
|
+
/** `[data-x-island]` in the served DOM — server-rendered, whatever the client then did. */
|
|
69
|
+
readonly declared: number;
|
|
70
|
+
/** Islands whose chunk the hydration runtime asked for. See `ISLAND_PROBE` for the distance. */
|
|
71
|
+
readonly booted: number;
|
|
72
|
+
/** Islands whose `mount()` RESOLVED. This is the one that answers "does the page work". */
|
|
73
|
+
readonly mounted: number;
|
|
74
|
+
/** Islands whose `mount()` REJECTED — the case a picture can never show. */
|
|
75
|
+
readonly failed: number;
|
|
76
|
+
/** `data-x-hydrate` value → count, so a `never` island is never read as a failure to boot. */
|
|
77
|
+
readonly byStrategy: Readonly<Record<string, number>>;
|
|
78
|
+
/** Which island threw, and what it said. Empty when `failed` is 0. */
|
|
79
|
+
readonly failures: readonly IslandFailure[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface IslandFailure {
|
|
83
|
+
readonly island: string;
|
|
84
|
+
readonly message: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const islandProbeSchema: StandardSchemaV1<unknown, IslandCount> = t.object({
|
|
88
|
+
declared: t.number,
|
|
89
|
+
booted: t.number,
|
|
90
|
+
mounted: t.number,
|
|
91
|
+
failed: t.number,
|
|
92
|
+
byStrategy: t.record(t.number),
|
|
93
|
+
failures: t.array(t.object({ island: t.string, message: t.string })),
|
|
94
|
+
}) as unknown as StandardSchemaV1<unknown, IslandCount>;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `evaluate()` answers `unknown` on every driver — a page can return anything at all — so the
|
|
98
|
+
* probe's result is PARSED and never cast. `null` for anything that does not fit, because a
|
|
99
|
+
* malformed probe must not be able to take a capture down after the picture was already taken.
|
|
100
|
+
*/
|
|
101
|
+
export function parseIslandProbe(value: unknown): IslandCount | null {
|
|
102
|
+
const result = validate(islandProbeSchema, value);
|
|
103
|
+
return result.issues === undefined ? result.value : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ShotCanvas {
|
|
107
|
+
readonly width: number;
|
|
108
|
+
readonly height: number;
|
|
109
|
+
readonly format: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The picture's own pixel size, read from the header bytes by `@ultimat3/core`'s one image probe —
|
|
114
|
+
* never a viewport number the tool asked for and cannot prove it got. `null` when the bytes are
|
|
115
|
+
* not a decodable image, which is a fact worth reporting rather than an exception worth throwing:
|
|
116
|
+
* the offline drivers answer a PNG signature with no IHDR behind it.
|
|
117
|
+
*/
|
|
118
|
+
export function canvasOf(bytes: Uint8Array): ShotCanvas | null {
|
|
119
|
+
try {
|
|
120
|
+
const info = probeImage(bytes);
|
|
121
|
+
return { width: info.width, height: info.height, format: info.format };
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ShotInput {
|
|
128
|
+
readonly route: string;
|
|
129
|
+
readonly requestedUrl: string;
|
|
130
|
+
readonly finalUrl: string;
|
|
131
|
+
readonly server: 'booted' | 'reused';
|
|
132
|
+
readonly capturedAt: string;
|
|
133
|
+
/** The picture's filename, beside the verdict — a sibling, so the pair moves as one directory. */
|
|
134
|
+
readonly screenshot: string;
|
|
135
|
+
readonly bytes: Uint8Array;
|
|
136
|
+
readonly console: readonly ConsoleLine[];
|
|
137
|
+
/**
|
|
138
|
+
* Uncaught exceptions, which are NOT console lines: a throw calls no console method, so a page
|
|
139
|
+
* whose island exploded can have `console: []`. This is the field the whole command was for —
|
|
140
|
+
* "a picture cannot tell you the island threw".
|
|
141
|
+
*/
|
|
142
|
+
readonly pageErrors: readonly PageError[];
|
|
143
|
+
/** Page errors the ring evicted, so `pageErrors.length` reads as a floor and not a total. */
|
|
144
|
+
readonly pageErrorsDropped: number;
|
|
145
|
+
readonly network: readonly NetworkEntry[];
|
|
146
|
+
readonly networkDropped: number;
|
|
147
|
+
/** `null` when the probe could not run or did not parse. Never a zero standing in for unknown. */
|
|
148
|
+
readonly islands: IslandCount | null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface ShotVerdict extends ShotInput {
|
|
152
|
+
readonly ok: boolean;
|
|
153
|
+
/** The route asked for is not the document photographed. An `auth: 'required'` route's default. */
|
|
154
|
+
readonly redirected: boolean;
|
|
155
|
+
readonly errors: number;
|
|
156
|
+
readonly warnings: number;
|
|
157
|
+
readonly canvas: ShotCanvas | null;
|
|
158
|
+
readonly refused: number;
|
|
159
|
+
/** What this verdict cannot see, stated every time — a `0` whose blind spots are named. */
|
|
160
|
+
readonly blind: readonly string[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* What a shot is blind to, each naming the mechanism rather than apologising. Constant because
|
|
165
|
+
* these are properties of the browser port, not of a run — and in the artifact because `errors: 0`
|
|
166
|
+
* read without them is a claim the tool cannot support.
|
|
167
|
+
*
|
|
168
|
+
* It was three. `pageErrors` and `hydration` both left on 2026-08-21, when `@ultimat3/scraping`
|
|
169
|
+
* learned to capture `pageerror` and the hydration prelude learned to mark a mount's outcome. A
|
|
170
|
+
* blind spot is worth stating while it is true and worth DELETING the moment it is not: a stale
|
|
171
|
+
* one teaches an agent to distrust an answer the tool can now give.
|
|
172
|
+
*/
|
|
173
|
+
export const BLIND_SPOTS = ['cli.shot.blind.status'] as const;
|
|
174
|
+
|
|
175
|
+
const levelCount = (lines: readonly ConsoleLine[], level: ConsoleLine['level']): number =>
|
|
176
|
+
lines.filter((line) => line.level === level).length;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `ok` is three conditions, and every one is something a picture cannot show: nothing on the page
|
|
180
|
+
* logged an error, nothing THREW, and the document photographed is the route that was asked for.
|
|
181
|
+
*
|
|
182
|
+
* The throw is its own clause rather than folded into `errors` because an uncaught exception calls
|
|
183
|
+
* no console method — a page whose island died can log nothing at all, and `errors === 0` would
|
|
184
|
+
* then pass it. A redirect is a failure of the CAPTURE rather than of the app: an agent that
|
|
185
|
+
* photographs the sign-in page and files "the island did not mount" is the outcome this prevents.
|
|
186
|
+
*/
|
|
187
|
+
export function buildVerdict(input: ShotInput): ShotVerdict {
|
|
188
|
+
const errors = levelCount(input.console, 'error');
|
|
189
|
+
return {
|
|
190
|
+
...input,
|
|
191
|
+
ok: errors === 0 && input.pageErrors.length === 0 && input.requestedUrl === input.finalUrl,
|
|
192
|
+
redirected: input.requestedUrl !== input.finalUrl,
|
|
193
|
+
errors,
|
|
194
|
+
warnings: levelCount(input.console, 'warn'),
|
|
195
|
+
canvas: canvasOf(input.bytes),
|
|
196
|
+
refused: input.network.filter((entry) => entry.refused !== undefined).length,
|
|
197
|
+
blind: BLIND_SPOTS.map((key) => msg(key)),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const consoleJson = (lines: readonly ConsoleLine[]): JsonValue =>
|
|
202
|
+
lines.map((line) => ({ level: line.level, text: line.text, at: line.at }));
|
|
203
|
+
|
|
204
|
+
const islandJson = (islands: IslandCount | null): JsonValue =>
|
|
205
|
+
islands === null
|
|
206
|
+
? null
|
|
207
|
+
: {
|
|
208
|
+
declared: islands.declared,
|
|
209
|
+
booted: islands.booted,
|
|
210
|
+
mounted: islands.mounted,
|
|
211
|
+
failed: islands.failed,
|
|
212
|
+
byStrategy: islands.byStrategy,
|
|
213
|
+
failures: islands.failures.map((failure) => ({
|
|
214
|
+
island: failure.island,
|
|
215
|
+
message: failure.message,
|
|
216
|
+
})),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/** The artifact, and the same object `--json` carries under `data.verdict`. One shape, two files. */
|
|
220
|
+
export function verdictJson(verdict: ShotVerdict): JsonValue {
|
|
221
|
+
return {
|
|
222
|
+
ok: verdict.ok,
|
|
223
|
+
route: verdict.route,
|
|
224
|
+
requestedUrl: verdict.requestedUrl,
|
|
225
|
+
finalUrl: verdict.finalUrl,
|
|
226
|
+
redirected: verdict.redirected,
|
|
227
|
+
server: verdict.server,
|
|
228
|
+
capturedAt: verdict.capturedAt,
|
|
229
|
+
screenshot: verdict.screenshot,
|
|
230
|
+
bytes: verdict.bytes.byteLength,
|
|
231
|
+
canvas:
|
|
232
|
+
verdict.canvas === null
|
|
233
|
+
? null
|
|
234
|
+
: {
|
|
235
|
+
width: verdict.canvas.width,
|
|
236
|
+
height: verdict.canvas.height,
|
|
237
|
+
format: verdict.canvas.format,
|
|
238
|
+
},
|
|
239
|
+
console: {
|
|
240
|
+
total: verdict.console.length,
|
|
241
|
+
errors: verdict.errors,
|
|
242
|
+
warnings: verdict.warnings,
|
|
243
|
+
lines: consoleJson(verdict.console),
|
|
244
|
+
},
|
|
245
|
+
// Its own object, never merged into `console`: an uncaught exception calls no console method,
|
|
246
|
+
// and a reader who finds throws under `console` will look for them in the wrong stream.
|
|
247
|
+
pageErrors: {
|
|
248
|
+
total: verdict.pageErrors.length,
|
|
249
|
+
dropped: verdict.pageErrorsDropped,
|
|
250
|
+
thrown: verdict.pageErrors.map((error) => ({
|
|
251
|
+
message: error.message,
|
|
252
|
+
stack: error.stack ?? null,
|
|
253
|
+
at: error.at,
|
|
254
|
+
})),
|
|
255
|
+
},
|
|
256
|
+
islands: islandJson(verdict.islands),
|
|
257
|
+
network: {
|
|
258
|
+
requests: verdict.network.length,
|
|
259
|
+
refused: verdict.refused,
|
|
260
|
+
dropped: verdict.networkDropped,
|
|
261
|
+
},
|
|
262
|
+
blind: [...verdict.blind],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface ShotArtifacts {
|
|
267
|
+
readonly verdict: ShotVerdict;
|
|
268
|
+
/** Absolute path of the picture. */
|
|
269
|
+
readonly image: string;
|
|
270
|
+
/** Absolute path of this verdict on disk. */
|
|
271
|
+
readonly verdictFile: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const consoleLines = (verdict: ShotVerdict): readonly string[] =>
|
|
275
|
+
verdict.console
|
|
276
|
+
.filter((line) => line.level === 'error' || line.level === 'warn')
|
|
277
|
+
.map((line) => msg('cli.shot.console', { level: line.level, text: line.text.slice(0, 200) }));
|
|
278
|
+
|
|
279
|
+
/** Human lines. Every fact here is a fact `--json` carries under `data.verdict`. */
|
|
280
|
+
export function shotLines(artifacts: ShotArtifacts): readonly string[] {
|
|
281
|
+
const verdict = artifacts.verdict;
|
|
282
|
+
const islands = verdict.islands;
|
|
283
|
+
const canvas = verdict.canvas;
|
|
284
|
+
return [
|
|
285
|
+
msg(`cli.shot.server.${verdict.server}`, { url: verdict.finalUrl }),
|
|
286
|
+
canvas === null
|
|
287
|
+
? msg('cli.shot.canvasUnreadable', { bytes: verdict.bytes.byteLength })
|
|
288
|
+
: msg('cli.shot.canvas', { width: canvas.width, height: canvas.height }),
|
|
289
|
+
islands === null
|
|
290
|
+
? msg('cli.shot.islandsUnknown')
|
|
291
|
+
: msg('cli.shot.islands', {
|
|
292
|
+
booted: islands.mounted,
|
|
293
|
+
declared: islands.declared,
|
|
294
|
+
strategies: Object.entries(islands.byStrategy)
|
|
295
|
+
.map(([name, count]) => `${name}=${count}`)
|
|
296
|
+
.join(' '),
|
|
297
|
+
}),
|
|
298
|
+
msg('cli.shot.network', {
|
|
299
|
+
requests: verdict.network.length,
|
|
300
|
+
refused: verdict.refused,
|
|
301
|
+
dropped: verdict.networkDropped,
|
|
302
|
+
}),
|
|
303
|
+
...verdict.pageErrors.map((error) =>
|
|
304
|
+
msg('cli.shot.pageError', {
|
|
305
|
+
message: error.message,
|
|
306
|
+
// The frame that names the island module and line. `message` alone says what went wrong
|
|
307
|
+
// and never where, which is the difference between a report and a lead.
|
|
308
|
+
at: error.stack?.split('\n')[1]?.trim() ?? '',
|
|
309
|
+
}),
|
|
310
|
+
),
|
|
311
|
+
...consoleLines(verdict),
|
|
312
|
+
msg('cli.shot.picture', { path: artifacts.image }),
|
|
313
|
+
msg('cli.shot.verdict', { path: artifacts.verdictFile }),
|
|
314
|
+
];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The one line a reader sees first, and it names the gating fact rather than the file count. */
|
|
318
|
+
export const shotSummary = (verdict: ShotVerdict): string => {
|
|
319
|
+
if (verdict.redirected) {
|
|
320
|
+
return msg('cli.shot.redirected', { route: verdict.route, url: verdict.finalUrl });
|
|
321
|
+
}
|
|
322
|
+
// Ahead of the console count, because a throw is the more severe fact AND the quieter one: an
|
|
323
|
+
// island that died can log nothing at all, so `errors` would report a clean page.
|
|
324
|
+
if (verdict.pageErrors.length > 0) {
|
|
325
|
+
return msg('cli.shot.threw', {
|
|
326
|
+
route: verdict.route,
|
|
327
|
+
thrown: verdict.pageErrors.length,
|
|
328
|
+
first: verdict.pageErrors[0]?.message ?? '',
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (verdict.errors > 0) {
|
|
332
|
+
return msg('cli.shot.errors', { route: verdict.route, errors: verdict.errors });
|
|
333
|
+
}
|
|
334
|
+
// `mounted`, not `booted`: "the runtime asked for the chunk" is not the claim worth making when
|
|
335
|
+
// the DOM can now say the mount RESOLVED.
|
|
336
|
+
return msg('cli.shot.ok', { route: verdict.route, islands: verdict.islands?.mounted ?? 0 });
|
|
337
|
+
};
|