diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 9bb046e0f..5cfc4e9a7 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -58,6 +58,7 @@ "commands", "sidePanel", "bookmarks", + "bookmarksDiagnostic", "history", "downloads", "downloadsDiagnostic", @@ -79,6 +80,19 @@ "download-not-evaluated", } ) +BOOKMARK_DIAGNOSTIC_VALUES = frozenset( + { + "bookmark-source-rejected", + "bookmark-create-rejected", + "bookmark-get-missing", + "bookmark-id-mismatch", + "bookmark-title-mismatch", + "bookmark-url-mismatch", + "bookmark-remove-rejected", + "bookmark-complete-ready", + "bookmark-not-evaluated", + } +) WEBDRIVER_ERROR_CODES = frozenset( { "invalid argument", @@ -129,6 +143,8 @@ def _safe_surface_value(key: str, value: str) -> str: return value if value.isdecimal() and len(value) <= 20 else "invalid" if key == "downloadsDiagnostic": return value if value in DOWNLOAD_DIAGNOSTIC_VALUES else "unexpected" + if key == "bookmarksDiagnostic": + return value if value in BOOKMARK_DIAGNOSTIC_VALUES else "unexpected" return value if value in SURFACE_EVIDENCE_VALUES else "unexpected" @@ -330,6 +346,8 @@ def _wait_for_extension_evidence( commands: document.documentElement.dataset.originweaveCommands || "missing", sidePanel: document.documentElement.dataset.originweaveSidePanel || "missing", bookmarks: document.documentElement.dataset.originweaveBookmarks || "missing", + bookmarksDiagnostic: + document.documentElement.dataset.originweaveBookmarksDiagnostic || "bookmark-not-evaluated", history: document.documentElement.dataset.originweaveHistory || "missing", downloads: document.documentElement.dataset.originweaveDownloads || "missing", downloadsDiagnostic: @@ -350,6 +368,7 @@ def _wait_for_extension_evidence( "commands": "ready", "sidePanel": "ready", "bookmarks": "ready", + "bookmarksDiagnostic": "bookmark-complete-ready", "history": "ready", "downloads": "ready", "downloadsDiagnostic": "download-complete-ready", diff --git a/tests/fixtures/mv3_basic/content_script.js b/tests/fixtures/mv3_basic/content_script.js index b70d1a27f..352dc6393 100644 --- a/tests/fixtures/mv3_basic/content_script.js +++ b/tests/fixtures/mv3_basic/content_script.js @@ -26,6 +26,8 @@ document.documentElement.dataset.originweaveCommands = response?.commands ?? "missing"; document.documentElement.dataset.originweaveSidePanel = response?.sidePanel ?? "missing"; document.documentElement.dataset.originweaveBookmarks = response?.bookmarks ?? "missing"; + document.documentElement.dataset.originweaveBookmarksDiagnostic = + response?.bookmarksDiagnostic ?? "bookmark-not-evaluated"; document.documentElement.dataset.originweaveHistory = response?.history ?? "missing"; document.documentElement.dataset.originweaveDownloads = response?.downloads ?? "missing"; document.documentElement.dataset.originweaveDownloadsDiagnostic = diff --git a/tests/fixtures/mv3_basic/service_worker.js b/tests/fixtures/mv3_basic/service_worker.js index 45ccbaf85..276157f4e 100644 --- a/tests/fixtures/mv3_basic/service_worker.js +++ b/tests/fixtures/mv3_basic/service_worker.js @@ -94,6 +94,71 @@ async function exerciseDownload(sender) { return waitForDownload(downloadId, url); } +async function exerciseBookmarkMutation(sender) { + const sourceUrl = sender?.tab?.url; + if (typeof sourceUrl !== "string") { + return { ready: false, diagnostic: "bookmark-source-rejected" }; + } + + let parsed; + try { + parsed = new URL(sourceUrl); + } catch (_error) { + return { ready: false, diagnostic: "bookmark-source-rejected" }; + } + if ( + parsed.protocol !== "http:" || + parsed.hostname !== "127.0.0.1" || + parsed.pathname !== "/page.html" || + parsed.username !== "" || + parsed.password !== "" + ) { + return { ready: false, diagnostic: "bookmark-source-rejected" }; + } + + const title = "OriginWeave MV3 compatibility bookmark"; + let bookmarkId; + try { + const created = await chrome.bookmarks.create({ title, url: sourceUrl }); + const createdId = created?.id; + if (typeof createdId !== "string" || createdId.length === 0) { + return { ready: false, diagnostic: "bookmark-create-rejected" }; + } + bookmarkId = createdId; + } catch (_error) { + return { ready: false, diagnostic: "bookmark-create-rejected" }; + } + + let diagnostic = "bookmark-get-missing"; + let bookmarkMutationReady = false; + try { + const nodes = await chrome.bookmarks.get(bookmarkId); + if (!Array.isArray(nodes) || nodes.length !== 1) { + diagnostic = "bookmark-get-missing"; + } else if (nodes[0]?.id !== bookmarkId) { + diagnostic = "bookmark-id-mismatch"; + } else if (nodes[0]?.title !== title) { + diagnostic = "bookmark-title-mismatch"; + } else if (nodes[0]?.url !== sourceUrl) { + diagnostic = "bookmark-url-mismatch"; + } else { + diagnostic = "bookmark-complete-ready"; + bookmarkMutationReady = true; + } + } catch (_error) { + diagnostic = "bookmark-get-missing"; + bookmarkMutationReady = false; + } finally { + try { + await chrome.bookmarks.remove(bookmarkId); + } catch (_error) { + diagnostic = "bookmark-remove-rejected"; + bookmarkMutationReady = false; + } + } + return { ready: bookmarkMutationReady, diagnostic }; +} + async function exerciseCoreApis(sender) { const tabId = sender?.tab?.id; if (!Number.isInteger(tabId)) { @@ -124,8 +189,8 @@ async function exerciseCoreApis(sender) { const sidePanelOptions = await chrome.sidePanel.getOptions({ tabId }); const sidePanelReady = sidePanelOptions?.path === "side_panel.html"; - const bookmarkTree = await chrome.bookmarks.getTree(); - const bookmarksReady = Array.isArray(bookmarkTree) && bookmarkTree.length > 0; + const bookmarkResult = await exerciseBookmarkMutation(sender); + const bookmarksReady = bookmarkResult.ready; const historyItems = await chrome.history.search({ text: "", @@ -144,6 +209,7 @@ async function exerciseCoreApis(sender) { commands: commandsReady ? "ready" : "missing", sidePanel: sidePanelReady ? "ready" : "missing", bookmarks: bookmarksReady ? "ready" : "missing", + bookmarksDiagnostic: bookmarkResult.diagnostic, history: historyReady ? "ready" : "missing", downloads: downloadsReady ? "ready" : "missing", downloadsDiagnostic: downloadResult.diagnostic, @@ -173,6 +239,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { commands: "missing", sidePanel: "missing", bookmarks: "missing", + bookmarksDiagnostic: "bookmark-not-evaluated", history: "missing", downloads: "missing", downloadsDiagnostic: "download-not-evaluated", diff --git a/tests/test_mv3_bookmark_mutation_contract.py b/tests/test_mv3_bookmark_mutation_contract.py new file mode 100644 index 000000000..f5f3e2feb --- /dev/null +++ b/tests/test_mv3_bookmark_mutation_contract.py @@ -0,0 +1,125 @@ +"""Fail-first contract for real Manifest V3 bookmark mutation compatibility.""" + +from __future__ import annotations + +import importlib.util +import json +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests" / "fixtures" / "mv3_basic" +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +def _load_runner_module(): + """Load the compatibility runner without invoking its command-line entry point.""" + + spec = importlib.util.spec_from_file_location("originweave_mv3_runner", RUNNER) + if spec is None or spec.loader is None: + raise AssertionError("unable to load the MV3 compatibility runner") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class ManifestV3BookmarkMutationContractTests(unittest.TestCase): + """Require one bounded create/read/delete bookmark lifecycle in real Chromium.""" + + def test_fixture_declares_bookmarks_permission(self) -> None: + """The controlled extension must explicitly request bookmark authority.""" + + manifest = json.loads((FIXTURE / "manifest.json").read_text(encoding="utf-8")) + self.assertIn("bookmarks", manifest["permissions"]) + + def test_service_worker_executes_bounded_bookmark_mutation_lifecycle(self) -> None: + """Compatibility evidence must require create/read/delete, not only tree reads.""" + + worker = (FIXTURE / "service_worker.js").read_text(encoding="utf-8") + for expected in ( + "exerciseBookmarkMutation", + "chrome.bookmarks.create", + "chrome.bookmarks.get", + "chrome.bookmarks.remove", + '"OriginWeave MV3 compatibility bookmark"', + "bookmarkMutationReady", + ): + with self.subTest(expected=expected): + self.assertIn(expected, worker) + + def test_bookmark_mutation_is_bound_to_controlled_fixture_url_and_cleanup(self) -> None: + """The fixture must not mutate bookmarks for an arbitrary sender or leave residue.""" + + worker = (FIXTURE / "service_worker.js").read_text(encoding="utf-8") + for expected in ( + 'parsed.protocol !== "http:"', + 'parsed.hostname !== "127.0.0.1"', + 'parsed.pathname !== "/page.html"', + "finally", + "chrome.bookmarks.remove", + ): + with self.subTest(expected=expected): + self.assertIn(expected, worker) + self.assertNotIn("_error.message", worker) + self.assertNotIn("String(_error)", worker) + + def test_bookmark_failures_emit_only_bounded_stage_diagnostics(self) -> None: + """Every bookmark stage must return a reviewed token without raw browser values.""" + + worker = (FIXTURE / "service_worker.js").read_text(encoding="utf-8") + for expected in ( + "bookmark-source-rejected", + "bookmark-create-rejected", + "bookmark-get-missing", + "bookmark-id-mismatch", + "bookmark-title-mismatch", + "bookmark-url-mismatch", + "bookmark-remove-rejected", + "bookmark-complete-ready", + "bookmarksDiagnostic", + ): + with self.subTest(expected=expected): + self.assertIn(expected, worker) + self.assertNotIn("_error.message", worker) + self.assertNotIn("String(_error)", worker) + + def test_runner_preserves_only_reviewed_bookmark_diagnostic_tokens(self) -> None: + """Trial evidence must reduce arbitrary bookmark diagnostics to `unexpected`.""" + + runner = _load_runner_module() + approved = { + "bookmark-source-rejected", + "bookmark-create-rejected", + "bookmark-get-missing", + "bookmark-id-mismatch", + "bookmark-title-mismatch", + "bookmark-url-mismatch", + "bookmark-remove-rejected", + "bookmark-complete-ready", + "bookmark-not-evaluated", + } + self.assertIn("bookmarksDiagnostic", runner.SURFACE_EVIDENCE_KEYS) + self.assertEqual(runner.BOOKMARK_DIAGNOSTIC_VALUES, frozenset(approved)) + for token in approved: + with self.subTest(token=token): + self.assertEqual( + runner._safe_surface_value("bookmarksDiagnostic", token), token + ) + + for raw in ( + "OriginWeave MV3 compatibility bookmark", + "Error: secret bookmark failure", + ): + with self.subTest(raw=raw): + error = runner.CompatibilitySurfaceError( + {"bookmarks": "missing", "bookmarksDiagnostic": raw} + ) + evidence = runner._failure_evidence(error) + self.assertEqual( + evidence["observed"]["bookmarksDiagnostic"], "unexpected" + ) + self.assertNotIn(raw, repr(evidence)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mv3_bookmarks_history_contract.py b/tests/test_mv3_bookmarks_history_contract.py index 1c7c2f7fa..164105adc 100644 --- a/tests/test_mv3_bookmarks_history_contract.py +++ b/tests/test_mv3_bookmarks_history_contract.py @@ -1,4 +1,4 @@ -"""Fail-first contract for real Manifest V3 bookmarks and history compatibility.""" +"""Compatibility contract for real Manifest V3 bookmarks and history surfaces.""" from __future__ import annotations @@ -12,7 +12,7 @@ class ManifestV3BookmarksHistoryContractTests(unittest.TestCase): - """Require two additional read-only Chrome API surfaces in the real browser lane.""" + """Require bounded bookmarks mutation plus read-only history compatibility evidence.""" def test_fixture_declares_bookmarks_and_history_permissions(self) -> None: """The controlled fixture must request the APIs it exercises.""" @@ -22,11 +22,16 @@ def test_fixture_declares_bookmarks_and_history_permissions(self) -> None: with self.subTest(permission=permission): self.assertIn(permission, manifest["permissions"]) - def test_service_worker_exercises_read_only_bookmarks_and_history_apis(self) -> None: - """Compatibility evidence must come from executing the real extension APIs.""" + def test_service_worker_exercises_bookmark_lifecycle_and_history_api(self) -> None: + """Compatibility evidence must execute bounded bookmark and history operations.""" worker = (FIXTURE / "service_worker.js").read_text(encoding="utf-8") - for expected in ("chrome.bookmarks.getTree", "chrome.history.search"): + for expected in ( + "chrome.bookmarks.create", + "chrome.bookmarks.get", + "chrome.bookmarks.remove", + "chrome.history.search", + ): with self.subTest(expected=expected): self.assertIn(expected, worker)