autokap 1.9.8 → 1.9.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-pool.d.ts +17 -0
- package/dist/browser-pool.js +90 -1
- package/dist/browser.js +33 -4
- package/dist/cli-runner.js +9 -1
- package/dist/opcode-runner.js +57 -14
- package/dist/wait-contract.js +4 -1
- package/dist/web-playwright-local.js +9 -1
- package/package.json +1 -1
package/dist/browser-pool.d.ts
CHANGED
|
@@ -2,6 +2,23 @@ import { type BrowserContext } from 'playwright';
|
|
|
2
2
|
import type { BrowserStorageState } from './types.js';
|
|
3
3
|
/** Chromium flags for server-side headless operation (used by pool and standalone launches). */
|
|
4
4
|
export declare const CHROMIUM_ARGS: string[];
|
|
5
|
+
/**
|
|
6
|
+
* Cloud Run NVIDIA L4 GPU launch flags for HEADLESS screenshot capture. Returns
|
|
7
|
+
* null off Cloud Run (Vercel/local have no GPU → keep software rasterization).
|
|
8
|
+
*
|
|
9
|
+
* The clip path (Browser.forClipCapture) proves the L4 binds via ANGLE/Vulkan on
|
|
10
|
+
* this image; screenshots don't need Xvfb (no ffmpeg x11grab), so we use Chromium
|
|
11
|
+
* NEW headless (`--headless=new` — the OLD `--headless` has no GPU) and strip
|
|
12
|
+
* Playwright's default `--headless` so the two don't clash. Verify the binding
|
|
13
|
+
* with the `[gpu-check]` log below: "NVIDIA"/"ANGLE (NVIDIA…)" = hardware,
|
|
14
|
+
* "SwiftShader"/"llvmpipe" = it fell back to software (then we'd need Xvfb).
|
|
15
|
+
* The GPU accelerates page render/composite (the win for animated/blur-heavy
|
|
16
|
+
* pages); the final PNG encode stays CPU-bound.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveCloudGpuLaunch(): {
|
|
19
|
+
args: string[];
|
|
20
|
+
ignoreDefaultArgs: string[];
|
|
21
|
+
} | null;
|
|
5
22
|
declare class BrowserPool {
|
|
6
23
|
private browser;
|
|
7
24
|
private launchPromise;
|
package/dist/browser-pool.js
CHANGED
|
@@ -48,6 +48,72 @@ export const CHROMIUM_ARGS = [
|
|
|
48
48
|
const MAX_CONCURRENT = parseInt(process.env.BROWSER_POOL_MAX_CONCURRENT ?? '5', 10);
|
|
49
49
|
/** Restart the shared browser process after this many captures to prevent memory leaks. */
|
|
50
50
|
const MAX_CAPTURES_BEFORE_RESTART = 100;
|
|
51
|
+
/**
|
|
52
|
+
* Default per-action Playwright timeout for pooled contexts. Below the 30s
|
|
53
|
+
* CAPTURE_SCREENSHOT global deadline so a starved call fails with budget left
|
|
54
|
+
* for recovery instead of eating the whole variant. See usage in acquireContext.
|
|
55
|
+
*/
|
|
56
|
+
const POOL_CONTEXT_ACTION_TIMEOUT_MS = 25000;
|
|
57
|
+
/**
|
|
58
|
+
* Cloud Run NVIDIA L4 GPU launch flags for HEADLESS screenshot capture. Returns
|
|
59
|
+
* null off Cloud Run (Vercel/local have no GPU → keep software rasterization).
|
|
60
|
+
*
|
|
61
|
+
* The clip path (Browser.forClipCapture) proves the L4 binds via ANGLE/Vulkan on
|
|
62
|
+
* this image; screenshots don't need Xvfb (no ffmpeg x11grab), so we use Chromium
|
|
63
|
+
* NEW headless (`--headless=new` — the OLD `--headless` has no GPU) and strip
|
|
64
|
+
* Playwright's default `--headless` so the two don't clash. Verify the binding
|
|
65
|
+
* with the `[gpu-check]` log below: "NVIDIA"/"ANGLE (NVIDIA…)" = hardware,
|
|
66
|
+
* "SwiftShader"/"llvmpipe" = it fell back to software (then we'd need Xvfb).
|
|
67
|
+
* The GPU accelerates page render/composite (the win for animated/blur-heavy
|
|
68
|
+
* pages); the final PNG encode stays CPU-bound.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveCloudGpuLaunch() {
|
|
71
|
+
const isCloudRunner = process.env.AUTOKAP_CLOUD_RUNNER === '1';
|
|
72
|
+
if (process.platform !== 'linux' || !isCloudRunner)
|
|
73
|
+
return null;
|
|
74
|
+
return {
|
|
75
|
+
args: [
|
|
76
|
+
'--headless=new',
|
|
77
|
+
'--use-gl=angle',
|
|
78
|
+
'--use-angle=vulkan',
|
|
79
|
+
'--enable-features=Vulkan',
|
|
80
|
+
'--ignore-gpu-blocklist',
|
|
81
|
+
'--enable-gpu-rasterization',
|
|
82
|
+
'--enable-zero-copy',
|
|
83
|
+
],
|
|
84
|
+
ignoreDefaultArgs: ['--headless'],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Log the active WebGL renderer once per pooled browser launch so a Cloud Run
|
|
88
|
+
* deploy can confirm the L4 bound (NVIDIA) vs a silent SwiftShader fallback. */
|
|
89
|
+
async function logPoolGpuRenderer(browser) {
|
|
90
|
+
try {
|
|
91
|
+
const context = await browser.newContext();
|
|
92
|
+
try {
|
|
93
|
+
const page = await context.newPage();
|
|
94
|
+
const info = await page.evaluate(() => {
|
|
95
|
+
const canvas = document.createElement('canvas');
|
|
96
|
+
const gl = (canvas.getContext('webgl2') || canvas.getContext('webgl'));
|
|
97
|
+
if (!gl)
|
|
98
|
+
return { error: 'no webgl context' };
|
|
99
|
+
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
|
100
|
+
if (!ext)
|
|
101
|
+
return { error: 'no debug_renderer_info extension' };
|
|
102
|
+
return {
|
|
103
|
+
vendor: gl.getParameter(ext.UNMASKED_VENDOR_WEBGL),
|
|
104
|
+
renderer: gl.getParameter(ext.UNMASKED_RENDERER_WEBGL),
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
console.info('[gpu-check] pool WebGL renderer:', JSON.stringify(info));
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await context.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
console.warn('[gpu-check] pool WebGL query failed:', err.message);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
51
117
|
function normalizeDeviceScaleFactor(value) {
|
|
52
118
|
if (!Number.isFinite(value))
|
|
53
119
|
return 2;
|
|
@@ -79,6 +145,12 @@ class BrowserPool {
|
|
|
79
145
|
// any conflict). See PRIVACY_HEADERS / installAnalyticsBlock (AUT-234).
|
|
80
146
|
extraHTTPHeaders: { ...PRIVACY_HEADERS, ...(extra ?? {}) },
|
|
81
147
|
});
|
|
148
|
+
// Stop every Playwright call from inheriting the built-in 30 000 ms default.
|
|
149
|
+
// Pooled captures run many variants per Cloud Run container; under CPU
|
|
150
|
+
// contention a starved call (e.g. page.screenshot) would otherwise block the
|
|
151
|
+
// full 30s — equal to the CAPTURE_SCREENSHOT global deadline — leaving no
|
|
152
|
+
// budget for recovery and voiding the variant. 25s sits under that cap.
|
|
153
|
+
context.setDefaultTimeout(POOL_CONTEXT_ACTION_TIMEOUT_MS);
|
|
82
154
|
// Block third-party analytics so pooled (server-side) captures don't
|
|
83
155
|
// register a phantom visit in the captured site's analytics (AUT-234).
|
|
84
156
|
// Skippable per-project via blockAnalytics === false.
|
|
@@ -122,7 +194,24 @@ class BrowserPool {
|
|
|
122
194
|
await this.launchPromise;
|
|
123
195
|
}
|
|
124
196
|
async launchBrowser() {
|
|
125
|
-
|
|
197
|
+
const gpu = resolveCloudGpuLaunch();
|
|
198
|
+
if (gpu) {
|
|
199
|
+
// Cloud Run L4: drop the software-only flags and enable Vulkan/ANGLE GPU
|
|
200
|
+
// rasterization for headless screenshot capture, mirroring the clip path.
|
|
201
|
+
const args = [
|
|
202
|
+
...CHROMIUM_ARGS.filter((a) => a !== '--disable-gpu' && a !== '--disable-gpu-sandbox'),
|
|
203
|
+
...gpu.args,
|
|
204
|
+
];
|
|
205
|
+
this.browser = await chromium.launch({
|
|
206
|
+
headless: true,
|
|
207
|
+
ignoreDefaultArgs: gpu.ignoreDefaultArgs,
|
|
208
|
+
args,
|
|
209
|
+
});
|
|
210
|
+
await logPoolGpuRenderer(this.browser);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
this.browser = await chromium.launch({ headless: true, args: CHROMIUM_ARGS });
|
|
214
|
+
}
|
|
126
215
|
this.captureCount = 0;
|
|
127
216
|
this.browser.on('disconnected', () => {
|
|
128
217
|
// Browser crashed — active contexts are now dead, they'll throw naturally
|
package/dist/browser.js
CHANGED
|
@@ -209,6 +209,31 @@ async function seedCloudChromiumPreferences(userDataDir) {
|
|
|
209
209
|
};
|
|
210
210
|
await writeFile(preferencesPath, `${JSON.stringify(preferences)}\n`, 'utf8');
|
|
211
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Default per-action timeout applied to every Playwright context we create.
|
|
214
|
+
* Without this, calls (page.screenshot / page.title / locator actions) inherit
|
|
215
|
+
* Playwright's built-in 30 000 ms default. Under Cloud Run CPU contention (many
|
|
216
|
+
* variants per container) a single starved call would then block for the full
|
|
217
|
+
* 30s — which equals the CAPTURE_SCREENSHOT global deadline, leaving zero budget
|
|
218
|
+
* for recovery/recapture and voiding the whole variant. 25s sits just under that
|
|
219
|
+
* global cap so a hung call fails with budget left for a clean, bounded error.
|
|
220
|
+
* NAVIGATE keeps its own bounded path — we intentionally do NOT touch
|
|
221
|
+
* navigationTimeout here.
|
|
222
|
+
*/
|
|
223
|
+
const CONTEXT_ACTION_TIMEOUT_MS = 25000;
|
|
224
|
+
/**
|
|
225
|
+
* Explicit backstop for the heavy screenshot primitives. Sits just under the 45s
|
|
226
|
+
* global screenshot deadline and ABOVE the runner's reserved action budget
|
|
227
|
+
* (~global − recovery reserve), so the runner's per-attempt bound is the real
|
|
228
|
+
* control and this only catches a screenshot that has genuinely wedged. Must not
|
|
229
|
+
* be smaller than a legitimate slow high-res capture, or it would pre-empt the
|
|
230
|
+
* runner and fail captures that would have completed.
|
|
231
|
+
*/
|
|
232
|
+
const SCREENSHOT_PRIMITIVE_TIMEOUT_MS = 40000;
|
|
233
|
+
/** Apply our engine-wide Playwright defaults to a freshly created context. */
|
|
234
|
+
function applyContextDefaults(context) {
|
|
235
|
+
context.setDefaultTimeout(CONTEXT_ACTION_TIMEOUT_MS);
|
|
236
|
+
}
|
|
212
237
|
async function withHelperTimeout(label, timeoutMs, work) {
|
|
213
238
|
if (!timeoutMs || timeoutMs <= 0) {
|
|
214
239
|
return work();
|
|
@@ -1085,6 +1110,7 @@ export class Browser {
|
|
|
1085
1110
|
headless: false,
|
|
1086
1111
|
args: clipArgs,
|
|
1087
1112
|
});
|
|
1113
|
+
applyContextDefaults(instance.context);
|
|
1088
1114
|
instance.browser = instance.context.browser();
|
|
1089
1115
|
instance.persistentContext = true;
|
|
1090
1116
|
// launchPersistentContext opens an initial about:blank page automatically.
|
|
@@ -1110,6 +1136,7 @@ export class Browser {
|
|
|
1110
1136
|
args: clipArgs,
|
|
1111
1137
|
});
|
|
1112
1138
|
instance.context = await instance.browser.newContext(contextOptions);
|
|
1139
|
+
applyContextDefaults(instance.context);
|
|
1113
1140
|
}
|
|
1114
1141
|
// Block third-party analytics beacons so clip/video capture doesn't
|
|
1115
1142
|
// register a phantom visit either (AUT-234). Context-level so it covers
|
|
@@ -1253,6 +1280,7 @@ export class Browser {
|
|
|
1253
1280
|
args: CHROMIUM_ARGS,
|
|
1254
1281
|
});
|
|
1255
1282
|
this.context = await this.browser.newContext(this.buildContextOptions());
|
|
1283
|
+
applyContextDefaults(this.context);
|
|
1256
1284
|
if (this.options.blockAnalytics !== false) {
|
|
1257
1285
|
await installAnalyticsBlock(this.context);
|
|
1258
1286
|
}
|
|
@@ -1345,6 +1373,7 @@ export class Browser {
|
|
|
1345
1373
|
this.context = null;
|
|
1346
1374
|
}
|
|
1347
1375
|
this.context = await this.browser.newContext(this.buildContextOptions());
|
|
1376
|
+
applyContextDefaults(this.context);
|
|
1348
1377
|
if (this.options.blockAnalytics !== false) {
|
|
1349
1378
|
await installAnalyticsBlock(this.context);
|
|
1350
1379
|
}
|
|
@@ -1840,7 +1869,7 @@ export class Browser {
|
|
|
1840
1869
|
// terminal block where ANSI escape literals were producing tofu boxes).
|
|
1841
1870
|
await stripInvisibleControlChars(page);
|
|
1842
1871
|
await logFontDiagnostics(page, 'before screenshot');
|
|
1843
|
-
const screenshot = Buffer.from(await page.screenshot({ type: 'png', fullPage: false }));
|
|
1872
|
+
const screenshot = Buffer.from(await page.screenshot({ type: 'png', fullPage: false, timeout: SCREENSHOT_PRIMITIVE_TIMEOUT_MS }));
|
|
1844
1873
|
await logCaptureRenderDiagnostics(page, screenshot, this.options, 'after screenshot');
|
|
1845
1874
|
return screenshot;
|
|
1846
1875
|
}
|
|
@@ -5330,7 +5359,7 @@ export class Browser {
|
|
|
5330
5359
|
try {
|
|
5331
5360
|
await this.waitForFontsBeforeScreenshot(page);
|
|
5332
5361
|
await logFontDiagnostics(page, 'before region screenshot');
|
|
5333
|
-
const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true }));
|
|
5362
|
+
const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true, timeout: SCREENSHOT_PRIMITIVE_TIMEOUT_MS }));
|
|
5334
5363
|
const image = sharp(fullPage);
|
|
5335
5364
|
const meta = await image.metadata();
|
|
5336
5365
|
const imageWidth = meta.width ?? 0;
|
|
@@ -5453,7 +5482,7 @@ export class Browser {
|
|
|
5453
5482
|
const dpr = pageInfo.dpr;
|
|
5454
5483
|
await this.waitForFontsBeforeScreenshot(page);
|
|
5455
5484
|
await logFontDiagnostics(page, 'before selector screenshot');
|
|
5456
|
-
const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true }));
|
|
5485
|
+
const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true, timeout: SCREENSHOT_PRIMITIVE_TIMEOUT_MS }));
|
|
5457
5486
|
const image = sharp(fullPage);
|
|
5458
5487
|
const meta = await image.metadata();
|
|
5459
5488
|
const imgW = meta.width ?? 0;
|
|
@@ -5517,7 +5546,7 @@ export class Browser {
|
|
|
5517
5546
|
const dpr = pageInfo.dpr;
|
|
5518
5547
|
await this.waitForFontsBeforeScreenshot(page);
|
|
5519
5548
|
await logFontDiagnostics(page, 'before bounding-region screenshot');
|
|
5520
|
-
const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true }));
|
|
5549
|
+
const fullPage = Buffer.from(await page.screenshot({ type: 'png', fullPage: true, timeout: SCREENSHOT_PRIMITIVE_TIMEOUT_MS }));
|
|
5521
5550
|
const image = sharp(fullPage);
|
|
5522
5551
|
const meta = await image.metadata();
|
|
5523
5552
|
const imgW = meta.width ?? 0;
|
package/dist/cli-runner.js
CHANGED
|
@@ -209,7 +209,15 @@ export async function runCapture(options) {
|
|
|
209
209
|
// serialize variants (CPU/RAM bound on the encoder side). Only 'screenshot'
|
|
210
210
|
// honors the requested concurrency.
|
|
211
211
|
const isRecordable = program.mediaMode === 'clip' || program.mediaMode === 'video';
|
|
212
|
-
|
|
212
|
+
// AUTOKAP_MAX_PARALLEL_CAPTURES lets the RUNTIME (e.g. the Cloud Run container)
|
|
213
|
+
// clamp per-run variant concurrency below what the server compiled, decoupled
|
|
214
|
+
// from billing/queue limits. Fewer concurrent captures = more CPU/GPU per
|
|
215
|
+
// capture on a shared L4 container. Unset/invalid → honor the compiled value.
|
|
216
|
+
const runtimeConcurrencyCap = Number.parseInt(process.env.AUTOKAP_MAX_PARALLEL_CAPTURES ?? '', 10);
|
|
217
|
+
const requestedConcurrency = Number.isFinite(runtimeConcurrencyCap) && runtimeConcurrencyCap > 0
|
|
218
|
+
? Math.min(program.maxParallelCaptures ?? runtimeConcurrencyCap, runtimeConcurrencyCap)
|
|
219
|
+
: program.maxParallelCaptures;
|
|
220
|
+
const maxParallelVariants = isRecordable ? 1 : requestedConcurrency;
|
|
213
221
|
// AUT-149: collect structured logs + progress events for export on failure.
|
|
214
222
|
const logCollector = new LogCollector();
|
|
215
223
|
logCollector.start();
|
package/dist/opcode-runner.js
CHANGED
|
@@ -55,6 +55,20 @@ export class NoOpRecoveryChain {
|
|
|
55
55
|
}
|
|
56
56
|
const MIN_CLIP_FINALIZATION_TIMEOUT_MS = 30000;
|
|
57
57
|
const DEFAULT_VIDEO_RECORDING_RESOLUTION = { width: 1920, height: 1080 };
|
|
58
|
+
/**
|
|
59
|
+
* Recovery budget reserved from the global capture deadline. The screenshot
|
|
60
|
+
* buffer (the artifact-critical call) may use the REST of the deadline, so a
|
|
61
|
+
* slow-but-completing high-res capture (e.g. a 5K device frame legitimately
|
|
62
|
+
* needing 15-30s under load) succeeds, while a truly hung one still fails with
|
|
63
|
+
* this slice left for the recovery/recapture path. Replaces the old flat 12s
|
|
64
|
+
* buffer cap, which was smaller than legitimate heavy captures and killed them
|
|
65
|
+
* (and killed the recapture too, since it re-applied the same 12s wall).
|
|
66
|
+
*/
|
|
67
|
+
const CAPTURE_RECOVERY_RESERVE_MS = 10000;
|
|
68
|
+
/** Floor for the first-attempt capture budget when little global time remains. */
|
|
69
|
+
const MIN_CAPTURE_ACTION_BUDGET_MS = 8000;
|
|
70
|
+
/** Best-effort bound for browser-mockup metadata (URL / favicon / title). */
|
|
71
|
+
const CAPTURE_METADATA_TIMEOUT_MS = 3000;
|
|
58
72
|
/**
|
|
59
73
|
* The compiled per-opcode action budget. For CAPTURE_SCREENSHOT this governs
|
|
60
74
|
* ONLY the deterministic capture (visual stabilize + screenshot + favicon/title
|
|
@@ -463,9 +477,12 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
463
477
|
error: `recovery succeeded but produced no ${opcode.kind === 'END_CLIP' ? 'clip' : 'screenshot'} artifact: ${reason}`,
|
|
464
478
|
};
|
|
465
479
|
};
|
|
466
|
-
// Track page context for circuit breaker
|
|
480
|
+
// Track page context for circuit breaker. Bounded + best-effort: this runs on
|
|
481
|
+
// EVERY opcode inside its global-deadline window, so a getCurrentUrl starved by
|
|
482
|
+
// CPU contention must not eat the budget the capture + recovery need (it would
|
|
483
|
+
// otherwise be capped only by the 25s context default).
|
|
467
484
|
try {
|
|
468
|
-
const url = await adapter.getCurrentUrl();
|
|
485
|
+
const url = await withTimeout(() => adapter.getCurrentUrl(), CAPTURE_METADATA_TIMEOUT_MS);
|
|
469
486
|
breaker.setPage(url);
|
|
470
487
|
}
|
|
471
488
|
catch { /* ignore */ }
|
|
@@ -498,7 +515,15 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
498
515
|
const isPureWait = opcode.kind === 'WAIT_FOR';
|
|
499
516
|
const usesGlobalDeadline = isPureWait || isArtifactProducingOpcode(opcode.kind);
|
|
500
517
|
const actionDeadlineMs = usesGlobalDeadline ? globalDeadlineMs : deadlineMs;
|
|
501
|
-
|
|
518
|
+
let actionBudgetMs = getRemainingTimeMs(actionDeadlineMs);
|
|
519
|
+
// Reserve a slice of the global deadline for recovery/recapture on the
|
|
520
|
+
// artifact-critical CAPTURE_SCREENSHOT: the capture uses the rest (so a slow
|
|
521
|
+
// high-res shot completes) yet a hung one still leaves room to recover.
|
|
522
|
+
// END_CLIP keeps its full budget — its finalization hard-fails without a
|
|
523
|
+
// recapture, so reserving would only shorten it.
|
|
524
|
+
if (opcode.kind === 'CAPTURE_SCREENSHOT') {
|
|
525
|
+
actionBudgetMs = Math.max(MIN_CAPTURE_ACTION_BUDGET_MS, actionBudgetMs - CAPTURE_RECOVERY_RESERVE_MS);
|
|
526
|
+
}
|
|
502
527
|
if (actionBudgetMs <= 0) {
|
|
503
528
|
const reason = `timeout after ${effectiveTimeoutMs}ms`;
|
|
504
529
|
logger.debug(`[opcode ${index}] no budget left after captureBeforeState (deadline=${actionDeadlineMs}, now=${Date.now()})`);
|
|
@@ -879,25 +904,43 @@ async function executeOpcodeAction(opcode, opcodeIndex, adapter, artifacts, tele
|
|
|
879
904
|
lowConfidenceReasons.push(`captured before visual stability: ${stability.reason}`);
|
|
880
905
|
}
|
|
881
906
|
const captureLowConfidenceReason = lowConfidenceReasons.join('; ') || undefined;
|
|
882
|
-
|
|
907
|
+
// Capture the screenshot buffer FIRST — the only artifact-critical call.
|
|
908
|
+
// It is bounded by the reserved action budget (the withTimeout around this
|
|
909
|
+
// whole action in executeOpcode) and the screenshot primitive's own
|
|
910
|
+
// timeout — NOT a flat sub-cap — so a legitimately slow high-res capture
|
|
911
|
+
// can use most of the deadline while a hung one still frees the reserved
|
|
912
|
+
// recovery slice (handleFailure → recapture in `failWithRecovery`).
|
|
883
913
|
const buffer = await takeCaptureBuffer(adapter, opcode);
|
|
884
|
-
//
|
|
914
|
+
// Everything below is best-effort browser-mockup metadata. The buffer is
|
|
915
|
+
// already in hand, so a slow/hung metadata call must NEVER throw out of
|
|
916
|
+
// the action and discard a good screenshot. Each is individually bounded
|
|
917
|
+
// and degrades gracefully (empty URL / no favicon / hostname title).
|
|
918
|
+
let captureUrl = '';
|
|
919
|
+
try {
|
|
920
|
+
captureUrl = await withTimeout(() => adapter.getCurrentUrl(), CAPTURE_METADATA_TIMEOUT_MS);
|
|
921
|
+
}
|
|
922
|
+
catch { /* empty captureUrl is tolerated downstream */ }
|
|
885
923
|
let tabIconData;
|
|
886
924
|
let tabIconMimeType;
|
|
887
925
|
if (adapter.extractFavicon) {
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
926
|
+
try {
|
|
927
|
+
const favicon = await withTimeout(() => adapter.extractFavicon(), CAPTURE_METADATA_TIMEOUT_MS);
|
|
928
|
+
if (favicon) {
|
|
929
|
+
tabIconData = favicon.buffer;
|
|
930
|
+
tabIconMimeType = favicon.mimeType;
|
|
931
|
+
}
|
|
892
932
|
}
|
|
933
|
+
catch { /* ship the screenshot without a favicon */ }
|
|
893
934
|
}
|
|
894
|
-
//
|
|
895
|
-
// title instead of falling back to the hostname.
|
|
935
|
+
// Real tab title for the browser mockup; falls back to hostname when null.
|
|
896
936
|
let pageTitle;
|
|
897
937
|
if (adapter.getPageTitle) {
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
938
|
+
try {
|
|
939
|
+
const resolved = await withTimeout(() => adapter.getPageTitle(), CAPTURE_METADATA_TIMEOUT_MS);
|
|
940
|
+
if (resolved)
|
|
941
|
+
pageTitle = resolved;
|
|
942
|
+
}
|
|
943
|
+
catch { /* fall back to the hostname mockup */ }
|
|
901
944
|
}
|
|
902
945
|
artifacts.push({
|
|
903
946
|
mediaMode: 'screenshot',
|
package/dist/wait-contract.js
CHANGED
|
@@ -23,7 +23,10 @@ export const WAIT_CONTRACT_VERSION = 1;
|
|
|
23
23
|
* so it never shortens an intentionally-long opcode (`SLEEP`, `END_CLIP`).
|
|
24
24
|
*/
|
|
25
25
|
export const GLOBAL_WAIT_CAP_MS = {
|
|
26
|
-
|
|
26
|
+
// Heavy high-res captures (5K device frames, retina, animated pages) can take
|
|
27
|
+
// 15-30s under cloud CPU load with software rasterization. 45s gives them room
|
|
28
|
+
// to complete (and to reserve a recovery slice) instead of failing prematurely.
|
|
29
|
+
screenshot: 45_000,
|
|
27
30
|
clip: 45_000,
|
|
28
31
|
video: 60_000,
|
|
29
32
|
};
|
|
@@ -194,7 +194,15 @@ export class WebPlaywrightLocal {
|
|
|
194
194
|
async getPageTitle() {
|
|
195
195
|
const page = await this.browser.currentPage;
|
|
196
196
|
try {
|
|
197
|
-
|
|
197
|
+
// Metadata for the browser-mockup tab, not the artifact itself. page.title()
|
|
198
|
+
// takes no timeout and inherits Playwright's 30s default; under CPU
|
|
199
|
+
// contention (Cloud Run) it can swallow the whole capture action budget
|
|
200
|
+
// AFTER the screenshot buffer was already produced, discarding a good shot.
|
|
201
|
+
// Bound it tightly and degrade to the hostname fallback (null) on timeout.
|
|
202
|
+
const raw = await Promise.race([
|
|
203
|
+
page.title(),
|
|
204
|
+
new Promise((resolve) => setTimeout(() => resolve(null), 2000)),
|
|
205
|
+
]);
|
|
198
206
|
const trimmed = raw?.trim();
|
|
199
207
|
if (!trimmed)
|
|
200
208
|
return null;
|