diff --git a/scripts/chain-probe.mjs b/scripts/chain-probe.mjs new file mode 100644 index 0000000..3add651 --- /dev/null +++ b/scripts/chain-probe.mjs @@ -0,0 +1,73 @@ +// Reproduce a multi-hop Wikipedia click chain (Istanbul → Turkish language → +// Turkey → Aegean Sea) and print the captured parentage, to find missing edges. +// +// node scripts/chain-probe.mjs (after `npm run build`) + +import { chromium } from "playwright"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const DIST = resolve(dirname(fileURLToPath(import.meta.url)), "../dist"); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const context = await chromium.launchPersistentContext(mkdtempSync(resolve(tmpdir(), "hg-")), { + headless: false, + args: [`--disable-extensions-except=${DIST}`, `--load-extension=${DIST}`], +}); + +try { + let [sw] = context.serviceWorkers(); + if (!sw) sw = await context.waitForEvent("serviceworker", { timeout: 10_000 }); + const extId = new URL(sw.url()).host; + sw.on("console", (m) => { + const t = m.text(); + if (t.includes("history-graph")) console.log(" [SW]", t.split(" {")[0]); + }); + + const page = await context.newPage(); + // Warm up so the worker doesn't cold-start mid-chain. + await page.goto("https://en.wikipedia.org/wiki/Main_Page", { waitUntil: "load" }); + await sleep(1500); + + await page.goto("https://en.wikipedia.org/wiki/Istanbul", { waitUntil: "load" }); + await sleep(1500); + + for (const target of ["/wiki/Turkish_language", "/wiki/Turkey", "/wiki/Aegean_Sea"]) { + await page.evaluate((href) => { + const a = document.createElement("a"); + a.href = href; + a.textContent = "go"; + document.body.prepend(a); + a.click(); + }, target); + await page.waitForLoadState("load"); + await sleep(1800); + } + + const viewer = await context.newPage(); + await viewer.goto(`chrome-extension://${extId}/viewer.html`, { waitUntil: "load" }); + const visits = await viewer.evaluate(async () => { + const db = await new Promise((res, rej) => { + const q = indexedDB.open("history-graph"); + q.onsuccess = () => res(q.result); + q.onerror = () => rej(q.error); + }); + return await new Promise((res, rej) => { + const q = db.transaction("visits").objectStore("visits").getAll(); + q.onsuccess = () => res(q.result); + q.onerror = () => rej(q.error); + }); + }); + + const byId = new Map(visits.map((v) => [v.id, v])); + const short = (u) => u.replace("https://en.wikipedia.org/wiki/", ""); + console.log(`\ncaptured ${visits.length} visit(s):`); + for (const v of visits.sort((a, b) => a.ts - b.ts)) { + const parent = v.parentId ? short(byId.get(v.parentId)?.url ?? "?") : "(root)"; + console.log(` ${short(v.url).padEnd(22)} [${v.transition}] q=${JSON.stringify(v.qualifiers)} ← ${parent}`); + } +} finally { + await context.close(); +} diff --git a/scripts/orphan-probe.mjs b/scripts/orphan-probe.mjs new file mode 100644 index 0000000..ac05236 --- /dev/null +++ b/scripts/orphan-probe.mjs @@ -0,0 +1,73 @@ +// Verify the orphan-rescue fallback: if the current-visit pointer is lost +// (storage.session cleared, simulating SW eviction / restart with a stable tab), +// a follow-on click should still attach to the tab's last page via IndexedDB. +// +// node scripts/orphan-probe.mjs (after `npm run build`) + +import { chromium } from "playwright"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const DIST = resolve(dirname(fileURLToPath(import.meta.url)), "../dist"); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const context = await chromium.launchPersistentContext(mkdtempSync(resolve(tmpdir(), "hg-")), { + headless: false, + args: [`--disable-extensions-except=${DIST}`, `--load-extension=${DIST}`], +}); + +try { + let [sw] = context.serviceWorkers(); + if (!sw) sw = await context.waitForEvent("serviceworker", { timeout: 10_000 }); + const extId = new URL(sw.url()).host; + + const page = await context.newPage(); + await page.goto("https://en.wikipedia.org/wiki/Main_Page", { waitUntil: "load" }); + await sleep(1500); + await page.goto("https://en.wikipedia.org/wiki/Istanbul", { waitUntil: "load" }); + await sleep(1500); + + // Simulate a lost current-visit pointer (eviction / browser restart). + const wiper = await context.newPage(); + await wiper.goto(`chrome-extension://${extId}/viewer.html`, { waitUntil: "load" }); + await wiper.evaluate(() => chrome.storage.session.clear()); + await wiper.close(); + + // Follow-on click — pointer is gone, so this would orphan without the fallback. + await page.bringToFront(); + await page.evaluate(() => { + const a = document.createElement("a"); + a.href = "/wiki/Turkish_language"; + document.body.prepend(a); + a.click(); + }); + await page.waitForLoadState("load"); + await sleep(1800); + + const viewer = await context.newPage(); + await viewer.goto(`chrome-extension://${extId}/viewer.html`, { waitUntil: "load" }); + const visits = await viewer.evaluate(async () => { + const db = await new Promise((res, rej) => { + const q = indexedDB.open("history-graph"); + q.onsuccess = () => res(q.result); + q.onerror = () => rej(q.error); + }); + return await new Promise((res, rej) => { + const q = db.transaction("visits").objectStore("visits").getAll(); + q.onsuccess = () => res(q.result); + q.onerror = () => rej(q.error); + }); + }); + + const byId = new Map(visits.map((v) => [v.id, v])); + const short = (u) => u.replace("https://en.wikipedia.org/wiki/", ""); + console.log("\nafter clearing the pointer, the next click:"); + for (const v of visits.sort((a, b) => a.ts - b.ts)) { + const parent = v.parentId ? short(byId.get(v.parentId)?.url ?? "?") : "(root)"; + console.log(` ${short(v.url).padEnd(20)} ← ${parent}`); + } +} finally { + await context.close(); +} diff --git a/src/background/capture.ts b/src/background/capture.ts index 784e8fc..08e2fc4 100644 --- a/src/background/capture.ts +++ b/src/background/capture.ts @@ -1,5 +1,6 @@ import type { Visit } from "../model/types"; import { getCurrentVisit, takePendingOpener } from "../storage/sessionMap"; +import { getLatestVisitInTab } from "../storage/db"; /** * Transition types that mean "the user deliberately started somewhere new", @@ -25,6 +26,10 @@ const ROOT_TRANSITIONS = new Set([ * 2. If the navigation is a "fresh start" transition, it's a root (null). * 3. Otherwise it's a follow-on within the tab → child of the tab's current * visit (a clicked link, a form submit, a reload, back/forward...). + * 4. If the in-memory current pointer is missing (the service worker was + * evicted mid-navigation, storage.session cleared, etc.), fall back to the + * most recent visit recorded in this tab from durable storage — so a + * follow-on click is linked to its predecessor instead of orphaned. */ export async function determineParentId( details: chrome.webNavigation.WebNavigationTransitionCallbackDetails, @@ -34,7 +39,11 @@ export async function determineParentId( if (ROOT_TRANSITIONS.has(details.transitionType)) return null; - return (await getCurrentVisit(details.tabId))?.id ?? null; + const current = await getCurrentVisit(details.tabId); + if (current) return current.id; + + const latest = await getLatestVisitInTab(details.tabId); + return latest?.id ?? null; } let counter = 0; diff --git a/src/storage/db.ts b/src/storage/db.ts index 46b52f7..6b11686 100644 --- a/src/storage/db.ts +++ b/src/storage/db.ts @@ -73,6 +73,26 @@ export async function clearAllVisits(): Promise { }); } +/** The most recent visit recorded in a tab (any URL). */ +export async function getLatestVisitInTab(tabId: number): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + let best: Visit | undefined; + const req = tx(db, "readonly").index("tabId").openCursor(IDBKeyRange.only(tabId)); + req.onsuccess = () => { + const cursor = req.result; + if (cursor) { + const v = cursor.value as Visit; + if (!best || v.ts > best.ts) best = v; + cursor.continue(); + } else { + resolve(best); + } + }; + req.onerror = () => reject(req.error); + }); +} + /** * The most recent visit in a tab whose URL matches. Used by the title handler * to attach a title to the right visit even if the "current" pointer has since