From d9278f19a90d6a54eb5e98926ba21da99d955dcb Mon Sep 17 00:00:00 2001 From: gololdf1sh Date: Wed, 30 Sep 2026 17:03:11 +0300 Subject: [PATCH] fix(screen-recording): Sign out, Disconnect and Forget stop the screen recording too The erase stopped only the console & network recorder. Clearing session storage dropped the screen recording's state but not the recorder: it kept taking frames, the debugger stayed on the tab (a new recording there failed with "Another debugger is already attached"), and at the cap the take came back and its review opened by itself. A take waiting for review survived as well. The erase now asks the worker to wipe the screen recording beside the log, both at once under the same 5 s timeout. The worker takes the cast off the tab, closes the recorder page (the capture and every take's bytes end with it), removes the recording's keys and announces it; the review closes itself and other panels drop their plaque. A take pushed after its recorder page is gone is not parked. If either recorder will not stop, the erase still happens and the warning names which one. The confirm texts and the settings guide list the screen recording. Mutations through SREC_SRC, SCREENS_SRC and SHARED_MODULES: 19 of 19 caught. In real Chromium, after Sign out mid-recording there is no recorder page and no debugger session left, a new recording starts on the tab, a waiting take's blob no longer fetches, and with a 10 s cap nothing comes back 15 s later. Closes #403 Co-authored-by: Claude Opus 5.5 --- docs/architecture.md | 12 +- docs/guide/settings.md | 15 +-- extension/screenrec/review.js | 4 + extension/screenrec/session.js | 25 +++- extension/sidepanel/screens/screen-rec.js | 2 +- extension/sidepanel/screens/settings-erase.js | 47 ++++++-- tests/screen-rec.test.mjs | 8 ++ tests/screenrec-review.test.mjs | 28 +++++ tests/screenrec-session.test.mjs | 110 +++++++++++++++++- tests/settings-erase.test.mjs | 49 +++++++- tests/settings.test.mjs | 12 +- 11 files changed, 278 insertions(+), 34 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 12cd1ab..dcc0818 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -981,9 +981,10 @@ the worker stops an evidence recording ~2 s after the last one is gone). | `SCREENREC_REVIEWED` / `SCREENREC_TRIMMED` `{url, …}` | review page → worker | The review approved the take as recorded, or cut it. Only then does the worker broadcast `SCREENREC_EVENT {event:'file'}` — nothing is attached before that. | | `SCREENREC_CLAIM` / `SCREENREC_UNCLAIM` `{by}` | panel → worker | One panel document at a time owns the upload: the `file` event is a broadcast, and every open panel would otherwise upload the same take. Serialized in `screenrec/claim.js`; an upload that fails un-claims so the next *Retry attach…* — here or in another panel — can take it. | | `SCREENREC_REVIEW_KEY` | injected review overlay → worker | The one-shot `screenRecReviewKey`, which is how a framed `screenrec/review.html` proves the extension framed it and the page under test did not. | -| `SCREENREC_EVENT` `{event, …}` | worker → panel (broadcast) | `started` / `review` / `file` / `ended`. | +| `SCREENREC_EVENT` `{event, …}` | worker → panel (broadcast) | `started` / `review` / `file` / `ended`. An `ended` with reason `wiped` also closes an open review page. | | `SCREENREC_OFF` `{cmd, …}` | worker → offscreen document (broadcast); the review page too, for the trim | `start` / `cast-start` / `frame` / `pause` / `stop` / `state` / `revoke` from the worker, and `trim-begin` / `trim-chunk` / `trim-swap` from `screenrec/review.js`. Frames go down a dedicated `screenrec-frames` port instead when one is up: a broadcast would copy every JPEG, several a second, into every extension page. | -| `SCREENREC_FILE` `{file}` | offscreen document → worker | Pushed when a cap or a closed tab ended the recording, with no stop to detach the cast. | +| `SCREENREC_FILE` `{file}` | offscreen document → worker | Pushed when a cap or a closed tab ended the recording, with no stop to detach the cast. Ignored when no offscreen document is open any more: an erase closed it, and the take's bytes went with it. | +| `SCREENREC_WIPE` | panel → worker | Sign out, Disconnect and Forget on the ACTIVE instance: take the cast off the tab, close the offscreen document — the capture and every take's bytes end with it — remove `screenRec`, `screenRecFile`, `screenRecReviewKey` and `screenRecTarget`, and broadcast `ended` with reason `wiped`. Replies `{ok}` or `{ok:false, error}`. | The evidence handler ignores anything outside its `EVIDENCE_REQUESTS` set so the two `onMessage` listeners in the worker plus the recorder's do not fight over one @@ -2041,7 +2042,8 @@ live credential. Two keys are carried back over that wipe: `theme` (`shared/theme.js`) and `viewMode` (`shared/view-mode.js`) — neither a credential nor scoped to one, and both re-written after `clear()` rather than exempted from it, so the whole-area wipe stays whole. -The sign out first attempts `EVIDENCE_WIPE` (§3.4): the +The sign out first attempts `EVIDENCE_WIPE` (§3.4) and `SCREENREC_WIPE`, side by side, each under +the same 5 s timeout: the evidence buffer lives in the worker, so a recording still RUNNING would re-mirror it over the clear ~2 s later. A missing listener is tolerated — no worker, no recording. A refusal or a 5 s timeout does NOT abort the sign out, @@ -2052,7 +2054,7 @@ rides a one-shot page-`sessionStorage` breadcrumb (`signOutRecorderWarning`, §5.4) that `SettingsErase.takeWarning()` paints onto `settings-forget-status` — a status line set before `reloadPanel()` would die with the document. `forget()` takes the same two steps for the ACTIVE instance only — -`EVIDENCE_WIPE` first, then `storage.session.clear()` — because that area is +the two wipes first, then `storage.session.clear()` — because that area is scoped to no instance but the panel is being reset anyway, and it holds the recorded steps, the evidence buffer, unsaved editor drafts and pending screenshot hand-offs. Forgetting an INACTIVE instance touches neither: that data belongs to @@ -2129,7 +2131,7 @@ Panel document only. It exists because the panel *navigates away* to that page rather than embedding it — the panel document is destroyed and rebuilt. `signOutRecorderWarning` — a one-shot reason string written by -`SettingsErase.leaveWarning()` when an erase's `EVIDENCE_WIPE` failed, and +`SettingsErase.leaveWarning()` when an erase's `EVIDENCE_WIPE` or `SCREENREC_WIPE` failed (it names which), and consumed by `SettingsErase.takeWarning()` off `fillSettingsForm()` (`screens/settings.js`), for the same reason: the erase succeeded and the panel reloads, so the warning has to outlive the document that raised it. Not one of diff --git a/docs/guide/settings.md b/docs/guide/settings.md index 37760cc..4703c85 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -64,16 +64,16 @@ saved. - **Forget this instance** — deletes the saved token, project and preferences of the instance the form points at, after a confirmation. If that is the instance you are on, its restored session, queued results - waiting to be sent, recorded steps, captured log and unsaved drafts go - too, a running recording is stopped, and the panel returns to the connect - screen. Other instances are kept. + waiting to be sent, recorded steps, captured log, screen recording and + unsaved drafts go too, a running recording is stopped, and the panel + returns to the connect screen. Other instances are kept. ## Stored credentials **Sign out** is for a shared machine: after a confirmation it erases every saved token, instance, history entry, queued result, session, unsaved test -draft, recorded step and captured log from this browser, stops a running -recording, and reloads the panel onto the connect screen. The colour +draft, recorded step, captured log and screen recording from this browser, +stops a running recording, and reloads the panel onto the connect screen. The colour scheme and the side-panel-or-window choice stay. Site access is Chrome's own setting for the extension, on `chrome://extensions`, and is not touched. @@ -124,5 +124,6 @@ From any tab, `Alt+Shift+R` starts or stops a - **"Nothing saved for …"** — Forget was pressed for a server that was never saved; nothing was erased. - **A warning about the recording after Forget or Sign out** — the erase - happened, but the console & network recorder could not be stopped; - restart the browser to be sure its log is gone. + happened, but the console & network recorder or the screen recorder could + not be stopped; the warning names which. Restart the browser to be sure + the log or the video is gone. diff --git a/extension/screenrec/review.js b/extension/screenrec/review.js index a3c0368..c2783ed 100644 --- a/extension/screenrec/review.js +++ b/extension/screenrec/review.js @@ -314,6 +314,10 @@ }); window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !exporting) closeReview(); }); + // Disconnect, Forget or Sign out threw the take away: nothing is left here to review. + chrome.runtime.onMessage.addListener((msg) => { + if (msg && msg.type === 'SCREENREC_EVENT' && msg.event === 'ended' && msg.reason === 'wiped') closeReview(); + }); // ---- boot ------------------------------------------------------------------ diff --git a/extension/screenrec/session.js b/extension/screenrec/session.js index 453a05d..d816ed7 100644 --- a/extension/screenrec/session.js +++ b/extension/screenrec/session.js @@ -242,6 +242,24 @@ async function srecStop(reason) { return { ok: true }; } +// Disconnect, Forget and Sign out: closing the recorder page ends the capture and frees every take. +async function srecWipe() { + await castSeeded; // a worker woken by this message must still know which tab it holds + await srecTeardownCast(await srecGet()); + try { await chrome.offscreen.closeDocument(); } catch { /* none open */ } + await chrome.storage.session.remove([SREC_KEY, SREC_FILE_KEY, SREC_RKEY_KEY, SREC_TARGET_KEY]); + srecTell({ type: 'SCREENREC_EVENT', event: 'ended', reason: 'wiped' }); + return { ok: true }; +} + +// A take pushed by a recorder page that is already gone has no bytes left to review or attach. +async function srecDocOpen() { + try { + const open = await chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] }); + return !!(open && open.length); + } catch { return true; } // no way to tell: keep the take, as before +} + // Everything that ends a recording funnels through here: state cleared, file parked — and the // REVIEW opened over the page (#68 preview+trim). Nothing is attached until the tester says so // there; the panel only hears 'file' once the review answers. @@ -473,8 +491,13 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return true; // Pushed by the offscreen document when a cap or a closed tab ended the recording. case 'SCREENREC_FILE': - srecGet().then((st) => srecFinish(msg.file, st, msg.file && msg.file.reason)); + srecDocOpen().then(async (open) => { + if (open) await srecFinish(msg.file, await srecGet(), msg.file && msg.file.reason); + }); return false; + case 'SCREENREC_WIPE': + srecWipe().then(sendResponse, (e) => sendResponse({ ok: false, error: String((e && e.message) || e) })); + return true; default: return undefined; } }); diff --git a/extension/sidepanel/screens/screen-rec.js b/extension/sidepanel/screens/screen-rec.js index 2c045d7..8e77fc4 100644 --- a/extension/sidepanel/screens/screen-rec.js +++ b/extension/sidepanel/screens/screen-rec.js @@ -169,7 +169,7 @@ chrome.runtime.onMessage.addListener((msg) => { if (msg.event === 'file' && msg.file) srecAttach(msg.file); else { // Nothing left to attach — nothing was recorded, or the take is gone — so the plaque goes too. - if (msg.event === 'ended' && (msg.empty || msg.reason === 'discarded')) hideToast(); + if (msg.event === 'ended' && (msg.empty || msg.reason === 'discarded' || msg.reason === 'wiped')) hideToast(); srecRefresh(); } return undefined; diff --git a/extension/sidepanel/screens/settings-erase.js b/extension/sidepanel/screens/settings-erase.js index d3817f9..c7849c8 100644 --- a/extension/sidepanel/screens/settings-erase.js +++ b/extension/sidepanel/screens/settings-erase.js @@ -23,9 +23,21 @@ const EVIDENCE_WIPE_MS = 5000; // PAGE sessionStorage, like `tcReturn`: not an area the erase claims to wipe, holds // no credential, and dies with the browser — which is when the buffer dies too. const EVIDENCE_WIPE_WARN_KEY = 'signOutRecorderWarning'; -const evidenceWipeWarning = (why, lead) => `${lead} — but the console & network ` - + `recording could not be stopped: ${why}. Assume its log is still on this machine until you ` - + `restart the browser.`; + +// The two recorders an erase stops, each in the words its warning uses. +const RECORDER_WIPES = [ + { type: 'EVIDENCE_WIPE', what: 'the console & network recording', left: 'its log', short: 'console & network' }, + { type: 'SCREENREC_WIPE', what: 'the screen recording', left: 'its video', short: 'screen' }, +]; +const evidenceWipeWarning = (failed, lead) => { + const tail = 'still on this machine until you restart the browser.'; + if (failed.length === 1) { + const [f] = failed; + return `${lead} — but ${f.what} could not be stopped: ${f.why}. Assume ${f.left} is ${tail}`; + } + const whys = failed.map((f) => `${f.short}: ${f.why}`).join('; '); + return `${lead} — but neither recording could be stopped (${whys}). Assume the log and the video are ${tail}`; +}; const SettingsErase = { HOST_SCOPED_KEYS, @@ -79,8 +91,8 @@ const SettingsErase = { const ok = await ConfirmDialog.ask( `${verb} ${host}? Its saved token, project and preferences are deleted from this browser` + (active ? ', together with its restored session, any queued results still waiting to be sent, ' - + 'and this session\'s recorded steps, captured log and unsaved drafts — a running recording ' - + 'is stopped for you' : '') + + 'and this session\'s recorded steps, captured log, screen recording and unsaved drafts — a ' + + 'running recording is stopped for you' : '') + '. Other instances are kept.', verb); if (!ok) return; const hostSettings = { ...state.hostSettings }; @@ -119,8 +131,10 @@ const SettingsErase = { setStatusLine(statusId, `${host} forgotten`, 'ok'); }, + // `e.failed` names each recorder that would not stop; a bare error is the log's, as before. leaveWarning(e, lead) { - try { sessionStorage.setItem(EVIDENCE_WIPE_WARN_KEY, evidenceWipeWarning(String((e && e.message) || e), lead)); } + const failed = (e && e.failed) || [{ ...RECORDER_WIPES[0], why: String((e && e.message) || e) }]; + try { sessionStorage.setItem(EVIDENCE_WIPE_WARN_KEY, evidenceWipeWarning(failed, lead)); } catch { /* sessionStorage unavailable — the erase still stands */ } }, @@ -137,10 +151,9 @@ const SettingsErase = { // #183: `evidenceMirror` is only a copy of the worker's ring buffer — a RUNNING // recording writes it back ~2 s after a clear. Throws on anything but a clean wipe. - async wipeRecording() { - if (!hasChrome || !chrome.runtime || !chrome.runtime.sendMessage) return; + async wipeOne(type) { const resp = await Promise.race([ - chrome.runtime.sendMessage({ type: 'EVIDENCE_WIPE' }).catch((e) => { + chrome.runtime.sendMessage({ type }).catch((e) => { // No worker to answer means no recording to stop — proceed, don't fail. if (/receiving end|Could not establish/i.test(String((e && e.message) || e))) return { ok: true }; throw e; @@ -151,10 +164,24 @@ const SettingsErase = { if (!resp || resp.ok !== true) throw new Error((resp && resp.error) || 'the recorder could not be stopped'); }, + // Both at once, so 5 s at most; the error's `failed` lists every recorder that would not stop. + async wipeRecording() { + if (!hasChrome || !chrome.runtime || !chrome.runtime.sendMessage) return; + const results = await Promise.allSettled(RECORDER_WIPES.map((w) => SettingsErase.wipeOne(w.type))); + const failed = RECORDER_WIPES + .map((w, i) => (results[i].status === 'rejected' + ? { ...w, why: String((results[i].reason && results[i].reason.message) || results[i].reason) } : null)) + .filter(Boolean); + if (!failed.length) return; + const err = new Error(failed[0].why); + err.failed = failed; + throw err; + }, + async signOut() { const ok = await ConfirmDialog.ask( 'Sign out? Every saved token, instance, history entry, queued result, session, unsaved ' - + 'test draft, recorded step and captured log is deleted from this ' + + 'test draft, recorded step, captured log and screen recording is deleted from this ' + 'browser. A running recording is stopped for you. Site access stays — it is Chrome\'s own ' + 'setting, under chrome://extensions → Details → Site access.', 'Sign out'); if (!ok) return; diff --git a/tests/screen-rec.test.mjs b/tests/screen-rec.test.mjs index b42b02f..d482380 100644 --- a/tests/screen-rec.test.mjs +++ b/tests/screen-rec.test.mjs @@ -962,6 +962,14 @@ test('31: a take the tester discarded in the review takes the plaque down too', assert.deepEqual(h.types(), ['SCREENREC_STATUS']); }); +test('31b: a take an erase threw away takes the plaque down in every other panel too', async () => { + const h = load(); + h.message({ type: 'SCREENREC_EVENT', event: 'ended', reason: 'wiped' }); + await settle(); + assert.equal(h.calls.hides, 1); + assert.deepEqual(h.types(), ['SCREENREC_STATUS']); +}); + test('32: an ordinary end leaves the plaque standing and only repaints', async () => { const h = load(); h.worker.SCREENREC_STATUS = IDLE(TAKE({ reviewed: false })); diff --git a/tests/screenrec-review.test.mjs b/tests/screenrec-review.test.mjs index d2eab83..faa1f93 100644 --- a/tests/screenrec-review.test.mjs +++ b/tests/screenrec-review.test.mjs @@ -155,6 +155,9 @@ async function load(opts = {}) { sessionFail, }); fake.chrome.runtime.sendMessage = async (msg) => { sent.push(msg); return reply(msg, sent.length); }; + // The worker's broadcasts reach this page through its own runtime listener. + const heard = []; + fake.chrome.runtime.onMessage = { addListener: (fn) => { heard.push(fn); } }; if (stallKey) { const read = fake.chrome.storage.session.get; fake.chrome.storage.session.get = (k) => (k === 'screenRecReviewKey' ? new Promise(() => {}) : read(k)); @@ -233,6 +236,7 @@ async function load(opts = {}) { const h = { doc, video, timeline, clock, seeks, plays, recLog, posts, sent, closes, $, x, + broadcast: (msg) => { for (const fn of heard) fn(msg, {}, () => {}); }, session: fake.session, // render() writes the live cut object onto the element it paints — the module's own readout. cuts: () => timeline.querySelectorAll('.cut').map((el) => plain(el._cut)), @@ -963,3 +967,27 @@ test('34f (#105): a click harvested before the key check has answered acts on no assert.deepEqual(h.types(), []); assert.deepEqual(h.posts, []); }); + +// ---- the erase --------------------------------------------------------------- + +test('an erase that threw the take away closes the review, framed or in a tab of its own', async () => { + const framed = await load(); + framed.broadcast({ type: 'SCREENREC_EVENT', event: 'ended', reason: 'wiped' }); + assert.deepEqual(framed.posts.map((p) => p.data), [{ type: 'TESTOMAT_REVIEW_CLOSE' }]); + const tab = await load({ framed: false }); + tab.broadcast({ type: 'SCREENREC_EVENT', event: 'ended', reason: 'wiped' }); + assert.equal(tab.closes.length, 1); +}); + +test('any other end the worker announces leaves the review open', async () => { + const h = await load(); + for (const msg of [ + { type: 'SCREENREC_EVENT', event: 'ended', reason: 'discarded' }, + { type: 'SCREENREC_EVENT', event: 'ended', reason: 'user', empty: true }, + { type: 'SCREENREC_EVENT', event: 'review' }, + { type: 'EVIDENCE_WIPE' }, + null, + ]) h.broadcast(msg); + assert.deepEqual(h.posts, []); + assert.equal(h.closes.length, 0); +}); diff --git a/tests/screenrec-session.test.mjs b/tests/screenrec-session.test.mjs index 79ef04b..d7a3b96 100644 --- a/tests/screenrec-session.test.mjs +++ b/tests/screenrec-session.test.mjs @@ -42,7 +42,7 @@ const NAMES = [ 'srecOff', 'srecTell', 'srecEnsureDoc', 'srecCloseDoc', 'castSend', 'castAttach', 'castDetach', 'srecStartCast', 'srecTeardownCast', 'srecName', 'srecStart', 'srecStop', 'srecFinish', 'srecOpenReview', 'srecPause', 'srecStatus', 'srecInjectBar', 'srecMenu', 'srecTarget', - 'srecToggle', 'CAST_PARAMS', 'SREC_CLAIM_MS', 'SREC_TIME_CAP_MS', 'SREC_KEY', 'SREC_FILE_KEY', + 'srecToggle', 'srecWipe', 'srecDocOpen', 'CAST_PARAMS', 'SREC_CLAIM_MS', 'SREC_TIME_CAP_MS', 'SREC_KEY', 'SREC_FILE_KEY', 'SREC_TARGET_KEY', 'SREC_DOC', 'SREC_MENU_ID', 'SREC_COMMAND', ]; const PICK = `;({ ${NAMES.map((n) => `${n}: typeof ${n} === 'undefined' ? undefined : ${n}`).join(', ')} });`; @@ -67,7 +67,7 @@ function readKeys(store, keys) { } function load(opts = {}) { - const { now = NOW, session = {} } = opts; + const { now = NOW, session = {}, hooks: early = {} } = opts; const calls = []; // every stubbed call, in order: {name, args} const sess = plain(session); // storage.session, a plain object @@ -99,6 +99,8 @@ function load(opts = {}) { executeScript: async () => [], updateTab: async (id, props) => ({ id, ...props }), createTab: async (props) => ({ id: 99, ...props }), + storageGet: null, // set to hold a read back; gets the real answer as a thunk + ...early, // what must already be in place while the file itself loads }; const bus = () => { @@ -142,7 +144,11 @@ function load(opts = {}) { }; const storageArea = (store, area) => ({ - get: async (keys) => { log(`storage.${area}.get`, keys); return plain(readKeys(store, keys)); }, + get: async (keys) => { + log(`storage.${area}.get`, keys); + const read = () => plain(readKeys(store, keys)); + return hooks.storageGet ? hooks.storageGet(area, keys, read) : read(); + }, // Chrome structured-clones on the way in; an alias would hide what a read-modify-write guards. set: async (obj) => { log(`storage.${area}.set`, obj); @@ -943,3 +949,101 @@ test('55g (#105): an empty take mints no key', async () => { await h.api.srecFinish(null, { recording: true, mode: 'tab', tabId: 7 }, 'user'); assert.equal(h.session.screenRecReviewKey, undefined); }); + +// ---- the erase: Disconnect, Forget and Sign out ---------------------------- + +const WIPED = { type: 'SCREENREC_EVENT', event: 'ended', reason: 'wiped' }; +const SREC_KEYS = ['screenRec', 'screenRecFile', 'screenRecReviewKey', 'screenRecTarget']; + +test('56: signing out mid-recording closes the recorder page and leaves nothing to come back', async () => { + const h = await open({ session: { screenRec: { recording: true, paused: false, tabId: 7, recordId: 'r-1', mode: 'tab', startedAt: NOW - 5000 }, screenRecTarget: 'r-1' } }); + h.clearCalls(); + assert.deepEqual(await h.message({ type: 'SCREENREC_WIPE' }), { ok: true }); + assert.deepEqual(h.named('offscreen.closeDocument'), [[]]); + assert.deepEqual(h.named('storage.session.remove'), [[SREC_KEYS]]); + assert.equal(h.live(), undefined); + assert.equal(h.parked(), undefined); + assert.equal(h.session.screenRecTarget, undefined); + assert.deepEqual(h.events(), [WIPED]); + assert.deepEqual(h.named('scripting.executeScript'), [], 'no review opens for a take that was thrown away'); +}); + +test('57: a cast is let go of before the page closes — the debugger leaves the tab, frames go back', async () => { + const h = await open({ session: CASTING({ framesOut: true }) }); + h.clearCalls(); + await h.message({ type: 'SCREENREC_WIPE' }); + assert.deepEqual(h.named('debugger.sendCommand'), [[{ tabId: 7 }, 'Page.stopScreencast', {}]]); + assert.deepEqual(h.named('debugger.detach'), [[{ tabId: 7 }]]); + assert.deepEqual(h.named('foreignFramesBack'), [[7]]); + const at = (name) => h.order().indexOf(name); + assert.ok(at('debugger.detach') < at('offscreen.closeDocument')); + assert.equal(h.api.srecCastOwns(7), false); +}); + +test('57b: a worker woken by the erase itself still finds the cast it holds', async () => { + // The re-seed's read — the first one, made while the file loads — answers only after the wipe asked. + let release; + const held = new Promise((r) => { release = r; }); + let reads = 0; + const h = load({ + session: CASTING(), + hooks: { storageGet: async (_area, _keys, read) => { reads += 1; if (reads === 1) await held; return read(); } }, + }); + const answer = h.message({ type: 'SCREENREC_WIPE' }); + await h.settle(); + release(); + assert.deepEqual(await answer, { ok: true }); + assert.deepEqual(h.named('debugger.detach'), [[{ tabId: 7 }]]); +}); + +test('58: a take waiting for review is thrown away with its key, and its review is told to close', async () => { + const h = await open({ session: { screenRecFile: TAKE(), screenRecReviewKey: 'rk-9' } }); + h.clearCalls(); + assert.deepEqual(await h.message({ type: 'SCREENREC_WIPE' }), { ok: true }); + assert.deepEqual(h.named('offscreen.closeDocument'), [[]], 'the parked take no longer keeps the page open'); + assert.equal(h.parked(), undefined); + assert.equal(h.session.screenRecReviewKey, undefined); + assert.deepEqual(h.events(), [WIPED]); +}); + +test('59: with nothing recorded the erase still answers ok, even when there is no page to close', async () => { + const h = await open(); + h.hooks.closeDocument = async () => { throw new Error('No current offscreen document.'); }; + assert.deepEqual(await h.message({ type: 'SCREENREC_WIPE' }), { ok: true }); + assert.deepEqual(h.named('debugger.detach'), []); +}); + +test('59b: an erase that cannot clear the state says so instead of claiming it did', async () => { + const h = await open({ session: { screenRecFile: TAKE() } }); + const remove = h.context.chrome.storage.session.remove; + h.context.chrome.storage.session.remove = async () => { throw new Error('storage locked'); }; + assert.deepEqual(await h.message({ type: 'SCREENREC_WIPE' }), { ok: false, error: 'storage locked' }); + h.context.chrome.storage.session.remove = remove; +}); + +test('60: a take that arrives after its recorder page is gone is not parked and opens no review', async () => { + const h = await open(); + h.hooks.getContexts = () => []; + await h.message({ type: 'SCREENREC_FILE', file: { url: 'blob:late', size: 999, ms: 300000, reason: 'time' } }); + await h.settle(6); + assert.equal(h.parked(), undefined); + assert.deepEqual(h.named('scripting.executeScript'), []); + assert.deepEqual(h.named('tabs.create'), []); + assert.deepEqual(h.events(), []); +}); + +test('60b: a take pushed while its recorder page is still open is parked as before, session or not', async () => { + const h = await open(); + await h.message({ type: 'SCREENREC_FILE', file: { url: 'blob:cap', size: 999, ms: 300000, reason: 'time' } }); + await h.settle(6); + assert.equal(h.parked().url, 'blob:cap'); + assert.deepEqual(h.named('runtime.getContexts'), [[{ contextTypes: ['OFFSCREEN_DOCUMENT'] }]]); +}); + +test('60c: a Chrome that cannot list its pages keeps the take rather than lose it', async () => { + const h = await open(); + h.hooks.getContexts = () => { throw new Error('getContexts is not a function'); }; + await h.message({ type: 'SCREENREC_FILE', file: { url: 'blob:cap', size: 999, ms: 300000, reason: 'time' } }); + await h.settle(6); + assert.equal(h.parked().url, 'blob:cap'); +}); diff --git a/tests/settings-erase.test.mjs b/tests/settings-erase.test.mjs index 8bfa928..fdd5b9c 100644 --- a/tests/settings-erase.test.mjs +++ b/tests/settings-erase.test.mjs @@ -28,6 +28,8 @@ const { hostOf } = runInNewContext( const WARN_KEY = 'signOutRecorderWarning'; const KEYS = ['settings', 'session', 'offlineQueue']; +// A worker where only the named recorders refuse to stop. +const refuses = (...types) => async (m) => (types.includes(m.type) ? { ok: false, error: 'busy' } : { ok: true }); // `state` is an ACCESSOR bag, not a plain object: the safety property is that storage is written // before any of these is touched, and only a recorded write can show that. @@ -175,7 +177,8 @@ test('#203 Forget on the active instance runs one fixed sequence: storage first, assert.deepEqual(h.order, [ 'confirm', 'state.booting=true', - 'send:EVIDENCE_WIPE', // the recorder is stopped BEFORE either write + 'send:EVIDENCE_WIPE', // both recorders are stopped BEFORE either write + 'send:SCREENREC_WIPE', 'local.set', 'local.remove(settings,session,offlineQueue)', 'session.clear', @@ -321,13 +324,13 @@ test('#203 a recorder that never answers FAILS at five seconds — a timeout is const h = load({ reply: () => new Promise(() => {}) }); const p = rejection(h.erase.wipeRecording()); await settle(); - assert.deepEqual(h.clock.arms(), [5000]); // the number the promise is worth + assert.deepEqual(h.clock.arms(), [5000, 5000]); // the number the promise is worth, once per recorder await h.clock.tick(); assert.equal((await p).message, 'the recorder did not answer in 5s'); }); test('#203 a recorder that will not stop does not hold up the erase — the warning rides the reload', async () => { - const h = load({ reply: async () => ({ ok: false, error: 'busy' }) }); + const h = load({ reply: refuses('EVIDENCE_WIPE') }); await h.erase.forget(); assert.equal(h.order.includes('reload'), true); assert.equal(h.state.settings, null); @@ -346,6 +349,7 @@ test('#203 Sign out reads theme and surface BEFORE the clear, and writes them ba 'confirm', 'state.booting=true', 'send:EVIDENCE_WIPE', + 'send:SCREENREC_WIPE', 'Theme.get', // both reads stand ahead of the clear, or they read the wiped store 'ViewMode.mode', 'local.clear', @@ -404,3 +408,42 @@ test('#203 a browser that refuses sessionStorage neither throws nor invents a wa assert.deepEqual(h.calls.status, []); h.erase.leaveWarning(new Error('busy'), 'Signed out'); // the write side, same guarantee }); + +// ---------- the screen recording goes too ---------- + +test('a screen recording that will not stop is named in the warning, with its video', async () => { + const h = load({ reply: refuses('SCREENREC_WIPE') }); + await h.erase.signOut(); + assert.equal(h.order.includes('reload'), true); + assert.equal(h.sess[WARN_KEY], + 'Signed out — but the screen recording could not be stopped: busy. ' + + 'Assume its video is still on this machine until you restart the browser.'); +}); + +test('when neither recorder stops, the warning names both and the erase still happens', async () => { + const h = load({ reply: refuses('EVIDENCE_WIPE', 'SCREENREC_WIPE') }); + await h.erase.disconnect(); + assert.equal(h.state.settings, null); + assert.equal(h.sess[WARN_KEY], + 'Instance forgotten — but neither recording could be stopped (console & network: busy; screen: busy). ' + + 'Assume the log and the video are still on this machine until you restart the browser.'); +}); + +test('the wipe reports every recorder that refused, and its message is the first reason', async () => { + const h = load({ reply: async (m) => ({ ok: false, error: m.type === 'SCREENREC_WIPE' ? 'no page' : 'busy' }) }); + const e = await rejection(h.erase.wipeRecording()); + assert.equal(e.message, 'busy'); + assert.deepEqual(plain(e.failed.map((f) => [f.type, f.why])), [['EVIDENCE_WIPE', 'busy'], ['SCREENREC_WIPE', 'no page']]); + const screenOnly = await rejection(load({ reply: refuses('SCREENREC_WIPE') }).erase.wipeRecording()); + assert.equal(screenOnly.message, 'busy'); + assert.deepEqual(plain(screenOnly.failed.map((f) => f.type)), ['SCREENREC_WIPE']); +}); + +test('the confirm names the screen recording among what is deleted', async () => { + const forget = load({ confirm: false }); + await forget.erase.forget(); + assert.match(forget.calls.confirms[0].message, /captured log, screen recording and unsaved drafts/); + const out = load({ confirm: false }); + await out.erase.signOut(); + assert.match(out.calls.confirms[0].message, /recorded step, captured log and screen recording is deleted/); +}); diff --git a/tests/settings.test.mjs b/tests/settings.test.mjs index 117ac33..40ea250 100644 --- a/tests/settings.test.mjs +++ b/tests/settings.test.mjs @@ -49,6 +49,8 @@ const { const DEFAULT = 'https://app.testomat.io'; const WARN_KEY = 'signOutRecorderWarning'; +// Only the console & network recorder refuses; the screen recorder stops cleanly. +const LOG_REFUSES = async (m) => (m.type === 'EVIDENCE_WIPE' ? { ok: false, error: 'busy' } : { ok: true }); const REQUIRED = 'Instance and access token are required'; const NOT_HTTPS = 'Instance URL must be https://'; const NOT_URL = 'Instance is not a valid URL'; @@ -1644,6 +1646,7 @@ test('59: the erase writes STORAGE first and in-memory state only after, in one 'confirm', 'state.booting=true', 'send:EVIDENCE_WIPE', + 'send:SCREENREC_WIPE', 'local.set', 'local.remove(settings,session,offlineQueue)', 'session.clear', @@ -1715,7 +1718,7 @@ test('61b: a failure with no message still names something the tester can act on }); test('62: a recorder that will not stop does not hold up the erase — the warning rides the reload', async () => { - const h = load({ ...CONFIGURED, reply: async () => ({ ok: false, error: 'busy' }) }); + const h = load({ ...CONFIGURED, reply: LOG_REFUSES }); await h.fn.forgetInstance(); assert.equal(h.calls.reloads, 1); assert.equal(h.state.settings, null); @@ -1787,7 +1790,7 @@ test('65a: with nothing saved anywhere, Disconnect defaults to the Connection ca test('66: a recorder that answers cleanly lets the wipe resolve', async () => { const h = load({ reply: async () => ({ ok: true }) }); await h.fn.wipeEvidenceRecording(); - assert.deepEqual(h.calls.sends, [{ type: 'EVIDENCE_WIPE' }]); + assert.deepEqual(h.calls.sends, [{ type: 'EVIDENCE_WIPE' }, { type: 'SCREENREC_WIPE' }]); }); test('67: a recorder that refuses hands its own reason up, so the tester reads it', async () => { @@ -1821,7 +1824,7 @@ test('69: a recorder that never answers is a FAILURE after five seconds, not a s const h = load({ reply: () => new Promise(() => {}) }); const p = rejection(h.fn.wipeEvidenceRecording()); await settle(); - assert.deepEqual(h.clock.arms(), [5000]); + assert.deepEqual(h.clock.arms(), [5000, 5000]); // both recorders, side by side assert.equal(h.screen.EVIDENCE_WIPE_MS, 5000); await h.clock.tick(); assert.equal((await p).message, 'the recorder did not answer in 5s'); @@ -1839,6 +1842,7 @@ test('70: Sign out carries the theme and the surface ACROSS the wipe, and reload 'confirm', 'state.booting=true', 'send:EVIDENCE_WIPE', + 'send:SCREENREC_WIPE', 'Theme.get', 'ViewMode.mode', 'local.clear', @@ -1885,7 +1889,7 @@ test('72: the two defaults are not written back — nothing to carry across mean }); test('72a: Sign out with a recorder that will not stop still erases, and leaves the warning', async () => { - const h = load({ ...CONFIGURED, reply: async () => ({ ok: false, error: 'busy' }) }); + const h = load({ ...CONFIGURED, reply: LOG_REFUSES }); await h.fn.signOut(); assert.deepEqual(h.stored(), {}); assert.equal(h.calls.reloads, 1);