specpi 0.12.0 → 0.13.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.
@@ -1,9 +1,23 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
5
+ import type { Browser, BrowserContext, Page } from "playwright";
6
+ import { Check } from "typebox/value";
7
+ import { BrowserDiagnostics, DIAGNOSTIC_CATEGORIES } from "./diagnostics.ts";
8
+ import { BrowserCleanupError, settleBrowserCleanup } from "./lifecycle.ts";
9
+ import {
10
+ PressParams,
11
+ SelectionParams,
12
+ WaitParams,
13
+ interactionTimeout,
14
+ selectionOptions,
15
+ validateKey,
16
+ validateWait,
17
+ waitForCondition,
18
+ } from "./interactions.ts";
5
19
  import { StringEnum } from "@earendil-works/pi-ai";
6
- import { Type } from "typebox";
20
+ import { Type, type TSchema } from "typebox";
7
21
  import {
8
22
  MAX_CAPTURE_DIMENSION,
9
23
  MAX_CAPTURE_PIXELS,
@@ -75,10 +89,25 @@ const CompareParams = Type.Object({
75
89
  maxDiffPixelRatio: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
76
90
  });
77
91
 
92
+ const DiagnosticsParams = Type.Object(
93
+ {
94
+ maxEntries: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
95
+ maxChars: Type.Optional(Type.Integer({ minimum: 1000, maximum: 30000 })),
96
+ category: Type.Optional(StringEnum(DIAGNOSTIC_CATEGORIES)),
97
+ cursor: Type.Optional(Type.String({ maxLength: 80 })),
98
+ clear: Type.Optional(
99
+ Type.Boolean({
100
+ description: "Atomically read then clear ALL retained records, including filtered/unreturned records.",
101
+ }),
102
+ ),
103
+ },
104
+ { additionalProperties: false },
105
+ );
106
+
78
107
  type BrowserState = {
79
- browser?: any;
80
- context?: any;
81
- page?: any;
108
+ browser?: Browser;
109
+ context?: BrowserContext;
110
+ page?: Page;
82
111
  runtime?: Awaited<ReturnType<typeof loadBrowserRuntime>>;
83
112
  acceptedRefs: Set<string>;
84
113
  assignedRefs: Set<string>;
@@ -105,6 +134,12 @@ function imageContent(file: string, data: Buffer, note: string) {
105
134
  export default function browserExtension(pi: ExtensionAPI) {
106
135
  const state: BrowserState = { acceptedRefs: new Set(), assignedRefs: new Set() };
107
136
  let operationTail: Promise<unknown> = Promise.resolve();
137
+ const diagnostics = new BrowserDiagnostics();
138
+ let detachPageListeners: (() => void) | undefined;
139
+ let shutdownTask: Promise<void> | undefined;
140
+ let cleanupFailure: BrowserCleanupError | undefined;
141
+ const sessionAbort = new AbortController();
142
+ const lateDiscards = new Set<() => Promise<unknown>>();
108
143
 
109
144
  function serialized<T>(operation: () => Promise<T>): Promise<T> {
110
145
  const result = operationTail.then(operation, operation);
@@ -113,15 +148,51 @@ export default function browserExtension(pi: ExtensionAPI) {
113
148
  return result;
114
149
  }
115
150
 
116
- async function shutdownNow() {
151
+ function shutdownNow(): Promise<void> {
152
+ if (shutdownTask) {
153
+ return shutdownTask;
154
+ }
155
+
117
156
  const context = state.context;
118
157
  const browser = state.browser;
158
+ detachPageListeners?.();
159
+ detachPageListeners = undefined;
160
+ diagnostics.reset();
119
161
  state.page = undefined;
120
162
  state.context = undefined;
121
163
  state.browser = undefined;
122
164
  state.acceptedRefs.clear();
123
165
  state.assignedRefs.clear();
124
- await Promise.allSettled([context?.close(), browser?.close()].filter(Boolean));
166
+ // Defer one microtask so all nested abort handlers can register pending launch disposal.
167
+ const task = Promise.resolve().then(async () => {
168
+ const discards = [...lateDiscards];
169
+ try {
170
+ // Closing the browser closes its contexts too; concurrent closes race in Chromium.
171
+ await settleBrowserCleanup([
172
+ ...(browser ? [browser.close()] : context ? [context.close()] : []),
173
+ ...discards.map((discard) => discard()),
174
+ ]);
175
+ for (const discard of discards) {
176
+ lateDiscards.delete(discard);
177
+ }
178
+
179
+ cleanupFailure = undefined;
180
+ } catch (error) {
181
+ cleanupFailure = error instanceof BrowserCleanupError ? error : new BrowserCleanupError();
182
+ // Retain only teardown handles for an explicit close retry, including late launch handles.
183
+ state.context = context;
184
+ state.browser = browser;
185
+ throw cleanupFailure;
186
+ }
187
+ });
188
+ shutdownTask = task;
189
+ void task
190
+ .finally(() => {
191
+ shutdownTask = undefined;
192
+ })
193
+ .catch(() => {});
194
+
195
+ return task;
125
196
  }
126
197
 
127
198
  async function cancellable<T>(
@@ -130,6 +201,7 @@ export default function browserExtension(pi: ExtensionAPI) {
130
201
  discard?: (value: T) => Promise<unknown>,
131
202
  ): Promise<T> {
132
203
  if (signal?.aborted) {
204
+ await shutdownNow();
133
205
  throw abortedError();
134
206
  }
135
207
 
@@ -148,7 +220,8 @@ export default function browserExtension(pi: ExtensionAPI) {
148
220
 
149
221
  settled = true;
150
222
  if (discard && operation) {
151
- void operation.then(discard).catch(() => {});
223
+ const pending = operation;
224
+ lateDiscards.add(() => pending.then(discard, () => undefined));
152
225
  }
153
226
 
154
227
  void shutdownNow().then(() => reject(abortedError()), reject);
@@ -195,8 +268,17 @@ export default function browserExtension(pi: ExtensionAPI) {
195
268
  });
196
269
  }
197
270
 
198
- async function ensurePage(signal?: AbortSignal) {
271
+ async function ensurePage(signal?: AbortSignal): Promise<Page> {
272
+ if (shutdownTask) {
273
+ await shutdownTask;
274
+ }
275
+
276
+ if (cleanupFailure) {
277
+ throw cleanupFailure;
278
+ }
279
+
199
280
  if (signal?.aborted) {
281
+ await shutdownNow();
200
282
  throw abortedError();
201
283
  }
202
284
 
@@ -204,17 +286,17 @@ export default function browserExtension(pi: ExtensionAPI) {
204
286
  state.runtime = await cancellable(() => loadBrowserRuntime(runtimeDir), signal);
205
287
  }
206
288
 
207
- if (!state.browser?.isConnected()) {
289
+ if (!state.browser?.isConnected() || !state.page || state.page.isClosed()) {
208
290
  await shutdownNow();
209
291
  try {
210
292
  state.browser = await cancellable(
211
293
  () => state.runtime!.playwright.chromium.launch({ headless: true }),
212
294
  signal,
213
- (browser: any) => browser.close(),
295
+ (browser) => browser.close(),
214
296
  );
215
297
  state.context = await cancellable(
216
298
  () =>
217
- state.browser.newContext({
299
+ state.browser!.newContext({
218
300
  viewport: resolveViewport({ preset: "desktop" }),
219
301
  reducedMotion: "reduce",
220
302
  colorScheme: "light",
@@ -222,22 +304,37 @@ export default function browserExtension(pi: ExtensionAPI) {
222
304
  }),
223
305
  signal,
224
306
  );
225
- state.page = await cancellable(() => state.context.newPage(), signal);
307
+ state.page = await cancellable(() => state.context!.newPage(), signal);
308
+ const page = state.page;
309
+ const detachDiagnostics = diagnostics.attach(page);
310
+ const navigated = (frame: import("playwright").Frame) => {
311
+ if (frame === page.mainFrame()) {
312
+ state.acceptedRefs.clear();
313
+ state.assignedRefs.clear();
314
+ diagnostics.navigated();
315
+ }
316
+ };
317
+
318
+ page.on("framenavigated", navigated);
319
+ detachPageListeners = () => {
320
+ detachDiagnostics();
321
+ page.off("framenavigated", navigated);
322
+ };
226
323
  } catch (error) {
227
324
  await shutdownNow();
228
325
  throw error;
229
326
  }
230
327
  }
231
328
 
232
- return state.page;
329
+ return state.page!;
233
330
  }
234
331
 
235
- async function stabilize(page: any, signal?: AbortSignal) {
332
+ async function stabilize(page: Page, signal?: AbortSignal) {
236
333
  await cancellable(() => page.addStyleTag({ content: deterministicStyle }), signal);
237
334
  await cancellable(() => page.evaluate(() => document.fonts?.ready), signal);
238
335
  }
239
336
 
240
- async function assertCaptureBounds(page: any, fullPage: boolean, signal?: AbortSignal) {
337
+ async function assertCaptureBounds(page: Page, fullPage: boolean, signal?: AbortSignal) {
241
338
  const dimensions = fullPage
242
339
  ? await cancellable(
243
340
  () =>
@@ -293,7 +390,7 @@ export default function browserExtension(pi: ExtensionAPI) {
293
390
  : makeArtifactPath(agentDir, process.env.PI_SESSION_ID, kind);
294
391
  }
295
392
 
296
- function targetLocator(page: any, target: string) {
393
+ function targetLocator(page: Page, target: string) {
297
394
  const value = target.trim();
298
395
  if (value.startsWith("@spec-")) {
299
396
  if (!state.acceptedRefs.has(value)) {
@@ -310,15 +407,105 @@ export default function browserExtension(pi: ExtensionAPI) {
310
407
  return page.locator(value);
311
408
  }
312
409
 
313
- const register = (definition: any) => {
410
+ const register = <T extends TSchema>(definition: ToolDefinition<T, unknown>) => {
314
411
  const execute = definition.execute;
315
412
  pi.registerTool({
316
413
  ...definition,
317
414
  executionMode: "sequential",
318
- execute: (...args: any[]) => serialized(() => execute(...args)),
415
+ async execute(...args) {
416
+ if (!Check(definition.parameters, args[1])) {
417
+ throw new Error("Invalid browser tool parameters.");
418
+ }
419
+
420
+ const bounded = ["browser_press", "browser_select_option", "browser_wait_for"].includes(
421
+ definition.name,
422
+ );
423
+ const controller = new AbortController();
424
+ const originalSignal = args[2];
425
+ const signal = AbortSignal.any([
426
+ sessionAbort.signal,
427
+ controller.signal,
428
+ ...(originalSignal ? [originalSignal] : []),
429
+ ]);
430
+ const timer = bounded
431
+ ? setTimeout(
432
+ () => controller.abort(),
433
+ interactionTimeout((args[1] as { timeoutMs?: number }).timeoutMs),
434
+ )
435
+ : undefined;
436
+ try {
437
+ return await cancellable(
438
+ () =>
439
+ serialized(() => {
440
+ if (signal.aborted) {
441
+ throw abortedError();
442
+ }
443
+
444
+ return execute(args[0], args[1], signal, args[3], args[4]);
445
+ }),
446
+ signal,
447
+ );
448
+ } catch (error) {
449
+ if (error instanceof BrowserCleanupError) {
450
+ throw error;
451
+ }
452
+
453
+ if (controller.signal.aborted && !originalSignal?.aborted) {
454
+ throw new Error("Browser condition/action timed out before its deadline.");
455
+ }
456
+
457
+ throw error;
458
+ } finally {
459
+ clearTimeout(timer);
460
+ }
461
+ },
319
462
  });
320
463
  };
321
464
 
465
+ async function interaction<T>(
466
+ timeout: number,
467
+ signal: AbortSignal | undefined,
468
+ operation: (page: Page, signal: AbortSignal) => Promise<T>,
469
+ ): Promise<T> {
470
+ const controller = new AbortController();
471
+ let expired = false;
472
+ const abort = () => controller.abort();
473
+ signal?.addEventListener("abort", abort, { once: true });
474
+ if (signal?.aborted) {
475
+ controller.abort();
476
+ }
477
+
478
+ const timer = setTimeout(() => {
479
+ expired = true;
480
+ controller.abort();
481
+ }, timeout);
482
+ try {
483
+ const page = await ensurePage(controller.signal);
484
+
485
+ return await cancellable(() => operation(page, controller.signal), controller.signal);
486
+ } catch (error) {
487
+ if (error instanceof BrowserCleanupError) {
488
+ throw error;
489
+ }
490
+
491
+ // Never expose Playwright call logs: these can echo input values, selectors, or URLs.
492
+ if (expired || (error instanceof Error && error.name === "TimeoutError")) {
493
+ throw new Error("Browser condition/action timed out before its deadline.");
494
+ }
495
+
496
+ if (signal?.aborted) {
497
+ throw abortedError();
498
+ }
499
+
500
+ throw new Error(
501
+ "Browser action failed. Check the target, state, key/options, and runtime setup; refresh the snapshot if refs are stale.",
502
+ );
503
+ } finally {
504
+ clearTimeout(timer);
505
+ signal?.removeEventListener("abort", abort);
506
+ }
507
+ }
508
+
322
509
  register({
323
510
  name: "browser_open",
324
511
  label: "Browser Open",
@@ -327,13 +514,13 @@ export default function browserExtension(pi: ExtensionAPI) {
327
514
  promptSnippet: "Open local or remote web pages in an isolated browser for rendered validation",
328
515
  promptGuidelines: ["Use browser_open and browser_snapshot before claiming a web UI renders correctly."],
329
516
  parameters: OpenParams,
330
- async execute(_id: string, params: any, signal: AbortSignal | undefined) {
517
+ async execute(_id, params, signal) {
331
518
  const page = await ensurePage(signal);
519
+ state.acceptedRefs.clear();
332
520
  const response = await cancellable(
333
521
  () => page.goto(normalizeBrowserUrl(params.url), { waitUntil: params.waitUntil ?? "domcontentloaded" }),
334
522
  signal,
335
523
  );
336
- state.acceptedRefs.clear();
337
524
  const title = await cancellable(() => page.title(), signal);
338
525
 
339
526
  return {
@@ -353,11 +540,11 @@ export default function browserExtension(pi: ExtensionAPI) {
353
540
  label: "Browser Viewport",
354
541
  description: "Set the active browser viewport to desktop, tablet, mobile, or bounded explicit dimensions.",
355
542
  parameters: ViewportParams,
356
- async execute(_id: string, params: any, signal: AbortSignal | undefined) {
543
+ async execute(_id, params, signal) {
357
544
  const page = await ensurePage(signal);
358
545
  const viewport = resolveViewport(params);
359
- await cancellable(() => page.setViewportSize(viewport), signal);
360
546
  state.acceptedRefs.clear();
547
+ await cancellable(() => page.setViewportSize(viewport), signal);
361
548
 
362
549
  return {
363
550
  content: [{ type: "text", text: `Viewport set to ${viewport.width}x${viewport.height}.` }],
@@ -370,10 +557,10 @@ export default function browserExtension(pi: ExtensionAPI) {
370
557
  name: "browser_snapshot",
371
558
  label: "Browser Snapshot",
372
559
  description:
373
- "Inspect bounded rendered page text and interactive elements. Returns namespaced refs usable by browser_click and browser_fill.",
560
+ "Inspect bounded rendered page text and interactive elements. Returns namespaced refs usable by click, fill, press, select_option, and wait_for browser tools.",
374
561
  promptSnippet: "Inspect rendered DOM text and interactive controls",
375
562
  parameters: SnapshotParams,
376
- async execute(_id: string, params: any, signal: AbortSignal | undefined) {
563
+ async execute(_id, params, signal) {
377
564
  const page = await ensurePage(signal);
378
565
  const maxChars = params.maxChars ?? 30000;
379
566
  const namespace = crypto.randomUUID();
@@ -381,10 +568,10 @@ export default function browserExtension(pi: ExtensionAPI) {
381
568
  const snapshot = await cancellable(
382
569
  () =>
383
570
  page.evaluate(
384
- ({ namespace, bodyLimit, priorRefs }: any) => {
571
+ ({ namespace, bodyLimit, priorRefs }) => {
385
572
  const prior = new Set(priorRefs);
386
573
  document.querySelectorAll("[data-specpi-ref]").forEach((element) => {
387
- if (prior.has(element.getAttribute("data-specpi-ref"))) {
574
+ if (prior.has(element.getAttribute("data-specpi-ref") ?? "")) {
388
575
  element.removeAttribute("data-specpi-ref");
389
576
  }
390
577
  });
@@ -500,8 +687,8 @@ export default function browserExtension(pi: ExtensionAPI) {
500
687
  snapshot.controls.pop();
501
688
  }
502
689
 
503
- state.assignedRefs = new Set(snapshot.controls.map((control: any) => control.ref.slice(1)));
504
- state.acceptedRefs = new Set(snapshot.controls.map((control: any) => control.ref));
690
+ state.assignedRefs = new Set(snapshot.controls.map((control) => control.ref.slice(1)));
691
+ state.acceptedRefs = new Set(snapshot.controls.map((control) => control.ref));
505
692
  const originalTextLength = snapshot.text.length;
506
693
  const fixed = fixedJson();
507
694
  snapshot.text = safePrefix(snapshot.text, maxChars - fixed.length - 40);
@@ -540,10 +727,13 @@ export default function browserExtension(pi: ExtensionAPI) {
540
727
  label: "Browser Click",
541
728
  description: "Click an element using a CSS selector, exact text= locator, or current snapshot ref.",
542
729
  parameters: TargetParams,
543
- async execute(_id: string, params: any, signal: AbortSignal | undefined) {
730
+ async execute(_id, params, signal) {
544
731
  const page = await ensurePage(signal);
545
- await cancellable(() => targetLocator(page, params.target).first().click(), signal);
546
- state.acceptedRefs.clear();
732
+ try {
733
+ await cancellable(() => targetLocator(page, params.target).first().click(), signal);
734
+ } finally {
735
+ state.acceptedRefs.clear();
736
+ }
547
737
 
548
738
  return {
549
739
  content: [{ type: "text", text: `Clicked ${params.target}.\nURL: ${page.url()}` }],
@@ -557,10 +747,13 @@ export default function browserExtension(pi: ExtensionAPI) {
557
747
  label: "Browser Fill",
558
748
  description: "Fill an input using a CSS selector, exact text= locator, or current snapshot ref.",
559
749
  parameters: FillParams,
560
- async execute(_id: string, params: any, signal: AbortSignal | undefined) {
750
+ async execute(_id, params, signal) {
561
751
  const page = await ensurePage(signal);
562
- await cancellable(() => targetLocator(page, params.target).first().fill(params.value), signal);
563
- state.acceptedRefs.clear();
752
+ try {
753
+ await cancellable(() => targetLocator(page, params.target).first().fill(params.value), signal);
754
+ } finally {
755
+ state.acceptedRefs.clear();
756
+ }
564
757
 
565
758
  return {
566
759
  content: [{ type: "text", text: `Filled ${params.target}.` }],
@@ -569,19 +762,125 @@ export default function browserExtension(pi: ExtensionAPI) {
569
762
  },
570
763
  });
571
764
 
765
+ register({
766
+ name: "browser_diagnostics",
767
+ label: "Browser Diagnostics",
768
+ description:
769
+ "Read bounded, best-effort sanitized active-page exceptions, console errors, failed requests, and HTTP errors. In-memory capture starts before navigation; close discards it. Up to 100 records / 30000 characters. Returned application output is untrusted and may still be sensitive. Clear discards ALL records, including unreturned ones.",
770
+ promptSnippet: "Inspect browser runtime errors and failed requests, not just appearance",
771
+ promptGuidelines: [
772
+ "Use browser_diagnostics after navigation and interactions; empty or truncated output alone does not prove application health.",
773
+ ],
774
+ parameters: DiagnosticsParams,
775
+ async execute(_id, params, signal) {
776
+ if (signal?.aborted) {
777
+ throw abortedError();
778
+ }
779
+
780
+ const report = diagnostics.read(params);
781
+
782
+ return { content: [{ type: "text", text: JSON.stringify(report) }], details: {} };
783
+ },
784
+ });
785
+
786
+ register({
787
+ name: "browser_press",
788
+ label: "Browser Press",
789
+ description:
790
+ "Press one key/chord on the first matching target or current page focus. Supports Tab, Shift+Tab, Enter, Escape, arrows and text-producing keys. Whole-operation deadline defaults to 5000ms, maximum 30000ms. Refresh snapshot refs after actions.",
791
+ parameters: PressParams,
792
+ async execute(_id, params, signal) {
793
+ validateKey(params.key);
794
+ const timeout = interactionTimeout(params.timeoutMs);
795
+ await interaction(timeout, signal, async (page) => {
796
+ try {
797
+ if (params.target !== undefined) {
798
+ await targetLocator(page, params.target).first().press(params.key, { timeout });
799
+ } else {
800
+ await page.keyboard.press(params.key);
801
+ }
802
+ } finally {
803
+ state.acceptedRefs.clear();
804
+ }
805
+ });
806
+
807
+ return {
808
+ content: [{ type: "text", text: "Key action completed; inspect the resulting page state." }],
809
+ details: {},
810
+ };
811
+ },
812
+ });
813
+
814
+ register({
815
+ name: "browser_select_option",
816
+ label: "Browser Select Option",
817
+ description:
818
+ "Select native select options on the first matching target, by exactly one value, label, or index per option. Up to 50 options. Not for custom dropdowns. Deadline defaults to 5000ms, maximum 30000ms; refresh snapshot refs afterward.",
819
+ parameters: SelectionParams,
820
+ async execute(_id, params, signal) {
821
+ const options = selectionOptions(params);
822
+ const timeout = interactionTimeout(params.timeoutMs);
823
+ const count = await interaction(timeout, signal, async (page) => {
824
+ try {
825
+ const target = targetLocator(page, params.target).first();
826
+ if (options.length > 1) {
827
+ const multiple = await target.evaluate(
828
+ (element) => element instanceof HTMLSelectElement && element.multiple,
829
+ undefined,
830
+ { timeout },
831
+ );
832
+ if (!multiple) {
833
+ throw new Error("Multiple options require a native multiple select.");
834
+ }
835
+ }
836
+
837
+ const selected = await target.selectOption(options, { timeout });
838
+
839
+ return selected.length;
840
+ } finally {
841
+ state.acceptedRefs.clear();
842
+ }
843
+ });
844
+
845
+ return {
846
+ content: [{ type: "text", text: `Selected ${count} option(s); inspect the resulting page state.` }],
847
+ details: { count },
848
+ };
849
+ },
850
+ });
851
+
852
+ register({
853
+ name: "browser_wait_for",
854
+ label: "Browser Wait For",
855
+ description:
856
+ "Wait for one explicit state: first matching target attached/detached/visible/hidden, exact target text, or exact normalized HTTP(S) URL. No scripts or sleep mode. Whole-operation deadline defaults to 5000ms, maximum 30000ms. Timeout is not success.",
857
+ parameters: WaitParams,
858
+ async execute(_id, params, signal) {
859
+ validateWait(params);
860
+ const timeout = interactionTimeout(params.timeoutMs);
861
+ await interaction(timeout, signal, (page) =>
862
+ waitForCondition(
863
+ page,
864
+ params.target ? targetLocator(page, params.target).first() : undefined,
865
+ params,
866
+ timeout,
867
+ ),
868
+ );
869
+
870
+ return {
871
+ content: [{ type: "text", text: `Observed requested ${params.condition} condition.` }],
872
+ details: { condition: params.condition },
873
+ };
874
+ },
875
+ });
876
+
572
877
  register({
573
878
  name: "browser_screenshot",
574
879
  label: "Browser Screenshot",
575
880
  description: "Capture a bounded rendered PNG and return it inline when conservatively sized.",
576
881
  promptSnippet: "Capture rendered desktop, tablet, or mobile screenshots for visual QA",
577
882
  parameters: ScreenshotParams,
578
- async execute(
579
- _id: string,
580
- params: any,
581
- signal: AbortSignal | undefined,
582
- _update: unknown,
583
- ctx: ExtensionContext,
584
- ) {
883
+ async execute(_id, params, signal, _update, ctx) {
585
884
  const shot = await captureMemory(signal, params.fullPage ?? false);
586
885
  const file = outputPath(ctx, params.path, "screenshot");
587
886
  await publishBuffer(file, shot.data, { overwrite: params.overwrite === true, signal });
@@ -605,13 +904,7 @@ export default function browserExtension(pi: ExtensionAPI) {
605
904
  description:
606
905
  "Explicitly create a visual-regression baseline PNG. Existing baselines are replaced only when overwrite=true.",
607
906
  parameters: BaselineParams,
608
- async execute(
609
- _id: string,
610
- params: any,
611
- signal: AbortSignal | undefined,
612
- _update: unknown,
613
- ctx: ExtensionContext,
614
- ) {
907
+ async execute(_id, params, signal, _update, ctx) {
615
908
  const file = resolveUserPath(ctx.cwd, params.path, "baseline path");
616
909
  const shot = await captureMemory(signal, params.fullPage ?? false);
617
910
  await publishBuffer(file, shot.data, { overwrite: params.overwrite === true, signal });
@@ -630,13 +923,7 @@ export default function browserExtension(pi: ExtensionAPI) {
630
923
  "Capture the current page and compare it with an explicit baseline PNG without changing the baseline.",
631
924
  promptSnippet: "Compare rendered output against an explicit PNG baseline with a pixel threshold",
632
925
  parameters: CompareParams,
633
- async execute(
634
- _id: string,
635
- params: any,
636
- signal: AbortSignal | undefined,
637
- _update: unknown,
638
- ctx: ExtensionContext,
639
- ) {
926
+ async execute(_id, params, signal, _update, ctx) {
640
927
  const baselinePath = resolveUserPath(ctx.cwd, params.baselinePath, "baseline path");
641
928
  const currentPath = outputPath(ctx, params.currentPath, "current");
642
929
  const diffPath = outputPath(ctx, params.diffPath, "diff");
@@ -719,5 +1006,10 @@ export default function browserExtension(pi: ExtensionAPI) {
719
1006
  },
720
1007
  });
721
1008
 
722
- pi.on("session_shutdown", () => serialized(shutdownNow));
1009
+ pi.on("session_shutdown", () => {
1010
+ // Teardown preempts queued/active work, including waits without their own Playwright timeout.
1011
+ sessionAbort.abort();
1012
+
1013
+ return shutdownNow();
1014
+ });
723
1015
  }