Repository navigation
Add a test suite, migrate to vite/vitest, and fix the match counts - #129
Merged
Merged
Conversation
The repo had no working tests: jest and ts-jest were installed with no config and no test script, and the single test file was an empty stub. Nothing typechecked or linted on CI either, and the Cypress job had been failing on every run because wordpress.cy.ts needs a dockerised WordPress that CI never starts. Tooling - Replace jest with vitest (jsdom) and webpack with vite; one IIFE bundle per entry, as an MV3 content script cannot use ESM imports - Add test/lint/format/typecheck/checks scripts and a CI checks job - `checks` ends with a build, so dist cannot silently go stale - Exclude wordpress.cy.ts from the default e2e run; it has its own script - TypeScript 4.4 -> 5.9; three tsconfigs now typecheck, including the Cypress specs, whose config listed "dom" as an @types package and so failed outright once anything checked it Tests - 614 unit tests, up from zero, covering regex, util, elements, hints, history, the background listeners and searchReplace itself - searchreplace.invariants.test.ts asserts, over a matrix of page shapes and option sets, that `replaced` never exceeds `original` - counts.cy.ts covers the same ground in a real browser, where CSS actually applies Count fixes (the "-2 matches" report) - ELEMENT_FILTER was unanchored, so HEADER matched HEAD and every site's <header> was dropped from the count while still being replaced - In HTML mode the count came from a filtered clone while the replacement rewrote the original element's innerHTML, so more was replaced than was counted; the count now comes from the same string that is rewritten - getRemainingMatchCount clamps the displayed figure at zero Other fixes - listenerContentResponse fired checkPreviousResponse without awaiting, so the background replied before merging iframe results - install.ts asked the background script for the language list from the background script, which MV3 never delivers, so detection always fell back to English - saved.ts dereferenced result.storage.saved unguarded - waitForIframeLoad had no timeout and could hang the whole search - History was capped at 10 before deduplication, dropping distinct entries - is now matched in text mode, where the DOM holds U+00A0 (#1) - The content script is injected into open tabs on install and update, so no reload is needed (#102); needs the new `scripting` permission - Enter runs the replacement and Shift+Enter inserts a newline, so multi-line terms can be typed (#28) - Drop popup.js, options.js, help.js, util.js and elements.js from content_scripts; only searchreplace.js is a content script - noImplicitAny enabled and the resulting 26 errors fixed; searchReplace takes a typed options object rather than 14 positional arguments UI - Revert the green-and-white colours to the original greys, keeping the redesign's layout; colours are now custom properties - Restore the original grey magnifying-glass icons - Consistent spacing scale, aligned panel gutters, no fixed-height body panel, buttons that no longer wrap, and no empty footer band Known limitations, pinned by tests named KNOWN LIMITATION: Replace HTML ignores the "Hidden content" checkbox and rewrites a script that shares a container with matching text, and its pre-replace count under-reports. All three need the excluded subtrees preserved across the rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The checks job failed on Node 20: jsdom 30 reaches for webidl.util.markAsUncloneable in undici, which that Node does not have. Both workflows now use Node 24, matching what the test suite is developed against, and package.json records the floor in `engines`. CodeQL also flagged the new workflow for not restricting the default GITHUB_TOKEN, so both now declare `contents: read`. Its two js/xss-through-dom findings are the pre-existing Replace HTML data flow, which was previously invisible to analysis behind a computed property assignment. Documented at setReplaceTarget rather than changed, as re-parsing the page's own markup is what the option does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
forgetso
added a commit
that referenced
this pull request
Sep 21, 2026
* Package releases with the manifest at the zip root The release script zipped ./dist, so every entry was nested under dist/ and the Chrome Web Store rejects an upload whose manifest.json is not at the root. It now zips the contents of dist instead, and builds first so an artifact cannot be cut from a stale dist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Record the Chrome Web Store permission justifications The 2.4.0 submission adds the tabs and scripting permissions, so the store asks for a justification for each. Keeping them in the repo next to the code they describe means they can be checked against it and reused on the next submission. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Trim the permission justifications to dashboard length Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Drop the tabs permission; host permissions already cover tab URLs The tabs permission was added in the unreleased 2.1.0. It is not needed: in manifest v3 a tab's url is exposed to an extension holding either the tabs permission or a host permission matching that tab, and this extension already declares http://*/* and https://*/*. The published 2.0.9 build is the proof. It declares only activeTab, storage and notifications, yet its background script already reads tab.url to match saved rules against the current page, and has been doing so in production. Keeping tabs would have added the "Read your browsing history" warning, which Chrome shows on update and which disables the extension until the user approves it. Relative to the live build, 2.4.0 now adds only `scripting`, which carries no warning at all. The injection query now uses the host permission patterns rather than the content script's `matches`, which also include file:///*. Without a host permission for file URLs those tabs cannot be queried or injected, so file:// pages still need a manual reload. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Bump to 2.4.1 master was merged carrying 2.4.0 with the tabs permission still in the manifest, so that version number would describe two different builds. 2.4.1 is what actually ships: no tabs permission, and a release script that produces a zip the Chrome Web Store will accept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bumps the extension to 2.4.0.
The repo had no working tests:
jestandts-jestwere installed with no config and notestscript, and the only test file was an empty stub with its assertion commented out. Nothing typechecked or linted on CI, and the Cypress job had been red on every run becausewordpress.cy.tsneeds a dockerised WordPress that CI never starts — which is why stale(failed)screenshots were sitting in the repo.Tooling
iifefor a multi-input build.scripts/build.mjsdrives it;vite.config.mtsexplains why.test/lint/format/typecheck/checksscripts, plus a CI checks jobchecksends withnpm run build, sodist/can't silently go stale — it did during development, because both test suites import fromsrc/wordpress.cy.tsexcluded from the default e2e run, withnpm run test:e2e:wordpressfor it"dom"as an@typespackage and failed outright the moment anything checked it.Tests
614 unit tests, up from zero, covering
regex,util,elements,hints,history, the background message listeners, andsearchReplaceitself.src/testing/chromeMock.tsis an in-memorychrome.*double so the background scripts are testable at all.Two suites are worth singling out:
searchreplace.invariants.test.ts— a matrix of 14 page shapes × 10 option sets asserting thatreplacednever exceedsoriginal, both are non-negative, and a full replace leaves nothing behind. It found four distinct bug classes; the original report only described one.counts.cy.ts— the same ground in a real browser, with a fixture that hides content via a stylesheet class the way real sites do. jsdom applies no CSS, so it cannot model that.The "-2 matches" bug
The popup renders
original - replacedas matches remaining, and it went negative. Two causes, both the same shape — counting and replacing used different traversals:ELEMENT_FILTERwas unanchored (/(HTML|HEAD|SCRIPT|STYLE|IFRAME)/i), so"HEADER".match(...)matchedHEAD. Every site's<header>was excluded from the count while still being replaced.innerHTML. The count now comes from the same string that gets rewritten.getRemainingMatchCountclamps the displayed figure at zero as a backstop.Verified against the live Guardian by injecting the real code over CDP: 4 replaced, 0 occurrences left, popup shows
0.Other fixes
listenerContentResponsefiredcheckPreviousResponsewithout awaiting it, so the background replied before merging iframe resultsinstall.tsasked the background script for the language list from the background script; MV3 never delivers a worker its own messages, so detection always fell back to Englishsaved.tsdereferencedresult.storage.savedunguarded — threw on every tab load before the popup had been opened oncewaitForIframeLoadhad no timeout, andsearchReplaceawaits it, so an iframe that never completes hung the whole search is now found in text mode, where the DOM holds U+00A0 rather than the six literal characters (Cannot replace in HTML #1)popup.js,options.js,help.js,util.jsandelements.jsdropped fromcontent_scripts— onlysearchreplace.jsis a content script;popup.jswas registering a message listener on every page you visitednoImplicitAnyenabled and the resulting 26 errors fixed;searchReplacenow takes a typed options object instead of 14 positional arguments (10 of them booleans)UI
--sr-*custom properties.Permissions
Against the published 2.0.9 — what users actually have installed — this adds one permission and removes one:
scriptingadded, for injecting the content script into already-open tabs (Inject the content script on page load #102). It carries no permission warning, so the update installs without prompting anyone.tabsremoved. It was added in the unreleased 2.1.0 and was never needed: in manifest v3 a tab'surlis exposed to an extension holding thetabspermission or a host permission matching that tab, andhttp://*/*andhttps://*/*were already declared. The published 2.0.9 build proves it — it declares notabspermission yet its background script already readstab.urlto match saved rules in production. Keeping it would have added the "Read your browsing history" warning, which disables the extension on update until the user approves it.One consequence: the injection query now uses the host permission patterns rather than the content script's
matches, which also includefile:///*. Without a host permission for file URLs those tabs cannot be queried or injected, sofile://pages still need a manual reload after install or update.docs/permission-justifications.mdholds the Chrome Web Store dashboard text for every permission requested, with a code reference for each claim.Release packaging
npm run releasepreviously zipped./dist, nesting every entry underdist/— the Chrome Web Store rejects a zip whosemanifest.jsonis not at the root. It now zips the contents ofdist, and builds first so an artifact cannot be cut from a staledist.Known limitations
Pinned by tests named
KNOWN LIMITATIONso they surface rather than lurk:<script>that shares a container with matching text (a top-level script is safe —<body>is the walk root and is never itself rewritten)All three share a root cause. An earlier attempt to fix them by skipping elements with excluded descendants broke replacement entirely on real pages — almost every container on a news site holds a script or hidden element, so text directly inside them became unreachable. The real fix is to preserve those subtrees across the rewrite (swap for placeholders, replace, restore) rather than avoid their ancestors. Deliberately left for a follow-up.
Verification
npm run checks(typecheck × 3 projects, lint, format, 614 unit tests, build) andnpm run test:e2e(28 Cypress tests) both pass. The built extension loads in Chrome with no manifest or service-worker errors.Happy to split this into separate PRs (tooling / UI / fixes) if you'd rather review it in pieces.
🤖 Generated with Claude Code
CodeQL
The initial run flagged three alerts. All are resolved:
actions/missing-workflow-permissions(medium) — valid, against the new workflow. Both workflows now declarepermissions: contents: read.2 ×
js/xss-through-dom(high), at theinnerHTML/outerHTMLwrites insetReplaceTarget. Not new behaviour. The same assignment previously went through a computed property —nodeElement[config.searchTarget] = newValue— which static analysis could not follow. Naming the property explicitly, as part of removing the string-index escape hatches, made the existing flow visible.The flow it describes is real: markup read from the page has the user's replacement substituted in and is written back, so it is re-parsed as HTML. That is also exactly what the "Replace HTML" option does. It runs only on the page the user is viewing, at their explicit request, with a replacement they typed; no content crosses an origin boundary, the page gains no capability it did not already have, and scripts inserted via
innerHTMLdo not execute.No code-level fix exists without removing the feature: breaking the taint flow means not reading and rewriting markup, after which Replace HTML can no longer match tags or attributes — and searching for
<divordisplay: none;are both existing, tested use cases.Both are therefore dismissed as "won't fix", with the reasoning recorded on each alert and a comment on
setReplaceTargetexplaining why the write is intentional. If you would rather they stayed open, reopen them from the Security tab; nothing in the code depends on the dismissal.Node version
The
checksjob initially failed on Node 20, where jsdom 30 callswebidl.util.markAsUncloneablein undici — absent on that release. Both workflows now use Node 24,package.jsondeclaresengines.node >= 22, and the README notes it.CI
All five checks pass:
checks,e2e,CodeQL,Analyze (actions),Analyze (javascript).