diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index 10dc045d0..f9477de48 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -3,3 +3,5 @@ - Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281). - Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281). - Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281). +- Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281). diff --git a/scripts/qa-chrome.mjs b/scripts/qa-chrome.mjs index 05c4dbff0..2416a04e3 100644 --- a/scripts/qa-chrome.mjs +++ b/scripts/qa-chrome.mjs @@ -18,3 +18,30 @@ export function chromeExecutablePath({ if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH; return exists(MACOS_CHROME) ? MACOS_CHROME : undefined; } + +/** Restore an owned test window before capture: focus emulation alone does not + * unminimize the native compositor, so CDP screenshots can otherwise stall. */ +export async function captureQaScreenshot( + page, + options, + { headful = true } = {}, +) { + if (headful) { + const session = await page.createCDPSession(); + try { + const { windowId, bounds } = await session.send( + 'Browser.getWindowForTarget', + ); + if (bounds.windowState === 'minimized') { + await session.send('Browser.setWindowBounds', { + windowId, + bounds: { windowState: 'normal' }, + }); + } + await page.bringToFront(); + } finally { + await session.detach(); + } + } + return page.screenshot(options); +} diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index d07b7196f..a6c0bdc53 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -31,6 +31,7 @@ */ import puppeteer from 'puppeteer'; +import { captureQaScreenshot } from './qa-chrome.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -49,7 +50,7 @@ appUrl.searchParams.set('welcome', '0'); const APP_URL = appUrl.href; const HEADFUL = argv.includes('--headful'); const FIXTURES = argv.includes('--fixtures'); -const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null; +const { installTrafficRoutes } = await import('./traffic-fixtures.mjs'); const CHROME_EXECUTABLE_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, @@ -158,11 +159,6 @@ async function main() { // Chrome minimized. Use browser focus emulation, not patched DOM getters. const focusSession = await page.createCDPSession(); await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true }); - const onFixture = (request) => { - const response = fixtureResponse(request); - if (response) void request.respond(response); else void request.continue(); - }; - if (FIXTURES) { await page.setCacheEnabled(false); await page.setRequestInterception(true); page.on('request', onFixture); } await page.setViewport({ width: 1440, height: 900 }); // Track flow-tile requests + traffic console lines for (ii)/(iii)/(v). @@ -172,6 +168,7 @@ async function main() { }); const trafficLogs = []; const pageErrors = []; + if (FIXTURES) await installTrafficRoutes(page, { origin: appUrl.origin, fixtures: true, onError: error => pageErrors.push(error.message) }); page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); page.on('console', (msg) => { @@ -210,7 +207,7 @@ async function main() { `coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`); if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1; await sleep(1200); - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }, { headful: HEADFUL }); } // ── (iv) uncovered-roads param — hide vs sim ───────────────────────────── @@ -243,7 +240,7 @@ async function main() { (hid.flowBuckets?.sim || 0) === 0 && hid.count > 0, `count=${hid.count} sim=${hid.flowBuckets?.sim}`); if ((hid.flowBuckets?.sim || 0) !== 0) exitCode = 1; - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }, { headful: HEADFUL }); } else { record('PARAM: hide mode renders zero sim (white) dots', null, 'view had 100% coverage (no sim dots to hide) — inconclusive here, covered by unit tests'); @@ -265,7 +262,7 @@ async function main() { } record('C4: oblique fetch box centers on the look-at point', ok, detail); if (!ok) exitCode = 1; - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }, { headful: HEADFUL }); } // ── (iii) budget honesty ───────────────────────────────────────────────── @@ -290,23 +287,11 @@ async function main() { await keylessFocus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); - await page.setCacheEnabled(false); - await page.setRequestInterception(true); const keylessFlowReqs = []; - page.on('request', (req) => { - const url = req.url(); - if (url.includes('/api/tomtom/status')) { - req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ hasKey: false }) }); - return; - } - if (url.includes('/api/tomtom/flow/')) { - keylessFlowReqs.push(url); - req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) }); - return; - } - const response = fixtureResponse(req); - if (response) { void req.respond(response); return; } - try { req.continue(); } catch { /* already handled */ } + await installTrafficRoutes(page, { + origin: appUrl.origin, fixtures: FIXTURES, keyless: true, + onFlow: url => keylessFlowReqs.push(url), + onError: error => pageErrors.push(error.message), }); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( @@ -338,7 +323,7 @@ async function main() { exitCode = 1; } await sleep(1000); - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }, { headful: HEADFUL }); } record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; ')); } catch (e) { diff --git a/scripts/traffic-fixtures.mjs b/scripts/traffic-fixtures.mjs index 2f94993cc..0ec8cdacd 100644 --- a/scripts/traffic-fixtures.mjs +++ b/scripts/traffic-fixtures.mjs @@ -117,3 +117,59 @@ export function trafficFixtureResponse(request) { body: trafficFixtureTile(Number(road[1])), }; } + +/** Intercept provider URLs only; Cesium worker/module loading stays untouched. */ +export async function installTrafficRoutes( + page, + { + origin, + fixtures = false, + keyless = false, + onFlow = () => {}, + onError = () => {}, + }, +) { + const session = await page.createCDPSession(); + const patterns = [ + `${origin}/api/tomtom/status*`, + `${origin}/api/tomtom/flow/*`, + ...(fixtures + ? ['https://tiles.openfreemap.org/*', 'https://terrain.reearth.land/*'] + : []), + ].map((urlPattern) => ({ urlPattern, requestStage: 'Request' })); + session.on('Fetch.requestPaused', async ({ requestId, request }) => { + try { + const url = new URL(request.url); + let response; + if (keyless && url.pathname === '/api/tomtom/status') { + response = json({ hasKey: false }); + } else if (keyless && url.pathname.startsWith('/api/tomtom/flow/')) { + onFlow(request.url); + response = { ...json({ error: 'no_key' }), status: 503 }; + } else if (fixtures) { + response = trafficFixtureResponse({ url: () => request.url }); + } + if (!response) { + await session.send('Fetch.continueRequest', { requestId }); + return; + } + await session.send('Fetch.fulfillRequest', { + requestId, + responseCode: response.status, + responseHeaders: Object.entries({ + 'content-type': response.contentType, + ...response.headers, + }).map(([name, value]) => ({ name, value })), + body: Buffer.from(response.body).toString('base64'), + }); + } catch (error) { + if (page.isClosed()) return; + onError(error); + await session + .send('Fetch.failRequest', { requestId, errorReason: 'Failed' }) + .catch(() => {}); + } + }); + await session.send('Fetch.enable', { patterns }); + return session; +} diff --git a/src/qaChrome.test.mjs b/src/qaChrome.test.mjs index 4414f3c93..398d23ab3 100644 --- a/src/qaChrome.test.mjs +++ b/src/qaChrome.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs'; +import { + MACOS_CHROME, + chromeExecutablePath, + captureQaScreenshot, +} from '../scripts/qa-chrome.mjs'; test('gates keep macOS Chrome where it is installed', () => { assert.equal( @@ -25,3 +29,54 @@ test('an explicit executable path wins', () => { 'C:\\Chrome\\chrome.exe', ); }); + +test('screenshots restore minimized test windows before capture and detach their session', async () => { + const calls = []; + const session = { + send: async (method, params) => { + calls.push([method, params]); + return { windowId: 7, bounds: { windowState: 'minimized' } }; + }, + detach: async () => calls.push(['detach']), + }; + const page = { + createCDPSession: async () => session, + bringToFront: async () => calls.push(['front']), + screenshot: async (options) => { + calls.push(['capture', options]); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, { path: 'proof.png' }), + 'pixels', + ); + assert.deepEqual( + calls.map((c) => c[0]), + [ + 'Browser.getWindowForTarget', + 'Browser.setWindowBounds', + 'front', + 'detach', + 'capture', + ], + ); + assert.deepEqual(calls[1][1], { + windowId: 7, + bounds: { windowState: 'normal' }, + }); +}); + +test('headless capture does not manipulate a native window', async () => { + const options = { path: 'proof.png' }; + const page = { + screenshot: async (supplied) => { + assert.equal(supplied, options); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, options, { headful: false }), + 'pixels', + ); +}); diff --git a/src/tooling/trafficFixtures.test.mjs b/src/tooling/trafficFixtures.test.mjs index e02b959e7..b5f6c6de4 100644 --- a/src/tooling/trafficFixtures.test.mjs +++ b/src/tooling/trafficFixtures.test.mjs @@ -90,3 +90,81 @@ test('flat terrain fixture decodes through Cesium and advertises no network chil await new Promise((resolve) => server.close(resolve)); } }); + +test('fixture interception targets provider URLs and leaves app workers alone', async () => { + const { installTrafficRoutes } = + await import('../../scripts/traffic-fixtures.mjs'); + const sent = []; + let paused; + const session = { + on: (_, handler) => { + paused = handler; + }, + send: async (name, args) => { + sent.push({ name, args }); + }, + }; + await installTrafficRoutes( + { createCDPSession: async () => session, isClosed: () => false }, + { origin: 'http://localhost:4173', fixtures: true }, + ); + const patterns = sent[0].args.patterns.map((p) => p.urlPattern); + assert.deepEqual(patterns, [ + 'http://localhost:4173/api/tomtom/status*', + 'http://localhost:4173/api/tomtom/flow/*', + 'https://tiles.openfreemap.org/*', + 'https://terrain.reearth.land/*', + ]); + await paused({ + requestId: 'terrain', + request: { url: 'https://terrain.reearth.land/layer.json' }, + }); + const reply = sent.at(-1); + assert.equal(reply.name, 'Fetch.fulfillRequest'); + assert.equal( + JSON.parse(Buffer.from(reply.args.body, 'base64')).format, + 'heightmap-1.0', + ); + assert.ok( + reply.args.responseHeaders.some( + (h) => h.name === 'access-control-allow-origin' && h.value === '*', + ), + ); +}); + +test('keyless interception refuses and records unexpected flow requests', async () => { + const { installTrafficRoutes } = + await import('../../scripts/traffic-fixtures.mjs'); + const sent = [], + flows = []; + let paused; + const session = { + on: (_, handler) => { + paused = handler; + }, + send: async (name, args) => { + sent.push({ name, args }); + }, + }; + await installTrafficRoutes( + { createCDPSession: async () => session, isClosed: () => false }, + { + origin: 'http://localhost:4173', + keyless: true, + onFlow: (url) => flows.push(url), + }, + ); + await paused({ + requestId: 'status', + request: { url: 'http://localhost:4173/api/tomtom/status' }, + }); + assert.deepEqual(JSON.parse(Buffer.from(sent.at(-1).args.body, 'base64')), { + hasKey: false, + }); + await paused({ + requestId: 'flow', + request: { url: 'http://localhost:4173/api/tomtom/flow/12/1/1.pbf' }, + }); + assert.equal(sent.at(-1).args.responseCode, 503); + assert.equal(flows.length, 1); +});