diff --git a/tests/uat/harness.test.ts b/tests/uat/harness.test.ts index 309cd25..fea57e1 100644 --- a/tests/uat/harness.test.ts +++ b/tests/uat/harness.test.ts @@ -263,7 +263,7 @@ async function main(): Promise { // ─── Setup: launch browser, attach to SW + open popup bridge ─── process.stdout.write('\nLaunching Chrome + opening popup bridge...\n'); handles = await launchHarnessBrowser(); - const { browser, sw, page, popup, extensionId, downloadsDir } = handles; + const { browser, sw, page, popup, extension, extensionId, downloadsDir } = handles; process.stdout.write(` extensionId: ${extensionId}\n`); process.stdout.write(` downloadsDir: ${downloadsDir}\n`); process.stdout.write(` popup: chrome-extension://${extensionId}/src/popup/index.html\n\n`); @@ -283,16 +283,134 @@ async function main(): Promise { const manifest = await getManifest(popupPage); const expectedVersion = manifest.version; - // ─── Wave 3 stubbed assertions (Tasks 4-7 will wire these) ────── + // ─── Assertion 1: SW bootstrap → setIdleMode ─────────────────── + const a1 = await runAssertion( + 1, + 'SW bootstrap → setIdleMode (badge="" + popup="" + isRecording=false)', + buffers, + async () => { + // Wake the SW via the keepalive ping first — this ensures the + // SW's initialize() has been observed before we sample state. + // In MV3, the SW may have suspended between launch and now; + // sendMessage wakes it for 30s. + await keepalivePing(popupPage); + // After SW init (production initialize() calls setIdleMode()), + // badge becomes empty + popup becomes empty + isRecording is false. + // Poll for the popup transition because setIdleMode's setPopup + // call is async-propagated through the action API; the popup + // page may briefly observe the manifest default before the + // override lands. + const popupUrl = await waitFor( + () => getPopup(popupPage), + (v) => v === '', + 3_000, + 'popup should become empty after SW initialize() runs setIdleMode', + 100, + ); + assertEqual(popupUrl, '', 'popup expected empty in idle mode'); + const badge = await getBadgeText(popupPage); + assertEqual(badge, '', 'badge expected empty after SW bootstrap'); + const recording = await getIsRecording(popupPage); + assertEqual(recording, false, 'isRecording expected false at bootstrap'); + }, + ); + results.push(a1); + + // ─── Assertion 2: Toolbar onClicked-idle → REC + popup ───────── + const a2 = await runAssertion( + 2, + 'toolbar onClicked-idle → badge "REC" + popup set + isRecording=true', + buffers, + async () => { + // Trigger the toolbar action — the production code's + // chrome.action.onClicked listener calls startVideoCapture(), + // which creates the offscreen + starts recording + transitions + // the badge to REC + sets the popup to popup/index.html. + await page.bringToFront(); + await page.triggerExtensionAction(extension); + // Badge transition is async (offscreen create + handshake + + // getDisplayMedia picker + post-grant validation + setBadgeText). + // Poll up to 8s; the auto-select picker shaves most of the time. + const badge = await waitFor( + () => getBadgeText(popupPage), + (v) => v === 'REC', + 8_000, + 'badge should become REC after toolbar click', + 200, + ); + assertEqual(badge, 'REC', 'badge after toolbar click'); + const popupUrl = await getPopup(popupPage); + assertMatch( + popupUrl, + /src\/popup\/index\.html$/, + 'popup should be set to popup/index.html during recording', + ); + const recording = await getIsRecording(popupPage); + assertEqual(recording, true, 'isRecording expected true after click'); + }, + ); + results.push(a2); + + // ─── Assertion 3: offscreen displaySurface === 'monitor' ─────── + const a3 = await runAssertion( + 3, + 'offscreen getCurrentStream displaySurface === "monitor"', + buffers, + async () => { + // A2 left recording active. The offscreen document was created + // by startVideoCapture; wait for the target + attach. + const offTarget = await waitForOffscreenTarget(browser, extensionId); + const offPage = await attachToOffscreen(offTarget); + offPage.on('console', (msg) => { + buffers.offscreenLines.push(`[OS:${msg.type()}] ${msg.text()}`); + }); + const ds = await getDisplaySurface(offPage); + assertEqual( + ds, + 'monitor', + 'displaySurface expected "monitor" per post-grant validation (D-15)', + ); + }, + ); + results.push(a3); + + // ─── Assertion 4: onClicked-recording → popup, no new offscreen + const a4 = await runAssertion( + 4, + 'toolbar onClicked while recording → popup opens, no new offscreen target', + buffers, + async () => { + const offscreenCountBefore = countOffscreenTargets(browser, extensionId); + // Triggering the action during recording opens the popup + // (production code keeps popup set in REC mode); the click + // does NOT spawn a second offscreen. + await page.bringToFront(); + await page.triggerExtensionAction(extension); + // Give the popup a moment to open + Chrome to settle. + await new Promise((r) => setTimeout(r, 500)); + const offscreenCountAfter = countOffscreenTargets(browser, extensionId); + assertEqual( + offscreenCountAfter, + offscreenCountBefore, + 'offscreen target count must not increase on second click during recording', + ); + // popup must still be set (recording is ongoing). + const popupUrl = await getPopup(popupPage); + assertMatch( + popupUrl, + /src\/popup\/index\.html$/, + 'popup must remain set while recording', + ); + }, + ); + results.push(a4); + + // ─── Wave 3 STUBBED assertions (Tasks 5-7 will wire these) ───── const stubs: Array<{ index: number; name: string; taskNumber: number; }> = [ - { index: 1, name: 'SW bootstrap → setIdleMode', taskNumber: 4 }, - { index: 2, name: 'toolbar onClicked-idle → badge REC + popup', taskNumber: 4 }, - { index: 3, name: 'offscreen displaySurface === monitor', taskNumber: 4 }, - { index: 4, name: 'toolbar onClicked-recording → popup, no new offscreen', taskNumber: 4 }, { index: 5, name: 'SAVE_ARCHIVE → download fires + zip appears', taskNumber: 5 }, { index: 6, name: 'BUG B canonical: simulateUserStop → badge OFF + no recovery notif', taskNumber: 5 }, { index: 7, name: 'RECORDING_ERROR codec-unsupported → badge ERR + recovery notif', taskNumber: 5 }, @@ -314,30 +432,17 @@ async function main(): Promise { results.push(rec); } - // Suppress unused-warning placeholders — Tasks 4-7 will use these - // imports + handles directly. Reference them here for type-clean. - void browser; - void page; - void popupPage; + // Suppress unused-warning placeholders for Tasks 5-7 helpers. void expectedVersion; - void waitForOffscreenTarget; - void attachToOffscreen; - void countOffscreenTargets; - void waitFor; - void getBadgeText; - void getPopup; - void getIsRecording; void getIconSize; void fireOnStartup; void sendSyntheticRecordingError; void getNotificationSnapshot; void keepalivePing; - void getDisplaySurface; void simulateUserStop; void getSegmentCount; void assertArchiveShape; void extractEntryToFile; - void assertMatch; void assertTrue; void assertGte; void waitForDownloadedZip; diff --git a/tests/uat/lib/sw.ts b/tests/uat/lib/sw.ts index 18c3ecc..bebe4ca 100644 --- a/tests/uat/lib/sw.ts +++ b/tests/uat/lib/sw.ts @@ -245,15 +245,21 @@ export async function getNotificationSnapshot( /** * Send a no-op keepalive ping to the SW so Chrome's ~30s idle timer * does not evict the worker during long waits (assertion 11's 35s - * recording window). Uses chrome.runtime.sendMessage as the cheapest - * wake-up signal; the SW's onMessage handler treats unknown messages - * as a warning-log no-op. + * recording window). Uses a __mokoshTestQuery 'snapshot' op as the + * cheapest round-trip with a guaranteed response — the bridge handler + * answers synchronously with the current notification state. * * @param popup - The extension popup page handle. */ export async function keepalivePing(popup: Page): Promise { await popup.evaluate(async () => { - await chrome.runtime.sendMessage({ type: '__mokoshKeepalive' }); + const msg = { type: '__mokoshTestQuery', op: 'snapshot' }; + return new Promise((resolve) => { + chrome.runtime.sendMessage(msg, () => resolve()); + // Cap at 2s — if the bridge is gone (SW reload race etc.) we + // still continue (the assertion may catch downstream issues). + setTimeout(() => resolve(), 2_000); + }); }); }