From 46e3a11ce76c92a64771a784bb05b89d0739996f Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Tue, 26 May 2026 13:21:12 -0400 Subject: [PATCH 01/14] fix(ext): dedupe popup.html + options.html via copy-html prebuild script Co-Authored-By: Claude Sonnet 4.6 --- packages/extension-chrome/.gitignore | 2 + packages/extension-chrome/package.json | 9 ++- .../extension-chrome/scripts/copy-html.mjs | 17 +++++ packages/extension-chrome/src/options.html | 64 ------------------- packages/extension-chrome/src/popup.html | 37 ----------- packages/extension-firefox/.gitignore | 2 + packages/extension-firefox/package.json | 4 ++ .../extension-firefox/scripts/copy-html.mjs | 17 +++++ packages/extension-firefox/src/options.html | 64 ------------------- packages/extension-firefox/src/popup.html | 37 ----------- 10 files changed, 48 insertions(+), 205 deletions(-) create mode 100644 packages/extension-chrome/.gitignore create mode 100644 packages/extension-chrome/scripts/copy-html.mjs delete mode 100644 packages/extension-chrome/src/options.html delete mode 100644 packages/extension-chrome/src/popup.html create mode 100644 packages/extension-firefox/.gitignore create mode 100644 packages/extension-firefox/scripts/copy-html.mjs delete mode 100644 packages/extension-firefox/src/options.html delete mode 100644 packages/extension-firefox/src/popup.html diff --git a/packages/extension-chrome/.gitignore b/packages/extension-chrome/.gitignore new file mode 100644 index 0000000..aecafef --- /dev/null +++ b/packages/extension-chrome/.gitignore @@ -0,0 +1,2 @@ +src/popup.html +src/options.html diff --git a/packages/extension-chrome/package.json b/packages/extension-chrome/package.json index c881e20..a656820 100644 --- a/packages/extension-chrome/package.json +++ b/packages/extension-chrome/package.json @@ -4,12 +4,15 @@ "private": true, "type": "module", "scripts": { - "build": "vite build", + "predev": "node ./scripts/copy-html.mjs", "dev": "vite build --watch --mode development", + "prebuild": "node ./scripts/copy-html.mjs", + "build": "vite build", + "pretypecheck": "node ./scripts/copy-html.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", + "pretest:e2e": "node ./scripts/copy-html.mjs && vite build", "e2e": "playwright test", - "e2e:headed": "playwright test --headed", - "pretest:e2e": "vite build" + "e2e:headed": "playwright test --headed" }, "dependencies": { "@gitmarks/core": "workspace:*", diff --git a/packages/extension-chrome/scripts/copy-html.mjs b/packages/extension-chrome/scripts/copy-html.mjs new file mode 100644 index 0000000..86d6f34 --- /dev/null +++ b/packages/extension-chrome/scripts/copy-html.mjs @@ -0,0 +1,17 @@ +import { copyFileSync, mkdirSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const shellRoot = resolve(here, ".."); +const sharedHtmlDir = resolve(shellRoot, "../extension-shared/src"); +const targetDir = resolve(shellRoot, "src"); + +if (!existsSync(sharedHtmlDir)) { + throw new Error(`shared html dir not found: ${sharedHtmlDir}`); +} +mkdirSync(targetDir, { recursive: true }); +for (const file of ["popup.html", "options.html"]) { + copyFileSync(resolve(sharedHtmlDir, file), resolve(targetDir, file)); +} +console.log("[chrome] copied popup.html + options.html from extension-shared"); diff --git a/packages/extension-chrome/src/options.html b/packages/extension-chrome/src/options.html deleted file mode 100644 index 16309e7..0000000 --- a/packages/extension-chrome/src/options.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - - gitmarks — settings - - - -

gitmarks settings

- - - - - - - - - - - -
- - -
- -

- - - - diff --git a/packages/extension-chrome/src/popup.html b/packages/extension-chrome/src/popup.html deleted file mode 100644 index 69a0b72..0000000 --- a/packages/extension-chrome/src/popup.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - gitmarks - - - -
loading…
- - - diff --git a/packages/extension-firefox/.gitignore b/packages/extension-firefox/.gitignore new file mode 100644 index 0000000..aecafef --- /dev/null +++ b/packages/extension-firefox/.gitignore @@ -0,0 +1,2 @@ +src/popup.html +src/options.html diff --git a/packages/extension-firefox/package.json b/packages/extension-firefox/package.json index 0334741..228cede 100644 --- a/packages/extension-firefox/package.json +++ b/packages/extension-firefox/package.json @@ -4,7 +4,11 @@ "private": true, "type": "module", "scripts": { + "predev": "node ./scripts/copy-html.mjs", + "dev": "vite", + "prebuild": "node ./scripts/copy-html.mjs", "build": "vite build && node ./scripts/copy-manifest.mjs", + "pretypecheck": "node ./scripts/copy-html.mjs", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/extension-firefox/scripts/copy-html.mjs b/packages/extension-firefox/scripts/copy-html.mjs new file mode 100644 index 0000000..2f7d1e3 --- /dev/null +++ b/packages/extension-firefox/scripts/copy-html.mjs @@ -0,0 +1,17 @@ +import { copyFileSync, mkdirSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const shellRoot = resolve(here, ".."); +const sharedHtmlDir = resolve(shellRoot, "../extension-shared/src"); +const targetDir = resolve(shellRoot, "src"); + +if (!existsSync(sharedHtmlDir)) { + throw new Error(`shared html dir not found: ${sharedHtmlDir}`); +} +mkdirSync(targetDir, { recursive: true }); +for (const file of ["popup.html", "options.html"]) { + copyFileSync(resolve(sharedHtmlDir, file), resolve(targetDir, file)); +} +console.log("[firefox] copied popup.html + options.html from extension-shared"); diff --git a/packages/extension-firefox/src/options.html b/packages/extension-firefox/src/options.html deleted file mode 100644 index 16309e7..0000000 --- a/packages/extension-firefox/src/options.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - - gitmarks — settings - - - -

gitmarks settings

- - - - - - - - - - - -
- - -
- -

- - - - diff --git a/packages/extension-firefox/src/popup.html b/packages/extension-firefox/src/popup.html deleted file mode 100644 index 69a0b72..0000000 --- a/packages/extension-firefox/src/popup.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - gitmarks - - - -
loading…
- - - From b08b2eb78e984b5f9c873a1ae8c489d96913fc67 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Tue, 26 May 2026 13:26:42 -0400 Subject: [PATCH 02/14] refactor(ext-shared): migrate chrome.* type refs to webextension-polyfill namespace; drop @types/chrome Replaces all type-position chrome.bookmarks.* and chrome.runtime.* references in extension-shared src and tests with Bookmarks.*/Runtime.* from webextension-polyfill. Adds test/globals.d.ts to declare the chrome global for the test stub without @types/chrome. Drops @types/chrome devDep and the "chrome" types entry from tsconfig.json across extension-shared, -chrome, and -firefox. Zero runtime changes; 283 tests still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/extension-chrome/package.json | 1 - packages/extension-chrome/tsconfig.json | 2 +- packages/extension-firefox/package.json | 1 - packages/extension-firefox/tsconfig.json | 2 +- packages/extension-shared/package.json | 1 - .../extension-shared/src/lib/apply-remote.ts | 3 ++- packages/extension-shared/src/lib/listeners.ts | 9 +++++---- packages/extension-shared/src/lib/reconcile.ts | 3 ++- packages/extension-shared/test/globals.d.ts | 10 ++++++++++ packages/extension-shared/test/setup.ts | 17 +++++++++-------- packages/extension-shared/tsconfig.json | 2 +- 11 files changed, 31 insertions(+), 20 deletions(-) create mode 100644 packages/extension-shared/test/globals.d.ts diff --git a/packages/extension-chrome/package.json b/packages/extension-chrome/package.json index a656820..971f13c 100644 --- a/packages/extension-chrome/package.json +++ b/packages/extension-chrome/package.json @@ -22,7 +22,6 @@ "devDependencies": { "@crxjs/vite-plugin": "^2.4.0", "@playwright/test": "^1.48.0", - "@types/chrome": "^0.0.268", "vite": "^5.4.0" } } diff --git a/packages/extension-chrome/tsconfig.json b/packages/extension-chrome/tsconfig.json index 5fab6db..66d69ac 100644 --- a/packages/extension-chrome/tsconfig.json +++ b/packages/extension-chrome/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["chrome", "vite/client"], + "types": ["vite/client"], "rootDir": "./", "outDir": "./dist-tsc", "noEmit": true diff --git a/packages/extension-firefox/package.json b/packages/extension-firefox/package.json index 228cede..0efeb0b 100644 --- a/packages/extension-firefox/package.json +++ b/packages/extension-firefox/package.json @@ -16,7 +16,6 @@ "@gitmarks/extension-shared": "workspace:*" }, "devDependencies": { - "@types/chrome": "^0.0.268", "@types/node": "^22.0.0", "@types/webextension-polyfill": "^0.12.0", "vite": "^5.4.0" diff --git a/packages/extension-firefox/tsconfig.json b/packages/extension-firefox/tsconfig.json index f665ba0..fa958a4 100644 --- a/packages/extension-firefox/tsconfig.json +++ b/packages/extension-firefox/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["chrome", "webextension-polyfill", "node"], + "types": ["webextension-polyfill", "node"], "rootDir": "./", "outDir": "./dist-tsc", "noEmit": true diff --git a/packages/extension-shared/package.json b/packages/extension-shared/package.json index bf7eb15..b174883 100644 --- a/packages/extension-shared/package.json +++ b/packages/extension-shared/package.json @@ -20,7 +20,6 @@ "zod": "^3.23.0" }, "devDependencies": { - "@types/chrome": "^0.0.268", "@types/webextension-polyfill": "^0.12.0", "jsdom": "^25.0.0", "vitest": "^2.0.0" diff --git a/packages/extension-shared/src/lib/apply-remote.ts b/packages/extension-shared/src/lib/apply-remote.ts index 05d1a47..7c158fe 100644 --- a/packages/extension-shared/src/lib/apply-remote.ts +++ b/packages/extension-shared/src/lib/apply-remote.ts @@ -1,4 +1,5 @@ import browser from "webextension-polyfill"; +import type { Bookmarks } from "webextension-polyfill"; import type { BookmarksFile } from "@gitmarks/core"; import { type IdMap, asUlid, asNodeId } from "./id-mapping.js"; import { splitFolderPath } from "./folder-path.js"; @@ -72,7 +73,7 @@ async function applyRemoteEdit( remoteUrl: string, remoteTitle: string, ): Promise { - let current: chrome.bookmarks.BookmarkTreeNode | undefined; + let current: Bookmarks.BookmarkTreeNode | undefined; try { const found = await browser.bookmarks.get(nodeId); current = found[0]; diff --git a/packages/extension-shared/src/lib/listeners.ts b/packages/extension-shared/src/lib/listeners.ts index 44c9d1a..0851ac6 100644 --- a/packages/extension-shared/src/lib/listeners.ts +++ b/packages/extension-shared/src/lib/listeners.ts @@ -1,4 +1,5 @@ import browser from "webextension-polyfill"; +import type { Bookmarks } from "webextension-polyfill"; import type { Bookmark, BookmarksFile, @@ -104,7 +105,7 @@ async function runFlush(): Promise { } } -function onCreated(_id: string, node: chrome.bookmarks.BookmarkTreeNode): void { +function onCreated(_id: string, node: Bookmarks.BookmarkTreeNode): void { if (node.url == null || node.url.length === 0) return; pending.push({ kind: "create", @@ -115,7 +116,7 @@ function onCreated(_id: string, node: chrome.bookmarks.BookmarkTreeNode): void { schedule(); } -function onChanged(id: string, changeInfo: chrome.bookmarks.BookmarkChangeInfo): void { +function onChanged(id: string, changeInfo: Bookmarks.OnChangedChangeInfoType): void { const url = changeInfo.url; const title = changeInfo.title; if (url === undefined && title === undefined) return; @@ -129,11 +130,11 @@ function onChanged(id: string, changeInfo: chrome.bookmarks.BookmarkChangeInfo): schedule(); } -function onMoved(_id: string, _moveInfo: chrome.bookmarks.BookmarkMoveInfo): void { +function onMoved(_id: string, _moveInfo: Bookmarks.OnMovedMoveInfoType): void { // Folder moves are intentionally not pushed from the listener; the periodic reconcile catches folder drift. } -function onRemoved(id: string, removeInfo: chrome.bookmarks.BookmarkRemoveInfo): void { +function onRemoved(id: string, removeInfo: Bookmarks.OnRemovedRemoveInfoType): void { // Only sync bookmarks (URL-bearing nodes), not folders. if (removeInfo.node.url == null || removeInfo.node.url.length === 0) return; pending.push({ kind: "remove", nodeId: id, url: removeInfo.node.url }); diff --git a/packages/extension-shared/src/lib/reconcile.ts b/packages/extension-shared/src/lib/reconcile.ts index 34f8185..28f4893 100644 --- a/packages/extension-shared/src/lib/reconcile.ts +++ b/packages/extension-shared/src/lib/reconcile.ts @@ -1,4 +1,5 @@ import browser from "webextension-polyfill"; +import type { Bookmarks } from "webextension-polyfill"; import type { BookmarksFile, Bookmark, @@ -121,7 +122,7 @@ async function collectLocalBookmarks( } function walk( - node: chrome.bookmarks.BookmarkTreeNode, + node: Bookmarks.BookmarkTreeNode, out: Map, ): void { if (node.url != null && node.url.length > 0) { diff --git a/packages/extension-shared/test/globals.d.ts b/packages/extension-shared/test/globals.d.ts new file mode 100644 index 0000000..a9fc417 --- /dev/null +++ b/packages/extension-shared/test/globals.d.ts @@ -0,0 +1,10 @@ +/** + * Declares the `chrome` global used by test stubs, typed via webextension-polyfill. + * Replaces the ambient `chrome` global that was previously supplied by @types/chrome. + */ +import type Browser from "webextension-polyfill"; + +declare global { + // eslint-disable-next-line no-var + var chrome: typeof Browser; +} diff --git a/packages/extension-shared/test/setup.ts b/packages/extension-shared/test/setup.ts index 481e5e5..8d34acd 100644 --- a/packages/extension-shared/test/setup.ts +++ b/packages/extension-shared/test/setup.ts @@ -1,4 +1,5 @@ import { vi, beforeEach } from "vitest"; +import type { Bookmarks, Runtime } from "webextension-polyfill"; interface StorageBackend { data: Record; @@ -34,18 +35,18 @@ const chromeStub = { openOptionsPage: vi.fn(), sendMessage: vi.fn(), onMessage: { addListener: vi.fn() }, - lastError: undefined as chrome.runtime.LastError | undefined, + lastError: undefined as Runtime.PropertyLastErrorType | undefined, }, bookmarks: { - create: vi.fn(async (props: chrome.bookmarks.BookmarkCreateArg) => { - return { id: `mock-${Math.random().toString(36).slice(2, 10)}`, ...props } as chrome.bookmarks.BookmarkTreeNode; + create: vi.fn(async (props: Bookmarks.CreateDetails) => { + return { id: `mock-${Math.random().toString(36).slice(2, 10)}`, ...props } as Bookmarks.BookmarkTreeNode; }), - update: vi.fn(async () => ({} as chrome.bookmarks.BookmarkTreeNode)), - move: vi.fn(async () => ({} as chrome.bookmarks.BookmarkTreeNode)), + update: vi.fn(async () => ({} as Bookmarks.BookmarkTreeNode)), + move: vi.fn(async () => ({} as Bookmarks.BookmarkTreeNode)), remove: vi.fn(async () => {}), - get: vi.fn(async () => [] as chrome.bookmarks.BookmarkTreeNode[]), - getTree: vi.fn(async () => [] as chrome.bookmarks.BookmarkTreeNode[]), - getSubTree: vi.fn(async () => [] as chrome.bookmarks.BookmarkTreeNode[]), + get: vi.fn(async () => [] as Bookmarks.BookmarkTreeNode[]), + getTree: vi.fn(async () => [] as Bookmarks.BookmarkTreeNode[]), + getSubTree: vi.fn(async () => [] as Bookmarks.BookmarkTreeNode[]), onCreated: { addListener: vi.fn(), removeListener: vi.fn() }, onChanged: { addListener: vi.fn(), removeListener: vi.fn() }, onMoved: { addListener: vi.fn(), removeListener: vi.fn() }, diff --git a/packages/extension-shared/tsconfig.json b/packages/extension-shared/tsconfig.json index 7a3e845..84c0903 100644 --- a/packages/extension-shared/tsconfig.json +++ b/packages/extension-shared/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["chrome"], + "rootDir": "./", "outDir": "./dist-tsc", "noEmit": true From 6543d0a345ffd90243c3108d41ccff39c2fbe0c3 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Tue, 26 May 2026 13:30:14 -0400 Subject: [PATCH 03/14] test(ext-shared): assert against browser.* mock paths to match production calls Co-Authored-By: Claude Sonnet 4.6 --- .../test/apply-remote.test.ts | 48 +++++++++---------- packages/extension-shared/test/globals.d.ts | 6 +-- .../extension-shared/test/listeners.test.ts | 44 ++++++++--------- .../extension-shared/test/machine-id.test.ts | 4 +- .../extension-shared/test/reconcile.test.ts | 12 ++--- .../extension-shared/test/settings.test.ts | 4 +- 6 files changed, 59 insertions(+), 59 deletions(-) diff --git a/packages/extension-shared/test/apply-remote.test.ts b/packages/extension-shared/test/apply-remote.test.ts index 9036b96..4d35810 100644 --- a/packages/extension-shared/test/apply-remote.test.ts +++ b/packages/extension-shared/test/apply-remote.test.ts @@ -36,7 +36,7 @@ describe("applyRemoteChanges", () => { const bm = bookmark({ id: "u1", url: "https://example.com/new" }); const idMap = await IdMap.load(); await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.create).toHaveBeenCalledWith({ + expect(browser.bookmarks.create).toHaveBeenCalledWith({ parentId: BAR, title: "Example", url: "https://example.com/new", @@ -54,13 +54,13 @@ describe("applyRemoteChanges", () => { idMap.set(asUlid("u1"), asNodeId("node-1")); // Current local node has the old title - (chrome.bookmarks.get as any).mockResolvedValueOnce([ + (browser.bookmarks.get as any).mockResolvedValueOnce([ { id: "node-1", parentId: BAR, title: "Old title", url: "https://example.com/" }, ]); await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.update).toHaveBeenCalledWith("node-1", { + expect(browser.bookmarks.update).toHaveBeenCalledWith("node-1", { title: "New title from another device", }); // The URL is unchanged, but we still suppress to avoid an onChanged echo @@ -76,13 +76,13 @@ describe("applyRemoteChanges", () => { const idMap = await IdMap.load(); idMap.set(asUlid("u1"), asNodeId("node-1")); - (chrome.bookmarks.get as any).mockResolvedValueOnce([ + (browser.bookmarks.get as any).mockResolvedValueOnce([ { id: "node-1", parentId: BAR, title: "Same title", url: "https://example.com/old-path" }, ]); await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.update).toHaveBeenCalledWith("node-1", { + expect(browser.bookmarks.update).toHaveBeenCalledWith("node-1", { url: "https://example.com/new-path", }); // Suppress BOTH the new URL (carried in the onChanged echo) AND the old @@ -91,27 +91,27 @@ describe("applyRemoteChanges", () => { expect(isSuppressed("https://example.com/old-path")).toBe(true); }); - it("silently skips a mapped-but-locally-deleted node (chrome.bookmarks.get throws 'not found')", async () => { + it("silently skips a mapped-but-locally-deleted node (browser.bookmarks.get throws 'not found')", async () => { const bm = bookmark({ id: "u1", url: "https://example.com/", title: "Doesn't matter" }); const idMap = await IdMap.load(); idMap.set(asUlid("u1"), asNodeId("node-gone")); - (chrome.bookmarks.get as any).mockRejectedValueOnce( + (browser.bookmarks.get as any).mockRejectedValueOnce( new Error("Can't find bookmark for id."), ); // Should not throw; should not invoke update await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.update).not.toHaveBeenCalled(); + expect(browser.bookmarks.update).not.toHaveBeenCalled(); }); - it("rethrows non-'not found' errors from chrome.bookmarks.get", async () => { + it("rethrows non-'not found' errors from browser.bookmarks.get", async () => { const bm = bookmark({ id: "u1", url: "https://example.com/", title: "Doesn't matter" }); const idMap = await IdMap.load(); idMap.set(asUlid("u1"), asNodeId("node-1")); - (chrome.bookmarks.get as any).mockRejectedValueOnce( + (browser.bookmarks.get as any).mockRejectedValueOnce( new Error("Extension context invalidated."), ); @@ -129,13 +129,13 @@ describe("applyRemoteChanges", () => { const idMap = await IdMap.load(); idMap.set(asUlid("u1"), asNodeId("node-1")); - (chrome.bookmarks.get as any).mockResolvedValueOnce([ + (browser.bookmarks.get as any).mockResolvedValueOnce([ { id: "node-1", parentId: BAR, title: "Same", url: "https://example.com/" }, ]); await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.update).not.toHaveBeenCalled(); + expect(browser.bookmarks.update).not.toHaveBeenCalled(); }); it("does not create a bookmark already mapped", async () => { @@ -143,7 +143,7 @@ describe("applyRemoteChanges", () => { const idMap = await IdMap.load(); idMap.set(asUlid("u1"), asNodeId("node-1")); await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.create).not.toHaveBeenCalled(); + expect(browser.bookmarks.create).not.toHaveBeenCalled(); }); it("removes a chrome node for a tombstoned remote bookmark", async () => { @@ -155,7 +155,7 @@ describe("applyRemoteChanges", () => { const idMap = await IdMap.load(); idMap.set(asUlid("u1"), asNodeId("node-1")); await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.remove).toHaveBeenCalledWith("node-1"); + expect(browser.bookmarks.remove).toHaveBeenCalledWith("node-1"); expect(isSuppressed("https://example.com/")).toBe(true); }); @@ -163,7 +163,7 @@ describe("applyRemoteChanges", () => { const bm = bookmark({ id: "u1", url: "https://example.com/o", folder: "_other" }); const idMap = await IdMap.load(); await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.create).toHaveBeenCalledWith({ + expect(browser.bookmarks.create).toHaveBeenCalledWith({ parentId: OTHER, title: "Example", url: "https://example.com/o", @@ -176,10 +176,10 @@ describe("applyRemoteChanges", () => { // First getSubTree call (under BAR): no existing "Research" folder // Second getSubTree call (under the new Research folder): no existing "AI" - (chrome.bookmarks.getSubTree as any) + (browser.bookmarks.getSubTree as any) .mockResolvedValueOnce([{ id: BAR, children: [] }]) .mockResolvedValueOnce([{ id: "research-id", children: [] }]); - (chrome.bookmarks.create as any) + (browser.bookmarks.create as any) .mockResolvedValueOnce({ id: "research-id", title: "Research" }) // folder 1 .mockResolvedValueOnce({ id: "ai-id", title: "AI" }) // folder 2 .mockResolvedValueOnce({ id: "bm-node", url: bm.url, title: bm.title }); // bookmark @@ -187,19 +187,19 @@ describe("applyRemoteChanges", () => { await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); // Verify the bookmark itself was created under the AI folder - const createCalls = (chrome.bookmarks.create as any).mock.calls; + const createCalls = (browser.bookmarks.create as any).mock.calls; const bmCreate = createCalls.find((c: any) => c[0].url === "https://example.com/nested"); expect(bmCreate).toBeDefined(); expect(bmCreate[0].parentId).toBe("ai-id"); }); - it("saves the id map even when a later chrome.bookmarks.create throws", async () => { + it("saves the id map even when a later browser.bookmarks.create throws", async () => { const bm1 = bookmark({ id: "u1", url: "https://example.com/ok" }); const bm2 = bookmark({ id: "u2", url: "https://example.com/fail" }); const idMap = await IdMap.load(); // First create succeeds, second throws. - (chrome.bookmarks.create as any) + (browser.bookmarks.create as any) .mockResolvedValueOnce({ id: "node-1", url: bm1.url, title: bm1.title }) .mockRejectedValueOnce(new Error("boom")); @@ -217,10 +217,10 @@ describe("applyRemoteChanges", () => { const idMap = await IdMap.load(); // Existing "Reading" folder under BAR - (chrome.bookmarks.getSubTree as any).mockResolvedValueOnce([ + (browser.bookmarks.getSubTree as any).mockResolvedValueOnce([ { id: BAR, children: [{ id: "reading-id", title: "Reading" }] }, ]); - (chrome.bookmarks.create as any).mockResolvedValueOnce({ + (browser.bookmarks.create as any).mockResolvedValueOnce({ id: "bm-node", url: bm.url, title: bm.title, @@ -229,8 +229,8 @@ describe("applyRemoteChanges", () => { await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); // Only one create call (the bookmark itself) — the folder was reused - expect((chrome.bookmarks.create as any).mock.calls.length).toBe(1); - const bmCreate = (chrome.bookmarks.create as any).mock.calls[0]; + expect((browser.bookmarks.create as any).mock.calls.length).toBe(1); + const bmCreate = (browser.bookmarks.create as any).mock.calls[0]; expect(bmCreate[0].parentId).toBe("reading-id"); }); }); diff --git a/packages/extension-shared/test/globals.d.ts b/packages/extension-shared/test/globals.d.ts index a9fc417..db0efec 100644 --- a/packages/extension-shared/test/globals.d.ts +++ b/packages/extension-shared/test/globals.d.ts @@ -1,10 +1,10 @@ /** - * Declares the `chrome` global used by test stubs, typed via webextension-polyfill. - * Replaces the ambient `chrome` global that was previously supplied by @types/chrome. + * Declares the `browser` global used by test stubs, typed via webextension-polyfill. + * test/setup.ts installs this via vi.stubGlobal("browser", chromeStub). */ import type Browser from "webextension-polyfill"; declare global { // eslint-disable-next-line no-var - var chrome: typeof Browser; + var browser: typeof Browser; } diff --git a/packages/extension-shared/test/listeners.test.ts b/packages/extension-shared/test/listeners.test.ts index ba7ce91..4609eca 100644 --- a/packages/extension-shared/test/listeners.test.ts +++ b/packages/extension-shared/test/listeners.test.ts @@ -32,10 +32,10 @@ describe("listeners", () => { getBarOtherIds: async () => ({ bar: BAR, other: OTHER }), getMachineId: async () => machineId, }); - expect(chrome.bookmarks.onCreated.addListener).toHaveBeenCalledTimes(1); - expect(chrome.bookmarks.onChanged.addListener).toHaveBeenCalledTimes(1); - expect(chrome.bookmarks.onMoved.addListener).toHaveBeenCalledTimes(1); - expect(chrome.bookmarks.onRemoved.addListener).toHaveBeenCalledTimes(1); + expect(browser.bookmarks.onCreated.addListener).toHaveBeenCalledTimes(1); + expect(browser.bookmarks.onChanged.addListener).toHaveBeenCalledTimes(1); + expect(browser.bookmarks.onMoved.addListener).toHaveBeenCalledTimes(1); + expect(browser.bookmarks.onRemoved.addListener).toHaveBeenCalledTimes(1); }); it("flush pushes a pending create through GitHubClient.update", async () => { @@ -53,7 +53,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const createListener = (chrome.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; + const createListener = (browser.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; createListener("node-new", { id: "node-new", parentId: BAR, @@ -85,7 +85,7 @@ describe("listeners", () => { suppress("https://suppressed.example/"); - const createListener = (chrome.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; + const createListener = (browser.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; createListener("node-x", { id: "node-x", parentId: BAR, @@ -109,7 +109,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const createListener = (chrome.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; + const createListener = (browser.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; for (let i = 0; i < 5; i++) { createListener(`node-${i}`, { id: `node-${i}`, @@ -150,7 +150,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const createListener = (chrome.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; + const createListener = (browser.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; createListener("node-new", { id: "node-new", parentId: BAR, @@ -205,7 +205,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const changeListener = (chrome.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; + const changeListener = (browser.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; changeListener("node-existing", { title: "New title" }); await flushPending(); @@ -238,8 +238,8 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const createListener = (chrome.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; - const changeListener = (chrome.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; + const createListener = (browser.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; + const changeListener = (browser.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; createListener("node-1", { id: "node-1", @@ -257,7 +257,7 @@ describe("listeners", () => { }); it("onChanged with no URL is suppressed when nodeId is in the node-suppression registry (title-only echo from apply-remote)", async () => { - // Issue #18 finding A: apply-remote's chrome.bookmarks.update({title}) + // Issue #18 finding A: apply-remote's browser.bookmarks.update({title}) // fires onChanged with changeInfo.url === undefined. URL-suppression // doesn't catch the echo. NodeId-suppression does. const update = vi.fn(); @@ -275,7 +275,7 @@ describe("listeners", () => { const { suppressNode } = await import("../src/lib/suppression.js"); suppressNode("node-1"); - const changeListener = (chrome.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; + const changeListener = (browser.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; changeListener("node-1", { title: "new" }); await flushPending(); @@ -294,7 +294,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const removeListener = (chrome.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; + const removeListener = (browser.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; removeListener("never-mapped-node", { parentId: BAR, index: 0, @@ -326,7 +326,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const changeListener = (chrome.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; + const changeListener = (browser.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; changeListener("never-mapped-node", { title: "Whatever" }); await flushPending(); @@ -370,7 +370,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const removeListener = (chrome.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; + const removeListener = (browser.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; removeListener("node-doomed", { parentId: BAR, index: 0, @@ -403,7 +403,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const removeListener = (chrome.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; + const removeListener = (browser.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; removeListener("node-x", { parentId: BAR, index: 0, @@ -431,7 +431,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const removeListener = (chrome.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; + const removeListener = (browser.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; removeListener("folder-node", { parentId: BAR, index: 0, @@ -458,7 +458,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const createListener = (chrome.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; + const createListener = (browser.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; createListener("node-1", { id: "node-1", parentId: BAR, @@ -481,7 +481,7 @@ describe("listeners", () => { it("clears gitmarks:lastError after a successful flush", async () => { // Seed an error - await chrome.storage.local.set({ + await browser.storage.local.set({ "gitmarks:lastError": { when: 1, message: "old", source: "flush" }, }); @@ -499,7 +499,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const createListener = (chrome.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; + const createListener = (browser.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; createListener("node-1", { id: "node-1", parentId: BAR, @@ -510,7 +510,7 @@ describe("listeners", () => { // Advance past the debounce window to trigger runFlush (which clears the error key on success) await vi.advanceTimersByTimeAsync(600); - const stored = await chrome.storage.local.get("gitmarks:lastError"); + const stored = await browser.storage.local.get("gitmarks:lastError"); expect(stored["gitmarks:lastError"]).toBeUndefined(); }); }); diff --git a/packages/extension-shared/test/machine-id.test.ts b/packages/extension-shared/test/machine-id.test.ts index 0d32848..f987c03 100644 --- a/packages/extension-shared/test/machine-id.test.ts +++ b/packages/extension-shared/test/machine-id.test.ts @@ -13,9 +13,9 @@ describe("machine-id", () => { expect(a).toBe(b); }); - it("persists the id in chrome.storage.local under 'gitmarks:machineId'", async () => { + it("persists the id in browser.storage.local under 'gitmarks:machineId'", async () => { const id = await getMachineId(); - const stored = await chrome.storage.local.get("gitmarks:machineId"); + const stored = await browser.storage.local.get("gitmarks:machineId"); expect(stored["gitmarks:machineId"]).toBe(id); }); }); diff --git a/packages/extension-shared/test/reconcile.test.ts b/packages/extension-shared/test/reconcile.test.ts index 0b088c4..5759181 100644 --- a/packages/extension-shared/test/reconcile.test.ts +++ b/packages/extension-shared/test/reconcile.test.ts @@ -47,7 +47,7 @@ describe("reconcile", () => { const read = vi.fn(async () => ({ data: remote, sha: "s0", etag: "" })); const client = fakeClient({ read, update }); - (chrome.bookmarks.getTree as any).mockResolvedValueOnce([ + (browser.bookmarks.getTree as any).mockResolvedValueOnce([ { id: "root", children: [ { id: BAR, title: "Bookmarks Bar", children: [] }, { id: OTHER, title: "Other Bookmarks", children: [] }, @@ -57,7 +57,7 @@ describe("reconcile", () => { const idMap = await IdMap.load(); await reconcile(client, idMap, BAR, OTHER, machineId, nowIso); - expect(chrome.bookmarks.create).toHaveBeenCalledWith({ + expect(browser.bookmarks.create).toHaveBeenCalledWith({ parentId: BAR, title: "Example", url: "https://remote.example/", @@ -78,7 +78,7 @@ describe("reconcile", () => { }); const client = fakeClient({ read, update }); - (chrome.bookmarks.getTree as any).mockResolvedValueOnce([ + (browser.bookmarks.getTree as any).mockResolvedValueOnce([ { id: "root", children: [ { id: BAR, title: "Bookmarks Bar", children: [ { id: "node-1", parentId: BAR, title: "Local", url: "https://local.example/" }, @@ -107,7 +107,7 @@ describe("reconcile", () => { const update = vi.fn(); const client = fakeClient({ read, update }); - (chrome.bookmarks.getTree as any).mockResolvedValueOnce([ + (browser.bookmarks.getTree as any).mockResolvedValueOnce([ { id: "root", children: [ { id: BAR, title: "Bookmarks Bar", children: [ { id: "node-existing", parentId: BAR, title: "Shared", url: "https://shared.example/" }, @@ -119,7 +119,7 @@ describe("reconcile", () => { const idMap = await IdMap.load(); await reconcile(client, idMap, BAR, OTHER, machineId, nowIso); - expect(chrome.bookmarks.create).not.toHaveBeenCalled(); + expect(browser.bookmarks.create).not.toHaveBeenCalled(); expect(update).not.toHaveBeenCalled(); expect(idMap.nodeForUlid(asUlid("u-existing"))).toBe("node-existing"); }); @@ -140,7 +140,7 @@ describe("reconcile", () => { }); const client = fakeClient({ read, write, update }); - (chrome.bookmarks.getTree as any).mockResolvedValueOnce([ + (browser.bookmarks.getTree as any).mockResolvedValueOnce([ { id: "root", children: [ { id: BAR, title: "Bookmarks Bar", children: [ { id: "node-local", parentId: BAR, title: "Local", url: "https://local.example/" }, diff --git a/packages/extension-shared/test/settings.test.ts b/packages/extension-shared/test/settings.test.ts index f6e9def..69f6eba 100644 --- a/packages/extension-shared/test/settings.test.ts +++ b/packages/extension-shared/test/settings.test.ts @@ -20,7 +20,7 @@ describe("settings", () => { it("defaults stripTrackingParams to false when omitted in stored data", async () => { // Legacy stored settings without the field should still parse via Zod's default. - await chrome.storage.local.set({ + await browser.storage.local.set({ "gitmarks:settings": { token: "t", owner: "alice", @@ -33,7 +33,7 @@ describe("settings", () => { }); it("throws SettingsCorruptError when the stored value is malformed", async () => { - await chrome.storage.local.set({ "gitmarks:settings": { not: "valid" } }); + await browser.storage.local.set({ "gitmarks:settings": { not: "valid" } }); await expect(loadSettings()).rejects.toThrow(/invalid/); }); From 183d69ddfb9196c36b80bf13935e80473214f101 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Tue, 26 May 2026 13:36:29 -0400 Subject: [PATCH 04/14] fix(ext): correct e2e prebuild hook name; pin extension-shared types to polyfill --- packages/extension-chrome/package.json | 3 ++- packages/extension-shared/tsconfig.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/extension-chrome/package.json b/packages/extension-chrome/package.json index 971f13c..3e02b2a 100644 --- a/packages/extension-chrome/package.json +++ b/packages/extension-chrome/package.json @@ -10,8 +10,9 @@ "build": "vite build", "pretypecheck": "node ./scripts/copy-html.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", - "pretest:e2e": "node ./scripts/copy-html.mjs && vite build", + "pree2e": "node ./scripts/copy-html.mjs && vite build", "e2e": "playwright test", + "pree2e:headed": "node ./scripts/copy-html.mjs && vite build", "e2e:headed": "playwright test --headed" }, "dependencies": { diff --git a/packages/extension-shared/tsconfig.json b/packages/extension-shared/tsconfig.json index 84c0903..d59e395 100644 --- a/packages/extension-shared/tsconfig.json +++ b/packages/extension-shared/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": ["ES2022", "DOM", "DOM.Iterable"], - + "types": ["webextension-polyfill"], "rootDir": "./", "outDir": "./dist-tsc", "noEmit": true From 72ef6aebc2cd71570db2a46920ba3da19b8f693a Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Tue, 26 May 2026 13:38:10 -0400 Subject: [PATCH 05/14] feat(security): explicit CSP on web index.html and both extension manifests Adds CSP meta tag to the web UI (connect-src restricted to api.github.com, no unsafe-eval, frame-ancestors none) and pins content_security_policy in both Chrome and Firefox MV3 manifests (script-src/object-src 'self' + connect-src https://api.github.com). --- packages/extension-chrome/manifest.config.ts | 3 +++ packages/extension-firefox/manifest.json | 3 +++ packages/web/index.html | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/packages/extension-chrome/manifest.config.ts b/packages/extension-chrome/manifest.config.ts index 583c8b0..6cbaea2 100644 --- a/packages/extension-chrome/manifest.config.ts +++ b/packages/extension-chrome/manifest.config.ts @@ -16,4 +16,7 @@ export default defineManifest({ service_worker: "src/background.ts", type: "module", }, + content_security_policy: { + extension_pages: "script-src 'self'; object-src 'self'; connect-src https://api.github.com", + }, }); diff --git a/packages/extension-firefox/manifest.json b/packages/extension-firefox/manifest.json index 7276473..93a329b 100644 --- a/packages/extension-firefox/manifest.json +++ b/packages/extension-firefox/manifest.json @@ -17,6 +17,9 @@ "service_worker": "background.js", "type": "module" }, + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'; connect-src https://api.github.com" + }, "browser_specific_settings": { "gecko": { "id": "gitmarks@paperhurts.dev", diff --git a/packages/web/index.html b/packages/web/index.html index 73654e6..7a81823 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -3,6 +3,10 @@ + gitmarks From 425b311a2b47683b97f0094d4a99963e1af2df01 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Tue, 26 May 2026 13:40:36 -0400 Subject: [PATCH 06/14] feat(web): sign-out button that clears localStorage and returns to setup Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/components/Layout.tsx | 12 +++++++++++- packages/web/src/routes/ListPage.tsx | 10 +++++++++- packages/web/src/routes/TagsPage.tsx | 10 +++++++++- packages/web/src/routes/TrashPage.tsx | 10 +++++++++- packages/web/test/components.Layout.test.tsx | 19 +++++++++++++++++++ 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/web/src/components/Layout.tsx b/packages/web/src/components/Layout.tsx index 2a8ebb8..3b98b4a 100644 --- a/packages/web/src/components/Layout.tsx +++ b/packages/web/src/components/Layout.tsx @@ -12,6 +12,7 @@ interface Props { status: LayoutStatus; onRefresh: () => void; onExport?: () => void; + onSignOut?: () => void; refreshing: boolean; } @@ -19,7 +20,7 @@ const navLinkBase = "px-3 py-1 rounded"; const navLinkActive = "bg-fog text-cyan"; const navLinkInactive = "text-cyan-soft hover:text-cyan"; -export function Layout({ children, status, onRefresh, onExport, refreshing }: Props) { +export function Layout({ children, status, onRefresh, onExport, onSignOut, refreshing }: Props) { return (
@@ -62,6 +63,15 @@ export function Layout({ children, status, onRefresh, onExport, refreshing }: Pr Export )} + {onSignOut !== undefined && ( + + )} diff --git a/packages/web/src/routes/ListPage.tsx b/packages/web/src/routes/ListPage.tsx index 99f8ff5..aed04e8 100644 --- a/packages/web/src/routes/ListPage.tsx +++ b/packages/web/src/routes/ListPage.tsx @@ -30,6 +30,7 @@ export function ListPage({ client }: Props) { const [query, setQuery] = useState(""); const [selectedTag, setSelectedTag] = useState(null); const [refreshing, setRefreshing] = useState(false); + const [writing, setWriting] = useState(false); const [writeError, setWriteError] = useState(null); const visible = useMemo( @@ -86,16 +87,19 @@ export function ListPage({ client }: Props) { mutator: (f: BookmarksFile) => BookmarksFile, ) { setWriteError(null); + setWriting(true); try { await writeBookmarks(mutator, message); selection.clear(); } catch (err) { setWriteError(err instanceof Error ? err.message : String(err)); + } finally { + setWriting(false); } } return ( - +