diff --git a/README.md b/README.md index 8f39f8d..fba18b0 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,71 @@ Then load it in Chrome: After code changes, `npm run dev` rebuilds automatically — just hit the reload icon on the extension card to pick up the new build. +## Test + +```sh +npm test # pure-logic checks (URL matching, forest assembly) +npm run test:e2e # builds, loads the extension in a real headed Chromium via + # Playwright, drives navigations, then reads the extension's + # own IndexedDB and asserts the captured tree (titles, edges, + # no same-URL dupes) +``` + +`test:e2e` (`scripts/capture-check.mjs`) is the real end-to-end check: it +launches Chromium with `--load-extension=dist`, so the actual service worker / +`chrome.webNavigation` capture path runs — not a mock. + ## Status MVP / prototype. Live capture + IndexedDB + a simple tree viewer work end to end. +Same-page navigations are collapsed rather than duplicated: back/forward moves +the "current" pointer onto the existing node (like git HEAD moving to a prior +commit), same-URL replaceState/reloads are skipped, and client redirects update +the node in place to their final URL. + Known gaps (intentionally deferred): no seeding of pre-install history; subframe -navigations ignored; back/forward currently creates a new node rather than -revisiting the existing one. +navigations ignored. + +## Roadmap + +Loosely ordered; the north star is making the data model rich enough that many +visualizations can sit on top of it. + +### Visualizations (the point of the project) + +Each is just a new `GraphView` implementation, swapped in `src/viewer/main.ts`: + +- [x] **Indented tree** — v1, the baseline. +- [ ] **Git-graph lanes** — branch/merge style with commit-like nodes and lane + routing; nodes colored by transition (the dot colors are a first step). +- [ ] **Timeline + branches** — time axis with navigations branching off. +- [ ] **Force / radial graph** — D3 layout for exploring large forests. +- [ ] Shared chrome across views: collapse/expand subtrees, node detail panel, + search highlighting, jump-to-now. + +### Data model & capture fidelity + +- [ ] **Dwell time** (`durationMs`) — measure time on page (tab activation + + navigation-away) so nodes can be weighted/sized. +- [ ] **Session grouping** (`sessionId`) — group branches by browsing session. +- [ ] **Favicon cache** — store favicons by origin instead of per-visit. +- [ ] Decide subframe policy; optionally capture meaningful iframe navigations. +- [ ] Optional **seed from `chrome.history`** on install (reconstruct a partial + tree from `referringVisitId`) for a non-empty day one. +- [ ] Treat back/forward as an explicit "revisit" edge if we want to show it. + +### UX & product + +- [ ] Date-range picker (beyond the preset dropdown) and per-domain filtering. +- [ ] **Pause capture** + per-site ignore list; define incognito behavior. +- [ ] Export / import the graph (JSON). +- [ ] Onboarding + privacy explanation; eventual store-publish polish. + +### Tooling & quality + +- [x] Automated **end-to-end extension tests** — `npm run test:e2e` loads the + unpacked extension in real Chromium, drives navigations, asserts the tree. +- [x] `npm test` for the pure-logic checks in `tests/logic.test.ts`. +- [ ] Performance pass if forests get large (virtualization beyond + `content-visibility`). diff --git a/package-lock.json b/package-lock.json index 2726b97..10b0fe1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "devDependencies": { "@types/chrome": "^0.0.304", "@types/node": "^25.9.3", + "playwright": "^1.61.0", + "tsx": "^4.22.4", "typescript": "^5.7.0", "vite": "^6.0.0" } @@ -1011,6 +1013,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -1112,6 +1161,509 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index da9bd9b..dd18c6c 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,15 @@ "scripts": { "build": "tsc --noEmit && vite build", "dev": "vite build --watch --mode development", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "tsx tests/logic.test.ts", + "test:e2e": "npm run build && node scripts/capture-check.mjs" }, "devDependencies": { "@types/chrome": "^0.0.304", "@types/node": "^25.9.3", + "playwright": "^1.61.0", + "tsx": "^4.22.4", "typescript": "^5.7.0", "vite": "^6.0.0" } diff --git a/scripts/capture-check.mjs b/scripts/capture-check.mjs new file mode 100644 index 0000000..cd80808 --- /dev/null +++ b/scripts/capture-check.mjs @@ -0,0 +1,115 @@ +// End-to-end check of the capture pipeline. +// +// Loads the built extension into a real (headed) Chromium via Playwright, +// drives some real navigations, then reads the extension's OWN IndexedDB from +// the viewer page and reports the captured tree + a few assertions. +// +// node scripts/capture-check.mjs +// +// Requires `npm run build` first (reads ./dist). + +import { chromium } from "playwright"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DIST = resolve(__dirname, "../dist"); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const context = await chromium.launchPersistentContext(mkdtempSync(resolve(tmpdir(), "hg-")), { + headless: false, // extensions require a headed (or new-headless) context + args: [`--disable-extensions-except=${DIST}`, `--load-extension=${DIST}`], +}); + +try { + // The MV3 service worker registers shortly after launch; its URL gives us the + // extension id. + let [sw] = context.serviceWorkers(); + if (!sw) sw = await context.waitForEvent("serviceworker", { timeout: 10_000 }); + const extId = new URL(sw.url()).host; + console.log(`extension id: ${extId}\n`); + + const page = await context.newPage(); + + // 1. Root (address-bar style) navigation. + await page.goto("https://developer.chrome.com/", { waitUntil: "load" }); + await sleep(1500); + + // 2. Click a real in-page link → should be a child via the "link" transition. + // Click in the DOM so element visibility (collapsed nav menus) doesn't matter. + const clicked = await page.evaluate(() => { + const a = document.querySelector('a[href*="/docs/"]') || document.querySelector('a[href^="/docs"]'); + if (!a) return null; + a.click(); + return a.href; + }); + if (!clicked) throw new Error("no /docs link found on homepage"); + await page.waitForURL("**/docs/**", { timeout: 15_000 }); + await page.waitForLoadState("load"); + await sleep(1500); + const afterClickUrl = page.url(); + + // 3. Re-navigate to the SAME url plus a #fragment → must NOT create a dup, and + // the title must still be attached (the fragment fix). + await page.goto(afterClickUrl + "#installation", { waitUntil: "load" }); + await sleep(1500); + + // 4. Back → should move the pointer, not fork a new node. + await page.goBack(); + await page.waitForLoadState("load"); + await sleep(1500); + + // Read the extension's IndexedDB from a page on the extension origin. + const viewer = await context.newPage(); + await viewer.goto(`chrome-extension://${extId}/viewer.html`, { waitUntil: "load" }); + await sleep(500); + const visits = await viewer.evaluate(async () => { + const db = await new Promise((res, rej) => { + const r = indexedDB.open("history-graph"); + r.onsuccess = () => res(r.result); + r.onerror = () => rej(r.error); + }); + return await new Promise((res, rej) => { + const r = db.transaction("visits").objectStore("visits").getAll(); + r.onsuccess = () => res(r.result); + r.onerror = () => rej(r.error); + }); + }); + + // Report. + console.log(`captured ${visits.length} visits:\n`); + const byId = new Map(visits.map((v) => [v.id, v])); + for (const v of [...visits].sort((a, b) => a.ts - b.ts)) { + const depth = (() => { + let d = 0, + cur = v; + while (cur.parentId && byId.get(cur.parentId)) { + d++; + cur = byId.get(cur.parentId); + } + return d; + })(); + const title = v.title ? `"${v.title}"` : "(no title)"; + console.log(`${" ".repeat(depth)}• [${v.transition}] ${title}`); + console.log(`${" ".repeat(depth)} ${v.url}`); + } + + // Assertions. + console.log("\nchecks:"); + const httpVisits = visits.filter((v) => v.url.startsWith("http")); + const missingTitle = httpVisits.filter((v) => !v.title); + const dupEdges = visits.filter((v) => { + const p = v.parentId && byId.get(v.parentId); + return p && p.url === v.url; + }); + const report = (ok, msg) => console.log(` ${ok ? "✓" : "✗"} ${msg}`); + report(missingTitle.length === 0, `all http visits have titles (${missingTitle.length} missing)`); + report(dupEdges.length === 0, `no same-URL parent/child pairs (${dupEdges.length} found)`); + report(httpVisits.some((v) => v.parentId), "at least one parent→child edge captured"); + if (missingTitle.length) console.log(" missing:", missingTitle.map((v) => v.url)); +} finally { + await context.close(); +} diff --git a/src/background/capture.ts b/src/background/capture.ts index a440ffc..784e8fc 100644 --- a/src/background/capture.ts +++ b/src/background/capture.ts @@ -34,7 +34,7 @@ export async function determineParentId( if (ROOT_TRANSITIONS.has(details.transitionType)) return null; - return (await getCurrentVisit(details.tabId)) ?? null; + return (await getCurrentVisit(details.tabId))?.id ?? null; } let counter = 0; diff --git a/src/background/index.ts b/src/background/index.ts index 55f151b..51a2117 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,11 +1,15 @@ import { buildVisit, determineParentId } from "./capture"; -import { putVisit, patchVisit } from "../storage/db"; +import { putVisit, patchVisit, getLatestVisitForTabUrl } from "../storage/db"; import { setCurrentVisit, setPendingOpener, getCurrentVisit, + setPendingTitle, + takePendingTitle, clearTab, } from "../storage/sessionMap"; +import type { Visit } from "../model/types"; +import { sameDocument } from "../model/url"; /** * History Graph — capture layer (MV3 service worker). @@ -22,7 +26,7 @@ const MAIN_FRAME = 0; chrome.webNavigation.onCreatedNavigationTarget.addListener(async (details) => { const sourceVisit = await getCurrentVisit(details.sourceTabId); if (sourceVisit) { - await setPendingOpener(details.tabId, sourceVisit); + await setPendingOpener(details.tabId, sourceVisit.id); } }); @@ -42,11 +46,52 @@ chrome.webNavigation.onHistoryStateUpdated.addListener(async (details) => { async function recordNavigation( details: chrome.webNavigation.WebNavigationTransitionCallbackDetails, ): Promise { + const { tabId, url } = details; + const qualifiers = details.transitionQualifiers ?? []; + + // Back/forward: don't fork a new node. Move the "current" pointer back onto + // the existing node for this URL — like git's HEAD moving to a prior commit. + if (qualifiers.includes("forward_back")) { + const existing = await getLatestVisitForTabUrl(tabId, url); + if (existing) { + await setCurrentVisit(tabId, { id: existing.id, url: existing.url }); + console.log(`[history-graph] back/forward → ${url}`); + return; + } + // No node for this URL (history predates our capture) — fall through. + } + + const current = await getCurrentVisit(tabId); + + // Same URL as the current node: a replaceState-to-self (common on hydration), + // a reload, or a self-link. It's the same stop, not a new child — skip it. + // This is the usual cause of a same-URL parent/child pair. + if (current?.url === url) { + console.log(`[history-graph] same-url, skipped ↻ ${url}`); + return; + } + + // Client-side redirect: the page we just recorded is bouncing to a new URL. + // Update that node in place rather than creating a near-duplicate child, so a + // redirect chain collapses to a single node at its final URL. + if (current && qualifiers.includes("client_redirect")) { + await patchVisit(current.id, { url }); + await setCurrentVisit(tabId, { id: current.id, url }); + console.log(`[history-graph] redirect ⇒ ${url}`); + return; + } + const parentId = await determineParentId(details); const visit = buildVisit(details, parentId); + // A title may have arrived (and been stashed) before this row existed — e.g. + // a fast page whose parsed while we were still awaiting the write. + const pending = await takePendingTitle(details.tabId, visit.url); + if (pending?.title) visit.title = pending.title; + if (pending?.faviconUrl) visit.faviconUrl = pending.faviconUrl; + await putVisit(visit); - await setCurrentVisit(details.tabId, visit.id); + await setCurrentVisit(details.tabId, { id: visit.id, url: visit.url }); // Milestone 2 visibility: watch these in the service worker console. console.log( @@ -57,14 +102,57 @@ async function recordNavigation( } // Fill in the page title/favicon once the tab reports them. -chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => { +// +// CRUCIAL: titles arrive on their own event stream, decoupled from navigation, +// so we must attach each one to the visit for the URL it actually belongs to — +// NOT to whatever happens to be "current". Otherwise a title can land on the +// previous page's visit (the bug this guards against). tab.url tells us the URL +// the title is for. +chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { if (!changeInfo.title && !changeInfo.favIconUrl) return; - const visitId = await getCurrentVisit(tabId); - if (!visitId) return; - await patchVisit(visitId, { + const url = tab.url; + if (!url) return; + + const patch: Partial<Visit> = { ...(changeInfo.title ? { title: changeInfo.title } : {}), ...(changeInfo.favIconUrl ? { faviconUrl: changeInfo.favIconUrl } : {}), - }); + }; + + // Fast path: the current visit is for this document — patch it directly. + // (sameDocument ignores the #fragment, which tab.url often gains after load.) + const current = await getCurrentVisit(tabId); + if (current && sameDocument(current.url, url)) { + await patchVisit(current.id, patch); + return; + } + + // Race path: current pointer hasn't caught up (or moved past). Find the + // visit that genuinely matches this URL in this tab and patch that one. + const match = await getLatestVisitForTabUrl(tabId, url); + if (match) { + await patchVisit(match.id, patch); + return; + } + + // The visit row doesn't exist yet — stash so recordNavigation can pick it up. + await setPendingTitle(tabId, { url, title: changeInfo.title, faviconUrl: changeInfo.favIconUrl }); +}); + +// Deterministic title capture: by the time a navigation completes, the page is +// loaded and its <title> parsed. details.url matches the visit we stored exactly +// (same source as onCommitted), so this reliably fills in titles that the racy +// onUpdated stream missed — including chrome:// and extension pages. +chrome.webNavigation.onCompleted.addListener(async (details) => { + if (details.frameId !== MAIN_FRAME) return; + const visit = await getLatestVisitForTabUrl(details.tabId, details.url); + if (!visit) return; + const tab = await chrome.tabs.get(details.tabId).catch(() => undefined); + if (!tab) return; + const patch: Partial<Visit> = { + ...(tab.title ? { title: tab.title } : {}), + ...(tab.favIconUrl ? { faviconUrl: tab.favIconUrl } : {}), + }; + if (patch.title || patch.faviconUrl) await patchVisit(visit.id, patch); }); // Tab gone → drop its current-visit bookkeeping. diff --git a/src/model/url.ts b/src/model/url.ts new file mode 100644 index 0000000..e8f63e8 --- /dev/null +++ b/src/model/url.ts @@ -0,0 +1,18 @@ +/** + * URL helpers for matching a title/favicon to the visit it belongs to. + * + * The document (and therefore its title) is identified by everything up to the + * fragment: "/page" and "/page#section" are the same document. tab.url often + * gains a #fragment after load (scroll anchors, in-page nav) while the visit's + * committed url has none, so we must compare with the fragment stripped or the + * title silently fails to attach. + */ + +export function stripHash(url: string): string { + const i = url.indexOf("#"); + return i === -1 ? url : url.slice(0, i); +} + +export function sameDocument(a: string, b: string): boolean { + return stripHash(a) === stripHash(b); +} diff --git a/src/storage/db.ts b/src/storage/db.ts index bd090af..46b52f7 100644 --- a/src/storage/db.ts +++ b/src/storage/db.ts @@ -1,4 +1,5 @@ import type { Visit } from "../model/types"; +import { sameDocument } from "../model/url"; /** * IndexedDB persistence for visits. @@ -62,6 +63,43 @@ export async function patchVisit(id: string, patch: Partial<Visit>): Promise<voi await putVisit({ ...existing, ...patch }); } +/** Wipe every stored visit. Used by the viewer's "Clear" button. */ +export async function clearAllVisits(): Promise<void> { + const db = await openDb(); + await new Promise<void>((resolve, reject) => { + const req = tx(db, "readwrite").clear(); + req.onsuccess = () => resolve(); + 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 + * moved on. Returns undefined if no visit for that tab+url exists yet. + */ +export async function getLatestVisitForTabUrl( + tabId: number, + url: string, +): Promise<Visit | undefined> { + 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 (sameDocument(v.url, url) && (!best || v.ts > best.ts)) best = v; + cursor.continue(); + } else { + resolve(best); + } + }; + req.onerror = () => reject(req.error); + }); +} + /** All visits in a time range [from, to], ascending by timestamp. */ export async function getVisitsInRange(from: number, to: number): Promise<Visit[]> { const db = await openDb(); diff --git a/src/storage/sessionMap.ts b/src/storage/sessionMap.ts index 3da83e2..a93a962 100644 --- a/src/storage/sessionMap.ts +++ b/src/storage/sessionMap.ts @@ -7,53 +7,72 @@ * worker restarts (and is wiped when the browser closes), which is exactly the * lifetime we want for in-flight navigation bookkeeping. * - * We track two things: - * - currentVisit[tabId]: the id of the latest committed visit in that tab, - * used as the parent for same-tab link navigations. + * We track three things: + * - currentVisit[tabId]: the latest committed visit in that tab (id + url), + * used as the parent for same-tab link navigations. We keep the url too so + * the title handler can verify a title belongs to this visit before applying + * it (titles arrive on a different, racy event stream — see background). * - pendingOpener[newTabId]: when a link opens a NEW tab, the source visit id, * so the new tab's first commit can attach to the link that opened it. + * - pendingTitle[tabId]: a title/favicon that arrived before its visit row was + * written, stashed by url so the visit can pick it up once created. */ +import { sameDocument } from "../model/url"; + const CURRENT_KEY = "currentVisitByTab"; const OPENER_KEY = "pendingOpenerByTab"; +const TITLE_KEY = "pendingTitleByTab"; + +export interface CurrentVisit { + id: string; + url: string; +} -type IdByTab = Record<number, string>; +export interface PendingTitle { + url: string; + title?: string; + faviconUrl?: string; +} -async function read(key: string): Promise<IdByTab> { +async function read<T>(key: string): Promise<Record<number, T>> { const obj = await chrome.storage.session.get(key); - return (obj[key] as IdByTab | undefined) ?? {}; + return (obj[key] as Record<number, T> | undefined) ?? {}; } -async function write(key: string, value: IdByTab): Promise<void> { +async function write<T>(key: string, value: Record<number, T>): Promise<void> { await chrome.storage.session.set({ [key]: value }); } -export async function getCurrentVisit(tabId: number): Promise<string | undefined> { - return (await read(CURRENT_KEY))[tabId]; +export async function getCurrentVisit(tabId: number): Promise<CurrentVisit | undefined> { + return (await read<CurrentVisit>(CURRENT_KEY))[tabId]; } -export async function setCurrentVisit(tabId: number, visitId: string): Promise<void> { - const map = await read(CURRENT_KEY); - map[tabId] = visitId; +export async function setCurrentVisit(tabId: number, visit: CurrentVisit): Promise<void> { + const map = await read<CurrentVisit>(CURRENT_KEY); + map[tabId] = visit; await write(CURRENT_KEY, map); } export async function clearTab(tabId: number): Promise<void> { - const map = await read(CURRENT_KEY); - delete map[tabId]; - await write(CURRENT_KEY, map); + const current = await read<CurrentVisit>(CURRENT_KEY); + delete current[tabId]; + await write(CURRENT_KEY, current); + const titles = await read<PendingTitle>(TITLE_KEY); + delete titles[tabId]; + await write(TITLE_KEY, titles); } /** Record that `newTabId` was opened from `sourceVisitId`. */ export async function setPendingOpener(newTabId: number, sourceVisitId: string): Promise<void> { - const map = await read(OPENER_KEY); + const map = await read<string>(OPENER_KEY); map[newTabId] = sourceVisitId; await write(OPENER_KEY, map); } /** Consume and clear the opener for a tab (single-use). */ export async function takePendingOpener(tabId: number): Promise<string | undefined> { - const map = await read(OPENER_KEY); + const map = await read<string>(OPENER_KEY); const id = map[tabId]; if (id !== undefined) { delete map[tabId]; @@ -61,3 +80,25 @@ export async function takePendingOpener(tabId: number): Promise<string | undefin } return id; } + +/** Stash a title/favicon that arrived before its visit row existed. */ +export async function setPendingTitle(tabId: number, pending: PendingTitle): Promise<void> { + const map = await read<PendingTitle>(TITLE_KEY); + map[tabId] = pending; + await write(TITLE_KEY, map); +} + +/** Consume a stashed title IFF it matches `url` (single-use). */ +export async function takePendingTitle( + tabId: number, + url: string, +): Promise<PendingTitle | undefined> { + const map = await read<PendingTitle>(TITLE_KEY); + const pending = map[tabId]; + if (pending && sameDocument(pending.url, url)) { + delete map[tabId]; + await write(TITLE_KEY, map); + return pending; + } + return undefined; +} diff --git a/src/viewer/main.ts b/src/viewer/main.ts index c958269..f1d2271 100644 --- a/src/viewer/main.ts +++ b/src/viewer/main.ts @@ -1,4 +1,5 @@ import { getForest } from "../query/getForest"; +import { clearAllVisits } from "../storage/db"; import { IndentedTreeView } from "./IndentedTreeView"; import type { GraphView } from "./GraphView"; @@ -8,6 +9,7 @@ const graphEl = document.getElementById("graph") as HTMLElement; const searchEl = document.getElementById("search") as HTMLInputElement; const rangeEl = document.getElementById("range") as HTMLSelectElement; const countEl = document.getElementById("count") as HTMLElement; +const clearEl = document.getElementById("clear") as HTMLButtonElement; // Swap this line to try a different visualization — that's the whole point of // the GraphView seam. @@ -30,4 +32,14 @@ searchEl.addEventListener("input", () => { }); rangeEl.addEventListener("change", () => void refresh()); +clearEl.addEventListener("click", async () => { + if (!confirm("Delete all captured visits? This can't be undone.")) return; + await clearAllVisits(); + // Also reset the capture layer's in-flight bookkeeping (current-visit + // pointers, pending titles/openers) so already-open tabs don't re-attach new + // visits to now-deleted parents. + await chrome.storage.session.clear(); + await refresh(); +}); + void refresh(); diff --git a/src/viewer/styles.css b/src/viewer/styles.css index 210a502..ae97873 100644 --- a/src/viewer/styles.css +++ b/src/viewer/styles.css @@ -13,7 +13,9 @@ body { margin: 0; - font: 14px/1.5 system-ui, sans-serif; + font: + 14px/1.5 system-ui, + sans-serif; background: var(--bg); color: var(--fg); } @@ -45,7 +47,8 @@ h1 { } input, -select { +select, +button { font: inherit; padding: 0.35rem 0.5rem; border: 1px solid var(--line); @@ -58,6 +61,15 @@ input[type="search"] { min-width: 16rem; } +button.danger { + border-color: light-dark(#e5b3b3, #5c2b2b); + color: light-dark(#b91c1c, #f87171); +} + +button.danger:hover { + background: light-dark(#fdecec, #2a1c1c); +} + .count { color: var(--muted); font-variant-numeric: tabular-nums; diff --git a/tests/logic.test.ts b/tests/logic.test.ts new file mode 100644 index 0000000..624ff8c --- /dev/null +++ b/tests/logic.test.ts @@ -0,0 +1,66 @@ +import { strict as assert } from "node:assert"; +import { sameDocument, stripHash } from "../src/model/url.ts"; +import { buildForest } from "../src/query/getForest.ts"; +import type { Visit } from "../src/model/types.ts"; + +let passed = 0; +function check(name: string, fn: () => void) { + fn(); + passed++; + console.log(` ✓ ${name}`); +} + +function visit(p: Partial<Visit> & Pick<Visit, "id" | "url" | "ts">): Visit { + return { + tabId: 1, + parentId: null, + transition: "link", + qualifiers: [], + ...p, + }; +} + +console.log("URL matching (the missing-title fix):"); +check("strips fragment", () => { + assert.equal(stripHash("https://x.com/a#sec"), "https://x.com/a"); + assert.equal(stripHash("https://x.com/a"), "https://x.com/a"); +}); +check("a page and its in-page anchor are the same document", () => { + assert.ok(sameDocument("https://x.com/get-started", "https://x.com/get-started#installation")); +}); +check("different paths are different documents", () => { + assert.ok(!sameDocument("https://x.com/a", "https://x.com/b")); +}); + +console.log("\nForest assembly:"); +check("orphan (parent outside window) surfaces as a root", () => { + const f = buildForest([visit({ id: "b", url: "u/b", ts: 2, parentId: "missing" })]); + assert.equal(f.roots.length, 1); + assert.equal(f.roots[0].id, "b"); +}); +check("children nest under parent, sorted by time", () => { + const f = buildForest([ + visit({ id: "root", url: "u/root", ts: 1 }), + visit({ id: "c2", url: "u/c2", ts: 3, parentId: "root" }), + visit({ id: "c1", url: "u/c1", ts: 2, parentId: "root" }), + ]); + assert.equal(f.count, 3); + assert.equal(f.roots.length, 1); + assert.deepEqual( + f.roots[0].children.map((c) => c.id), + ["c1", "c2"], + ); +}); +check("a same-URL parent/child pair is still rendered as nested (data, not viz, must prevent dupes)", () => { + // buildForest faithfully nests whatever edges exist; de-duplication is the + // capture layer's job (recordNavigation skip), which this asserts is the + // contract — buildForest should NOT silently merge them. + const f = buildForest([ + visit({ id: "p", url: "same", ts: 1 }), + visit({ id: "c", url: "same", ts: 2, parentId: "p" }), + ]); + assert.equal(f.count, 2); + assert.equal(f.roots[0].children[0].id, "c"); +}); + +console.log(`\n${passed} checks passed.`); diff --git a/viewer.html b/viewer.html index 8887f76..c0faea0 100644 --- a/viewer.html +++ b/viewer.html @@ -18,6 +18,7 @@ <option value="36500">All time</option> </select> <span id="count" class="count"></span> + <button id="clear" type="button" class="danger">Clear data</button> </div> </header> <main id="graph"></main>