From 5eada7e410aa60329b22c1378aa5fa3fdcfa9e99 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:33:06 +0800 Subject: [PATCH] refactor: centralize Electron execution limits and action projection --- src/browser/ElectronCaptureCleanup.ts | 2 +- .../PlaywrightElectronActiveActions.test.ts | 2 +- .../PlaywrightElectronActiveActions.ts | 85 +++---------------- .../PlaywrightElectronActiveProvider.ts | 2 +- src/browser/PlaywrightElectronExecution.ts | 52 ++++++++++++ 5 files changed, 67 insertions(+), 76 deletions(-) create mode 100644 src/browser/PlaywrightElectronExecution.ts diff --git a/src/browser/ElectronCaptureCleanup.ts b/src/browser/ElectronCaptureCleanup.ts index 791169a263..b8f8ee3ed3 100644 --- a/src/browser/ElectronCaptureCleanup.ts +++ b/src/browser/ElectronCaptureCleanup.ts @@ -12,7 +12,7 @@ import type { ProcessCleanupResult, ProcessLineageObservation, } from "../process/ProcessOwnership.js"; -import { runWithExecutionLimits } from "./PlaywrightElectronActiveActions.js"; +import { runWithExecutionLimits } from "./PlaywrightElectronExecution.js"; import { cleanupElectronProcesses } from "./ElectronProcessCleanup.js"; type ElectronApplication = Awaited>; diff --git a/src/browser/PlaywrightElectronActiveActions.test.ts b/src/browser/PlaywrightElectronActiveActions.test.ts index 3e29f3a6bd..58d7fc4f9d 100644 --- a/src/browser/PlaywrightElectronActiveActions.test.ts +++ b/src/browser/PlaywrightElectronActiveActions.test.ts @@ -1,3 +1,4 @@ +import { runWithExecutionLimits } from "./PlaywrightElectronExecution.js"; // Fixture-backed action coverage; real Electron verification lives in // `npm run verify:electron`. import { afterEach, expect, it, vi } from "vitest"; @@ -13,7 +14,6 @@ import { createElectronActiveObservationFixtureResult } from "../domain/javascri import { readFinalApplicationState, runElectronActions, - runWithExecutionLimits, } from "./PlaywrightElectronActiveActions.js"; const fixtureBrowserWindow = async () => ({ diff --git a/src/browser/PlaywrightElectronActiveActions.ts b/src/browser/PlaywrightElectronActiveActions.ts index 18341d9022..d87804ab58 100644 --- a/src/browser/PlaywrightElectronActiveActions.ts +++ b/src/browser/PlaywrightElectronActiveActions.ts @@ -1,5 +1,6 @@ import type { ElectronApplication, JSHandle, Page } from "playwright-core"; import { z } from "zod"; +import { runWithExecutionLimits } from "./PlaywrightElectronExecution.js"; import type { ExecutionOptions } from "../application/AnalysisProvider.js"; import type { @@ -101,6 +102,7 @@ export const runElectronActions = async ( let selectedHandle: SelectedElectronWindow | undefined; let handleOwner: ArtifactResourceOwner | undefined; let target: string | null = null; + let actionError: string | null = null; try { if (options.signal?.aborted === true) throw new BrowserObservationError(OPERATION, "cancelled"); @@ -143,33 +145,19 @@ export const runElectronActions = async ( selectedHandle, options, }); - actions.push({ - step_id: action.step_id, - kind: action.kind, - window_index: windowIndex, - target, - status: "completed", - elapsed_ms: Math.max( - 0, - Math.round(performance.now() - actionStartedAt), - ), - error: null, - }); } catch (cause: unknown) { stoppedBy = options.signal?.aborted ? "cancelled" : "failed"; - actions.push({ - step_id: action.step_id, - kind: action.kind, - window_index: windowIndex, - target, - status: stoppedBy, - elapsed_ms: Math.max( - 0, - Math.round(performance.now() - actionStartedAt), - ), - error: safeActionErrorMessage(cause), - }); + actionError = safeActionErrorMessage(cause); } + actions.push({ + step_id: action.step_id, + kind: action.kind, + window_index: windowIndex, + target, + status: stoppedBy ?? "completed", + elapsed_ms: Math.max(0, Math.round(performance.now() - actionStartedAt)), + error: actionError, + }); if (handleOwner !== undefined) { cleanupError = await releaseActionWindow(resources, handleOwner); if (cleanupError !== undefined) stoppedBy = "failed"; @@ -500,55 +488,6 @@ export const readFinalApplicationState = async ( }); }; -/** Apply an optional lifecycle deadline and caller cancellation to an operation. */ -export const runWithExecutionLimits = async ( - operation: Promise, - signal: AbortSignal | undefined, - deadline?: number, -): Promise => { - if (signal?.aborted === true) { - // The operation has already started; observe its abandoned settlement. - void operation.catch(() => undefined); - throw new BrowserObservationError(OPERATION, "cancelled", { - cause: signal.reason, - }); - } - if (signal === undefined && deadline === undefined) return operation; - let timer: ReturnType | undefined; - let abortListener: (() => void) | undefined; - const interruptions: Promise[] = []; - if (deadline !== undefined) - interruptions.push( - new Promise((_, reject) => { - timer = setTimeout( - () => reject(new BrowserObservationError(OPERATION, "timeout")), - Math.max(1, deadline - performance.now()), - ); - }), - ); - if (signal !== undefined) - interruptions.push( - new Promise((_, reject) => { - abortListener = () => - reject( - new BrowserObservationError(OPERATION, "cancelled", { - cause: signal.reason, - }), - ); - signal.addEventListener("abort", abortListener, { once: true }); - }), - ); - try { - return interruptions.length === 0 - ? await operation - : await Promise.race([operation, ...interruptions]); - } finally { - if (timer !== undefined) clearTimeout(timer); - if (abortListener !== undefined) - signal?.removeEventListener("abort", abortListener); - } -}; - const safeActionErrorMessage = (cause: unknown): string => { return cause instanceof Error ? cause.message : "Electron action failed"; }; diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index 79bb49a3a9..2ecc2a9527 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -1,3 +1,4 @@ +import { runWithExecutionLimits } from "./PlaywrightElectronExecution.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; @@ -38,7 +39,6 @@ import { runElectronActions, readApplicationState, readFinalApplicationState, - runWithExecutionLimits, } from "./PlaywrightElectronActiveActions.js"; import { projectElectronActiveCapture } from "./PlaywrightElectronActiveProjection.js"; import { readElectronFuseImage } from "../electron/ElectronFuseReader.js"; diff --git a/src/browser/PlaywrightElectronExecution.ts b/src/browser/PlaywrightElectronExecution.ts new file mode 100644 index 0000000000..843099e13a --- /dev/null +++ b/src/browser/PlaywrightElectronExecution.ts @@ -0,0 +1,52 @@ +import { BrowserObservationError } from "../domain/browserObservationError.js"; + +const OPERATION = "capture_electron_scenario" as const; + +/** Apply an optional lifecycle deadline and caller cancellation to an operation. */ +export const runWithExecutionLimits = async ( + operation: Promise, + signal: AbortSignal | undefined, + deadline?: number, +): Promise => { + if (signal?.aborted === true) { + // The operation has already started; observe its abandoned settlement. + void operation.catch(() => undefined); + throw new BrowserObservationError(OPERATION, "cancelled", { + cause: signal.reason, + }); + } + if (signal === undefined && deadline === undefined) return operation; + let timer: ReturnType | undefined; + let abortListener: (() => void) | undefined; + const interruptions: Promise[] = []; + if (deadline !== undefined) + interruptions.push( + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new BrowserObservationError(OPERATION, "timeout")), + Math.max(1, deadline - performance.now()), + ); + }), + ); + if (signal !== undefined) + interruptions.push( + new Promise((_, reject) => { + abortListener = () => + reject( + new BrowserObservationError(OPERATION, "cancelled", { + cause: signal.reason, + }), + ); + signal.addEventListener("abort", abortListener, { once: true }); + }), + ); + try { + return interruptions.length === 0 + ? await operation + : await Promise.race([operation, ...interruptions]); + } finally { + if (timer !== undefined) clearTimeout(timer); + if (abortListener !== undefined) + signal?.removeEventListener("abort", abortListener); + } +};