pk-annotator 0.6.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/dist/pka.mjs ADDED
@@ -0,0 +1,1678 @@
1
+ #!/usr/bin/env node
2
+ import { B as Id, F as readJsonLines, I as resolveInside, M as isErrno, O as PkaError, P as readJson, S as wait, W as Timestamp, _ as prune, a as ErrorsInput, c as ReplyInput, h as list, i as TimelineEntry, j as findStore, l as SetStatusInput, m as get, n as RecordingManifest, o as GetInput, p as errors, s as ListInput, u as attach, v as reply, x as validateAttachTarget, y as setStatus, z as ID_PATTERN } from "./recording-CpEDddkZ.mjs";
3
+ import { createRequire } from "node:module";
4
+ import { createWriteStream } from "node:fs";
5
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { parseArgs } from "node:util";
8
+ import { z } from "zod";
9
+ import { tmpdir } from "node:os";
10
+ import { setTimeout } from "node:timers/promises";
11
+ import { pipeline } from "node:stream/promises";
12
+ import { createGzip } from "node:zlib";
13
+ //#region src/shared/verdict.ts
14
+ const LabMetric = z.enum([
15
+ "LCP",
16
+ "INP",
17
+ "CLS"
18
+ ]);
19
+ const NetworkPreset = z.enum([
20
+ "slow4g",
21
+ "fast4g",
22
+ "none"
23
+ ]);
24
+ const LoafScript = z.strictObject({
25
+ url: z.string(),
26
+ function: z.string(),
27
+ invoker: z.string(),
28
+ subpart: z.string(),
29
+ durationMs: z.number()
30
+ });
31
+ const HotFunction = z.strictObject({
32
+ function: z.string(),
33
+ url: z.string(),
34
+ line: z.number().int().positive(),
35
+ column: z.number().int().positive(),
36
+ selfMs: z.number()
37
+ });
38
+ const Culprit = z.strictObject({
39
+ run: z.number().int().positive(),
40
+ page: z.string(),
41
+ element: z.string().optional(),
42
+ resource: z.string().optional(),
43
+ breakdown: z.record(z.string(), z.number()),
44
+ script: LoafScript.optional(),
45
+ hotFunction: HotFunction.optional(),
46
+ insight: z.string().optional()
47
+ });
48
+ const MetricVerdict = z.strictObject({
49
+ unit: z.enum(["ms", "score"]),
50
+ values: z.array(z.number().nullable()).describe("Per run; null when the run did not report it"),
51
+ median: z.number().nullable(),
52
+ min: z.number().nullable(),
53
+ max: z.number().nullable(),
54
+ band: z.number().nullable().describe("max minus min"),
55
+ budget: z.number(),
56
+ status: z.enum([
57
+ "pass",
58
+ "fail",
59
+ "unmeasured"
60
+ ]),
61
+ culprit: Culprit.optional().describe("From the run whose value is the median"),
62
+ note: z.string().optional()
63
+ });
64
+ const InsightVerdict = z.strictObject({
65
+ name: z.string(),
66
+ page: z.string(),
67
+ state: z.enum([
68
+ "pass",
69
+ "fail",
70
+ "informative"
71
+ ]),
72
+ runs: z.number().int().nonnegative(),
73
+ failed: z.number().int().nonnegative(),
74
+ summary: z.string()
75
+ });
76
+ const Verdict = z.strictObject({
77
+ version: z.literal(1),
78
+ createdAt: Timestamp,
79
+ status: z.enum([
80
+ "pass",
81
+ "fail",
82
+ "incomplete"
83
+ ]),
84
+ conditions: z.strictObject({
85
+ url: z.string(),
86
+ flow: z.string(),
87
+ steps: z.number().int().nonnegative(),
88
+ runs: z.number().int().positive(),
89
+ measurementTracing: z.literal(false).optional(),
90
+ diagnosticRuns: z.number().int().nonnegative().max(1).optional(),
91
+ cpuRate: z.number().positive(),
92
+ network: NetworkPreset,
93
+ networkConditions: z.strictObject({
94
+ latencyMs: z.number(),
95
+ downloadBytesPerSec: z.number(),
96
+ uploadBytesPerSec: z.number()
97
+ }).nullable(),
98
+ cache: z.literal("cold"),
99
+ chromium: z.string(),
100
+ viewport: z.string()
101
+ }),
102
+ metrics: z.strictObject({
103
+ LCP: MetricVerdict,
104
+ INP: MetricVerdict,
105
+ CLS: MetricVerdict
106
+ }),
107
+ insights: z.array(InsightVerdict),
108
+ unavailable: z.array(z.strictObject({
109
+ name: z.string(),
110
+ reason: z.string()
111
+ })),
112
+ failures: z.array(z.strictObject({
113
+ run: z.number().int().positive(),
114
+ step: z.number().int().nonnegative().optional(),
115
+ message: z.string()
116
+ })),
117
+ traces: z.array(z.string())
118
+ });
119
+ //#endregion
120
+ //#region src/lab/insights.ts
121
+ const REQUESTED_INSIGHTS = [
122
+ "LCPBreakdown",
123
+ "INPBreakdown",
124
+ "CLSCulprits",
125
+ "RenderBlocking",
126
+ "ForcedReflow",
127
+ "DocumentLatency",
128
+ "NetworkDependencyTree"
129
+ ];
130
+ const SUMMARY_CAP = 400;
131
+ /** A trace that could not be recorded, saved, or parsed; it costs the insights, not the verdict. */
132
+ var TraceError = class extends Error {};
133
+ let engine;
134
+ var NodeDOMRect = class {
135
+ x;
136
+ y;
137
+ width;
138
+ height;
139
+ constructor(x = 0, y = 0, width = 0, height = 0) {
140
+ this.x = x;
141
+ this.y = y;
142
+ this.width = width;
143
+ this.height = height;
144
+ }
145
+ get left() {
146
+ return this.x + Math.min(0, this.width);
147
+ }
148
+ get right() {
149
+ return this.x + Math.max(0, this.width);
150
+ }
151
+ get top() {
152
+ return this.y + Math.min(0, this.height);
153
+ }
154
+ get bottom() {
155
+ return this.y + Math.max(0, this.height);
156
+ }
157
+ };
158
+ function loadEngine() {
159
+ if (!("DOMRect" in globalThis)) Object.defineProperty(globalThis, "DOMRect", {
160
+ value: NodeDOMRect,
161
+ configurable: true
162
+ });
163
+ engine ??= import("@paulirish/trace_engine");
164
+ return engine;
165
+ }
166
+ const ms = (micro) => Math.round(micro / 1e3);
167
+ function cap(text) {
168
+ return text.length <= SUMMARY_CAP ? text : `${text.slice(0, 399)}…`;
169
+ }
170
+ function uiText(text) {
171
+ return text.i18nId.replace(/\{(\w+)\}/g, (match, key) => {
172
+ const value = text.values?.[key];
173
+ return value === void 0 ? match : String(value);
174
+ });
175
+ }
176
+ function frameLabel(frame) {
177
+ if (frame === null) return "(unknown)";
178
+ return `${frame.functionName || "(anonymous)"} ${frame.url}:${frame.lineNumber + 1}`;
179
+ }
180
+ function lcpSummary(model) {
181
+ if (model.lcpMs === void 0 || model.subparts === void 0) return "No LCP in this page";
182
+ const parts = Object.entries(model.subparts).filter(([, subpart]) => subpart !== void 0).map(([part, subpart]) => `${part} ${ms(subpart.range)} ms`);
183
+ const request = model.lcpRequest === void 0 ? "text" : model.lcpRequest.args.data.url;
184
+ return `LCP ${Math.round(model.lcpMs)} ms (${request}): ${parts.join(", ")}`;
185
+ }
186
+ function inpSummary(model) {
187
+ const event = model.longestInteractionEvent;
188
+ if (event === void 0) return "No interactions in this page";
189
+ return `Longest interaction ${event.type} ${ms(event.dur)} ms: input delay ${ms(event.inputDelay)} ms, processing ${ms(event.mainThreadHandling)} ms, presentation ${ms(event.presentationDelay)} ms`;
190
+ }
191
+ function clsSummary(model) {
192
+ const worst = model.worstCluster;
193
+ if (worst === void 0) return "No layout shifts";
194
+ const culprits = (model.topCulpritsByCluster.get(worst) ?? []).map((item) => uiText(item.description));
195
+ return `Worst cluster ${Math.round(worst.clusterCumulativeScore * 1e4) / 1e4}; ${culprits.length > 0 ? `culprits: ${culprits.join("; ")}` : "no culprit identified"}`;
196
+ }
197
+ function renderBlockingSummary(model) {
198
+ const requests = model.renderBlockingRequests;
199
+ if (requests.length === 0) return "No render-blocking requests";
200
+ const saving = model.metricSavings?.FCP;
201
+ const urls = requests.map((request) => request.args.data.url).join(", ");
202
+ return `${requests.length} render-blocking: ${urls}${saving === void 0 ? "" : `; FCP saving ~${Math.round(saving)} ms`}`;
203
+ }
204
+ function forcedReflowSummary(model) {
205
+ const top = model.topLevelFunctionCallData;
206
+ const sites = model.aggregatedBottomUpData.slice(0, 3).map((site) => `${frameLabel(site.bottomUpData)} ${ms(site.totalTime)} ms`);
207
+ if (top === void 0 && sites.length === 0) return "No forced reflow";
208
+ return `${top === void 0 ? "" : `${ms(top.totalReflowTime)} ms forced reflow; `}${sites.join("; ")}`;
209
+ }
210
+ function documentLatencySummary(model) {
211
+ const data = model.data;
212
+ if (data === void 0) return "No document request";
213
+ const failed = Object.values(data.checklist).filter((item) => !item.value).map((item) => uiText(item.label));
214
+ const timing = `Server response ${Math.round(data.serverResponseTime)} ms, redirects ${Math.round(data.redirectDuration)} ms`;
215
+ return failed.length > 0 ? `${timing}; failed: ${failed.join("; ")}` : timing;
216
+ }
217
+ function dependencySummary(model) {
218
+ let chain = [];
219
+ let nodes = model.rootNodes;
220
+ while (nodes.length > 0) {
221
+ const next = nodes.find((node) => node.isLongest) ?? nodes[0];
222
+ if (next === void 0) break;
223
+ chain.push(new URL(next.request.args.data.url).pathname);
224
+ nodes = next.children;
225
+ }
226
+ if (chain.length > 6) chain = [
227
+ ...chain.slice(0, 3),
228
+ "…",
229
+ ...chain.slice(-2)
230
+ ];
231
+ return `Critical chain ${ms(model.maxTime)} ms: ${chain.join(" → ") || "none"}`;
232
+ }
233
+ function genericSummary(model) {
234
+ const savings = Object.entries(model.metricSavings ?? {}).map(([metric, value]) => `${metric} ${Math.round(Number(value))}`).join(", ");
235
+ return `${uiText(model.title)}${savings === "" ? "" : ` (savings: ${savings})`}`;
236
+ }
237
+ function summarize(name, models) {
238
+ switch (name) {
239
+ case "LCPBreakdown": return models.LCPBreakdown && lcpSummary(models.LCPBreakdown);
240
+ case "INPBreakdown": return models.INPBreakdown && inpSummary(models.INPBreakdown);
241
+ case "CLSCulprits": return models.CLSCulprits && clsSummary(models.CLSCulprits);
242
+ case "RenderBlocking": return models.RenderBlocking && renderBlockingSummary(models.RenderBlocking);
243
+ case "ForcedReflow": return models.ForcedReflow && forcedReflowSummary(models.ForcedReflow);
244
+ case "DocumentLatency": return models.DocumentLatency && documentLatencySummary(models.DocumentLatency);
245
+ case "NetworkDependencyTree": return models.NetworkDependencyTree && dependencySummary(models.NetworkDependencyTree);
246
+ default: {
247
+ const model = models[name];
248
+ return model && genericSummary(model);
249
+ }
250
+ }
251
+ }
252
+ function longestInteraction(sets) {
253
+ let longest;
254
+ for (const set of sets) {
255
+ const event = set.model.INPBreakdown?.longestInteractionEvent;
256
+ if (event !== void 0 && (longest === void 0 || event.dur > longest.dur)) longest = event;
257
+ }
258
+ return longest;
259
+ }
260
+ function hotFunction(parsed, interaction) {
261
+ const thread = parsed.data.Renderer.processes.get(interaction.pid)?.threads.get(interaction.tid);
262
+ if (thread === void 0) return void 0;
263
+ const start = interaction.ts;
264
+ const end = interaction.ts + interaction.dur;
265
+ const totals = /* @__PURE__ */ new Map();
266
+ for (const call of thread.profileCalls) {
267
+ if (call.ts < start || call.ts >= end || call.callFrame.url === "") continue;
268
+ const self = parsed.data.Renderer.entryToNode.get(call)?.selfTime ?? 0;
269
+ const frame = call.callFrame;
270
+ const key = `${frame.url}:${frame.lineNumber}:${frame.columnNumber}:${frame.functionName}`;
271
+ const total = totals.get(key) ?? {
272
+ function: frame.functionName || "(anonymous)",
273
+ url: frame.url,
274
+ line: frame.lineNumber + 1,
275
+ column: frame.columnNumber + 1,
276
+ selfMs: 0
277
+ };
278
+ total.selfMs += self / 1e3;
279
+ totals.set(key, total);
280
+ }
281
+ let top;
282
+ for (const total of totals.values()) if (top === void 0 || total.selfMs > top.selfMs) top = total;
283
+ if (top === void 0 || Math.round(top.selfMs) === 0) return void 0;
284
+ return {
285
+ ...top,
286
+ selfMs: Math.round(top.selfMs)
287
+ };
288
+ }
289
+ /** Parses one trace with the DevTools trace engine and reduces its insights to short summaries. */
290
+ async function analyzeTrace(events) {
291
+ const model = (await loadEngine()).TraceModel.Model.createWithAllHandlers();
292
+ try {
293
+ await model.parse(events);
294
+ } catch (thrown) {
295
+ throw new TraceError(`trace_engine could not parse the trace: ${thrown instanceof Error ? thrown.message : String(thrown)}`, { cause: thrown });
296
+ }
297
+ const parsed = model.parsedTrace();
298
+ if (parsed === null) throw new Error("trace_engine parsed the trace but returned no data");
299
+ const sets = [...parsed.insights?.values() ?? []].filter((set) => set.url.protocol.startsWith("http"));
300
+ const insights = [];
301
+ const unavailable = [];
302
+ if (sets.length === 0) for (const name of REQUESTED_INSIGHTS) unavailable.push({
303
+ name,
304
+ reason: "the trace has no page navigation to compute insights for"
305
+ });
306
+ for (const set of sets) {
307
+ const requested = new Set(REQUESTED_INSIGHTS);
308
+ for (const name of Object.keys(set.model)) {
309
+ const insight = set.model[name];
310
+ if (insight === void 0) continue;
311
+ if (!requested.has(name) && insight.state !== "fail") continue;
312
+ const summary = summarize(name, set.model);
313
+ if (summary === void 0) continue;
314
+ insights.push({
315
+ name,
316
+ page: set.url.href,
317
+ state: insight.state,
318
+ summary: cap(summary)
319
+ });
320
+ }
321
+ for (const name of REQUESTED_INSIGHTS) {
322
+ if (set.model[name] !== void 0) continue;
323
+ const error = set.modelErrors[name];
324
+ unavailable.push({
325
+ name,
326
+ reason: error === void 0 ? `not produced for ${set.url.pathname}` : `failed for ${set.url.pathname}: ${error.message}`
327
+ });
328
+ }
329
+ }
330
+ const longest = longestInteraction(sets);
331
+ return {
332
+ insights,
333
+ unavailable,
334
+ longestInteraction: longest === void 0 ? void 0 : {
335
+ id: longest.interactionId,
336
+ hotFunction: hotFunction(parsed, longest)
337
+ }
338
+ };
339
+ }
340
+ //#endregion
341
+ //#region src/lab/verdict.ts
342
+ const DEFAULT_BUDGETS = {
343
+ LCP: 2500,
344
+ INP: 200,
345
+ CLS: .1
346
+ };
347
+ /** Parses `lcp=2500,inp=200,cls=0.1`; metrics left out keep the Core Web Vitals "good" threshold. */
348
+ const BudgetOption = z.string().optional().transform((text, ctx) => {
349
+ const budgets = { ...DEFAULT_BUDGETS };
350
+ if (text === void 0) return budgets;
351
+ const seen = /* @__PURE__ */ new Set();
352
+ for (const part of text.split(",")) {
353
+ const match = /^(lcp|inp|cls)=(\d+(?:\.\d+)?)$/.exec(part.trim());
354
+ if (match === null || match[1] === void 0 || match[2] === void 0) {
355
+ ctx.addIssue({
356
+ code: "custom",
357
+ message: `budget entry ${JSON.stringify(part)} must look like lcp=2500, inp=200, or cls=0.1`
358
+ });
359
+ return z.NEVER;
360
+ }
361
+ if (seen.has(match[1])) {
362
+ ctx.addIssue({
363
+ code: "custom",
364
+ message: `budget names ${match[1]} twice`
365
+ });
366
+ return z.NEVER;
367
+ }
368
+ seen.add(match[1]);
369
+ budgets[LabMetric.parse(match[1].toUpperCase())] = Number(match[2]);
370
+ }
371
+ return budgets;
372
+ });
373
+ const CULPRIT_INSIGHT = {
374
+ LCP: "LCPBreakdown",
375
+ INP: "INPBreakdown",
376
+ CLS: "CLSCulprits"
377
+ };
378
+ function round(metric, value) {
379
+ return metric === "CLS" ? Math.round(value * 1e4) / 1e4 : Math.round(value);
380
+ }
381
+ function median(sorted) {
382
+ const middle = Math.floor(sorted.length / 2);
383
+ const upper = sorted[middle];
384
+ if (upper === void 0) throw new Error("median of an empty list");
385
+ if (sorted.length % 2 === 1) return upper;
386
+ const lower = sorted[middle - 1];
387
+ if (lower === void 0) throw new Error("median index out of range");
388
+ return (lower + upper) / 2;
389
+ }
390
+ function pagePath(url) {
391
+ try {
392
+ const parsed = new URL(url);
393
+ return parsed.pathname + parsed.search;
394
+ } catch {
395
+ return url;
396
+ }
397
+ }
398
+ function culpritFor(metric, result, measured, diagnostic) {
399
+ const insights = diagnostic.insights.filter((insight) => insight.name === CULPRIT_INSIGHT[metric]);
400
+ const insight = insights.find((candidate) => pagePath(candidate.page) === pagePath(measured.page)) ?? (insights.length === 1 ? insights[0] : void 0);
401
+ const culprit = {
402
+ run: result.run,
403
+ page: measured.page,
404
+ breakdown: Object.fromEntries(Object.entries(measured.breakdown).map(([name, value]) => [name, round(metric, value)]))
405
+ };
406
+ if (measured.element !== void 0) culprit.element = measured.element;
407
+ if (measured.resource !== void 0) culprit.resource = measured.resource;
408
+ if (measured.script !== void 0) culprit.script = {
409
+ ...measured.script,
410
+ durationMs: Math.round(measured.script.durationMs)
411
+ };
412
+ if (insight !== void 0) culprit.insight = insight.summary;
413
+ return culprit;
414
+ }
415
+ function metricVerdict(metric, input) {
416
+ const byRun = new Map(input.runs.map((result) => [result.run, result]));
417
+ const values = Array.from({ length: input.conditions.runs }, (_, index) => {
418
+ const measured = byRun.get(index + 1)?.metrics[metric];
419
+ return measured === void 0 ? null : round(metric, measured.value);
420
+ });
421
+ const budget = input.budgets[metric];
422
+ const unit = metric === "CLS" ? "score" : "ms";
423
+ const present = values.filter((value) => value !== null).sort((a, b) => a - b);
424
+ if (present.length === 0) return {
425
+ unit,
426
+ values,
427
+ median: null,
428
+ min: null,
429
+ max: null,
430
+ band: null,
431
+ budget,
432
+ status: "unmeasured",
433
+ note: input.runs.length === 0 ? "No run completed; see failures" : metric === "INP" ? "No interaction of 16 ms or longer was reported; the flow may have no clicks, keys, or taps" : `No run reported ${metric}`
434
+ };
435
+ const middle = round(metric, median(present));
436
+ const min = present[0] ?? middle;
437
+ const max = present.at(-1) ?? middle;
438
+ let representative;
439
+ let distance = Number.POSITIVE_INFINITY;
440
+ for (const result of input.runs) {
441
+ const measured = result.metrics[metric];
442
+ if (measured === void 0) continue;
443
+ const gap = Math.abs(round(metric, measured.value) - middle);
444
+ const current = representative?.metrics[metric]?.value ?? Number.NEGATIVE_INFINITY;
445
+ if (gap < distance || gap === distance && measured.value > current) {
446
+ representative = result;
447
+ distance = gap;
448
+ }
449
+ }
450
+ const measured = representative?.metrics[metric];
451
+ if (representative === void 0 || measured === void 0) throw new Error(`no representative run for ${metric} although values exist`);
452
+ return {
453
+ unit,
454
+ values,
455
+ median: middle,
456
+ min,
457
+ max,
458
+ band: round(metric, max - min),
459
+ budget,
460
+ status: middle <= budget ? "pass" : "fail",
461
+ culprit: culpritFor(metric, representative, measured, input.diagnostic)
462
+ };
463
+ }
464
+ function aggregateInsights(insights) {
465
+ const groups = /* @__PURE__ */ new Map();
466
+ for (const insight of insights) {
467
+ const page = pagePath(insight.page);
468
+ const key = `${insight.name}\u0000${page}`;
469
+ const group = groups.get(key) ?? {
470
+ name: insight.name,
471
+ page,
472
+ state: insight.state,
473
+ runs: 0,
474
+ failed: 0,
475
+ summary: insight.summary
476
+ };
477
+ group.runs += 1;
478
+ if (insight.state === "fail") {
479
+ group.failed += 1;
480
+ group.state = "fail";
481
+ group.summary = insight.summary;
482
+ } else if (group.failed === 0) {
483
+ group.state = insight.state;
484
+ group.summary = insight.summary;
485
+ }
486
+ groups.set(key, group);
487
+ }
488
+ return [...groups.values()];
489
+ }
490
+ function formatValue(metric, value) {
491
+ return metric === "CLS" ? value.toFixed(3) : `${value} ms`;
492
+ }
493
+ /** One line for the perf attachment summary and the first line of the CLI output. */
494
+ function summarizeVerdict(verdict) {
495
+ const metrics = LabMetric.options.map((metric) => {
496
+ const result = verdict.metrics[metric];
497
+ if (result.median === null) return `${metric} unmeasured`;
498
+ const over = result.status === "fail" ? ` > ${formatValue(metric, result.budget)}` : "";
499
+ return `${metric} ${formatValue(metric, result.median)} ${result.status}${over}`;
500
+ });
501
+ const { conditions } = verdict;
502
+ const failed = verdict.failures.length > 0 ? `, ${verdict.failures.length} failed` : "";
503
+ return `lab ${verdict.status}: ${metrics.join(", ")} (median of ${conditions.runs} runs${failed}, ${conditions.cpuRate}x CPU, ${conditions.network})`;
504
+ }
505
+ function computeVerdict(input) {
506
+ const metrics = {
507
+ LCP: metricVerdict("LCP", input),
508
+ INP: metricVerdict("INP", input),
509
+ CLS: metricVerdict("CLS", input)
510
+ };
511
+ const computed = new Set(input.diagnostic.insights.map((insight) => insight.name));
512
+ const unavailable = /* @__PURE__ */ new Map();
513
+ for (const missing of input.diagnostic.unavailable) if (!computed.has(missing.name)) unavailable.set(`${missing.name}\u0000${missing.reason}`, missing);
514
+ const culprit = metrics.INP.culprit;
515
+ const hot = input.diagnostic.hot;
516
+ if (culprit !== void 0 && hot !== void 0) {
517
+ if (hot.element !== void 0 && hot.element === culprit.element && pagePath(hot.page) === pagePath(culprit.page)) culprit.hotFunction = hot.hotFunction;
518
+ else {
519
+ const missing = {
520
+ name: "hotFunction",
521
+ reason: `the traced run's INP was ${`${hot.element ?? "an unnamed element"} on ${pagePath(hot.page)}`}, not the INP culprit's interaction`
522
+ };
523
+ unavailable.set(`${missing.name}\u0000${missing.reason}`, missing);
524
+ }
525
+ }
526
+ const statuses = Object.values(metrics).map((metric) => metric.status);
527
+ const status = statuses.includes("fail") ? "fail" : statuses.includes("unmeasured") || input.failures.length > 0 ? "incomplete" : "pass";
528
+ return {
529
+ version: 1,
530
+ createdAt: input.createdAt,
531
+ status,
532
+ conditions: input.conditions,
533
+ metrics,
534
+ insights: aggregateInsights(input.diagnostic.insights),
535
+ unavailable: [...unavailable.values()],
536
+ failures: input.failures,
537
+ traces: input.traces
538
+ };
539
+ }
540
+ //#endregion
541
+ //#region src/lab/lab.ts
542
+ const NETWORK_PRESETS = {
543
+ slow4g: {
544
+ latencyMs: 562.5,
545
+ downloadBytesPerSec: 18e4,
546
+ uploadBytesPerSec: 84375
547
+ },
548
+ fast4g: {
549
+ latencyMs: 165,
550
+ downloadBytesPerSec: 1012500,
551
+ uploadBytesPerSec: 168750
552
+ },
553
+ none: null
554
+ };
555
+ const TRACE_CATEGORIES = [
556
+ "blink.console",
557
+ "blink.user_timing",
558
+ "loading",
559
+ "devtools.timeline",
560
+ "disabled-by-default-devtools.target-rundown",
561
+ "disabled-by-default-devtools.timeline.frame",
562
+ "disabled-by-default-devtools.timeline.stack",
563
+ "disabled-by-default-devtools.timeline",
564
+ "disabled-by-default-devtools.v8-source-rundown-sources",
565
+ "disabled-by-default-devtools.v8-source-rundown",
566
+ "disabled-by-default-layout_shift.debug",
567
+ "disabled-by-default-v8.inspector",
568
+ "disabled-by-default-v8.cpu_profiler.hires",
569
+ "disabled-by-default-lighthouse",
570
+ "v8.execute",
571
+ "v8",
572
+ "cppgc",
573
+ "navigation",
574
+ "rail",
575
+ "disabled-by-default-v8.cpu_profiler"
576
+ ];
577
+ const VIEWPORT = {
578
+ width: 1280,
579
+ height: 720
580
+ };
581
+ const NAVIGATION_TIMEOUT_MS = 6e4;
582
+ const LOCATE_TIMEOUT_MS = 15e3;
583
+ const QUIET_MS = 500;
584
+ const SETTLE_MAX_MS = 1e4;
585
+ const TEXT_CAP = 40;
586
+ const BINDING = "__pkaLabReport";
587
+ const NAVIGATION_BINDING = "__pkaLabNavigate";
588
+ /** A step that could not be replayed; it fails the run, not the command. */
589
+ var StepError = class extends Error {
590
+ step;
591
+ constructor(step, message, options) {
592
+ super(message, options);
593
+ this.step = step;
594
+ }
595
+ };
596
+ /** Keeps the actions and navigations of a timeline, in seq order. */
597
+ function flowFromTimeline(entries) {
598
+ const steps = entries.filter((entry) => entry.kind === "action" || entry.kind === "navigation").sort((a, b) => a.seq - b.seq);
599
+ if (!steps.some((step) => step.kind === "action")) throw new PkaError("The flow has no actions; record a flow that clicks or types something.");
600
+ return steps;
601
+ }
602
+ async function readFlowFile(file) {
603
+ return flowFromTimeline((await readFile(file, "utf8")).split("\n").flatMap((line, index) => {
604
+ if (line.trim() === "") return [];
605
+ let data;
606
+ try {
607
+ data = JSON.parse(line);
608
+ } catch (thrown) {
609
+ throw new PkaError(`Invalid JSON in ${file}:${index + 1}`, { cause: thrown });
610
+ }
611
+ const parsed = TimelineEntry.safeParse(data);
612
+ if (!parsed.success) throw new PkaError(`Invalid timeline entry in ${file}:${index + 1}:\n${z.prettifyError(parsed.error)}`);
613
+ return [parsed.data];
614
+ }));
615
+ }
616
+ /**
617
+ * Reads the flow from an annotation's recording. The recording attachment
618
+ * points at capture/summary.md; timeline.jsonl and manifest.json sit in the same
619
+ * directory. A recording started on an already loaded page has no navigation
620
+ * before its first action, so the replay starts with a load of the manifest's
621
+ * start URL, rebased onto --url like every recorded navigation.
622
+ */
623
+ async function readAnnotationFlow(store, id) {
624
+ const { annotation } = await get(store, {
625
+ id,
626
+ detail: "concise"
627
+ });
628
+ const recording = annotation.attachments.find((item) => item.kind === "recording");
629
+ if (recording === void 0) throw new PkaError(`Annotation ${id} has no recording; record a flow in the overlay or pass a timeline file to --flow.`);
630
+ const dir = path.posix.dirname(recording.path);
631
+ const file = resolveInside(annotation.dir, dir, "timeline.jsonl");
632
+ const entries = await readJsonLines(store, file, TimelineEntry);
633
+ if (entries.length === 0) throw new PkaError(`Annotation ${id}'s ${path.relative(annotation.dir, file)} is missing or empty; record the flow again or pass a timeline file to --flow.`);
634
+ const steps = flowFromTimeline(entries);
635
+ const first = steps[0];
636
+ if (first === void 0 || first.kind === "navigation") return steps;
637
+ const manifestFile = resolveInside(annotation.dir, dir, "manifest.json");
638
+ let manifest;
639
+ try {
640
+ manifest = await readJson(store, manifestFile, RecordingManifest);
641
+ } catch (thrown) {
642
+ if (!isErrno(thrown, "ENOENT")) throw thrown;
643
+ throw new PkaError(`Annotation ${id}'s ${path.relative(annotation.dir, manifestFile)} is missing, so the start page is unknown; record the flow again or pass a timeline file to --flow.`, { cause: thrown });
644
+ }
645
+ return [{
646
+ kind: "navigation",
647
+ seq: Math.max(0, first.seq - 1),
648
+ at: manifest.startedAt,
649
+ type: "load",
650
+ to: manifest.url
651
+ }, ...steps];
652
+ }
653
+ const PageMetric = z.strictObject({
654
+ doc: z.string(),
655
+ navigation: z.string(),
656
+ name: LabMetric,
657
+ value: z.number(),
658
+ url: z.string(),
659
+ target: z.string().optional(),
660
+ resource: z.string().optional(),
661
+ breakdown: z.record(z.string(), z.number()),
662
+ script: LoafScript.optional(),
663
+ interaction: z.number().int().optional()
664
+ });
665
+ const PageReport = z.string().transform((text, ctx) => {
666
+ try {
667
+ return JSON.parse(text);
668
+ } catch {
669
+ ctx.addIssue({
670
+ code: "custom",
671
+ message: "the report is not JSON"
672
+ });
673
+ return z.NEVER;
674
+ }
675
+ }).pipe(PageMetric);
676
+ const HistoryChange = z.enum([
677
+ "push",
678
+ "replace",
679
+ "reload",
680
+ "traverse"
681
+ ]);
682
+ const NavigationReport = z.strictObject({
683
+ type: HistoryChange,
684
+ url: z.url()
685
+ });
686
+ function webVitalsSource() {
687
+ const entry = createRequire(import.meta.url).resolve("web-vitals/attribution");
688
+ return path.join(path.dirname(entry), "web-vitals.attribution.iife.js");
689
+ }
690
+ async function pageScript() {
691
+ return `(() => {
692
+ if (window !== window.top) return;
693
+ ${(await readFile(webVitalsSource(), "utf8")).replace(/\/\/# sourceMappingURL=.*$/m, "")}
694
+ const doc = Math.random().toString(36).slice(2);
695
+ const describe = (node) => {
696
+ if (!(node instanceof Element)) return undefined;
697
+ const tag = node.localName;
698
+ const testId = node.getAttribute("data-testid");
699
+ if (testId) return tag + '[data-testid="' + testId + '"]';
700
+ if (node.id) return tag + "#" + node.id;
701
+ return undefined;
702
+ };
703
+ const send = (metric, extra) => {
704
+ const report = window[${JSON.stringify(BINDING)}];
705
+ if (typeof report !== "function") return;
706
+ report(JSON.stringify({
707
+ doc,
708
+ navigation: String(metric.navigationId ?? ""),
709
+ name: metric.name,
710
+ value: metric.value,
711
+ url: metric.navigationURL || location.href,
712
+ ...extra,
713
+ }));
714
+ };
715
+ navigation.addEventListener("navigate", (event) => {
716
+ const report = window[${JSON.stringify(NAVIGATION_BINDING)}];
717
+ if (typeof report === "function") report(event.navigationType, event.destination.url);
718
+ });
719
+ const options = { reportAllChanges: true, reportSoftNavs: true, generateTarget: describe };
720
+ webVitals.onLCP((metric) => {
721
+ const a = metric.attribution;
722
+ send(metric, {
723
+ target: a.target,
724
+ resource: a.url,
725
+ breakdown: {
726
+ timeToFirstByte: a.timeToFirstByte,
727
+ resourceLoadDelay: a.resourceLoadDelay,
728
+ resourceLoadDuration: a.resourceLoadDuration,
729
+ elementRenderDelay: a.elementRenderDelay,
730
+ },
731
+ });
732
+ }, options);
733
+ webVitals.onINP((metric) => {
734
+ const a = metric.attribution;
735
+ const longest = a.longestScript;
736
+ send(metric, {
737
+ target: a.interactionTarget,
738
+ interaction: metric.entries[0]?.interactionId,
739
+ breakdown: {
740
+ inputDelay: a.inputDelay,
741
+ processingDuration: a.processingDuration,
742
+ presentationDelay: a.presentationDelay,
743
+ },
744
+ script: longest && {
745
+ url: longest.entry.sourceURL,
746
+ function: longest.entry.sourceFunctionName,
747
+ invoker: longest.entry.invoker,
748
+ subpart: longest.subpart,
749
+ durationMs: longest.intersectingDuration,
750
+ },
751
+ });
752
+ }, { ...options, durationThreshold: 16 });
753
+ webVitals.onCLS((metric) => {
754
+ const a = metric.attribution;
755
+ send(metric, {
756
+ target: a.largestShiftTarget,
757
+ breakdown: a.largestShiftValue === undefined ? {} : { largestShiftValue: a.largestShiftValue },
758
+ });
759
+ }, options);
760
+ })();`;
761
+ }
762
+ function escapeRegExp(text) {
763
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
764
+ }
765
+ function nameMatcher(text) {
766
+ const escaped = escapeRegExp(text).replace(/ /g, "\\s+");
767
+ return new RegExp(text.length >= TEXT_CAP ? `^\\s*${escaped}` : `^\\s*${escaped}\\s*$`);
768
+ }
769
+ const IMPLICIT_ROLES = /* @__PURE__ */ new Map([
770
+ ["a", "link"],
771
+ ["button", "button"],
772
+ ["select", "combobox"],
773
+ ["textarea", "textbox"],
774
+ ["summary", "button"],
775
+ ["h1", "heading"],
776
+ ["h2", "heading"],
777
+ ["h3", "heading"]
778
+ ]);
779
+ function candidates(page, target) {
780
+ const found = [];
781
+ if (target.testId !== void 0) found.push(["testId", page.getByTestId(target.testId)]);
782
+ if (target.id !== void 0) found.push(["id", page.locator(`[id="${target.id.replace(/["\\]/g, "\\$&")}"]`)]);
783
+ const name = target.text ?? target.label;
784
+ const role = target.role ?? IMPLICIT_ROLES.get(target.tag);
785
+ if (role !== void 0 && name !== void 0) found.push(["role", page.getByRole(role, { name: nameMatcher(name) })]);
786
+ if (target.label !== void 0) found.push(["label", page.getByLabel(nameMatcher(target.label))]);
787
+ if (target.text !== void 0) found.push(["text", page.locator(target.tag).filter({
788
+ hasText: nameMatcher(target.text),
789
+ visible: true
790
+ })]);
791
+ return found;
792
+ }
793
+ /** Finds the one element a recorded descriptor names, waiting for it to render. */
794
+ async function locate(page, target, step) {
795
+ const strategies = candidates(page, target);
796
+ if (strategies.length === 0) throw new StepError(step, `Step ${step}: the target ${JSON.stringify(target)} has no test id, id, role, label, or text to find it by`);
797
+ const deadline = Date.now() + LOCATE_TIMEOUT_MS;
798
+ let notes = [];
799
+ for (;;) {
800
+ notes = [];
801
+ for (const [strategy, locator] of strategies) {
802
+ let count;
803
+ try {
804
+ count = await locator.count();
805
+ } catch (thrown) {
806
+ if (page.isClosed()) throw thrown;
807
+ notes.push(`${strategy} rejected: ${thrown instanceof Error ? thrown.message.split("\n")[0] : String(thrown)}`);
808
+ continue;
809
+ }
810
+ if (count === 1) return locator;
811
+ if (count > 1) notes.push(`${strategy} matched ${count}`);
812
+ }
813
+ if (Date.now() >= deadline) break;
814
+ await setTimeout(100);
815
+ }
816
+ const why = notes.length > 0 ? notes.join(", ") : "nothing matched";
817
+ throw new StepError(step, `Step ${step}: cannot find ${JSON.stringify(target)} (${why})`);
818
+ }
819
+ function placeholder(type) {
820
+ if (type === "number" || type === "range") return "1";
821
+ if (type === "email") return "lab@example.com";
822
+ if (type === "url") return "https://example.com";
823
+ if (type === "tel") return "5550100";
824
+ return "pka lab";
825
+ }
826
+ function sameTarget(a, b) {
827
+ return JSON.stringify(a) === JSON.stringify(b);
828
+ }
829
+ function trackRequests(page) {
830
+ const inflight = /* @__PURE__ */ new Set();
831
+ let lastChange = Date.now();
832
+ page.on("request", (request) => {
833
+ if (request.resourceType() === "eventsource" || request.resourceType() === "websocket") return;
834
+ inflight.add(request);
835
+ lastChange = Date.now();
836
+ });
837
+ const done = (request) => {
838
+ if (inflight.delete(request)) lastChange = Date.now();
839
+ };
840
+ page.on("requestfinished", done);
841
+ page.on("requestfailed", done);
842
+ return { quiet: (since) => inflight.size === 0 && Date.now() - Math.max(lastChange, since) >= QUIET_MS };
843
+ }
844
+ async function settle(page, tracker) {
845
+ const since = Date.now();
846
+ const deadline = since + SETTLE_MAX_MS;
847
+ while (!tracker.quiet(since) && Date.now() < deadline) await setTimeout(50);
848
+ await page.waitForLoadState("load", { timeout: NAVIGATION_TIMEOUT_MS });
849
+ }
850
+ function samePage(current, target) {
851
+ const a = new URL(current);
852
+ const b = new URL(target);
853
+ return a.origin === b.origin && a.pathname === b.pathname && a.search === b.search;
854
+ }
855
+ function producedByReplay(step, target, state) {
856
+ const want = new URL(target);
857
+ return state.changes.some(({ type, url, entered }) => {
858
+ if (type === "reload" || type === "traverse" !== (step.type === "traverse")) return false;
859
+ const got = new URL(url);
860
+ if (got.origin !== want.origin || got.pathname !== want.pathname) return false;
861
+ return entered || got.search === want.search;
862
+ });
863
+ }
864
+ async function fieldOf(locator) {
865
+ const { tag, inputType } = await locator.evaluate((element) => ({
866
+ tag: element.localName,
867
+ inputType: element instanceof HTMLInputElement ? element.type : ""
868
+ }));
869
+ if (inputType === "checkbox" || inputType === "radio") return {
870
+ kind: "checkable",
871
+ inputType
872
+ };
873
+ return {
874
+ kind: tag === "select" ? "select" : "text",
875
+ inputType
876
+ };
877
+ }
878
+ function alreadyCaused(step, previous, field) {
879
+ if (previous?.kind !== "action" || !sameTarget(previous.target, step.target)) return false;
880
+ if (field.kind === "checkable") return step.type !== "click" && previous.type !== "change";
881
+ return field.kind === "select" && step.type === "change" && previous.type === "input";
882
+ }
883
+ async function enterValue(locator, field) {
884
+ if (field.kind === "checkable") await locator.click({ timeout: LOCATE_TIMEOUT_MS });
885
+ else if (field.kind === "select") await selectAnother(locator);
886
+ else {
887
+ await locator.fill("", { timeout: LOCATE_TIMEOUT_MS });
888
+ await locator.pressSequentially(placeholder(field.inputType));
889
+ }
890
+ }
891
+ async function replayAction(page, step, previous, index, state) {
892
+ if (step.type === "submit" && previous?.kind === "action") {
893
+ if (previous.type === "click" || previous.type === "keydown" && previous.key === "Enter") return;
894
+ }
895
+ const locator = await locate(page, step.target, index);
896
+ const field = await fieldOf(locator);
897
+ if (alreadyCaused(step, previous, field)) return;
898
+ state.changes.length = 0;
899
+ switch (step.type) {
900
+ case "click":
901
+ await locator.click({ timeout: LOCATE_TIMEOUT_MS });
902
+ return;
903
+ case "input":
904
+ state.entered = true;
905
+ await enterValue(locator, field);
906
+ return;
907
+ case "change":
908
+ if (field.kind === "text") await locator.blur();
909
+ else {
910
+ state.entered = true;
911
+ await enterValue(locator, field);
912
+ }
913
+ return;
914
+ case "submit":
915
+ await locator.evaluate((element) => {
916
+ if (!(element instanceof HTMLFormElement)) throw new Error(`submit target is <${element.localName}>, not a form`);
917
+ element.requestSubmit();
918
+ });
919
+ return;
920
+ case "keydown":
921
+ if (step.key === void 0) throw new StepError(index, `Step ${index}: keydown without Enter or Escape`);
922
+ await locator.press(step.key, { timeout: LOCATE_TIMEOUT_MS });
923
+ return;
924
+ }
925
+ }
926
+ async function selectAnother(locator) {
927
+ const next = await locator.evaluate((element) => {
928
+ if (!(element instanceof HTMLSelectElement)) throw new Error("not a select");
929
+ const other = [...element.options].filter((option) => !option.disabled).find((option) => option.index !== element.selectedIndex);
930
+ if (other === void 0) throw new Error("the select has no other enabled option");
931
+ return other.value;
932
+ });
933
+ await locator.selectOption(next);
934
+ }
935
+ /** Maps a recorded URL onto the build under test when it points at the recorded app. */
936
+ function rebase(recorded, appOrigin, base) {
937
+ const url = new URL(recorded, base);
938
+ if (appOrigin !== void 0 && url.origin !== appOrigin) return url.href;
939
+ return new URL(url.pathname + url.search + url.hash, base).href;
940
+ }
941
+ /**
942
+ * Replays the steps in order. `state.changes` receives the page's navigations
943
+ * as they start; the replay empties it before each step it performs.
944
+ */
945
+ async function replay(page, tracker, state, steps, base) {
946
+ const first = steps.find((step) => step.kind === "navigation");
947
+ const appOrigin = first === void 0 ? void 0 : new URL(first.to, base).origin;
948
+ if (steps[0]?.kind !== "navigation") {
949
+ await page.goto(base.href, { timeout: NAVIGATION_TIMEOUT_MS });
950
+ await settle(page, tracker);
951
+ }
952
+ for (const [index, step] of steps.entries()) try {
953
+ if (step.kind === "navigation") {
954
+ const target = rebase(step.to, appOrigin, base);
955
+ if (step.type === "reload") {
956
+ await flushVitals(page);
957
+ state.changes.length = 0;
958
+ await page.reload({ timeout: NAVIGATION_TIMEOUT_MS });
959
+ } else if (step.type === "load" || !producedByReplay(step, target, state) && !samePage(page.url(), target)) {
960
+ await flushVitals(page);
961
+ state.changes.length = 0;
962
+ await page.goto(target, { timeout: NAVIGATION_TIMEOUT_MS });
963
+ }
964
+ } else await replayAction(page, step, steps[index - 1], index, state);
965
+ await settle(page, tracker);
966
+ } catch (thrown) {
967
+ if (thrown instanceof StepError) throw thrown;
968
+ throw new StepError(index, `Step ${index} (${step.kind === "action" ? `${step.type} ${JSON.stringify(step.target)}` : `${step.type} ${step.to}`}) failed: ${thrown instanceof Error ? thrown.message.split("\n")[0] : String(thrown)}`, { cause: thrown });
969
+ }
970
+ await flushVitals(page);
971
+ }
972
+ async function flushVitals(page) {
973
+ await page.evaluate(() => new Promise((resolve) => {
974
+ requestIdleCallback(() => resolve(), { timeout: 2e3 });
975
+ }));
976
+ }
977
+ async function startTrace(cdp) {
978
+ await cdp.send("Tracing.start", {
979
+ transferMode: "ReturnAsStream",
980
+ traceConfig: {
981
+ includedCategories: TRACE_CATEGORIES,
982
+ excludedCategories: ["*"]
983
+ }
984
+ });
985
+ }
986
+ /** Ends the trace and returns the handle of the stream Chrome wrote it to. */
987
+ async function endTrace(cdp) {
988
+ const complete = new Promise((resolve, reject) => {
989
+ cdp.once("Tracing.tracingComplete", (event) => {
990
+ if (event.stream === void 0) reject(/* @__PURE__ */ new Error("Chrome returned no trace stream"));
991
+ else resolve(event.stream);
992
+ });
993
+ });
994
+ await cdp.send("Tracing.end");
995
+ return await complete;
996
+ }
997
+ async function saveTrace(cdp, handle, file) {
998
+ const chunks = [];
999
+ await pipeline(async function* () {
1000
+ for (;;) {
1001
+ const read = await cdp.send("IO.read", { handle });
1002
+ const chunk = Buffer.from(read.data, read.base64Encoded === true ? "base64" : "utf8");
1003
+ chunks.push(chunk);
1004
+ yield chunk;
1005
+ if (read.eof) return;
1006
+ }
1007
+ }, createGzip(), createWriteStream(file));
1008
+ await cdp.send("IO.close", { handle });
1009
+ return Buffer.concat(chunks).toString("utf8");
1010
+ }
1011
+ const TraceFile = z.union([z.array(z.unknown()), z.looseObject({ traceEvents: z.array(z.unknown()) }).transform((file) => file.traceEvents)]);
1012
+ function worstPerMetric(reports) {
1013
+ const metrics = {};
1014
+ for (const report of reports.values()) {
1015
+ const current = metrics[report.name];
1016
+ if (current !== void 0 && current.value >= report.value) continue;
1017
+ const measured = {
1018
+ value: report.value,
1019
+ page: report.url,
1020
+ element: report.target,
1021
+ resource: report.resource,
1022
+ breakdown: report.breakdown,
1023
+ script: report.script,
1024
+ interaction: report.interaction
1025
+ };
1026
+ metrics[report.name] = measured;
1027
+ }
1028
+ return metrics;
1029
+ }
1030
+ async function openSession(options) {
1031
+ const { context } = options;
1032
+ const reports = /* @__PURE__ */ new Map();
1033
+ const state = {
1034
+ changes: [],
1035
+ entered: false
1036
+ };
1037
+ const invalid = [];
1038
+ await context.exposeBinding(BINDING, (_source, report) => {
1039
+ const parsed = PageReport.safeParse(report);
1040
+ if (!parsed.success) {
1041
+ invalid.push(z.prettifyError(parsed.error));
1042
+ return;
1043
+ }
1044
+ const metric = parsed.data;
1045
+ reports.set(`${metric.doc}:${metric.navigation}:${metric.name}`, metric);
1046
+ });
1047
+ await context.exposeBinding(NAVIGATION_BINDING, (_source, type, url) => {
1048
+ const parsed = NavigationReport.safeParse({
1049
+ type,
1050
+ url
1051
+ });
1052
+ if (parsed.success) state.changes.push({
1053
+ ...parsed.data,
1054
+ entered: state.entered
1055
+ });
1056
+ else invalid.push(z.prettifyError(parsed.error));
1057
+ });
1058
+ await context.addInitScript({ content: await pageScript() });
1059
+ const page = await context.newPage();
1060
+ page.on("domcontentloaded", () => {
1061
+ state.entered = false;
1062
+ });
1063
+ const cdp = await context.newCDPSession(page);
1064
+ await cdp.send("Emulation.setCPUThrottlingRate", { rate: options.cpuRate });
1065
+ const network = NETWORK_PRESETS[options.network];
1066
+ await cdp.send("Network.enable");
1067
+ if (network !== null) await cdp.send("Network.emulateNetworkConditions", {
1068
+ offline: false,
1069
+ latency: network.latencyMs,
1070
+ downloadThroughput: network.downloadBytesPerSec,
1071
+ uploadThroughput: network.uploadBytesPerSec
1072
+ });
1073
+ return {
1074
+ page,
1075
+ cdp,
1076
+ tracker: trackRequests(page),
1077
+ state,
1078
+ reports,
1079
+ invalid
1080
+ };
1081
+ }
1082
+ function checkReports(session, label) {
1083
+ if (session.invalid.length > 0) throw new Error(`${label}: the page script sent an invalid report:\n${session.invalid[0]}`);
1084
+ }
1085
+ /** One untraced replay; its metrics are a verdict sample. */
1086
+ async function measure(run, options) {
1087
+ const session = await openSession(options);
1088
+ await replay(session.page, session.tracker, session.state, options.steps, options.base);
1089
+ checkReports(session, `Run ${run}`);
1090
+ return {
1091
+ run,
1092
+ metrics: worstPerMetric(session.reports)
1093
+ };
1094
+ }
1095
+ async function traceStep(what, work) {
1096
+ try {
1097
+ return await work();
1098
+ } catch (thrown) {
1099
+ throw new TraceError(`${what}: ${thrown instanceof Error ? thrown.message.split("\n")[0] : String(thrown)}`, { cause: thrown });
1100
+ }
1101
+ }
1102
+ async function diagnose(options, file) {
1103
+ const session = await openSession(options);
1104
+ const { cdp } = session;
1105
+ await traceStep("starting the trace failed", () => startTrace(cdp));
1106
+ try {
1107
+ await replay(session.page, session.tracker, session.state, options.steps, options.base);
1108
+ } catch (thrown) {
1109
+ const traceError = await endTrace(cdp).then((handle) => cdp.send("IO.close", { handle })).then(() => void 0, (failure) => failure.message);
1110
+ if (thrown instanceof StepError && traceError !== void 0) throw new StepError(thrown.step, `${thrown.message} (trace discarded: ${traceError})`, { cause: thrown });
1111
+ throw thrown;
1112
+ }
1113
+ const handle = await traceStep("ending the trace failed", () => endTrace(cdp));
1114
+ checkReports(session, "The traced run");
1115
+ const trace = await traceStep(`saving the trace to ${file} failed`, () => saveTrace(cdp, handle, file));
1116
+ const analysis = await analyzeTrace(await traceStep(`the trace in ${file} is not Chrome trace JSON`, async () => {
1117
+ const parsed = TraceFile.safeParse(JSON.parse(trace));
1118
+ if (!parsed.success) throw new Error("it is neither an event array nor an object with a traceEvents array");
1119
+ return parsed.data;
1120
+ }));
1121
+ const diagnostic = {
1122
+ insights: analysis.insights,
1123
+ unavailable: analysis.unavailable,
1124
+ hot: void 0
1125
+ };
1126
+ const longest = analysis.longestInteraction;
1127
+ if (longest?.hotFunction === void 0) return diagnostic;
1128
+ const inp = worstPerMetric(session.reports).INP;
1129
+ if (inp?.interaction === longest.id) diagnostic.hot = {
1130
+ hotFunction: longest.hotFunction,
1131
+ page: inp.page,
1132
+ element: inp.element
1133
+ };
1134
+ else diagnostic.unavailable.push({
1135
+ name: "hotFunction",
1136
+ reason: "the traced run's longest interaction is not the INP interaction it reported"
1137
+ });
1138
+ return diagnostic;
1139
+ }
1140
+ async function loadPlaywright() {
1141
+ try {
1142
+ return await import("playwright");
1143
+ } catch (thrown) {
1144
+ if (thrown instanceof Error && "code" in thrown && thrown.code === "ERR_MODULE_NOT_FOUND") throw new PkaError("pka lab needs Playwright, an optional peer dependency: pnpm add -D playwright && pnpm exec playwright install chromium", { cause: thrown });
1145
+ throw thrown;
1146
+ }
1147
+ }
1148
+ /**
1149
+ * Replays the flow `runs` times untraced in fresh contexts, then once more under
1150
+ * a DevTools trace for insights, and writes <out>/verdict.json.
1151
+ */
1152
+ async function runLab(options) {
1153
+ const { chromium } = await loadPlaywright();
1154
+ const base = new URL(options.url);
1155
+ const out = path.resolve(options.out);
1156
+ await mkdir(out, { recursive: true });
1157
+ const browser = await chromium.launch({
1158
+ channel: "chromium",
1159
+ args: ["--disable-blink-features=AutomationControlled"]
1160
+ });
1161
+ const results = [];
1162
+ const failures = [];
1163
+ const conditions = {
1164
+ steps: options.flow,
1165
+ base,
1166
+ cpuRate: options.cpuRate,
1167
+ network: options.network
1168
+ };
1169
+ try {
1170
+ for (let run = 1; run <= options.runs; run += 1) {
1171
+ const context = await browser.newContext({ viewport: VIEWPORT });
1172
+ try {
1173
+ results.push(await measure(run, {
1174
+ context,
1175
+ ...conditions
1176
+ }));
1177
+ options.onRun(run, void 0);
1178
+ } catch (thrown) {
1179
+ if (!(thrown instanceof StepError)) throw thrown;
1180
+ const failure = {
1181
+ run,
1182
+ step: thrown.step,
1183
+ message: thrown.message
1184
+ };
1185
+ failures.push(failure);
1186
+ options.onRun(run, failure);
1187
+ } finally {
1188
+ await context.close();
1189
+ }
1190
+ }
1191
+ const traces = [];
1192
+ let diagnostic = {
1193
+ insights: [],
1194
+ unavailable: [],
1195
+ hot: void 0
1196
+ };
1197
+ if (results.length > 0) {
1198
+ const trace = path.join(out, "trace.json.gz");
1199
+ const context = await browser.newContext({ viewport: VIEWPORT });
1200
+ try {
1201
+ diagnostic = await diagnose({
1202
+ context,
1203
+ ...conditions
1204
+ }, trace);
1205
+ traces.push(trace);
1206
+ options.onRun("trace", void 0);
1207
+ } catch (thrown) {
1208
+ if (!(thrown instanceof StepError) && !(thrown instanceof TraceError)) throw thrown;
1209
+ diagnostic.unavailable.push({
1210
+ name: "trace",
1211
+ reason: `the traced run failed: ${thrown.message}`
1212
+ });
1213
+ options.onRun("trace", { message: thrown.message });
1214
+ } finally {
1215
+ await context.close();
1216
+ }
1217
+ }
1218
+ const network = NETWORK_PRESETS[options.network];
1219
+ const verdict = Verdict.parse(computeVerdict({
1220
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1221
+ conditions: {
1222
+ url: base.href,
1223
+ flow: options.flowLabel,
1224
+ steps: options.flow.length,
1225
+ runs: options.runs,
1226
+ measurementTracing: false,
1227
+ diagnosticRuns: results.length > 0 ? 1 : 0,
1228
+ cpuRate: options.cpuRate,
1229
+ network: options.network,
1230
+ networkConditions: network === null ? null : { ...network },
1231
+ cache: "cold",
1232
+ chromium: browser.version(),
1233
+ viewport: `${VIEWPORT.width}x${VIEWPORT.height}`
1234
+ },
1235
+ budgets: options.budgets,
1236
+ runs: results,
1237
+ failures,
1238
+ diagnostic,
1239
+ traces
1240
+ }));
1241
+ const file = path.join(out, "verdict.json");
1242
+ await writeFile(file, `${JSON.stringify(verdict, null, 2)}\n`);
1243
+ return {
1244
+ verdict,
1245
+ file
1246
+ };
1247
+ } finally {
1248
+ await browser.close();
1249
+ }
1250
+ }
1251
+ //#endregion
1252
+ //#region src/cli/main.ts
1253
+ const USAGE = `Usage: pka [--root DIR] [--json] <command>
1254
+
1255
+ list [--status pending|acknowledged|resolved|dismissed|all] [--limit N] [--cursor ID] [--detail concise|full]
1256
+ get <id> [--detail concise|full]
1257
+ watch [--once] [--timeout SECONDS] one line per new pending annotation
1258
+ status <id> <acknowledged|resolved|dismissed> [--note TEXT]
1259
+ reply <id> <text>
1260
+ errors [--since ISO-TIME] [--limit N] [--detail concise|full]
1261
+ prune remove resolved and dismissed annotations older than 7 days
1262
+ lab --url URL --flow FILE|ID [--runs 5] [--cpu 4] [--network slow4g|fast4g|none]
1263
+ [--budget lcp=2500,inp=200,cls=0.1] [--attach ID] [--out DIR]
1264
+ replay a recorded flow against a production build
1265
+ and write <out>/verdict.json (needs playwright)
1266
+
1267
+ The store is <root>/_interim/annotations. Without --root or PKA_ROOT, pka uses
1268
+ CLAUDE_PROJECT_DIR, then the nearest parent of the working directory that has one.
1269
+ status acknowledged claims an annotation as $PKA_CLAIMANT (default pka-cli); later
1270
+ status changes, replies, and lab --attach must come from the same claimant.
1271
+ pka lab: --flow is a timeline.jsonl file or an annotation id with a recording
1272
+ (its timeline.jsonl sits beside capture/summary.md). Budgets left out use the Core Web Vitals "good"
1273
+ thresholds. --out defaults to $TMPDIR/pka-lab/<time>. --attach adds the
1274
+ verdict to that annotation as a perf attachment.
1275
+ Exit codes: 0 ok, 1 operation error, 2 usage error, 3 lab verdict is fail or incomplete.`;
1276
+ const COMMAND_OPTIONS = {
1277
+ list: [
1278
+ "status",
1279
+ "limit",
1280
+ "cursor",
1281
+ "detail"
1282
+ ],
1283
+ get: ["detail"],
1284
+ watch: ["once", "timeout"],
1285
+ status: ["note"],
1286
+ reply: [],
1287
+ errors: [
1288
+ "since",
1289
+ "limit",
1290
+ "detail"
1291
+ ],
1292
+ prune: [],
1293
+ lab: [
1294
+ "url",
1295
+ "flow",
1296
+ "runs",
1297
+ "cpu",
1298
+ "network",
1299
+ "budget",
1300
+ "attach",
1301
+ "out"
1302
+ ]
1303
+ };
1304
+ const POSITIONALS = {
1305
+ list: 0,
1306
+ get: 1,
1307
+ watch: 0,
1308
+ status: 2,
1309
+ reply: 2,
1310
+ errors: 0,
1311
+ prune: 0,
1312
+ lab: 0
1313
+ };
1314
+ const Command = z.enum([
1315
+ "list",
1316
+ "get",
1317
+ "watch",
1318
+ "status",
1319
+ "reply",
1320
+ "errors",
1321
+ "prune",
1322
+ "lab"
1323
+ ]);
1324
+ const WatchInput = z.strictObject({
1325
+ once: z.boolean(),
1326
+ timeout: z.number().positive().max(86400).optional()
1327
+ });
1328
+ const LabInput = z.strictObject({
1329
+ url: z.url({ protocol: /^https?$/ }),
1330
+ flow: z.string().min(1),
1331
+ runs: z.number().int().min(1).max(50).default(5),
1332
+ cpu: z.number().min(1).max(20).default(4),
1333
+ network: NetworkPreset.default("slow4g"),
1334
+ budget: BudgetOption,
1335
+ attach: Id.optional(),
1336
+ out: z.string().min(1).optional()
1337
+ });
1338
+ var UsageError = class extends Error {
1339
+ name = "UsageError";
1340
+ };
1341
+ function cliClaimant() {
1342
+ const parsed = z.string().trim().min(1).max(200).safeParse(process.env.PKA_CLAIMANT ?? "pka-cli");
1343
+ if (!parsed.success) throw new UsageError("PKA_CLAIMANT must be 1 to 200 characters");
1344
+ return parsed.data;
1345
+ }
1346
+ function parseInput(schema, input) {
1347
+ const parsed = schema.safeParse(input);
1348
+ if (!parsed.success) throw new UsageError(z.prettifyError(parsed.error));
1349
+ return parsed.data;
1350
+ }
1351
+ function numberOption(value) {
1352
+ return value === void 0 ? void 0 : Number(value);
1353
+ }
1354
+ function parseCommandLine(argv) {
1355
+ const { values, positionals, tokens } = parseArgs({
1356
+ args: argv,
1357
+ allowPositionals: true,
1358
+ tokens: true,
1359
+ strict: true,
1360
+ options: {
1361
+ root: { type: "string" },
1362
+ json: {
1363
+ type: "boolean",
1364
+ default: false
1365
+ },
1366
+ help: {
1367
+ type: "boolean",
1368
+ short: "h",
1369
+ default: false
1370
+ },
1371
+ status: { type: "string" },
1372
+ limit: { type: "string" },
1373
+ cursor: { type: "string" },
1374
+ detail: { type: "string" },
1375
+ once: {
1376
+ type: "boolean",
1377
+ default: false
1378
+ },
1379
+ timeout: { type: "string" },
1380
+ note: { type: "string" },
1381
+ since: { type: "string" },
1382
+ url: { type: "string" },
1383
+ flow: { type: "string" },
1384
+ runs: { type: "string" },
1385
+ cpu: { type: "string" },
1386
+ network: { type: "string" },
1387
+ budget: { type: "string" },
1388
+ attach: { type: "string" },
1389
+ out: { type: "string" }
1390
+ }
1391
+ });
1392
+ const [name, ...rest] = positionals;
1393
+ const command = Command.safeParse(name);
1394
+ if (!command.success) throw new UsageError(name === void 0 ? "Missing command" : `Unknown command ${name}`);
1395
+ const allowed = /* @__PURE__ */ new Set([
1396
+ "root",
1397
+ "json",
1398
+ "help",
1399
+ ...COMMAND_OPTIONS[command.data]
1400
+ ]);
1401
+ const unexpected = tokens.flatMap((token) => token.kind === "option" && !allowed.has(token.name) ? [token.name] : []);
1402
+ if (unexpected.length > 0) throw new UsageError(`${command.data} does not take --${unexpected.join(", --")}`);
1403
+ if (rest.length !== POSITIONALS[command.data]) throw new UsageError(`${command.data} takes ${POSITIONALS[command.data]} positional argument(s), got ${rest.length}`);
1404
+ return {
1405
+ command: command.data,
1406
+ args: rest,
1407
+ values
1408
+ };
1409
+ }
1410
+ function line(text) {
1411
+ process.stdout.write(`${text}\n`);
1412
+ }
1413
+ function firstLine(text) {
1414
+ return text.split("\n", 1)[0] ?? "";
1415
+ }
1416
+ function printAnnotation(view) {
1417
+ line(`${view.id} ${view.status}${view.claimedBy === void 0 ? "" : ` (${view.claimedBy})`}`);
1418
+ line(`route: ${view.route}`);
1419
+ line(`dir: ${view.dir}`);
1420
+ line(`prompt: ${view.prompt}`);
1421
+ for (const element of view.elements) {
1422
+ const label = [element.selector.role, element.selector.name].filter(Boolean).join(" ");
1423
+ line(` [${element.n}] ${element.source ?? "(no source)"}${element.usedAt === void 0 ? "" : ` usedAt ${element.usedAt}`} ${element.owners.join(" > ")} ${label || element.selector.css}`);
1424
+ }
1425
+ for (const attachment of view.attachments) line(` ${attachment.kind}: ${attachment.path}${attachment.summary ? ` ${attachment.summary}` : ""}`);
1426
+ for (const entry of view.thread ?? []) line(` ${entry.at} ${entry.from}: ${entry.text}`);
1427
+ if (view.omitted !== void 0) line(view.omitted.note);
1428
+ }
1429
+ function printList(result) {
1430
+ if (result.items.length === 0) line("No annotations.");
1431
+ for (const item of result.items) line(`${item.id} ${item.status} ${item.route} ${firstLine(item.prompt)}`);
1432
+ if (result.nextCursor !== void 0) line(`more: --cursor ${result.nextCursor}`);
1433
+ }
1434
+ function printErrors(result) {
1435
+ if (result.updatedAt === null) line("No error snapshot yet.");
1436
+ else if (result.groups.length === 0) line("No open errors.");
1437
+ for (const group of result.groups) {
1438
+ line(`${group.count}x ${group.type}: ${firstLine(group.message)} ${group.topFrame ?? ""}`);
1439
+ if (group.stack !== void 0) line(group.stack);
1440
+ }
1441
+ }
1442
+ async function watchCommand(store, values, json) {
1443
+ const input = parseInput(WatchInput, {
1444
+ once: values.once,
1445
+ timeout: numberOption(values.timeout)
1446
+ });
1447
+ const deadline = input.timeout === void 0 ? void 0 : Date.now() + input.timeout * 1e3;
1448
+ const delivered = /* @__PURE__ */ new Set();
1449
+ for (;;) {
1450
+ const timeoutMs = deadline === void 0 ? void 0 : deadline - Date.now();
1451
+ const result = timeoutMs !== void 0 && timeoutMs <= 0 ? { timedOut: true } : await wait(store, {
1452
+ timeoutMs,
1453
+ signal: void 0,
1454
+ onProgress: void 0,
1455
+ skip: delivered
1456
+ });
1457
+ const annotation = "annotation" in result ? result.annotation : void 0;
1458
+ if (annotation === void 0) {
1459
+ line(json ? JSON.stringify({ timedOut: true }) : "Timed out.");
1460
+ return;
1461
+ }
1462
+ delivered.add(annotation.id);
1463
+ line(json ? JSON.stringify({
1464
+ timedOut: false,
1465
+ annotation
1466
+ }) : `${annotation.id} ${annotation.route} ${firstLine(annotation.prompt)}`);
1467
+ if (input.once) return;
1468
+ }
1469
+ }
1470
+ function openStore(values) {
1471
+ return findStore({
1472
+ explicit: values.root ?? (process.env.PKA_ROOT || void 0),
1473
+ claudeProjectDir: process.env.CLAUDE_PROJECT_DIR || void 0,
1474
+ cwd: process.cwd()
1475
+ });
1476
+ }
1477
+ async function isFile(file) {
1478
+ try {
1479
+ return (await stat(file)).isFile();
1480
+ } catch (thrown) {
1481
+ if (isErrno(thrown, "ENOENT")) return false;
1482
+ throw thrown;
1483
+ }
1484
+ }
1485
+ function formatMetric(metric, value) {
1486
+ if (value === null) return "-";
1487
+ return metric === "CLS" ? value.toFixed(3) : `${value} ms`;
1488
+ }
1489
+ function printVerdict(verdict, file) {
1490
+ line(summarizeVerdict(verdict));
1491
+ for (const metric of LabMetric.options) {
1492
+ const result = verdict.metrics[metric];
1493
+ line(` ${metric} ${formatMetric(metric, result.median)} range ${formatMetric(metric, result.min)}..${formatMetric(metric, result.max)} budget ${formatMetric(metric, result.budget)} ${result.status}`);
1494
+ const culprit = result.culprit;
1495
+ if (culprit !== void 0) {
1496
+ const where = [culprit.element, culprit.resource].filter(Boolean).join(" ");
1497
+ line(` run ${culprit.run} ${culprit.page}${where === "" ? "" : ` ${where}`}`);
1498
+ if (culprit.script !== void 0) {
1499
+ const script = culprit.script;
1500
+ line(` LoAF script ${script.durationMs} ms in ${script.subpart}: ${script.invoker} → ${script.function || "(anonymous)"} ${script.url}`);
1501
+ }
1502
+ if (culprit.hotFunction !== void 0) {
1503
+ const hot = culprit.hotFunction;
1504
+ line(` hot ${hot.function} ${hot.url}:${hot.line}:${hot.column} (${hot.selfMs} ms self)`);
1505
+ }
1506
+ if (culprit.insight !== void 0) line(` ${culprit.insight}`);
1507
+ }
1508
+ if (result.note !== void 0) line(` ${result.note}`);
1509
+ }
1510
+ for (const insight of verdict.insights.filter((item) => item.state === "fail")) line(` insight ${insight.name} ${insight.page} (${insight.failed}/${insight.runs}): ${insight.summary}`);
1511
+ for (const missing of verdict.unavailable) line(` no ${missing.name}: ${missing.reason}`);
1512
+ for (const failure of verdict.failures) line(` run ${failure.run} failed: ${failure.message}`);
1513
+ line(`verdict: ${file}`);
1514
+ }
1515
+ async function labCommand(values) {
1516
+ const input = parseInput(LabInput, {
1517
+ url: values.url,
1518
+ flow: values.flow,
1519
+ runs: numberOption(values.runs),
1520
+ cpu: numberOption(values.cpu),
1521
+ network: values.network,
1522
+ budget: values.budget,
1523
+ attach: values.attach,
1524
+ out: values.out
1525
+ });
1526
+ let flow;
1527
+ let flowLabel;
1528
+ if (await isFile(input.flow)) {
1529
+ flow = await readFlowFile(input.flow);
1530
+ flowLabel = path.resolve(input.flow);
1531
+ } else if (ID_PATTERN.test(input.flow)) {
1532
+ flow = await readAnnotationFlow(await openStore(values), input.flow);
1533
+ flowLabel = `annotation ${input.flow}`;
1534
+ } else throw new UsageError(`--flow ${input.flow} is neither a timeline file nor an annotation id`);
1535
+ const store = input.attach === void 0 ? void 0 : await openStore(values);
1536
+ const claimant = cliClaimant();
1537
+ if (store !== void 0 && input.attach !== void 0) await validateAttachTarget(store, input.attach, claimant);
1538
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
1539
+ const { verdict, file } = await runLab({
1540
+ url: input.url,
1541
+ flow,
1542
+ flowLabel,
1543
+ runs: input.runs,
1544
+ cpuRate: input.cpu,
1545
+ network: input.network,
1546
+ budgets: input.budget,
1547
+ out: input.out ?? path.join(tmpdir(), "pka-lab", stamp),
1548
+ onRun: (run, failure) => {
1549
+ const phase = run === "trace" ? "diagnostic trace" : `run ${run}/${input.runs}`;
1550
+ process.stderr.write(`pka lab: ${phase} ${failure === void 0 ? "done" : `failed: ${failure.message}`}\n`);
1551
+ }
1552
+ });
1553
+ let attached;
1554
+ if (store !== void 0 && input.attach !== void 0) {
1555
+ const attachment = {
1556
+ kind: "perf",
1557
+ path: `capture/perf/lab-${stamp}.json`,
1558
+ summary: summarizeVerdict(verdict),
1559
+ data: Buffer.from(`${JSON.stringify(verdict, null, 2)}\n`)
1560
+ };
1561
+ try {
1562
+ await attach(store, input.attach, [attachment], claimant);
1563
+ } catch (cause) {
1564
+ const message = `Lab verdict written to ${file}; attachment failed`;
1565
+ if (!(cause instanceof PkaError)) throw new Error(message, { cause });
1566
+ throw new PkaError(`${message}: ${cause.message}`, { cause });
1567
+ }
1568
+ attached = {
1569
+ id: input.attach,
1570
+ path: attachment.path
1571
+ };
1572
+ }
1573
+ if (values.json) line(JSON.stringify({
1574
+ file,
1575
+ attached,
1576
+ verdict
1577
+ }));
1578
+ else {
1579
+ printVerdict(verdict, file);
1580
+ if (attached !== void 0) line(`attached: ${attached.id} ${attached.path}`);
1581
+ }
1582
+ return verdict.status === "pass" ? 0 : 3;
1583
+ }
1584
+ async function run(command, args, values) {
1585
+ const json = values.json;
1586
+ const store = await openStore(values);
1587
+ const output = (result, human) => {
1588
+ if (json) line(JSON.stringify(result));
1589
+ else human();
1590
+ };
1591
+ switch (command) {
1592
+ case "list": {
1593
+ const input = parseInput(ListInput, {
1594
+ status: values.status,
1595
+ limit: numberOption(values.limit),
1596
+ cursor: values.cursor,
1597
+ detail: values.detail
1598
+ });
1599
+ const result = await list(store, input);
1600
+ output(result, () => printList(result));
1601
+ return;
1602
+ }
1603
+ case "get": {
1604
+ const input = parseInput(GetInput, {
1605
+ id: args[0],
1606
+ detail: values.detail
1607
+ });
1608
+ const result = await get(store, input);
1609
+ output(result, () => printAnnotation(result.annotation));
1610
+ return;
1611
+ }
1612
+ case "watch": return watchCommand(store, values, json);
1613
+ case "status": {
1614
+ const input = parseInput(SetStatusInput, {
1615
+ id: args[0],
1616
+ status: args[1],
1617
+ note: values.note
1618
+ });
1619
+ const result = await setStatus(store, input, cliClaimant());
1620
+ output(result, () => line(`${result.id} ${result.status}${result.changed ? "" : " (unchanged)"}`));
1621
+ return;
1622
+ }
1623
+ case "reply": {
1624
+ const input = parseInput(ReplyInput, {
1625
+ id: args[0],
1626
+ text: args[1]
1627
+ });
1628
+ const result = await reply(store, input, cliClaimant());
1629
+ output(result, () => line(`Replied to ${result.id}.`));
1630
+ return;
1631
+ }
1632
+ case "errors": {
1633
+ const input = parseInput(ErrorsInput, {
1634
+ since: values.since,
1635
+ limit: numberOption(values.limit),
1636
+ detail: values.detail
1637
+ });
1638
+ const result = await errors(store, input);
1639
+ output(result, () => printErrors(result));
1640
+ return;
1641
+ }
1642
+ case "prune": {
1643
+ const result = await prune(store);
1644
+ output(result, () => line(`Removed ${result.removed.length}: ${result.removed.join(" ")}`));
1645
+ return;
1646
+ }
1647
+ case "lab": throw new Error("lab is dispatched before run()");
1648
+ }
1649
+ }
1650
+ async function main(argv) {
1651
+ try {
1652
+ const { command, args, values } = parseCommandLine(argv);
1653
+ if (values.help) {
1654
+ line(USAGE);
1655
+ return 0;
1656
+ }
1657
+ if (command === "lab") return await labCommand(values);
1658
+ await run(command, args, values);
1659
+ return 0;
1660
+ } catch (cause) {
1661
+ if (argv.includes("--help") || argv.includes("-h")) {
1662
+ line(USAGE);
1663
+ return 0;
1664
+ }
1665
+ if (cause instanceof UsageError || cause instanceof Error && "code" in cause && String(cause.code).startsWith("ERR_PARSE_ARGS")) {
1666
+ process.stderr.write(`pka: ${cause.message}\n\n${USAGE}\n`);
1667
+ return 2;
1668
+ }
1669
+ if (cause instanceof PkaError) {
1670
+ process.stderr.write(`pka: ${cause.message}\n`);
1671
+ return 1;
1672
+ }
1673
+ throw cause;
1674
+ }
1675
+ }
1676
+ process.exitCode = await main(process.argv.slice(2));
1677
+ //#endregion
1678
+ export {};