From c286288d9030d74cb6842caf7d0765fbb92c7c4c Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:09:48 +0000 Subject: [PATCH 1/2] fix: cook wp-codebox --- packages/runtime-core/src/command-registry.ts | 2 + .../src/browser-actions-runner.ts | 89 +++++++++++++++++-- ...wser-actions-video-capture.browser.test.ts | 36 ++++++++ 3 files changed, 122 insertions(+), 5 deletions(-) diff --git a/packages/runtime-core/src/command-registry.ts b/packages/runtime-core/src/command-registry.ts index 969ec1e2..c68220e7 100644 --- a/packages/runtime-core/src/command-registry.ts +++ b/packages/runtime-core/src/command-registry.ts @@ -167,6 +167,7 @@ const browserActionsValidation: CommandValidationDescriptor = { { name: "step-timeout", kind: "duration", code: "invalid-duration", message: "wordpress.browser-actions step-timeout must look like 500ms or 2s." }, { name: "timeout", kind: "duration", code: "invalid-duration", message: "wordpress.browser-actions timeout must look like 500ms or 2s." }, { name: "capture", kind: "comma-list-enum", values: browserActionCaptureValues, code: "invalid-capture", message: "wordpress.browser-actions capture does not support" }, + { name: "video-quality", kind: "enum", values: ["default", "high"], code: "invalid-video-quality", message: "wordpress.browser-actions video-quality must be default or high." }, { name: "viewport", kind: "viewport", code: "invalid-viewport", message: "wordpress.browser-actions viewport must use x, for example 390x844." }, { name: "device-scale-factor", kind: "number", minimum: 0.01, maximum: 100, code: "invalid-device-scale-factor", message: "wordpress.browser-actions device-scale-factor must be a finite positive number." }, { name: "is-mobile", kind: "boolean", code: "invalid-is-mobile", message: "wordpress.browser-actions is-mobile must be true or false." }, @@ -1244,6 +1245,7 @@ export const commandRegistry = [ { name: "storage-state", description: "Optional Playwright storageState JSON, or @ to JSON, imported into a fresh browser context. Summaries redact cookie/localStorage values and report only schema/kind, counts, hosts, and diagnostics.", format: "JSON object or @path" }, { name: "transport-faults-json", description: "Optional canonical wp-codebox/transport-fault-model/v1 schedule applied only to HTTP(S) requests emitted by this isolated browser context. Server-side WordPress HTTP API requests are not intercepted.", format: "JSON object or @path" }, { name: "capture", description: "Comma-separated artifacts to capture after interactions.", format: "steps,console,errors,html,network,screenshot,dom-snapshot" }, + { name: "video-quality", description: "Video encoding mode when capture includes video. high captures device-pixel PNG frames and encodes them once at the end.", format: "default|high" }, { name: "max-dom-snapshot-elements", description: "Maximum visible elements captured in each screenshot sidecar DOM/style snapshot; defaults to 160.", format: "positive integer" }, ...browserActionEnvironmentAcceptedArgs, ], diff --git a/packages/runtime-playground/src/browser-actions-runner.ts b/packages/runtime-playground/src/browser-actions-runner.ts index a52c5f78..0714a36e 100644 --- a/packages/runtime-playground/src/browser-actions-runner.ts +++ b/packages/runtime-playground/src/browser-actions-runner.ts @@ -1,4 +1,6 @@ -import { copyFile, readFile, stat, writeFile } from "node:fs/promises" +import { copyFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises" +import { homedir } from "node:os" +import { spawn } from "node:child_process" import { join } from "node:path" import { BROWSER_ACTION_CORPUS_SCHEMA, BROWSER_ADAPTIVE_EXPLORATION_SCHEMA, BROWSER_MULTI_ACTOR_SCENARIO_SCHEMA, BROWSER_PROBE_PROFILES, BROWSER_TOOL_VERIFIER_RESULT_SCHEMA, HostToolRegistry, assertRuntimeCommandAllowed, browserActionCorpusArtifact, browserActionCorpusContract, browserAdaptiveExplorationContract, browserEnvironment, browserEnvironmentDigest, browserGeolocation, browserInteractionScriptUsesEvaluate, browserToolVerifierInputSummary, createHostToolRegistry, executeHostTool, resolveCommandPath, transportFaultModel, validateBrowserInteractionScript, type BrowserActionCorpusArtifact, type BrowserActionCorpusContract, type BrowserAdaptiveExplorationArtifact, type BrowserAdaptiveExplorationContract, type BrowserEnvironment, type BrowserGeolocationPermissionState, type BrowserInteractionStep, type BrowserMultiActorScenario, type BrowserToolVerifierResult, type ExecutionSpec, type HostToolDefinition, type JsonValue, type RuntimeCreateSpec, type TransportFaultModel } from "@automattic/wp-codebox-core" import { now, sha256 } from "@automattic/wp-codebox-core/internals" @@ -49,6 +51,7 @@ export interface BrowserActionsRunPlan { networkSettleTimeoutMs: number requestedViewport?: { width: number; height: number } videoSize?: { width: number; height: number } + videoQuality?: "default" | "high" requestedEnvironment?: BrowserEnvironment authRequest?: { userId: number } storageStateImport?: BrowserStorageStateImport @@ -151,6 +154,11 @@ export async function runBrowserActionsCommand({ let screenshotSha256: string | undefined const videoStagingDirectory = artifactSession.absolutePath("video-source") let videoRecording: import("playwright").Video | null = null + let videoCaptureTask: Promise | undefined + let stopVideoCapture = false + let videoFrameCount = 0 + let videoFrameError: Error | undefined + const highVideo = runPlan.videoQuality === "high" let videoSaved = false let videoCaptureFailed = false let videoStartedAt: number | undefined @@ -222,7 +230,7 @@ export async function runBrowserActionsCommand({ ...topology.contextOptions(), ...(storageStateImport ? { storageState: storageStateImport.storageState } : {}), ...(runPlan.transportFaults ? { serviceWorkers: "block" as const } : {}), - ...(capture.has("video") ? { recordVideo: { dir: videoStagingDirectory, ...(browserVideoSize(runPlan.videoSize, requestedEnvironment.viewport) ? { size: browserVideoSize(runPlan.videoSize, requestedEnvironment.viewport) } : {}) } } : {}), + ...(capture.has("video") && !highVideo ? { recordVideo: { dir: videoStagingDirectory, ...(browserVideoSize(runPlan.videoSize, requestedEnvironment.viewport) ? { size: browserVideoSize(runPlan.videoSize, requestedEnvironment.viewport) } : {}) } } : {}), }, }) : undefined) const context = environmentRuntime?.context ?? null @@ -232,9 +240,29 @@ export async function runBrowserActionsCommand({ if (context && runPlan.transportFaults) installedTransportFaults = await installBrowserTransportFaults(context, runPlan.transportFaults, { policy: browserPreviewTransportFaultPolicy(networkPolicy, topology.origins.localProxyOrigin), serviceWorkersBlocked: true }) const page = activePage = environmentRuntime?.page ?? await browser.newPage() if (capture.has("video")) { - videoRecording = page.video() + if (highVideo) { + const viewport = requestedEnvironment.viewport ?? { width: 1280, height: 720 } + const scale = requestedEnvironment.deviceScaleFactor ?? 1 + videoDimensions = { width: Math.round(viewport.width * scale), height: Math.round(viewport.height * scale) } + const frameDirectory = join(videoStagingDirectory, "frames") + await mkdir(frameDirectory, { recursive: true }) + videoCaptureTask = (async () => { + while (!stopVideoCapture) { + try { + const frame = await page.screenshot({ type: "png", scale: "device", animations: "allow" }) + await writeFile(join(frameDirectory, `frame-${String(++videoFrameCount).padStart(6, "0")}.png`), frame) + } catch (error) { + if (stopVideoCapture) return + } + const nextFrameAt = (videoRecordingOrigin ?? performance.now()) + videoFrameCount * 100 + await new Promise((resolve) => setTimeout(resolve, Math.max(0, nextFrameAt - performance.now()))) + } + })() + } else { + videoRecording = page.video() + videoDimensions = browserVideoSize(runPlan.videoSize, requestedEnvironment.viewport) ?? requestedEnvironment.viewport ?? { width: 1280, height: 720 } + } videoStartedAt = videoRecordingOrigin - videoDimensions = browserVideoSize(runPlan.videoSize, requestedEnvironment.viewport) ?? requestedEnvironment.viewport ?? { width: 1280, height: 720 } } navigationTracker = trackBrowserNavigation(page) if (onProgress) { @@ -557,12 +585,32 @@ export async function runBrowserActionsCommand({ }) } if (activePage && resolvedEnvironment) environmentEvidence = await observePlaywrightBrowserEnvironment(activePage, requestedEnvironment, resolvedEnvironment).catch(() => environmentEvidence) + if (videoCaptureTask) { + videoFinishedAt = performance.now() + stopVideoCapture = true + await videoCaptureTask + } const cleanupBrowser = session ? { close: async () => {} } : { close: async () => { await environmentRuntime?.close(); await browser.close() } } for (const routeError of await closeBrowserAndDrainPreviewRoutes(cleanupBrowser, routeTracker)) { errors.push(serializeBrowserError("probe-error", routeError)) if (browserPreviewCleanupErrorIsFatal(routeError)) pendingError ??= routeError } - if (videoRecording) { + if (videoCaptureTask) { + try { + videoFinishedAt ??= performance.now() + if (videoFrameError) throw videoFrameError + if (videoFrameCount === 0) throw new Error("wordpress.browser-actions capture=video-quality=high produced no frames") + const frames = join(videoStagingDirectory, "frames") + const output = join(videoStagingDirectory, "high-quality.webm") + const ffmpeg = await browserFfmpegPath() + await runFfmpeg(ffmpeg, ["-y", "-framerate", "10", "-i", join(frames, "frame-%06d.png"), "-c:v", "libvpx-vp9", "-deadline", "good", "-cpu-used", "0", "-crf", "18", "-b:v", "0", "-pix_fmt", "yuv420p", "-an", output]) + await artifactSession.writeGenerated("video", "video.webm", (path) => copyFile(output, path)) + videoSaved = true + } catch (error) { + errors.push(serializeBrowserError("probe-error", error)) + pendingError ??= error instanceof Error ? error : new Error(String(error)) + } + } else if (videoRecording) { // Playwright finalizes a recording on context close and names it itself. The // recording is adopted from its staging directory rather than through // video.saveAs(), which requires a browser connection that cleanup has closed. @@ -633,6 +681,7 @@ export async function runBrowserActionsCommand({ path: "files/browser/video.webm", width: videoDimensions?.width ?? 0, height: videoDimensions?.height ?? 0, + fps: highVideo ? 10 : 30, durationMs: Math.max(0, Math.round((videoFinishedAt ?? performance.now()) - (videoStartedAt ?? performance.now()))), markers: videoMarkers, } : undefined @@ -866,6 +915,7 @@ async function browserActionsRunPlanFromArgs(args: string[], artifactRoot: strin networkSettleTimeoutMs: durationArg(args, "network-settle-timeout", browserCommandLivenessPolicy().networkSettleTimeoutMs), requestedViewport: viewportArg(args, "viewport"), videoSize: browserVideoSizeArg(args), + videoQuality: browserVideoQualityArg(args), requestedEnvironment: adaptiveExplorationPlan.requestedEnvironment, authRequest: browserAuthRequest(args), storageStateImport: await browserStorageStateImportFromArgs(args, "wordpress.browser-actions", artifactRoot), @@ -878,6 +928,35 @@ async function browserActionsRunPlanFromArgs(args: string[], artifactRoot: strin } } +function browserVideoQualityArg(args: string[]): "default" | "high" { + const value = argValue(args, "video-quality")?.trim() ?? "default" + if (value !== "default" && value !== "high") throw new Error("wordpress.browser-actions video-quality must be default or high") + return value +} + +async function browserFfmpegPath(): Promise { + const candidates = [process.env.FFMPEG_PATH, "ffmpeg"].filter((candidate): candidate is string => Boolean(candidate)) + const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH ?? join(homedir(), ".cache", "ms-playwright") + for (const entry of await readdir(browsersPath).catch(() => [])) { + if (entry.startsWith("ffmpeg-")) candidates.push(join(browsersPath, entry, process.platform === "linux" ? "ffmpeg-linux" : process.platform === "darwin" ? "ffmpeg-mac" : "ffmpeg-win64.exe")) + } + for (const candidate of candidates) { + if (candidate === "ffmpeg") return candidate + try { await readFile(candidate); return candidate } catch { /* try next candidate */ } + } + throw new Error("High-quality video encoding requires ffmpeg (install it on PATH or set FFMPEG_PATH)") +} + +function runFfmpeg(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const process = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"] }) + let stderr = "" + process.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString() }) + process.once("error", reject) + process.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`ffmpeg exited with status ${code}: ${stderr.trim()}`))) + }) +} + async function browserAnnotationThemeFromArgs(args: string[]): Promise { const raw = argValue(args, "annotation-theme-json") if (!raw) return undefined diff --git a/tests/browser-actions-video-capture.browser.test.ts b/tests/browser-actions-video-capture.browser.test.ts index ed8adf49..e99368b1 100644 --- a/tests/browser-actions-video-capture.browser.test.ts +++ b/tests/browser-actions-video-capture.browser.test.ts @@ -122,6 +122,42 @@ test("browser actions records high-density viewport video without padding", asyn } }) +test("browser actions high video quality captures and encodes device-pixel frames", async () => { + const fixture = await pageFixture() + const artifactRoot = await mkdtemp(join(tmpdir(), "wp-codebox-browser-video-high-")) + try { + await runBrowserActionsCommand({ + artifactRoot, + runtimeSpec, + server: fixture.server, + spec: { command: "wordpress.browser-actions", args: [] }, + plan: { + steps: [{ kind: "navigate", url: fixture.url, waitFor: "load" }, { kind: "waitFor", selector: "#target", marker: "ready" }], + capture: new Set(["steps", "video"]), + videoQuality: "high", + requestedEnvironment: { viewport: { width: 540, height: 960 }, deviceScaleFactor: 2 }, + stepTimeoutMs: 2_000, + totalTimeoutMs: 10_000, + networkSettleTimeoutMs: 100, + maxDomSnapshotElements: 20, + }, + }) + const recording = join(artifactRoot, "files/browser/video.webm") + assert((await stat(recording)).size > 0) + const decoded = spawnSync("ffmpeg", ["-v", "error", "-i", recording, "-frames:v", "1", "-f", "image2pipe", "-vcodec", "png", "-"], { timeout: 30_000 }) + assert.equal(decoded.status, 0, "encoded high-quality video should decode") + assert.deepEqual([decoded.stdout.readUInt32BE(16), decoded.stdout.readUInt32BE(20)], [1080, 1920]) + const summary = JSON.parse(await readFile(join(artifactRoot, "files/browser/action-summary.json"), "utf8")) + assert.deepEqual([summary.video.width, summary.video.height], [1080, 1920]) + assert.equal(summary.video.fps, 10) + assert.deepEqual(summary.video.markers.map((marker: { name: string }) => marker.name), ["ready"]) + assert(summary.video.markers[0].endMs >= summary.video.markers[0].startMs) + } finally { + await rm(artifactRoot, { recursive: true, force: true }) + await fixture.close() + } +}) + test("browser actions rejects an unsupported capture value", async () => { const fixture = await pageFixture() const artifactRoot = await mkdtemp(join(tmpdir(), "wp-codebox-browser-video-")) From 65fd27aa701501fa6a9e38bb04cd30b57cc32eff Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:47:26 +0000 Subject: [PATCH 2/2] fix(browser-actions): encode high-quality video with Playwright's bundled ffmpeg The CI runner has no system ffmpeg, so video-quality=high failed with spawn ffmpeg ENOENT. Resolve FFMPEG_PATH, then Playwright's bundled ffmpeg, then PATH (checked for executability). Pipe JPEG frames as MJPEG over pipe:0 and encode VP8, the formats the minimal bundled build supports. --- .../src/browser-actions-runner.ts | 51 +++++++++++++------ ...wser-actions-video-capture.browser.test.ts | 16 ++++-- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/packages/runtime-playground/src/browser-actions-runner.ts b/packages/runtime-playground/src/browser-actions-runner.ts index 0714a36e..4a25aba7 100644 --- a/packages/runtime-playground/src/browser-actions-runner.ts +++ b/packages/runtime-playground/src/browser-actions-runner.ts @@ -1,7 +1,8 @@ -import { copyFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises" +import { access, copyFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises" +import { constants as fsConstants } from "node:fs" import { homedir } from "node:os" import { spawn } from "node:child_process" -import { join } from "node:path" +import { delimiter, join } from "node:path" import { BROWSER_ACTION_CORPUS_SCHEMA, BROWSER_ADAPTIVE_EXPLORATION_SCHEMA, BROWSER_MULTI_ACTOR_SCENARIO_SCHEMA, BROWSER_PROBE_PROFILES, BROWSER_TOOL_VERIFIER_RESULT_SCHEMA, HostToolRegistry, assertRuntimeCommandAllowed, browserActionCorpusArtifact, browserActionCorpusContract, browserAdaptiveExplorationContract, browserEnvironment, browserEnvironmentDigest, browserGeolocation, browserInteractionScriptUsesEvaluate, browserToolVerifierInputSummary, createHostToolRegistry, executeHostTool, resolveCommandPath, transportFaultModel, validateBrowserInteractionScript, type BrowserActionCorpusArtifact, type BrowserActionCorpusContract, type BrowserAdaptiveExplorationArtifact, type BrowserAdaptiveExplorationContract, type BrowserEnvironment, type BrowserGeolocationPermissionState, type BrowserInteractionStep, type BrowserMultiActorScenario, type BrowserToolVerifierResult, type ExecutionSpec, type HostToolDefinition, type JsonValue, type RuntimeCreateSpec, type TransportFaultModel } from "@automattic/wp-codebox-core" import { now, sha256 } from "@automattic/wp-codebox-core/internals" import { browserInteractionStepsFromArgs, browserStepTimeoutMs, durationStringMs, sanitizeScreenshotName } from "./browser-actions.js" @@ -249,8 +250,8 @@ export async function runBrowserActionsCommand({ videoCaptureTask = (async () => { while (!stopVideoCapture) { try { - const frame = await page.screenshot({ type: "png", scale: "device", animations: "allow" }) - await writeFile(join(frameDirectory, `frame-${String(++videoFrameCount).padStart(6, "0")}.png`), frame) + const frame = await page.screenshot({ type: "jpeg", quality: 95, scale: "device", animations: "allow" }) + await writeFile(join(frameDirectory, `frame-${String(++videoFrameCount).padStart(6, "0")}.jpg`), frame) } catch (error) { if (stopVideoCapture) return } @@ -603,7 +604,12 @@ export async function runBrowserActionsCommand({ const frames = join(videoStagingDirectory, "frames") const output = join(videoStagingDirectory, "high-quality.webm") const ffmpeg = await browserFfmpegPath() - await runFfmpeg(ffmpeg, ["-y", "-framerate", "10", "-i", join(frames, "frame-%06d.png"), "-c:v", "libvpx-vp9", "-deadline", "good", "-cpu-used", "0", "-crf", "18", "-b:v", "0", "-pix_fmt", "yuv420p", "-an", output]) + // Frames are piped as MJPEG and encoded as VP8 so the encoder works with + // Playwright's bundled minimal ffmpeg (VP8 encoder, image2pipe + mjpeg + // decoder only) as well as a full system build. Quality-first settings + // replace the realtime recorder's ~1 Mbps warm-up. + const frameFiles = (await readdir(frames)).filter((entry) => entry.endsWith(".jpg")).sort().map((entry) => join(frames, entry)) + await runFfmpeg(ffmpeg, ["-y", "-f", "image2pipe", "-c:v", "mjpeg", "-framerate", "10", "-i", "pipe:0", "-c:v", "libvpx", "-deadline", "good", "-cpu-used", "0", "-crf", "4", "-b:v", "40M", "-qmin", "0", "-qmax", "12", "-pix_fmt", "yuv420p", "-an", output], frameFiles) await artifactSession.writeGenerated("video", "video.webm", (path) => copyFile(output, path)) videoSaved = true } catch (error) { @@ -935,25 +941,38 @@ function browserVideoQualityArg(args: string[]): "default" | "high" { } async function browserFfmpegPath(): Promise { - const candidates = [process.env.FFMPEG_PATH, "ffmpeg"].filter((candidate): candidate is string => Boolean(candidate)) + // Prefer an explicit override, then the ffmpeg Playwright installs next to + // its browsers (present wherever video capture already works), then PATH. + const candidates = [process.env.FFMPEG_PATH].filter((candidate): candidate is string => Boolean(candidate)) const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH ?? join(homedir(), ".cache", "ms-playwright") - for (const entry of await readdir(browsersPath).catch(() => [])) { - if (entry.startsWith("ffmpeg-")) candidates.push(join(browsersPath, entry, process.platform === "linux" ? "ffmpeg-linux" : process.platform === "darwin" ? "ffmpeg-mac" : "ffmpeg-win64.exe")) + const binary = process.platform === "linux" ? "ffmpeg-linux" : process.platform === "darwin" ? "ffmpeg-mac" : "ffmpeg-win64.exe" + for (const entry of (await readdir(browsersPath).catch(() => [] as string[])).sort().reverse()) { + if (entry.startsWith("ffmpeg-")) candidates.push(join(browsersPath, entry, binary)) + } + for (const directory of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) { + candidates.push(join(directory, process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg")) } for (const candidate of candidates) { - if (candidate === "ffmpeg") return candidate - try { await readFile(candidate); return candidate } catch { /* try next candidate */ } + if (await access(candidate, fsConstants.X_OK).then(() => true, () => false)) return candidate } - throw new Error("High-quality video encoding requires ffmpeg (install it on PATH or set FFMPEG_PATH)") + throw new Error("video-quality=high requires ffmpeg: install Playwright's ffmpeg (npx playwright install ffmpeg), put ffmpeg on PATH, or set FFMPEG_PATH") } -function runFfmpeg(command: string, args: string[]): Promise { +function runFfmpeg(command: string, args: string[], stdinFiles: string[] = []): Promise { return new Promise((resolve, reject) => { - const process = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"] }) + const child = spawn(command, args, { stdio: ["pipe", "ignore", "pipe"] }) let stderr = "" - process.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString() }) - process.once("error", reject) - process.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`ffmpeg exited with status ${code}: ${stderr.trim()}`))) + child.stderr?.on("data", (chunk: Buffer) => { stderr = (stderr + chunk.toString()).slice(-4000) }) + child.once("error", reject) + child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`ffmpeg exited with status ${code}: ${stderr.trim()}`))) + child.stdin?.on("error", () => { /* surfaced through the exit status */ }) + void (async () => { + for (const file of stdinFiles) { + const chunk = await readFile(file) + if (!child.stdin?.write(chunk)) await new Promise((resume) => child.stdin?.once("drain", resume)) + } + child.stdin?.end() + })().catch((error) => { child.kill(); reject(error) }) }) } diff --git a/tests/browser-actions-video-capture.browser.test.ts b/tests/browser-actions-video-capture.browser.test.ts index e99368b1..bca8f888 100644 --- a/tests/browser-actions-video-capture.browser.test.ts +++ b/tests/browser-actions-video-capture.browser.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" -import { mkdtemp, readFile, rm, stat } from "node:fs/promises" +import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises" import { createServer } from "node:http" -import { tmpdir } from "node:os" +import { homedir, tmpdir } from "node:os" import { join } from "node:path" import test from "node:test" import { spawnSync } from "node:child_process" @@ -144,9 +144,15 @@ test("browser actions high video quality captures and encodes device-pixel frame }) const recording = join(artifactRoot, "files/browser/video.webm") assert((await stat(recording)).size > 0) - const decoded = spawnSync("ffmpeg", ["-v", "error", "-i", recording, "-frames:v", "1", "-f", "image2pipe", "-vcodec", "png", "-"], { timeout: 30_000 }) - assert.equal(decoded.status, 0, "encoded high-quality video should decode") - assert.deepEqual([decoded.stdout.readUInt32BE(16), decoded.stdout.readUInt32BE(20)], [1080, 1920]) + // Decode with the same ffmpeg the encoder resolves (Playwright's bundled + // build on CI), so the test needs no system ffmpeg. Its stream header + // reports the true encoded frame size. + const browsers = process.env.PLAYWRIGHT_BROWSERS_PATH ?? join(homedir(), ".cache", "ms-playwright") + const bundled = (await readdir(browsers).catch(() => [] as string[])).filter((entry) => entry.startsWith("ffmpeg-")).sort().reverse() + const ffmpeg = process.env.FFMPEG_PATH ?? (bundled[0] ? join(browsers, bundled[0], process.platform === "linux" ? "ffmpeg-linux" : process.platform === "darwin" ? "ffmpeg-mac" : "ffmpeg-win64.exe") : "ffmpeg") + const decoded = spawnSync(ffmpeg, ["-hide_banner", "-i", recording, "-frames:v", "1", "-c:v", "libvpx", "-f", "webm", "-y", join(artifactRoot, "decoded-probe.webm")], { timeout: 30_000, encoding: "utf8" }) + assert.equal(decoded.status, 0, `encoded high-quality video should decode: ${decoded.stderr}`) + assert.match(decoded.stderr, /Video: vp8[^\n]*\b1080x1920\b/, "video stream is encoded at device pixels") const summary = JSON.parse(await readFile(join(artifactRoot, "files/browser/action-summary.json"), "utf8")) assert.deepEqual([summary.video.width, summary.video.height], [1080, 1920]) assert.equal(summary.video.fps, 10)