From 0ba12c6414ef7c9a067dbafc62fd3c71d3ed07d9 Mon Sep 17 00:00:00 2001 From: pouria Date: Tue, 7 Jul 2026 01:11:48 +0330 Subject: [PATCH] Separate clean and grid screenshot artifacts --- runwave/agent/src/agent-player.js | 19 +++- runwave/agent/test/agent.test.js | 93 ++++++++++++++++++- runwave/controller/README.md | 7 +- runwave/controller/src/action-handler.js | 26 ++++-- runwave/controller/src/browser-session.js | 20 +++- runwave/controller/src/daemon.js | 6 +- runwave/controller/src/protocol.js | 1 - runwave/controller/src/step-executor.js | 14 ++- .../controller/test/action-handler.test.js | 80 ++++++++++++++++ .../controller/test/browser-session.test.js | 62 +++++++++++++ runwave/test/smoke.test.js | 1 - stress-test/build-playtest-viewer.js | 17 +++- stress-test/remote/run-playtest.js | 1 - .../test/build-playtest-viewer.test.js | 32 +++++++ stress-test/test/remote-process.test.js | 2 - 15 files changed, 345 insertions(+), 36 deletions(-) create mode 100644 runwave/controller/test/action-handler.test.js create mode 100644 stress-test/test/build-playtest-viewer.test.js diff --git a/runwave/agent/src/agent-player.js b/runwave/agent/src/agent-player.js index 4b42b80..f64fcf0 100644 --- a/runwave/agent/src/agent-player.js +++ b/runwave/agent/src/agent-player.js @@ -23,6 +23,13 @@ function latestScreenshot(response) { return (capture && capture.path) || body.screenshot || null; } +function latestAgentScreenshot(response) { + const body = responseBody(response); + const captures = Array.isArray(body.captures) ? body.captures : []; + const capture = captures.length ? captures[captures.length - 1] : null; + return (capture && (capture.gridPath || capture.path)) || body.gridScreenshot || body.screenshot || null; +} + function responseState(response) { const body = responseBody(response); return body.endState || body.state || {}; @@ -193,9 +200,10 @@ async function runAgenticPlaytest({ job, initialResponse, runAction, outputDir, const remainingMs = maxMs - elapsedMs - STOP_RESERVE_MS; if (remainingMs <= 0) break; - const screenshot = latestScreenshot(lastResponse); + const screenshot = latestAgentScreenshot(lastResponse); + const cleanScreenshot = latestScreenshot(lastResponse); const state = responseState(lastResponse); - recorder.observation({ step, elapsedMs, screenshot, state }); + recorder.observation({ step, elapsedMs, screenshot, cleanScreenshot, state }); let sequence; let model; @@ -284,7 +292,7 @@ async function runAgenticPlaytest({ job, initialResponse, runAction, outputDir, error: sequence.error, }); - const failedResult = failedActionResult({ screenshot, state, error: sequence.error }); + const failedResult = failedActionResult({ screenshot: cleanScreenshot, state, error: sequence.error }); history.push({ step, summary: sequence.summary, @@ -361,13 +369,13 @@ async function runAgenticPlaytest({ job, initialResponse, runAction, outputDir, cursorMoves: buckets.cursorMoves, viewMoves: buckets.viewMoves, failedAction: true, - result: failedActionResult({ screenshot, state, error: message }), + result: failedActionResult({ screenshot: cleanScreenshot, state, error: message }), }); continue; } lastResponse = stepResponse; - const result = postSequenceResult(lastResponse, screenshot); + const result = postSequenceResult(lastResponse, cleanScreenshot); history.push({ step, summary: sequence.summary, @@ -411,6 +419,7 @@ module.exports = { actionsWithinDuration, failedActionAfterInvalidJson, isRecoverableModelSequenceError, + latestAgentScreenshot, latestScreenshot, postSequenceResult, responseState, diff --git a/runwave/agent/test/agent.test.js b/runwave/agent/test/agent.test.js index da8af22..7f4e091 100644 --- a/runwave/agent/test/agent.test.js +++ b/runwave/agent/test/agent.test.js @@ -8,8 +8,14 @@ const test = require('node:test'); const { PNG } = require('pngjs'); const { normalizeSequence } = require('../src/action-parser'); -const { actionsWithinDuration, failedActionAfterInvalidJson, runAgenticPlaytest } = require('../src/agent-player'); -const { chatCompletion, parseJsonResponse } = require('../src/model-client'); +const { + actionsWithinDuration, + failedActionAfterInvalidJson, + latestAgentScreenshot, + latestScreenshot, + runAgenticPlaytest, +} = require('../src/agent-player'); +const { chatCompletion, dataUrl, parseJsonResponse } = require('../src/model-client'); const { buildPlaytesterPrompt, compactHistory, sequenceSchemaGuide } = require('../src/prompt'); const { drawMarkGridOnScreenshot } = require('../../controller/src/grid-overlay'); const { gridSafeSampleRatio, randomPointInCells } = require('../../protocol/src/mark-grid'); @@ -60,6 +66,26 @@ test('normalizes model sequences into controller steps', () => { assert.equal(sequence.previousSequenceOutcome, 'Enter opened the menu.'); }); +test('agent screenshot selection separates clean evidence from grid targeting images', () => { + const response = { + screenshot: '/tmp/initial-clean.png', + gridScreenshot: '/tmp/initial-grid.png', + captures: [ + { + path: '/tmp/capture-clean.png', + gridPath: '/tmp/capture-grid.png', + }, + ], + }; + + assert.equal(latestScreenshot(response), '/tmp/capture-clean.png'); + assert.equal(latestAgentScreenshot(response), '/tmp/capture-grid.png'); + assert.equal(latestScreenshot({ screenshot: '/tmp/clean.png' }), '/tmp/clean.png'); + assert.equal(latestAgentScreenshot({ screenshot: '/tmp/clean.png' }), '/tmp/clean.png'); + assert.equal(latestScreenshot({ gridScreenshot: '/tmp/grid-only.png' }), null); + assert.equal(latestAgentScreenshot({ gridScreenshot: '/tmp/grid-only.png' }), '/tmp/grid-only.png'); +}); + test('normalizes grid-cell model actions into concrete pointer events', () => { const sequence = normalizeSequence( { @@ -483,6 +509,69 @@ test('agent playtest loop calls model and executes returned sequence', async () assert.equal(fs.existsSync(path.join(dir, 'agent', 'agent-summary.json')), true); }); +test('agent loop sends grid screenshots to the model and records clean screenshots as evidence', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runwave-agent-grid-split-test-')); + const cleanInitial = path.join(dir, 'initial.png'); + const gridInitial = path.join(dir, 'initial.grid.png'); + const cleanAfter = path.join(dir, 'after.png'); + const gridAfter = path.join(dir, 'after.grid.png'); + fs.writeFileSync(cleanInitial, 'clean initial screenshot bytes'); + fs.writeFileSync(gridInitial, 'grid initial screenshot bytes'); + fs.writeFileSync(cleanAfter, 'clean after screenshot bytes'); + fs.writeFileSync(gridAfter, 'grid after screenshot bytes'); + + const modelScreenshots = []; + const result = await runAgenticPlaytest({ + job: { + playtestDurationMs: 7000, + agentMinPlaytestMs: 0, + viewport: { width: 640, height: 360 }, + }, + initialResponse: { + screenshot: cleanInitial, + gridScreenshot: gridInitial, + state: { screen: 'initial' }, + }, + outputDir: path.join(dir, 'agent'), + modelClient: async ({ messages }) => { + const image = messages[0].content.find((item) => item.type === 'image_url'); + modelScreenshots.push(image.image_url.url); + return { + model: 'fake-model', + usage: { total_tokens: 1 }, + json: { + summary: 'start screen is visible', + actions: [{ type: 'key', start: 0, end: 500, key: 'Enter' }], + should_stop: true, + }, + }; + }, + runAction: async () => ({ + ok: true, + captures: [{ path: cleanAfter, gridPath: gridAfter }], + endState: { screen: 'after' }, + }), + }); + + const promptLog = fs + .readFileSync(path.join(dir, 'agent', 'agent-prompts.jsonl'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + const observationLog = fs + .readFileSync(path.join(dir, 'agent', 'agent-observations.jsonl'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + + assert.equal(modelScreenshots[0], dataUrl(gridInitial)); + assert.equal(promptLog[0].screenshot, gridInitial); + assert.equal(observationLog[0].screenshot, gridInitial); + assert.equal(observationLog[0].cleanScreenshot, cleanInitial); + assert.equal(result.history[0].result.screenshot, cleanAfter); + assert.equal(result.history[0].result.screenshotChanged, true); +}); + test('agent loop records invalid JSON as a failed action and retries with the same screenshot', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runwave-agent-json-failed-action-test-')); const screenshot = path.join(dir, 'screen.png'); diff --git a/runwave/controller/README.md b/runwave/controller/README.md index f29e3a2..57edbd6 100644 --- a/runwave/controller/README.md +++ b/runwave/controller/README.md @@ -109,8 +109,11 @@ runwave-controller '{ }' ``` -Screenshots include a 16x16 red mark grid by default. Overlay column labels are -shown in the top/bottom margins and overlay row labels are shown in the +Screenshots returned in `screenshot` and `captures[].path` are clean game +screenshots. Responses also include playtester-only grid images in +`gridScreenshot` and `captures[].gridPath`. +The grid image contains a 16x16 red mark grid by default. Overlay column labels +are shown in the top/bottom margins and overlay row labels are shown in the left/right margins. Pointer actions may use overlay row/column grid objects instead of exact pixels. diff --git a/runwave/controller/src/action-handler.js b/runwave/controller/src/action-handler.js index d31d49e..a808485 100644 --- a/runwave/controller/src/action-handler.js +++ b/runwave/controller/src/action-handler.js @@ -24,6 +24,14 @@ function withVerboseLog(runtime, payload) { }; } +function screenshotFields(artifact) { + if (!artifact || typeof artifact !== 'object') return {}; + return { + screenshot: artifact.path, + ...(artifact.gridPath ? { gridScreenshot: artifact.gridPath } : {}), + }; +} + async function writeStateResponse(runtime, input) { const { output, paths } = runtime; const payload = await runtime.profiler.time('action.state.build_payload', { action_name: input.action_name }, async () => @@ -46,15 +54,16 @@ async function writeScreenshotResponse(runtime, input) { const outputDir = runtime.profiler.timeSync('action.screenshot.action_dir', { action_name: input.action_name }, () => output.actionDir(input.action_name) ); + const screenshot = await runtime.profiler.time('action.screenshot.capture', { action_name: input.action_name }, () => + browser.screenshotArtifact(outputDir, input.name || 'screenshot') + ); const payload = withVerboseLog(runtime, { ok: true, action: 'screenshot', action_name: input.action_name, session_id: runtime.sessionId, sessionDir: paths.runDir, - screenshot: await runtime.profiler.time('action.screenshot.capture', { action_name: input.action_name }, () => - browser.screenshot(outputDir, input.name || 'screenshot') - ), + ...screenshotFields(screenshot), state: await readState(runtime, input), }); return runtime.profiler.timeSync('action.screenshot.write_response', { action_name: input.action_name }, () => @@ -73,6 +82,9 @@ async function writeNavigateResponse(runtime, input) { browser.navigate(isReset ? { url: browser.launchUrl } : input) ); if (isReset) runtime.stepIndex = 0; + const screenshot = await runtime.profiler.time('action.navigate.screenshot', { action_name: input.action_name }, () => + browser.screenshotArtifact(outputDir, '000-after-navigate') + ); const payload = withVerboseLog(runtime, { ok: true, @@ -80,9 +92,7 @@ async function writeNavigateResponse(runtime, input) { action_name: input.action_name, session_id: runtime.sessionId, sessionDir: paths.runDir, - screenshot: await runtime.profiler.time('action.navigate.screenshot', { action_name: input.action_name }, () => - browser.screenshot(outputDir, '000-after-navigate') - ), + ...screenshotFields(screenshot), state: await readState(runtime, input), }); return runtime.profiler.timeSync('action.navigate.write_response', { action_name: input.action_name }, () => @@ -128,7 +138,7 @@ async function writeStopResponse(runtime, input) { input.finalScreenshot === false || config.finalScreenshot === false ? null : await runtime.profiler.time('action.stop.screenshot', { action_name: input.action_name }, () => - browser.screenshot(outputDir, '999-final') + browser.screenshotArtifact(outputDir, '999-final') ); const recording = await runtime.profiler.time('action.stop.browser_close', { action_name: input.action_name }, () => browser.close()); const video = typeof recording === 'string' ? recording : recording.video; @@ -148,7 +158,7 @@ async function writeStopResponse(runtime, input) { if (recording && typeof recording === 'object') { if (recording.audioVideo) payload.audioVideo = recording.audioVideo; } - if (screenshot) payload.screenshot = screenshot; + Object.assign(payload, screenshotFields(screenshot)); const response = runtime.profiler.timeSync('action.stop.write_response', { action_name: input.action_name }, () => output.response(input.action_name, payload) ); diff --git a/runwave/controller/src/browser-session.js b/runwave/controller/src/browser-session.js index 974f63a..2c95c11 100644 --- a/runwave/controller/src/browser-session.js +++ b/runwave/controller/src/browser-session.js @@ -55,6 +55,10 @@ function launchHeadless(config = {}) { return isRecording(config) ? false : config.headless !== false; } +function gridScreenshotPath(file) { + return file.toLowerCase().endsWith('.png') ? file.slice(0, -4) + '.grid.png' : `${file}.grid.png`; +} + async function pageViewportVideoSource(page, env = process.env) { const display = env.DISPLAY || ':0'; const metrics = await page.evaluate(() => ({ @@ -199,7 +203,7 @@ class BrowserSession { await this.time('browser.navigate.wait_after_load', { waitAfterLoad }, () => sleep(waitAfterLoad)); } - async screenshot(outputDir, name) { + async screenshotArtifact(outputDir, name) { const fileName = `${safeName(name || `capture-${timestamp()}`)}.png`; const file = path.join(outputDir, fileName); await this.time('browser.screenshot.capture', { file, fullPage: Boolean(this.config.fullPageScreenshots) }, () => @@ -209,10 +213,16 @@ class BrowserSession { scale: 'css', }) ); - if (this.config.gridScreenshots !== false) { - this.timeSync('browser.screenshot.grid_overlay', { file }, () => drawGridOnScreenshot(file, this.config)); - } - return file; + const gridFile = gridScreenshotPath(file); + this.timeSync('browser.screenshot.grid_copy', { file, gridFile }, () => fs.copyFileSync(file, gridFile)); + this.timeSync('browser.screenshot.grid_overlay', { file: gridFile }, () => drawGridOnScreenshot(gridFile, this.config)); + const artifact = { path: file, gridPath: gridFile }; + return artifact; + } + + async screenshot(outputDir, name) { + const artifact = await this.screenshotArtifact(outputDir, name); + return artifact.path; } async keyDown(key) { diff --git a/runwave/controller/src/daemon.js b/runwave/controller/src/daemon.js index e05780f..14430ce 100644 --- a/runwave/controller/src/daemon.js +++ b/runwave/controller/src/daemon.js @@ -102,9 +102,11 @@ async function main() { state: await profiler.time('daemon.start_response.state', () => browser.state()), }; if (config.initialScreenshot !== false) { - payload.screenshot = await profiler.time('daemon.start_response.screenshot', () => - browser.screenshot(outputDir, '000-initial') + const screenshot = await profiler.time('daemon.start_response.screenshot', () => + browser.screenshotArtifact(outputDir, '000-initial') ); + payload.screenshot = screenshot.path; + if (screenshot.gridPath) payload.gridScreenshot = screenshot.gridPath; } if (!payload.verboseLog) delete payload.verboseLog; const startResponse = profiler.timeSync('daemon.start_response.write_output', () => diff --git a/runwave/controller/src/protocol.js b/runwave/controller/src/protocol.js index 1a06deb..6d14c1b 100644 --- a/runwave/controller/src/protocol.js +++ b/runwave/controller/src/protocol.js @@ -145,7 +145,6 @@ function startSessionConfig(input, options = {}) { captureIntervalMs: optionalNumber(input.captureIntervalMs, 1000), finalScreenshot: input.finalScreenshot !== false, fullPageScreenshots: Boolean(input.fullPageScreenshots), - gridScreenshots: input.gridScreenshots !== false, markGridRows: optionalNumber(input.markGridRows ?? input.gridRows, DEFAULT_MARK_GRID.rows), markGridCols: optionalNumber(input.markGridCols ?? input.gridCols, DEFAULT_MARK_GRID.cols), }, diff --git a/runwave/controller/src/step-executor.js b/runwave/controller/src/step-executor.js index bc0cb45..8d29ec3 100644 --- a/runwave/controller/src/step-executor.js +++ b/runwave/controller/src/step-executor.js @@ -15,13 +15,17 @@ async function waitUntil(startedAt, offsetMs, profiler, fields = {}) { } async function captureAt({ browser, outputDir, prefix, stateExpression, at, profiler }) { + const name = `${prefix}-${String(at).padStart(5, '0')}ms`; + const captureScreenshot = () => browser.screenshotArtifact + ? browser.screenshotArtifact(outputDir, name) + : Promise.resolve(browser.screenshot(outputDir, name)).then((path) => ({ path })); + const screenshot = profiler + ? await profiler.time('timeline.capture.screenshot', { at }, captureScreenshot) + : await captureScreenshot(); return { at, - path: profiler - ? await profiler.time('timeline.capture.screenshot', { at }, () => - browser.screenshot(outputDir, `${prefix}-${String(at).padStart(5, '0')}ms`) - ) - : await browser.screenshot(outputDir, `${prefix}-${String(at).padStart(5, '0')}ms`), + path: screenshot.path, + ...(screenshot.gridPath ? { gridPath: screenshot.gridPath } : {}), state: profiler ? await profiler.time('timeline.capture.state', { at }, () => browser.state(stateExpression)) : await browser.state(stateExpression), diff --git a/runwave/controller/test/action-handler.test.js b/runwave/controller/test/action-handler.test.js new file mode 100644 index 0000000..007f8d7 --- /dev/null +++ b/runwave/controller/test/action-handler.test.js @@ -0,0 +1,80 @@ +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const test = require('node:test'); + +const { handleAction } = require('../src/action-handler'); +const { OutputWriter } = require('../src/output-writer'); +const { createProfiler } = require('../src/profiler'); + +function testRuntime(tmpDir, browserOverrides = {}) { + const browser = { + screenshotArtifact: async (outputDir, name) => ({ + path: path.join(outputDir, `${name}.png`), + gridPath: path.join(outputDir, `${name}.grid.png`), + }), + state: async () => ({ ready: true }), + click: async () => {}, + keyUp: async () => {}, + ...browserOverrides, + }; + + return { + sessionId: 'session-001', + sessionFile: path.join(tmpDir, 'session.json'), + stepIndex: 0, + config: { + autoCaptures: false, + captureIntervalMs: 1000, + keyAliases: {}, + }, + browser, + output: new OutputWriter(tmpDir), + paths: { runDir: tmpDir }, + profiler: createProfiler({ enabled: false, source: 'test' }), + }; +} + +test('screenshot action response exposes clean and grid screenshot fields', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'runwave-action-handler-')); + + try { + const response = await handleAction(testRuntime(tmpDir), { + action: 'screenshot', + action_name: 'inspect', + session_id: 'session-001', + name: 'current', + }); + + assert.equal(response.screenshot, path.join(response.outputDir, 'current.png')); + assert.equal(response.gridScreenshot, path.join(response.outputDir, 'current.grid.png')); + assert.equal(response.state.ready, true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('step action captures expose clean and grid screenshot fields', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'runwave-action-handler-')); + + try { + const response = await handleAction(testRuntime(tmpDir), { + action: 'step', + action_name: 'click-step', + session_id: 'session-001', + duration: 1, + autoCaptures: false, + captures: [1], + actions: [{ type: 'click', start: 0, end: 1, x: 10, y: 12 }], + }); + + assert.equal(response.captures.length, 1); + assert.equal(response.captures[0].path, path.join(response.outputDir, '001-click-step-00001ms.png')); + assert.equal(response.captures[0].gridPath, path.join(response.outputDir, '001-click-step-00001ms.grid.png')); + assert.equal(response.startState.ready, true); + assert.equal(response.endState.ready, true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/runwave/controller/test/browser-session.test.js b/runwave/controller/test/browser-session.test.js index 943290b..ebcb86d 100644 --- a/runwave/controller/test/browser-session.test.js +++ b/runwave/controller/test/browser-session.test.js @@ -1,6 +1,9 @@ const assert = require('node:assert/strict'); +const fs = require('fs'); const os = require('os'); +const path = require('path'); const test = require('node:test'); +const { PNG } = require('pngjs'); const { BrowserSession, @@ -66,6 +69,65 @@ test('browser viewport stabilizer hides overflow and prevents scrolling keys', ( assert.match(source, /preventDefault/); }); +test('browser screenshot artifacts always keep clean and grid images separate', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runwave-browser-screenshot-')); + const session = new BrowserSession( + { url: 'about:blank', markGridRows: 4, markGridCols: 4 }, + { runDir: dir } + ); + session.page = { + screenshot: async ({ path: file }) => { + const png = new PNG({ width: 40, height: 30 }); + for (let index = 0; index < png.data.length; index += 4) { + png.data[index] = 30; + png.data[index + 1] = 70; + png.data[index + 2] = 120; + png.data[index + 3] = 255; + } + fs.writeFileSync(file, PNG.sync.write(png)); + }, + }; + + try { + const artifact = await session.screenshotArtifact(dir, 'screen'); + + assert.equal(artifact.path, path.join(dir, 'screen.png')); + assert.equal(artifact.gridPath, path.join(dir, 'screen.grid.png')); + assert.ok(fs.existsSync(artifact.path)); + assert.ok(fs.existsSync(artifact.gridPath)); + + const clean = PNG.sync.read(fs.readFileSync(artifact.path)); + const grid = PNG.sync.read(fs.readFileSync(artifact.gridPath)); + assert.equal(clean.width, 40); + assert.equal(clean.height, 30); + assert.ok(grid.width > clean.width); + assert.ok(grid.height > clean.height); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('browser screenshot returns the clean screenshot path for compatibility', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runwave-browser-screenshot-')); + const session = new BrowserSession({ url: 'about:blank' }, { runDir: dir }); + session.page = { + screenshot: async ({ path: file }) => { + const png = new PNG({ width: 20, height: 20 }); + fs.writeFileSync(file, PNG.sync.write(png)); + }, + }; + + try { + const screenshot = await session.screenshot(dir, 'compat'); + + assert.equal(screenshot, path.join(dir, 'compat.png')); + assert.ok(fs.existsSync(screenshot)); + assert.ok(fs.existsSync(path.join(dir, 'compat.grid.png'))); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('browser clicks hold the mouse down for the normalized click interval', async () => { const session = new BrowserSession({ url: 'about:blank' }, { runDir: os.tmpdir() }); const calls = []; diff --git a/runwave/test/smoke.test.js b/runwave/test/smoke.test.js index 3fd4b0a..4085167 100644 --- a/runwave/test/smoke.test.js +++ b/runwave/test/smoke.test.js @@ -91,7 +91,6 @@ test('CLI opens a page, clicks, captures state, and finalizes recording', async outDir: recordingDir, initialScreenshot: true, finalScreenshot: false, - gridScreenshots: false, stateExpression, sessionWaitMs: 20000, ...runwaveLaunchOptions(launchConfig), diff --git a/stress-test/build-playtest-viewer.js b/stress-test/build-playtest-viewer.js index c91ec61..65ffb2f 100755 --- a/stress-test/build-playtest-viewer.js +++ b/stress-test/build-playtest-viewer.js @@ -66,6 +66,10 @@ function pickVideo(videos) { || videos[0]; } +function isCleanScreenshot(file) { + return file.endsWith('.png') && !file.endsWith('.grid.png'); +} + function collectRuns(artifacts, outFile, options = {}) { const summaries = walk(artifacts, (file) => path.basename(file) === 'summary.json'); return summaries.map((summaryPath) => { @@ -75,7 +79,7 @@ function collectRuns(artifacts, outFile, options = {}) { const game = relative[0] || summary.game || 'unknown'; const videos = walk(attemptDir, (file) => file.endsWith('.webm')).sort(); const video = pickVideo(videos); - const screenshots = walk(attemptDir, (file) => file.endsWith('.png')).sort(); + const screenshots = walk(attemptDir, isCleanScreenshot).sort(); return { game, attempt: relative[1] || 'attempt', @@ -332,4 +336,13 @@ function main() { console.log(JSON.stringify({ out: args.out, runs: runs.length }, null, 2)); } -main(); +if (require.main === module) { + main(); +} + +module.exports = { + collectRuns, + isCleanScreenshot, + pickVideo, + render, +}; diff --git a/stress-test/remote/run-playtest.js b/stress-test/remote/run-playtest.js index 47aa311..87aa825 100755 --- a/stress-test/remote/run-playtest.js +++ b/stress-test/remote/run-playtest.js @@ -212,7 +212,6 @@ function startOverridesFromJob(job = {}, audioCapture = {}) { chromiumArgs: job.chromiumArgs, chromiumArgsMode: job.chromiumArgsMode, keyAliases: job.keyAliases, - gridScreenshots: job.gridScreenshots, markGridRows: job.markGridRows, markGridCols: job.markGridCols, }; diff --git a/stress-test/test/build-playtest-viewer.test.js b/stress-test/test/build-playtest-viewer.test.js new file mode 100644 index 0000000..6286b67 --- /dev/null +++ b/stress-test/test/build-playtest-viewer.test.js @@ -0,0 +1,32 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { collectRuns, isCleanScreenshot } = require('../build-playtest-viewer'); + +test('playtest viewer treats grid screenshots as playtester-only artifacts', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runwave-viewer-test-')); + const attempt = path.join(dir, 'candy-crush', 'attempt-001'); + fs.mkdirSync(attempt, { recursive: true }); + fs.writeFileSync(path.join(attempt, 'summary.json'), JSON.stringify({ status: 'passed' })); + fs.writeFileSync(path.join(attempt, 'play.webm'), 'video'); + fs.writeFileSync(path.join(attempt, '000-initial.grid.png'), 'grid image'); + fs.writeFileSync(path.join(attempt, '000-initial.png'), 'clean image'); + + try { + const out = path.join(dir, 'index.html'); + const runs = collectRuns(dir, out); + + assert.equal(isCleanScreenshot(path.join(attempt, '000-initial.png')), true); + assert.equal(isCleanScreenshot(path.join(attempt, '000-initial.grid.png')), false); + assert.equal(runs.length, 1); + assert.equal(runs[0].poster, 'candy-crush/attempt-001/000-initial.png'); + assert.deepEqual(runs[0].screenshots, ['candy-crush/attempt-001/000-initial.png']); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/stress-test/test/remote-process.test.js b/stress-test/test/remote-process.test.js index 257a8d1..016031c 100644 --- a/stress-test/test/remote-process.test.js +++ b/stress-test/test/remote-process.test.js @@ -126,7 +126,6 @@ test('remote playtest start overrides carry mark grid dimensions', () => { { markGridRows: 16, markGridCols: 24, - gridScreenshots: true, videoSize: { width: 1280, height: 720 }, }, { audioSource: 'pulse.monitor' } @@ -134,7 +133,6 @@ test('remote playtest start overrides carry mark grid dimensions', () => { assert.equal(overrides.markGridRows, 16); assert.equal(overrides.markGridCols, 24); - assert.equal(overrides.gridScreenshots, true); assert.equal(overrides.audioSource, 'pulse.monitor'); assert.deepEqual(overrides.videoSize, { width: 1280, height: 720 }); });