@crouton-kit/capture 1.4.2 → 1.4.10
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/README.md +178 -0
- package/bin/capture +31278 -7312
- package/package.json +3 -1
- package/src/capture.ts +21 -18
- package/src/cdp/args.ts +269 -49
- package/src/cdp/bridge/server.ts +6 -5
- package/src/cdp/bridge/spawn.ts +41 -97
- package/src/cdp/commands/bridge-serve.ts +11 -21
- package/src/cdp/commands/cdp.ts +44 -22
- package/src/cdp/commands/heap/census.ts +61 -0
- package/src/cdp/commands/heap/common.ts +77 -0
- package/src/cdp/commands/heap/diff.ts +92 -0
- package/src/cdp/commands/heap/index.ts +38 -0
- package/src/cdp/commands/heap/objects.ts +42 -0
- package/src/cdp/commands/heap/retainers.ts +52 -0
- package/src/cdp/commands/heap/snapshot.ts +94 -0
- package/src/cdp/commands/lib.ts +9 -17
- package/src/cdp/commands/lighthouse.ts +414 -0
- package/src/cdp/commands/measure/census.ts +2 -1
- package/src/cdp/commands/measure/check.ts +70 -16
- package/src/cdp/commands/measure/diff.ts +1 -2
- package/src/cdp/commands/measure/explain.ts +44 -7
- package/src/cdp/commands/measure/index.ts +24 -32
- package/src/cdp/commands/measure/map-ax.ts +2 -1
- package/src/cdp/commands/measure/map-focus.ts +2 -1
- package/src/cdp/commands/measure/map-layers.ts +5 -3
- package/src/cdp/commands/measure/map-scroll.ts +2 -1
- package/src/cdp/commands/measure/snap.ts +5 -4
- package/src/cdp/commands/measure/sweep.ts +16 -11
- package/src/cdp/commands/measure/text.ts +147 -0
- package/src/cdp/commands/motion/index.ts +10 -18
- package/src/cdp/commands/motion/jank.ts +13 -22
- package/src/cdp/commands/motion/mask.ts +10 -9
- package/src/cdp/commands/motion/rec.ts +228 -26
- package/src/cdp/commands/motion/timeline.ts +1 -1
- package/src/cdp/commands/page/click.ts +15 -5
- package/src/cdp/commands/page/elements.ts +43 -11
- package/src/cdp/commands/page/exec.ts +53 -18
- package/src/cdp/commands/page/index.ts +20 -20
- package/src/cdp/commands/page/inspect.ts +338 -0
- package/src/cdp/commands/page/navigate.ts +83 -38
- package/src/cdp/commands/page/repeat.ts +176 -0
- package/src/cdp/commands/page/scroll.ts +10 -5
- package/src/cdp/commands/page/shot.ts +66 -31
- package/src/cdp/commands/page/type.ts +6 -2
- package/src/cdp/commands/perf/index.ts +32 -0
- package/src/cdp/commands/perf/insights.ts +214 -0
- package/src/cdp/commands/perf/trace.ts +235 -0
- package/src/cdp/commands/perf/vitals.ts +76 -0
- package/src/cdp/commands/session/collectors.ts +54 -0
- package/src/cdp/commands/stub.ts +10 -0
- package/src/cdp/commands/tab/close.ts +2 -2
- package/src/cdp/commands/tab/index.ts +16 -23
- package/src/cdp/commands/tab/launch.ts +1 -1
- package/src/cdp/commands/tab/mock/index.ts +24 -0
- package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
- package/src/cdp/commands/tab/mock/start.ts +65 -0
- package/src/cdp/commands/tab/mock/stop.ts +39 -0
- package/src/cdp/commands/tab/network.ts +22 -13
- package/src/cdp/commands/tab/reset.ts +11 -12
- package/src/cdp/connection.ts +19 -39
- package/src/cdp/detect.ts +27 -7
- package/src/cdp/dispatch.ts +25 -4
- package/src/cdp/domains.ts +2 -2
- package/src/cdp/har-recorder.ts +0 -1
- package/src/cdp/heap-snapshot.ts +647 -0
- package/src/cdp/host/client.ts +22 -0
- package/src/cdp/host/collector.ts +74 -0
- package/src/cdp/host/collectors/heap.ts +79 -0
- package/src/cdp/host/collectors/intercept.ts +247 -0
- package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
- package/src/cdp/host/collectors/trace.ts +108 -0
- package/src/cdp/host/core.ts +217 -0
- package/src/cdp/host/handle.ts +47 -0
- package/src/cdp/host/held-client.ts +91 -0
- package/src/cdp/host/kinds.ts +97 -0
- package/src/cdp/host/lifecycle.ts +123 -0
- package/src/cdp/host/server.ts +88 -0
- package/src/cdp/measure/check.ts +181 -16
- package/src/cdp/measure/collectors/hittest.ts +16 -10
- package/src/cdp/measure/collectors/scroll.ts +15 -7
- package/src/cdp/measure/collectors/style-provenance.ts +11 -0
- package/src/cdp/measure/collectors/styles.ts +4 -4
- package/src/cdp/measure/explain.ts +14 -2
- package/src/cdp/measure/map-layers.ts +52 -8
- package/src/cdp/measure/snapshot.ts +5 -3
- package/src/cdp/measure/sweep.ts +29 -6
- package/src/cdp/measure/text.ts +227 -0
- package/src/cdp/motion/jank.ts +29 -83
- package/src/cdp/motion/mask.ts +18 -9
- package/src/cdp/motion/recorder.ts +86 -731
- package/src/cdp/scope-lock.ts +9 -9
- package/src/cdp/screenshot.ts +51 -21
- package/src/cdp/timing.ts +5 -5
- package/src/cdp/trace-analysis.ts +320 -0
- package/src/cdp/types.ts +31 -1
- package/src/cdp.ts +4 -6
- package/src/har-manager.ts +2 -4
- package/src/interact.ts +50 -23
- package/src/output/artifact.ts +49 -5
- package/src/output/render.ts +60 -9
- package/src/output/selection.ts +6 -0
- package/src/session/artifacts.ts +122 -12
- package/src/session/commands.ts +348 -113
- package/src/session/coordinator.ts +0 -93
- package/src/session/har-redact.ts +29 -23
- package/src/session-context.ts +13 -9
- package/src/cdp/bridge/protocol.ts +0 -224
- package/src/cdp/recorder-client.ts +0 -326
- package/src/cdp/tab-lock.ts +0 -132
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type ParsedArgs } from '../../types.js';
|
|
2
|
+
import { emitResult, fact, formatArtifactList, lineList, text } from '../../../output/render.js';
|
|
3
|
+
import { completionAttrs, loadHeap, resolveHeapRef, resultReference } from './common.js';
|
|
4
|
+
import { selectRecords } from '../../../output/selection.js';
|
|
5
|
+
|
|
6
|
+
const HELP = `capture heap objects <snapshot> --constructor <name> [--limit <N>] [--sort retained|self] — the individual objects of one constructor
|
|
7
|
+
|
|
8
|
+
input:
|
|
9
|
+
<snapshot> heap snapshot id in the active session or an absolute snapshot path (required)
|
|
10
|
+
--constructor <name> required. Exact constructor name as reported by \`capture heap census\` — the id source for \`capture heap retainers\`
|
|
11
|
+
--limit <N> return the top N objects in prose and JSON; default 25
|
|
12
|
+
--sort <key> retained (default) or self
|
|
13
|
+
output: <heap-objects …> — one row per object with its Chrome snapshot object id, node type, self bytes, and retained bytes, carrying the same retained-size qualification as \`census\`; --json mirrors
|
|
14
|
+
effects: read-only — reads the finalized snapshot artifact, never drives the browser`;
|
|
15
|
+
|
|
16
|
+
export function cmdHeapObjects(parsed: ParsedArgs): void {
|
|
17
|
+
if (parsed.help) {
|
|
18
|
+
console.log(HELP);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const ref = resolveHeapRef(parsed.positional[0]);
|
|
22
|
+
const heap = loadHeap(ref);
|
|
23
|
+
const constructor = parsed.constructor!;
|
|
24
|
+
const sort = parsed.sort ?? 'retained';
|
|
25
|
+
const dominators = heap.computeDominators();
|
|
26
|
+
const objects = [] as Array<{ objectId: number; type: string; name: string; selfBytes: number; retainedBytes: number }>;
|
|
27
|
+
for (let index = 0; index < heap.nodeCount; index += 1) {
|
|
28
|
+
const node = heap.nodeAt(index);
|
|
29
|
+
if (node.name === constructor) objects.push({ objectId: node.id, type: node.type, name: node.name, selfBytes: node.selfSize, retainedBytes: dominators.retainedSizes[index] });
|
|
30
|
+
}
|
|
31
|
+
objects.sort((a, b) => (sort === 'self' ? b.selfBytes - a.selfBytes || b.retainedBytes - a.retainedBytes : b.retainedBytes - a.retainedBytes || b.selfBytes - a.selfBytes) || a.objectId - b.objectId);
|
|
32
|
+
const displayed = selectRecords(objects, parsed, 25);
|
|
33
|
+
emitResult({
|
|
34
|
+
tag: 'heap-objects',
|
|
35
|
+
attrs: { heap: ref.id, path: ref.dir, ...completionAttrs(ref.meta), constructor, objects: objects.length, displayed: displayed.length, sort, 'size-qualification': dominators.sizeQualification },
|
|
36
|
+
summary: text`Individual snapshot nodes of the selected constructor. Chrome snapshot object ids are not DOM backend node ids and are not stable across snapshots.`,
|
|
37
|
+
artifacts: formatArtifactList([{ name: 'snapshot.heapsnapshot' }]),
|
|
38
|
+
sections: [fact`${dominators.sizeQualification}`, lineList(displayed.map((object, index) => fact`${index + 1}. object-id=${object.objectId} type=${object.type} self-bytes=${object.selfBytes} retained-bytes=${object.retainedBytes}`))],
|
|
39
|
+
jsonSections: displayed.map(object => ({ ...object, sizeQualification: dominators.sizeQualification })),
|
|
40
|
+
followUp: fact`Use \`capture heap retainers ${resultReference(ref)} --node <object-id>\` with one Chrome snapshot object id from this result.`,
|
|
41
|
+
}, { json: parsed.json });
|
|
42
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type ParsedArgs } from '../../types.js';
|
|
2
|
+
import { emitResult, fact, formatArtifactList, line, lineList, text } from '../../../output/render.js';
|
|
3
|
+
import { completionAttrs, loadHeap, resolveHeapRef, resultReference } from './common.js';
|
|
4
|
+
|
|
5
|
+
const HELP = `capture heap retainers <snapshot> --node <object-id> [--paths <N>] — what is keeping one object alive
|
|
6
|
+
|
|
7
|
+
input:
|
|
8
|
+
<snapshot> heap snapshot id in the active session or an absolute snapshot path (required)
|
|
9
|
+
--node <object-id> required. Chrome snapshot object id from \`capture heap objects\`
|
|
10
|
+
--paths <N> return up to N paths; default 1
|
|
11
|
+
output: <retainers …> — each path from the nearest application-owned retainer to the selected object as an alternating node/edge chain, with each edge's kind and property name, and each node's self bytes under the same size qualification as \`census\`; V8 root plumbing is excluded from path selection; --json mirrors
|
|
12
|
+
effects: read-only — reads the finalized snapshot artifact, never drives the browser`;
|
|
13
|
+
|
|
14
|
+
export function cmdHeapRetainers(parsed: ParsedArgs): void {
|
|
15
|
+
if (parsed.help) {
|
|
16
|
+
console.log(HELP);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const ref = resolveHeapRef(parsed.positional[0]);
|
|
20
|
+
const heap = loadHeap(ref);
|
|
21
|
+
const objectId = Number(parsed.node);
|
|
22
|
+
let targetIndex = -1;
|
|
23
|
+
for (let index = 0; index < heap.nodeCount; index += 1) if (heap.nodeAt(index).id === objectId) { targetIndex = index; break; }
|
|
24
|
+
if (targetIndex < 0) throw new Error(`Heap snapshot ${ref.id} has no node with Chrome snapshot object id ${objectId}.`);
|
|
25
|
+
const paths = heap.applicationRetainingPaths(targetIndex, { maxPaths: parsed.paths ?? 1 });
|
|
26
|
+
const dominators = heap.computeDominators();
|
|
27
|
+
const prosePaths = paths.paths.map((path, index) => {
|
|
28
|
+
const chain = [] as ReturnType<typeof fact>[];
|
|
29
|
+
for (let node = 0; node < path.nodes.length; node += 1) {
|
|
30
|
+
const item = path.nodes[node];
|
|
31
|
+
chain.push(fact`object-id=${item.id} ${item.type} ${item.name} self-bytes=${item.selfSize}`);
|
|
32
|
+
if (node < path.edges.length) {
|
|
33
|
+
const edge = path.edges[node];
|
|
34
|
+
chain.push(fact` --${edge.type}:${String(edge.name)}--> `);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return line(fact`${index + 1}. `, ...chain);
|
|
38
|
+
});
|
|
39
|
+
emitResult({
|
|
40
|
+
tag: 'retainers',
|
|
41
|
+
attrs: { heap: ref.id, path: ref.dir, ...completionAttrs(ref.meta), node: objectId, paths: paths.paths.length, truncated: paths.truncated, selection: paths.selection, 'size-qualification': dominators.sizeQualification },
|
|
42
|
+
summary: text`Paths start at the nearest closure, object, or array that retains the selected object, rather than at V8's synthetic roots and handle tables. Other retaining references may exist and are not enumerated unless --paths asks for them.`,
|
|
43
|
+
artifacts: formatArtifactList([{ name: 'snapshot.heapsnapshot' }]),
|
|
44
|
+
sections: [fact`${dominators.sizeQualification}`, ...(prosePaths.length ? [lineList(prosePaths)] : [text`No application-owned retainer was found for this node.`])],
|
|
45
|
+
jsonSections: paths.paths.map(path => ({
|
|
46
|
+
path: path.nodes.map(node => ({ objectId: node.id, type: node.type, name: node.name, selfBytes: node.selfSize })),
|
|
47
|
+
edges: path.edges.map(edge => ({ type: edge.type, name: String(edge.name) })),
|
|
48
|
+
sizeQualification: dominators.sizeQualification,
|
|
49
|
+
})),
|
|
50
|
+
followUp: fact`Use \`capture heap objects ${resultReference(ref)} --constructor <name>\` to select another Chrome snapshot object id.`,
|
|
51
|
+
}, { json: parsed.json });
|
|
52
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { type ParsedArgs } from '../../types.js';
|
|
4
|
+
import { captureError } from '../../../errors.js';
|
|
5
|
+
import { detectCdpPort } from '../../detect.js';
|
|
6
|
+
import { findTabById, openTab } from '../../targets.js';
|
|
7
|
+
import { collectorHostSocketPath, startCollectorHost } from '../../bridge/spawn.js';
|
|
8
|
+
import { sendHostRequest } from '../../host/client.js';
|
|
9
|
+
import { scanCollectorHost, type CollectorHostHandle } from '../../host/handle.js';
|
|
10
|
+
import { stopAndReapCollectorHostAtSessionStop } from '../../host/lifecycle.js';
|
|
11
|
+
import { CAPTURE_ROOT, ensurePrivateDir, registerArtifactRoot } from '../../../session/artifacts.js';
|
|
12
|
+
import { getActiveSession } from '../../../session-context.js';
|
|
13
|
+
import { withSessionLifecycle } from '../../../session/coordinator.js';
|
|
14
|
+
import { emitResult, fact, formatArtifactList, text } from '../../../output/render.js';
|
|
15
|
+
import { completionAttrs, loadHeap, resolveHeapRef, resultReference } from './common.js';
|
|
16
|
+
|
|
17
|
+
const HELP = `capture heap snapshot [url] — take a V8 heap snapshot of the tab's JavaScript heap
|
|
18
|
+
|
|
19
|
+
input:
|
|
20
|
+
[url] navigate to this URL and snapshot it in a one-shot session; without a URL the active session tab is snapshotted in place
|
|
21
|
+
--artifact-dir <path> root for a sessionless heap snapshot bundle
|
|
22
|
+
output: <heap-snapshot …> — the finalized snapshot artifact, its node and edge counts, on-disk bytes, and completion state; --json mirrors
|
|
23
|
+
effects: drives the browser and writes a large artifact under the active session or, when sessionless, a bundle under --artifact-dir; spawns or joins the session's collector host for the duration of one snapshot and releases it immediately. Claims \`heap-snapshot\`; refused while another heap snapshot is streaming, and the refusal names the claim and its holder. Composes with a live trace and with a live mock.`;
|
|
24
|
+
|
|
25
|
+
async function liveHost(sessionDir: string, port: number, targetId: string): Promise<CollectorHostHandle> {
|
|
26
|
+
let scanned = scanCollectorHost(sessionDir);
|
|
27
|
+
if (scanned.classification === 'dead') {
|
|
28
|
+
const reaped = await stopAndReapCollectorHostAtSessionStop(sessionDir);
|
|
29
|
+
if (reaped.status === 'terminal') throw new Error(reaped.error);
|
|
30
|
+
scanned = scanCollectorHost(sessionDir);
|
|
31
|
+
}
|
|
32
|
+
if (scanned.classification === 'unknown' || scanned.classification === 'malformed') throw new Error(`Collector host is ${scanned.classification}.`);
|
|
33
|
+
if (scanned.classification === 'absent') {
|
|
34
|
+
await startCollectorHost(collectorHostSocketPath(sessionDir), port, targetId, sessionDir);
|
|
35
|
+
scanned = scanCollectorHost(sessionDir);
|
|
36
|
+
}
|
|
37
|
+
if (scanned.classification !== 'live' || !scanned.handle) throw new Error('Collector host did not publish a live handle.');
|
|
38
|
+
if (scanned.handle.targetId !== targetId) throw new Error('Collector host is bound to a different tab. Stop the session before changing tabs.');
|
|
39
|
+
return scanned.handle;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function collect(sessionDir: string, port: number, targetId: string): Promise<string> {
|
|
43
|
+
const host = await liveHost(sessionDir, port, targetId);
|
|
44
|
+
const started = await sendHostRequest(host.socketPath, { type: 'collector-start', nonce: host.nonce, kind: 'heap', config: {} }, 120_000);
|
|
45
|
+
if (!started.ok || !started.collector || typeof started.collector !== 'object') throw new Error(started.error ?? 'collector host refused to start heap snapshot');
|
|
46
|
+
const collector = started.collector as { id?: unknown; dir?: unknown };
|
|
47
|
+
if (typeof collector.id !== 'string' || typeof collector.dir !== 'string') throw new Error('collector host returned a malformed heap collector');
|
|
48
|
+
const stopped = await sendHostRequest(host.socketPath, { type: 'collector-stop', nonce: host.nonce, id: collector.id }, 120_000);
|
|
49
|
+
if (!stopped.ok) throw new Error(stopped.error ?? 'collector host refused to finalize heap snapshot');
|
|
50
|
+
return collector.dir;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function oneshotHeapDir(artifactDir?: string): string {
|
|
54
|
+
const dir = path.join(artifactDir === undefined ? CAPTURE_ROOT : registerArtifactRoot(artifactDir), `oneshot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, 'heap', 'snapshots');
|
|
55
|
+
ensurePrivateDir(dir);
|
|
56
|
+
return path.dirname(path.dirname(dir));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function cmdHeapSnapshot(parsed: ParsedArgs): Promise<void> {
|
|
60
|
+
if (parsed.help) {
|
|
61
|
+
console.log(HELP);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const url = parsed.positional[0];
|
|
65
|
+
const active = getActiveSession();
|
|
66
|
+
const selectedPort = parsed.port ?? active?.port;
|
|
67
|
+
if (!url && (!active?.targetId || selectedPort === null || selectedPort === undefined)) {
|
|
68
|
+
throw captureError('precondition', 'heap_snapshot_unavailable', 'heap snapshot without a URL requires an active capture session with a target tab and CDP port.');
|
|
69
|
+
}
|
|
70
|
+
const port = selectedPort ?? await detectCdpPort();
|
|
71
|
+
let snapshotDir: string;
|
|
72
|
+
let targetId: string;
|
|
73
|
+
if (url) {
|
|
74
|
+
const tab = await openTab(port, url);
|
|
75
|
+
targetId = tab.id;
|
|
76
|
+
const sessionDir = oneshotHeapDir(parsed.artifactDir);
|
|
77
|
+
snapshotDir = await collect(sessionDir, port, targetId);
|
|
78
|
+
} else {
|
|
79
|
+
targetId = active!.targetId!;
|
|
80
|
+
snapshotDir = await withSessionLifecycle(active!.dir, () => collect(active!.dir, port, targetId));
|
|
81
|
+
}
|
|
82
|
+
const target = await findTabById(port, targetId);
|
|
83
|
+
if (!target) throw new Error(`Heap snapshot target ${targetId} disappeared before its URL could be recorded.`);
|
|
84
|
+
const ref = resolveHeapRef(snapshotDir);
|
|
85
|
+
const heap = loadHeap(ref);
|
|
86
|
+
const bytes = fs.statSync(path.join(ref.dir, 'snapshot.heapsnapshot')).size;
|
|
87
|
+
emitResult({
|
|
88
|
+
tag: 'heap-snapshot',
|
|
89
|
+
attrs: { heap: ref.id, path: ref.dir, url: target.url, ...completionAttrs(ref.meta), nodes: heap.nodeCount, edges: heap.edgeCount, bytes },
|
|
90
|
+
summary: text`Chrome's own .heapsnapshot, taken after Chrome's forced pre-snapshot garbage collection. This is the V8 JavaScript heap of one renderer, not the browser process's memory.`,
|
|
91
|
+
artifacts: formatArtifactList([{ name: 'snapshot.heapsnapshot', note: `${bytes} bytes` }]),
|
|
92
|
+
followUp: fact`Query heap snapshot ${resultReference(ref)} with \`capture heap census ${resultReference(ref)}\`, \`capture heap objects ${resultReference(ref)} --constructor <name>\`, or \`capture heap retainers ${resultReference(ref)} --node <object-id>\`.`,
|
|
93
|
+
}, { json: parsed.json });
|
|
94
|
+
}
|
package/src/cdp/commands/lib.ts
CHANGED
|
@@ -37,25 +37,17 @@ import {
|
|
|
37
37
|
|
|
38
38
|
/** Root-help representation of this leaf, assembled by `src/capture.ts`. */
|
|
39
39
|
export const COMMAND_BLOCK = `<command name="lib">
|
|
40
|
-
vault-lib introspection (dev checkout only) — discover the forked vault libs and their function schemas
|
|
41
|
-
use when scripting against a bundled service
|
|
42
|
-
list · search · show · read — \`capture lib -h\`
|
|
40
|
+
vault-lib introspection (dev checkout only) — discover the forked vault libs and their function schemas
|
|
41
|
+
use when scripting against a bundled service library and need to find a function or read its schema
|
|
43
42
|
</command>`;
|
|
44
43
|
|
|
45
|
-
const LIB_USAGE =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
<subcommand name="list" args="" whenToUse="list every lib — name, function count, one-line summary"/>
|
|
54
|
-
<subcommand name="search" args="<query> [--limit <n>]" whenToUse="rank functions across all libs against a query (default limit 20, max 500; multi-word queries require every token to match)"/>
|
|
55
|
-
<subcommand name="show" args="<name>" whenToUse="one lib's function summaries — names + one-line descriptions, no schemas"/>
|
|
56
|
-
<subcommand name="read" args="<name> [fn…]" whenToUse="full input/output JSON Schemas for named functions — the full-fidelity step before exec"/>
|
|
57
|
-
|
|
58
|
-
capture lib <leaf> -h Per-leaf usage`;
|
|
44
|
+
const LIB_USAGE = `<command name="lib" description="vault library introspection for a development checkout">
|
|
45
|
+
<model>Pure local reads — no CDP connection. Requires a Capture checkout with vault/ source and esbuild; the published package answers every leaf with a structured dev_only error.</model>
|
|
46
|
+
<subcommand name="list" description="available libraries" whenToUse="Use to discover libraries and their summaries."/>
|
|
47
|
+
<subcommand name="search" description="functions matching a query" whenToUse="Use to find a function when its library or exact name is unknown."/>
|
|
48
|
+
<subcommand name="show" description="one library's function summaries" whenToUse="Use to inspect a known library's functions before selecting one."/>
|
|
49
|
+
<subcommand name="read" description="function input and output schemas" whenToUse="Use to read the full schema for functions selected from a library."/>
|
|
50
|
+
</command>`;
|
|
59
51
|
|
|
60
52
|
const LEAF_USAGE: Record<string, string> = {
|
|
61
53
|
list: `capture lib list — list every vault lib: name, function count, one-line summary.
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { detectCdpPort } from '../detect.js';
|
|
6
|
+
import { captureError } from '../../errors.js';
|
|
7
|
+
import { type ParsedArgs } from '../types.js';
|
|
8
|
+
import { selectRecords } from '../../output/selection.js';
|
|
9
|
+
import { CAPTURE_ROOT, ensurePrivateDir, processPidBirthProvider, registerArtifactRoot, writeJsonPrivate, writePrivateFile } from '../../session/artifacts.js';
|
|
10
|
+
import { getActiveSession } from '../../session-context.js';
|
|
11
|
+
import { withSessionLifecycle } from '../../session/coordinator.js';
|
|
12
|
+
import { collectorHostSocketPath, startCollectorHost } from '../bridge/spawn.js';
|
|
13
|
+
import { sendHostRequest } from '../host/client.js';
|
|
14
|
+
import { scanCollectorHost } from '../host/handle.js';
|
|
15
|
+
import { stopAndReapCollectorHostAtSessionStop } from '../host/lifecycle.js';
|
|
16
|
+
import { capped, emitResult, fact, formatArtifactList, line, lineList, text, type FactLine, type JsonValue, type RenderableResult } from '../../output/render.js';
|
|
17
|
+
|
|
18
|
+
export const COMMAND_BLOCK = `<command name="lighthouse">
|
|
19
|
+
a third-party scored diagnostic report — capture runs Lighthouse against a URL and stores its report unmodified
|
|
20
|
+
use for one Lighthouse report's audited accessibility, SEO, performance, or best-practices conditions, categories, scores, and audits
|
|
21
|
+
</command>`;
|
|
22
|
+
|
|
23
|
+
const HELP = `capture lighthouse <url> [--categories <list>] [--preset mobile|desktop] [--limit <N>] [--out <path>] — run Lighthouse's third-party scored diagnostic sweep against a URL and store its report
|
|
24
|
+
|
|
25
|
+
input:
|
|
26
|
+
<url> required. The URL Lighthouse navigates to. Lighthouse drives the browser destructively — it clears state and reloads — so it will not run against a tab another collector is recording
|
|
27
|
+
--categories <list> comma-separated Lighthouse categories; default performance. Any category Lighthouse ships (performance, accessibility, best-practices, seo)
|
|
28
|
+
--preset <preset> mobile (default, Lighthouse's mobile emulation and simulated throttling) or desktop
|
|
29
|
+
--limit <N> return at most N failing nodes per audit in prose and JSON; default 25
|
|
30
|
+
--out <path> also write Lighthouse's HTML report to this caller-owned path
|
|
31
|
+
--artifact-dir <path> root for this sessionless Lighthouse report bundle
|
|
32
|
+
output: <lighthouse …> — Lighthouse's own category scores, its audit pass/fail counts, one row per failing audit, and for each failing node that audit's DOM path with its selector, snippet, and Lighthouse's own explanation; plus the absolute path to the unmodified JSON report; capture adds no assessment of its own; --json mirrors
|
|
33
|
+
effects: drives the browser destructively — a sessionless report bundle lands under --artifact-dir; Lighthouse clears storage and cache and performs its own navigations on the target tab, and runs its own trace. Refused while anything holds the browser-global \`tracing\` claim, and refused while any collector is live on the target tab; both refusals name the holder.`;
|
|
34
|
+
|
|
35
|
+
type NodeItem = {
|
|
36
|
+
readonly path: string;
|
|
37
|
+
readonly selector: string;
|
|
38
|
+
readonly snippet: string;
|
|
39
|
+
readonly explanation: string;
|
|
40
|
+
readonly nodeLabel?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type FailingAudit = {
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly title: string;
|
|
46
|
+
readonly score: number;
|
|
47
|
+
readonly items: readonly NodeItem[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type Category = {
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly score: number | null;
|
|
53
|
+
readonly auditsPassed: number;
|
|
54
|
+
readonly auditsFailed: number;
|
|
55
|
+
readonly failingAudits: readonly FailingAudit[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type LighthouseReport = {
|
|
59
|
+
readonly requestedUrl?: string;
|
|
60
|
+
readonly finalUrl?: string;
|
|
61
|
+
readonly lighthouseVersion?: string;
|
|
62
|
+
readonly environment?: { readonly credits?: Record<string, string | undefined> };
|
|
63
|
+
readonly categories?: Record<string, {
|
|
64
|
+
readonly id?: string;
|
|
65
|
+
readonly score?: number | null;
|
|
66
|
+
readonly auditRefs?: readonly { readonly id?: string }[];
|
|
67
|
+
}>;
|
|
68
|
+
readonly audits?: Record<string, {
|
|
69
|
+
readonly id?: string;
|
|
70
|
+
readonly title?: string;
|
|
71
|
+
readonly score?: number | null;
|
|
72
|
+
readonly details?: { readonly items?: readonly unknown[] };
|
|
73
|
+
}>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
77
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function string(value: unknown): string | undefined {
|
|
81
|
+
return typeof value === 'string' ? value : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function nodeItem(value: unknown): NodeItem | undefined {
|
|
85
|
+
const item = record(value);
|
|
86
|
+
const node = item && record(item.node);
|
|
87
|
+
if (!node) return undefined;
|
|
88
|
+
const nodePath = string(node.path);
|
|
89
|
+
const rawSelector = node.selector;
|
|
90
|
+
const selector = typeof rawSelector === 'string'
|
|
91
|
+
? rawSelector
|
|
92
|
+
: Array.isArray(rawSelector) && rawSelector.every(part => typeof part === 'string')
|
|
93
|
+
? rawSelector.join(', ')
|
|
94
|
+
: undefined;
|
|
95
|
+
if (!nodePath || !selector) return undefined;
|
|
96
|
+
return {
|
|
97
|
+
path: nodePath,
|
|
98
|
+
selector,
|
|
99
|
+
snippet: string(node.snippet) ?? '',
|
|
100
|
+
explanation: string(node.explanation) ?? '',
|
|
101
|
+
nodeLabel: string(node.nodeLabel),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function categoriesFrom(report: LighthouseReport): readonly Category[] {
|
|
106
|
+
const audits = report.audits ?? {};
|
|
107
|
+
return Object.entries(report.categories ?? {}).map(([categoryId, category]) => {
|
|
108
|
+
const refs = category.auditRefs ?? [];
|
|
109
|
+
const categoryAudits = refs.map(ref => audits[ref.id ?? '']).filter((audit): audit is NonNullable<typeof audit> => audit !== undefined);
|
|
110
|
+
const failingAudits = categoryAudits
|
|
111
|
+
.filter((audit): audit is typeof audit & { score: number } => typeof audit.score === 'number' && audit.score < 1)
|
|
112
|
+
.map(audit => ({
|
|
113
|
+
id: audit.id ?? '',
|
|
114
|
+
title: audit.title ?? '',
|
|
115
|
+
score: audit.score,
|
|
116
|
+
items: (audit.details?.items ?? []).map(nodeItem).filter((item): item is NodeItem => item !== undefined),
|
|
117
|
+
}));
|
|
118
|
+
return {
|
|
119
|
+
id: category.id ?? categoryId,
|
|
120
|
+
score: category.score ?? null,
|
|
121
|
+
auditsPassed: categoryAudits.filter(audit => audit.score === 1).length,
|
|
122
|
+
auditsFailed: failingAudits.length,
|
|
123
|
+
failingAudits,
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
type TracingReservation = { socketPath: string; nonce: string; token: string; targetId: string };
|
|
129
|
+
|
|
130
|
+
type LighthousePage = NonNullable<Parameters<typeof import('lighthouse').default>[3]>;
|
|
131
|
+
|
|
132
|
+
type PuppeteerSession = { send(method: string, params?: Record<string, unknown>): Promise<unknown>; detach(): Promise<void> };
|
|
133
|
+
type PuppeteerPage = { target(): { createCDPSession(): Promise<PuppeteerSession> } };
|
|
134
|
+
type PuppeteerBrowser = { pages(): Promise<PuppeteerPage[]>; disconnect(): Promise<void> };
|
|
135
|
+
type PuppeteerModule = { connect(options: { browserURL: string; defaultViewport: null }): Promise<PuppeteerBrowser> };
|
|
136
|
+
|
|
137
|
+
function claimFailure(message: string): never {
|
|
138
|
+
throw captureError('precondition', 'claim_held', `${message} Run \`capture session collectors\` to see live collectors, stop the holder, then re-issue.`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function reserveTracing(port: number): Promise<TracingReservation | undefined> {
|
|
142
|
+
const active = getActiveSession();
|
|
143
|
+
if (!active?.targetId || active.port !== port) return undefined;
|
|
144
|
+
return withSessionLifecycle(active.dir, async () => {
|
|
145
|
+
const session = getActiveSession();
|
|
146
|
+
if (!session || session.dir !== active.dir || !session.targetId || session.port !== port) return undefined;
|
|
147
|
+
let scanned = scanCollectorHost(session.dir);
|
|
148
|
+
if (scanned.classification === 'unknown' || scanned.classification === 'malformed') {
|
|
149
|
+
throw captureError('precondition', 'collector_host_unavailable', `This session's collector host is ${scanned.classification}; Lighthouse cannot reserve the browser-global tracing claim.`);
|
|
150
|
+
}
|
|
151
|
+
if (scanned.classification === 'dead') {
|
|
152
|
+
const reaped = await stopAndReapCollectorHostAtSessionStop(session.dir);
|
|
153
|
+
if (reaped.status === 'terminal') throw captureError('precondition', 'collector_host_unavailable', `This session's collector host could not be reaped: ${reaped.error}`);
|
|
154
|
+
scanned = scanCollectorHost(session.dir);
|
|
155
|
+
}
|
|
156
|
+
if (scanned.classification === 'absent') {
|
|
157
|
+
await startCollectorHost(collectorHostSocketPath(session.dir), port, session.targetId, session.dir);
|
|
158
|
+
scanned = scanCollectorHost(session.dir);
|
|
159
|
+
}
|
|
160
|
+
if (scanned.classification !== 'live' || !scanned.handle) {
|
|
161
|
+
throw captureError('precondition', 'collector_host_unavailable', 'Collector host did not publish a live handle for Lighthouse.');
|
|
162
|
+
}
|
|
163
|
+
if (scanned.handle.targetId !== session.targetId) {
|
|
164
|
+
throw captureError('precondition', 'collector_host_target_mismatch', 'A collector host is bound to a different session target. Stop the session before running Lighthouse.');
|
|
165
|
+
}
|
|
166
|
+
const birth = processPidBirthProvider.read(process.pid);
|
|
167
|
+
if (birth.status !== 'found') {
|
|
168
|
+
throw captureError('precondition', 'collector_host_unavailable', 'Lighthouse could not establish its process identity to reserve the tracing claim.');
|
|
169
|
+
}
|
|
170
|
+
const response = await sendHostRequest(scanned.handle.socketPath, {
|
|
171
|
+
type: 'claim-reserve', nonce: scanned.handle.nonce, claims: ['tracing'], exclusive: true, holderLabel: 'lighthouse', pid: process.pid, birth: birth.identity,
|
|
172
|
+
});
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
const message = response.error ?? 'collector host refused the tracing reservation.';
|
|
175
|
+
if (message.startsWith('Claim ') || message.startsWith('Target ')) claimFailure(`Lighthouse could not reserve the target tab and browser-global tracing claim: ${message}`);
|
|
176
|
+
throw captureError('precondition', 'collector_host_reservation_failed', `Lighthouse could not reserve the target tab and browser-global tracing claim: ${message}`);
|
|
177
|
+
}
|
|
178
|
+
const reservation = response.reservation as { token?: unknown } | undefined;
|
|
179
|
+
if (!reservation || typeof reservation.token !== 'string') {
|
|
180
|
+
throw captureError('internal', 'collector_host_reservation_malformed', 'Collector host returned a malformed tracing reservation.');
|
|
181
|
+
}
|
|
182
|
+
return { socketPath: scanned.handle.socketPath, nonce: scanned.handle.nonce, token: reservation.token, targetId: session.targetId };
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function releaseTracing(reservation: TracingReservation): Promise<void> {
|
|
187
|
+
const response = await sendHostRequest(reservation.socketPath, { type: 'claim-release', nonce: reservation.nonce, token: reservation.token });
|
|
188
|
+
if (!response.ok) throw captureError('cleanup', 'tracing_claim_release_failed', `Lighthouse finished but its tracing reservation could not be released: ${response.error ?? 'collector host refused the release.'}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function pageForSessionTarget(port: number, targetId: string): Promise<{ browser: PuppeteerBrowser; page: LighthousePage }> {
|
|
192
|
+
const lighthouseRequire = createRequire(require.resolve('lighthouse'));
|
|
193
|
+
const puppeteer = lighthouseRequire('puppeteer-core') as PuppeteerModule;
|
|
194
|
+
const browser = await puppeteer.connect({ browserURL: `http://localhost:${port}`, defaultViewport: null });
|
|
195
|
+
try {
|
|
196
|
+
for (const page of await browser.pages()) {
|
|
197
|
+
const session = await page.target().createCDPSession();
|
|
198
|
+
try {
|
|
199
|
+
const result = await session.send('Target.getTargetInfo') as { targetInfo?: { targetId?: unknown } };
|
|
200
|
+
if (result.targetInfo?.targetId === targetId) return { browser, page: page as LighthousePage };
|
|
201
|
+
} finally {
|
|
202
|
+
await session.detach();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
try {
|
|
207
|
+
await browser.disconnect();
|
|
208
|
+
} catch (disconnectError) {
|
|
209
|
+
throw new AggregateError([error, disconnectError], 'Lighthouse could not resolve the active session target or disconnect its browser client.');
|
|
210
|
+
}
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
await browser.disconnect();
|
|
214
|
+
throw captureError('precondition', 'lighthouse_target_unavailable', `Lighthouse could not find the active session target ${targetId} on CDP port ${port}.`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function reportDirectory(artifactDir?: string): { id: string; dir: string } {
|
|
218
|
+
const id = `report-${Date.now().toString(36)}-${crypto.randomBytes(6).toString('hex')}`;
|
|
219
|
+
const parent = getActiveSession()?.dir ?? path.join(artifactDir === undefined ? CAPTURE_ROOT : registerArtifactRoot(artifactDir), `oneshot-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`);
|
|
220
|
+
const dir = path.join(parent, 'lighthouse', 'reports', id);
|
|
221
|
+
ensurePrivateDir(dir);
|
|
222
|
+
return { id, dir };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function writeHtml(pathname: string, html: string): void {
|
|
226
|
+
try {
|
|
227
|
+
ensurePrivateDir(path.dirname(pathname));
|
|
228
|
+
writePrivateFile(pathname, html);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (path.resolve(pathname).startsWith(`${CAPTURE_ROOT}${path.sep}`)) throw error;
|
|
231
|
+
fs.writeFileSync(pathname, html);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function reportStrings(report: string | readonly string[]): { json: string; html: string } {
|
|
236
|
+
const outputs = Array.isArray(report) ? report : [report];
|
|
237
|
+
const json = outputs.find(output => output.trimStart().startsWith('{'));
|
|
238
|
+
const html = outputs.find(output => output.trimStart().startsWith('<'));
|
|
239
|
+
if (!json || !html) throw captureError('world', 'lighthouse_report_missing', 'Lighthouse did not return both JSON and HTML reports.');
|
|
240
|
+
return { json, html };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const FULL_DATA_MAX = Number.MAX_SAFE_INTEGER;
|
|
244
|
+
|
|
245
|
+
function full(value: string) {
|
|
246
|
+
return capped(value, FULL_DATA_MAX);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function categoryJson(category: Category, limit: number): JsonValue {
|
|
250
|
+
return {
|
|
251
|
+
category: category.id,
|
|
252
|
+
score: category.score,
|
|
253
|
+
auditsPassed: category.auditsPassed,
|
|
254
|
+
auditsFailed: category.auditsFailed,
|
|
255
|
+
failingAudits: category.failingAudits.map(audit => ({
|
|
256
|
+
id: audit.id,
|
|
257
|
+
title: audit.title,
|
|
258
|
+
score: audit.score,
|
|
259
|
+
items: selectRecords(audit.items, { limit }, limit).map(item => ({
|
|
260
|
+
path: full(item.path),
|
|
261
|
+
selector: full(item.selector),
|
|
262
|
+
snippet: full(item.snippet),
|
|
263
|
+
explanation: full(item.explanation),
|
|
264
|
+
...(item.nodeLabel === undefined ? {} : { nodeLabel: full(item.nodeLabel) }),
|
|
265
|
+
})),
|
|
266
|
+
})),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function categoryProse(category: Category, limit: number): FactLine {
|
|
271
|
+
const rows: FactLine[] = [fact`${category.id}: score ${category.score ?? 'not-scored'}; ${category.auditsPassed} passed audit(s), ${category.auditsFailed} failed audit(s).`];
|
|
272
|
+
for (const audit of category.failingAudits) {
|
|
273
|
+
rows.push(line(text` `, fact`${audit.id} — ${audit.title}; score ${audit.score}`));
|
|
274
|
+
const displayed = selectRecords(audit.items, { limit }, limit);
|
|
275
|
+
for (const item of displayed) {
|
|
276
|
+
rows.push(line(text` path: `, fact`${full(item.path)}`));
|
|
277
|
+
rows.push(line(text` selector: `, fact`${full(item.selector)}`));
|
|
278
|
+
rows.push(line(text` snippet: `, fact`${full(item.snippet)}`));
|
|
279
|
+
rows.push(line(text` Lighthouse explanation: `, fact`${full(item.explanation)}`));
|
|
280
|
+
if (item.nodeLabel !== undefined) rows.push(line(text` node label: `, fact`${full(item.nodeLabel)}`));
|
|
281
|
+
}
|
|
282
|
+
if (audit.items.length === 0) rows.push(text` No failing nodes were attached to this audit by Lighthouse.`);
|
|
283
|
+
if (audit.items.length > displayed.length) rows.push(fact` ${displayed.length} of ${audit.items.length} failing node(s) rendered (--limit ${limit}).`);
|
|
284
|
+
}
|
|
285
|
+
return lineList(rows);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function resultFor(input: {
|
|
289
|
+
id: string;
|
|
290
|
+
dir: string;
|
|
291
|
+
url: string;
|
|
292
|
+
report: LighthouseReport;
|
|
293
|
+
preset: 'mobile' | 'desktop';
|
|
294
|
+
categories: readonly string[];
|
|
295
|
+
htmlStored: boolean;
|
|
296
|
+
limit: number;
|
|
297
|
+
}): RenderableResult {
|
|
298
|
+
const categories = categoriesFrom(input.report);
|
|
299
|
+
const lighthouseVersion = input.report.lighthouseVersion ?? 'unknown';
|
|
300
|
+
const axe = input.report.environment?.credits?.['axe-core'];
|
|
301
|
+
const finalUrl = input.report.finalUrl ?? input.url;
|
|
302
|
+
return {
|
|
303
|
+
tag: 'lighthouse',
|
|
304
|
+
attrs: {
|
|
305
|
+
report: input.id,
|
|
306
|
+
path: input.dir,
|
|
307
|
+
'report-path': full(path.join(input.dir, 'report.json')),
|
|
308
|
+
url: input.url,
|
|
309
|
+
'final-url': finalUrl,
|
|
310
|
+
preset: input.preset,
|
|
311
|
+
categories: input.categories.join(','),
|
|
312
|
+
lighthouse: lighthouseVersion,
|
|
313
|
+
axe,
|
|
314
|
+
},
|
|
315
|
+
summary: fact`Scores and audit results are Lighthouse's own, computed by lighthouse@${lighthouseVersion} under the ${input.preset} preset. Capture stores the report unmodified and adds no assessment of its own.`,
|
|
316
|
+
artifacts: formatArtifactList(input.htmlStored ? [{ name: 'report.json' }, { name: 'report.html' }] : [{ name: 'report.json' }]),
|
|
317
|
+
jsonArtifacts: input.htmlStored ? ['report.json', 'report.html'] : ['report.json'],
|
|
318
|
+
sections: [
|
|
319
|
+
...categories.map(category => categoryProse(category, input.limit)),
|
|
320
|
+
text`Lighthouse applies simulated throttling by default, so its timings are not the wall-clock timings of an unthrottled load and are not comparable with capture perf vitals.`,
|
|
321
|
+
text`Failing-node selectors are minimized by axe and may drop class modifiers, so a selector can match more elements than the one that failed; the DOM path identifies the failing node.`,
|
|
322
|
+
],
|
|
323
|
+
jsonSections: categories.map(category => categoryJson(category, input.limit)),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function cmdLighthouse(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
328
|
+
if (parsed.help) {
|
|
329
|
+
console.log(HELP);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let lighthouseModule: typeof import('lighthouse');
|
|
334
|
+
try {
|
|
335
|
+
lighthouseModule = await import('lighthouse');
|
|
336
|
+
} catch (error) {
|
|
337
|
+
throw captureError('precondition', 'lighthouse_unavailable', 'Lighthouse is not installed. Install capture with its production dependencies before running this command.', error);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const url = parsed.positional[0]!;
|
|
341
|
+
const preset = parsed.preset === 'desktop' ? 'desktop' : 'mobile';
|
|
342
|
+
const categories = parsed.categories?.split(',') ?? ['performance'];
|
|
343
|
+
const port = parsed.port ?? await detectCdpPort();
|
|
344
|
+
const reservation = await reserveTracing(port);
|
|
345
|
+
let run: Awaited<ReturnType<typeof lighthouseModule.default>> | undefined;
|
|
346
|
+
let browser: PuppeteerBrowser | undefined;
|
|
347
|
+
let primary: unknown;
|
|
348
|
+
try {
|
|
349
|
+
const target = reservation && await pageForSessionTarget(port, reservation.targetId);
|
|
350
|
+
browser = target?.browser;
|
|
351
|
+
run = await lighthouseModule.default(url, {
|
|
352
|
+
port,
|
|
353
|
+
output: ['json', 'html'],
|
|
354
|
+
onlyCategories: categories,
|
|
355
|
+
logLevel: 'error',
|
|
356
|
+
}, preset === 'desktop' ? lighthouseModule.desktopConfig : undefined, target?.page);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
primary = captureError('world', 'lighthouse_failed', `Lighthouse could not run: ${error instanceof Error ? error.message : String(error)}`, error);
|
|
359
|
+
}
|
|
360
|
+
const cleanupFailures: unknown[] = [];
|
|
361
|
+
if (browser) {
|
|
362
|
+
try {
|
|
363
|
+
await browser.disconnect();
|
|
364
|
+
} catch (error) {
|
|
365
|
+
cleanupFailures.push(captureError('cleanup', 'lighthouse_browser_disconnect_failed', `Lighthouse finished but its browser client could not be disconnected: ${error instanceof Error ? error.message : String(error)}`, error));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (reservation) {
|
|
369
|
+
try {
|
|
370
|
+
await releaseTracing(reservation);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
cleanupFailures.push(error);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (primary && cleanupFailures.length > 0) throw new AggregateError([primary, ...cleanupFailures], 'Lighthouse failed and cleanup could not complete.');
|
|
376
|
+
if (primary) throw primary;
|
|
377
|
+
if (cleanupFailures.length === 1) throw cleanupFailures[0];
|
|
378
|
+
if (cleanupFailures.length > 1) throw new AggregateError(cleanupFailures, 'Lighthouse completed but cleanup could not complete.');
|
|
379
|
+
if (!run) throw captureError('world', 'lighthouse_no_report', 'Lighthouse completed without a report.');
|
|
380
|
+
|
|
381
|
+
const reports = reportStrings(run.report);
|
|
382
|
+
const artifact = reportDirectory(parsed.artifactDir);
|
|
383
|
+
const reportPath = path.join(artifact.dir, 'report.json');
|
|
384
|
+
writePrivateFile(reportPath, reports.json);
|
|
385
|
+
if (parsed.out) {
|
|
386
|
+
writePrivateFile(path.join(artifact.dir, 'report.html'), reports.html);
|
|
387
|
+
try {
|
|
388
|
+
writeHtml(parsed.out, reports.html);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
throw captureError('world', 'lighthouse_html_write_failed', `Lighthouse report JSON was stored at ${reportPath}, but its HTML could not be written to ${parsed.out}: ${error instanceof Error ? error.message : String(error)}`, error);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const report = run.lhr as unknown as LighthouseReport;
|
|
394
|
+
writeJsonPrivate(path.join(artifact.dir, 'meta.json'), {
|
|
395
|
+
id: artifact.id,
|
|
396
|
+
url,
|
|
397
|
+
finalUrl: report.finalUrl ?? url,
|
|
398
|
+
preset,
|
|
399
|
+
categories,
|
|
400
|
+
lighthouse: report.lighthouseVersion ?? null,
|
|
401
|
+
axe: report.environment?.credits?.['axe-core'] ?? null,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
emitResult(resultFor({
|
|
405
|
+
id: artifact.id,
|
|
406
|
+
dir: artifact.dir,
|
|
407
|
+
url,
|
|
408
|
+
report,
|
|
409
|
+
preset,
|
|
410
|
+
categories,
|
|
411
|
+
htmlStored: parsed.out !== undefined,
|
|
412
|
+
limit: parsed.limit ?? 25,
|
|
413
|
+
}), { json: parsed.json });
|
|
414
|
+
}
|
|
@@ -13,8 +13,9 @@ input:
|
|
|
13
13
|
--snap <id|path> existing snapshot id or absolute artifact path (repeatable)
|
|
14
14
|
--url <url> URL to snap first (repeatable)
|
|
15
15
|
--set-file <path> file listing one snapshot id/path or URL per line
|
|
16
|
+
--artifact-dir <path> root for each one-shot snapshot bundle created by --url
|
|
16
17
|
output: <census axis=… snapshots=… distinct=…> — distributions with provenance where recorded, and per-region nondeterminism caveats for facts touching an unsettled capture; --json mirrors
|
|
17
|
-
effects: read-only over existing snapshot artifacts; each --url writes one one-shot snapshot
|
|
18
|
+
effects: read-only over existing snapshot artifacts; each --url writes one one-shot snapshot bundle under --artifact-dir when supplied`;
|
|
18
19
|
|
|
19
20
|
function targetsFromFile(filePath: string): string[] {
|
|
20
21
|
const raw = fs.readFileSync(filePath, 'utf8');
|