Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -3315,50 +3315,118 @@ async function assertA28(): Promise<AssertionResult> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Plan 03-01 Task 2 — A29 (rrweb DOM verification; SPEC §10 #4) ─
|
/* ─── Plan 04-03 — A29 rewrite (rrweb DOM verification; SPEC §10 #4)
|
||||||
|
* cs-injection-world pattern (verbatim port of A30
|
||||||
|
* per RESEARCH Q3 + Plan 03-02 SUMMARY) ──────────────
|
||||||
*
|
*
|
||||||
* A29 — REQ-rrweb-dom-buffer empirical: rrweb's record() (already
|
* A29 — REQ-rrweb-dom-buffer empirical: rrweb's record() (wired at
|
||||||
* wired at src/content/index.ts:285) emits Meta + FullSnapshot
|
* src/content/index.ts:285) MUST capture a DOM mutation that
|
||||||
* + at least one IncrementalSnapshot when the harness page
|
* WE injected — proving the rrweb pipeline is genuinely live
|
||||||
* contains the probe HTML (form + table + modal) AND the driver
|
* on the SAVE-active tab. The captured IncrementalSnapshot
|
||||||
* injects a DOM mutation before SAVE (RESEARCH Pitfall 1:
|
* payload MUST contain the sentinel string the host-side
|
||||||
* static probe HTML emits Meta + FullSnapshot but not
|
* driveA29 strict filter searches for.
|
||||||
* IncrementalSnapshot without mutation).
|
|
||||||
*
|
*
|
||||||
* Page side: dispatch the probe-page DOM mutations (input value +
|
* Plan 04-03 rewrite background (RESEARCH Q3 + Plan 03-02/03-03 SUMMARY):
|
||||||
* modal toggle), settle, setupFreshRecording, settle one segment,
|
* The pre-04-03 A29 implementation dispatched probe-page DOM mutations
|
||||||
* dispatch SAVE_ARCHIVE, push A29.1 ack check. The host-side driveA29
|
* on the harness page (chrome-extension://) and then relied on a
|
||||||
* does the EventType-enum-shape grep against rrweb/session.json from
|
* loose `EventType.{Meta,FullSnapshot,IncrementalSnapshot}` grep at
|
||||||
* the assembled zip (matches A26's chained-assertion pattern; JSZip
|
* the host-side. Two latent defects fell out of this:
|
||||||
* + @rrweb/types are host-only deps).
|
|
||||||
*
|
*
|
||||||
* FORBIDDEN_HOOK_STRINGS impact: NONE. A29 rides production rrweb
|
* 1. The chrome-extension:// harness page never had a content
|
||||||
* wiring (record() at src/content/index.ts:285 + GET_RRWEB_EVENTS
|
* script attached at all (`<all_urls>` does NOT cover
|
||||||
* round-trip at src/background/index.ts → src/content/index.ts:318)
|
* chrome-extension scheme per Chrome match-pattern spec —
|
||||||
* + the existing setupFreshRecording / sendMessageWithTimeout
|
* same root cause that drove the A30 cs-injection-world
|
||||||
* helpers. Tier-1 inventory stays at 12 entries.
|
* adaptation in Plan 03-02). rrweb's record() therefore never
|
||||||
|
* ran on the harness page.
|
||||||
|
* 2. The host-side loose grep would still pass because the
|
||||||
|
* produced zip contained iana.org leftover rrweb events from
|
||||||
|
* the A27/A28 probe tabs that were still open at SAVE time —
|
||||||
|
* i.e. A29 "passed" for the wrong reason at ~2/3 success rate
|
||||||
|
* (documented as a pre-existing flake in Plan 03-02 + 03-03
|
||||||
|
* SUMMARYs). A real rrweb regression at src/content/index.ts:284
|
||||||
|
* would have been masked because iana.org's home page emits
|
||||||
|
* plenty of mutations during normal rendering.
|
||||||
|
*
|
||||||
|
* Plan 04-03 fix (mechanical port of assertA30 / assertA31 skeleton):
|
||||||
|
* - Open a fresh https://example.com probe tab via chrome.tabs.create
|
||||||
|
* (RFC 2606 reserved domain; matches A30 + A31 pattern).
|
||||||
|
* - Wait the canonical 1.5s for content script attach.
|
||||||
|
* - Wait the canonical 11s for the first MediaRecorder segment to
|
||||||
|
* rotate (mirrors A30's SEGMENT_SETTLE_MS).
|
||||||
|
* - Use chrome.scripting.executeScript with default world: 'ISOLATED'
|
||||||
|
* (the content-script's realm where rrweb's MutationObserver lives)
|
||||||
|
* to inject a synthetic DOM mutation that carries a UNIQUE SENTINEL
|
||||||
|
* STRING ('a29-mutation-sentinel') into a fresh <div> appendChild'd
|
||||||
|
* to document.body.
|
||||||
|
* - Wait 500ms for rrweb's MutationObserver to enqueue the
|
||||||
|
* IncrementalSnapshot.
|
||||||
|
* - Dispatch SAVE_ARCHIVE while the probe tab is active (its content
|
||||||
|
* script is the source of rrweb/session.json).
|
||||||
|
* - finally-cleanup the probe tab via chrome.tabs.remove with the
|
||||||
|
* T-02-04-04 silent-ignore precedent.
|
||||||
|
*
|
||||||
|
* Host-side driveA29 (rewritten in Plan 04-03 Task 2) JSZip-parses the
|
||||||
|
* resulting archive, filters rrweb events for
|
||||||
|
* e.type === EventType.IncrementalSnapshot &&
|
||||||
|
* e.data?.source === IncrementalSource.Mutation,
|
||||||
|
* descends into the mutation payload's adds[*].node.textContent
|
||||||
|
* field, and asserts >=1 event contains the sentinel — proving the
|
||||||
|
* captured mutation came from OUR injection, not from leftover
|
||||||
|
* iana.org tabs (closes the documented flake).
|
||||||
|
*
|
||||||
|
* FORBIDDEN_HOOK_STRINGS impact: NONE. A29's new path rides production
|
||||||
|
* chrome.tabs.create + chrome.scripting.executeScript + existing
|
||||||
|
* setupFreshRecording + sendMessageWithTimeout helpers. Tier-1
|
||||||
|
* inventory stays at 12 entries.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** SAVE_ARCHIVE dispatch timeout for A29 — matches A24/A25/A27. */
|
/** SAVE_ARCHIVE dispatch timeout for A29 — matches A24/A25/A27/A30/A31. */
|
||||||
const A29_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
|
const A29_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
|
||||||
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */
|
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). Mirrors
|
||||||
|
* A30_SEGMENT_SETTLE_MS. */
|
||||||
const A29_SEGMENT_SETTLE_MS = 11_000;
|
const A29_SEGMENT_SETTLE_MS = 11_000;
|
||||||
/** Settle window between DOM mutation and SAVE so rrweb's
|
/** Settle between sentinel-bearing DOM mutation injection and SAVE so
|
||||||
* IncrementalSnapshot lands in the in-memory buffer before
|
* rrweb's MutationObserver enqueues the IncrementalSnapshot into the
|
||||||
* GET_RRWEB_EVENTS fires. */
|
* content-script's in-memory buffer before GET_RRWEB_EVENTS fires. */
|
||||||
const A29_MUTATION_SETTLE_MS = 500;
|
const A29_MUTATION_SETTLE_MS = 500;
|
||||||
|
/** Wait after chrome.tabs.create for the tab navigation to complete so
|
||||||
|
* the content script attaches + rrweb's record() is wired (mirrors
|
||||||
|
* A27/A30/A31's canonical 1.5s). */
|
||||||
|
const A29_TAB_NAVIGATION_WAIT_MS = 1_500;
|
||||||
|
/** Probe tab URL — example.com is RFC 2606 reserved + serves stable
|
||||||
|
* HTML under headless Chrome (Plan 02-04 A27 + Plan 03-02 A30 +
|
||||||
|
* Plan 03-03 A31 fixture parity). */
|
||||||
|
const A29_PROBE_TAB_URL = 'https://example.com/';
|
||||||
|
/** Unique sentinel string carried by the injected DOM mutation. The
|
||||||
|
* host-side driveA29 strict-sentinel filter searches the rrweb
|
||||||
|
* IncrementalSnapshot payload's `adds[*].node.textContent` field for
|
||||||
|
* this exact string. Distinctive enough to never collide with
|
||||||
|
* iana.org / example.com rendering noise. */
|
||||||
|
const A29_MUTATION_SENTINEL = 'a29-mutation-sentinel';
|
||||||
|
/** DOM id of the synthetic <div> appendChild'd to document.body inside
|
||||||
|
* the ISOLATED-world injection. Distinct from A31's probe-password /
|
||||||
|
* probe-control ids to avoid cross-assertion mixup. */
|
||||||
|
const A29_PROBE_DIV_ID = 'a29-probe-mutation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A29 — rrweb DOM event recording empirical (SPEC §10 #4).
|
* A29 — rrweb DOM event recording empirical (SPEC §10 #4 / REQ-rrweb-dom-buffer).
|
||||||
*
|
*
|
||||||
* Page-side dispatches the probe-page mutation (input.value + modal
|
* Plan 04-03 rewrite (cs-injection-world pattern; verbatim port of
|
||||||
* toggle), settles, runs setupFreshRecording + segment-settle, then
|
* assertA30/assertA31 skeleton). Creates a fresh https://example.com
|
||||||
* dispatches SAVE_ARCHIVE. Only the SAVE ack lives in the
|
* probe tab where the content script attaches normally + rrweb's
|
||||||
* page-side checks; the EventType-enum-shape grep is host-side
|
* record() is alive, injects a synthetic DOM mutation carrying the
|
||||||
* because @rrweb/types is host-only.
|
* A29_MUTATION_SENTINEL into the content-script's ISOLATED world via
|
||||||
|
* chrome.scripting.executeScript, settles a segment, SAVEs while the
|
||||||
|
* probe tab is active so the SW harvests rrweb/session.json from that
|
||||||
|
* tab, finally-cleanup the tab. Host-side driveA29 applies the
|
||||||
|
* strict-sentinel filter (IncrementalSource.Mutation +
|
||||||
|
* adds[*].node.textContent includes sentinel) to PROVE the captured
|
||||||
|
* mutation came from our injection (closes the iana.org-leftover-
|
||||||
|
* flake documented in Plan 03-02 + 03-03 SUMMARYs).
|
||||||
*
|
*
|
||||||
* @returns AssertionResult with 1 page-side check (SAVE ack); host-side
|
* @returns AssertionResult with 1 page-side check (SAVE ack); host-side
|
||||||
* driveA29 appends A29.0a/A29.2..A29.5 EventType checks.
|
* driveA29 appends the A29.2 strict-sentinel check + defense-
|
||||||
|
* in-depth presence checks for Meta + FullSnapshot.
|
||||||
*/
|
*/
|
||||||
async function assertA29(): Promise<AssertionResult> {
|
async function assertA29(): Promise<AssertionResult> {
|
||||||
const result: AssertionResult = {
|
const result: AssertionResult = {
|
||||||
@@ -3368,39 +3436,65 @@ async function assertA29(): Promise<AssertionResult> {
|
|||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let probeTabId: number | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
diag(result, 'Step 1: dispatch probe-page DOM mutation (input value + modal toggle)');
|
diag(result, 'Step 1: setupFreshRecording (A29 owns its recording — clean rrweb buffer window)');
|
||||||
const textInput = document.querySelector<HTMLInputElement>('#probe-text');
|
|
||||||
if (textInput !== null) {
|
|
||||||
textInput.value = 'probe';
|
|
||||||
textInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
}
|
|
||||||
const modalTrigger = document.querySelector<HTMLButtonElement>('#probe-modal-trigger');
|
|
||||||
if (modalTrigger !== null) {
|
|
||||||
modalTrigger.click();
|
|
||||||
}
|
|
||||||
diag(result, `Step 1 OK — mutations dispatched (#probe-text=${textInput !== null}, #probe-modal-trigger=${modalTrigger !== null})`);
|
|
||||||
|
|
||||||
diag(result, `Step 2: settle ${A29_MUTATION_SETTLE_MS}ms for rrweb IncrementalSnapshot to enqueue`);
|
|
||||||
await new Promise((r) => setTimeout(r, A29_MUTATION_SETTLE_MS));
|
|
||||||
|
|
||||||
diag(result, 'Step 3: setupFreshRecording');
|
|
||||||
const setupResp = await setupFreshRecording();
|
const setupResp = await setupFreshRecording();
|
||||||
if (!setupResp.ok) {
|
if (!setupResp.ok) {
|
||||||
throw new Error(`setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`);
|
throw new Error(`setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`);
|
||||||
}
|
}
|
||||||
diag(result, 'Step 3 OK — REC state established');
|
diag(result, 'Step 1 OK — REC state established');
|
||||||
|
|
||||||
|
diag(result, `Step 2: chrome.tabs.create(${A29_PROBE_TAB_URL}, active:true) — content script ISOLATED world is alive on https://, not on chrome-extension:// (Plan 03-02 lesson)`);
|
||||||
|
const probeTab = await chrome.tabs.create({ url: A29_PROBE_TAB_URL, active: true });
|
||||||
|
probeTabId = probeTab.id;
|
||||||
|
diag(result, `Step 2 result: probeTab.id=${probeTabId}, probeTab.url=${probeTab.url ?? '<pending>'}`);
|
||||||
|
if (probeTabId === undefined) {
|
||||||
|
throw new Error('chrome.tabs.create returned undefined tab.id');
|
||||||
|
}
|
||||||
|
|
||||||
|
diag(result, `Step 3: wait ${A29_TAB_NAVIGATION_WAIT_MS}ms for navigation + content script attach (rrweb's record() wires in this window)`);
|
||||||
|
await new Promise((r) => setTimeout(r, A29_TAB_NAVIGATION_WAIT_MS));
|
||||||
|
|
||||||
diag(result, `Step 4: settle ${A29_SEGMENT_SETTLE_MS}ms for first segment rotation`);
|
diag(result, `Step 4: settle ${A29_SEGMENT_SETTLE_MS}ms for first segment rotation`);
|
||||||
await new Promise((r) => setTimeout(r, A29_SEGMENT_SETTLE_MS));
|
await new Promise((r) => setTimeout(r, A29_SEGMENT_SETTLE_MS));
|
||||||
|
|
||||||
diag(result, 'Step 5: dispatch SAVE_ARCHIVE');
|
diag(result, 'Step 5: chrome.scripting.executeScript — inject sentinel-bearing <div> mutation in ISOLATED world (rrweb MutationObserver lives in same world; will capture)');
|
||||||
|
const injectionResults = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: probeTabId },
|
||||||
|
world: 'ISOLATED',
|
||||||
|
func: (sentinel: string, divId: string): { appended: boolean; sentinelEquals: boolean } => {
|
||||||
|
// Synthetic mutation: create a fresh <div>, set its textContent
|
||||||
|
// to the sentinel, appendChild to document.body. rrweb's
|
||||||
|
// MutationObserver wired by record() at src/content/index.ts:285
|
||||||
|
// observes document.body and enqueues an IncrementalSnapshot
|
||||||
|
// whose `data.source === IncrementalSource.Mutation` and whose
|
||||||
|
// `data.adds[*].node.textContent` carries the sentinel string.
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.id = divId;
|
||||||
|
div.textContent = sentinel;
|
||||||
|
document.body.appendChild(div);
|
||||||
|
return {
|
||||||
|
appended: document.getElementById(divId) !== null,
|
||||||
|
sentinelEquals: div.textContent === sentinel,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: [A29_MUTATION_SENTINEL, A29_PROBE_DIV_ID],
|
||||||
|
});
|
||||||
|
const injectionSummary = injectionResults[0]?.result ?? null;
|
||||||
|
diag(result, `Step 5 result: ${JSON.stringify(injectionSummary)}`);
|
||||||
|
|
||||||
|
diag(result, `Step 6: settle ${A29_MUTATION_SETTLE_MS}ms for rrweb MutationObserver to enqueue the IncrementalSnapshot`);
|
||||||
|
await new Promise((r) => setTimeout(r, A29_MUTATION_SETTLE_MS));
|
||||||
|
|
||||||
|
diag(result, 'Step 7: dispatch SAVE_ARCHIVE (probe tab is the active tab; SW will harvest from there)');
|
||||||
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
|
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
|
||||||
{ type: 'SAVE_ARCHIVE' },
|
{ type: 'SAVE_ARCHIVE' },
|
||||||
A29_SAVE_ARCHIVE_TIMEOUT_MS,
|
A29_SAVE_ARCHIVE_TIMEOUT_MS,
|
||||||
'SAVE_ARCHIVE (A29)',
|
'SAVE_ARCHIVE (A29)',
|
||||||
);
|
);
|
||||||
diag(result, `Step 5 result: ${JSON.stringify(ack)}`);
|
diag(result, `Step 7 result: ${JSON.stringify(ack)}`);
|
||||||
|
|
||||||
result.checks.push({
|
result.checks.push({
|
||||||
name: 'A29.1: SAVE_ARCHIVE ack received with success=true',
|
name: 'A29.1: SAVE_ARCHIVE ack received with success=true',
|
||||||
@@ -3413,6 +3507,16 @@ async function assertA29(): Promise<AssertionResult> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
result.error = err instanceof Error ? err.message : String(err);
|
result.error = err instanceof Error ? err.message : String(err);
|
||||||
diag(result, `THREW: ${result.error}`);
|
diag(result, `THREW: ${result.error}`);
|
||||||
|
} finally {
|
||||||
|
// T-02-04-04 mitigation parity (Plan 02-04 / 03-02 / 03-03 precedent):
|
||||||
|
// cleanup probe tab with silent-ignore on already-closed.
|
||||||
|
if (probeTabId !== undefined) {
|
||||||
|
try {
|
||||||
|
await chrome.tabs.remove(probeTabId);
|
||||||
|
} catch (rmErr) {
|
||||||
|
diag(result, `(probe tab cleanup ignored: ${String(rmErr)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
Reference in New Issue
Block a user