Skip to content

Add a test suite, migrate to vite/vitest, and fix the match counts - #129

Merged
forgetso merged 3 commits into
masterfrom
tooling-tests-and-count-fixes
Sep 21, 2026
Merged

forgetso merged 3 commits into
masterfrom
tooling-tests-and-count-fixes

Conversation

@forgetso

@forgetso forgetso commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

Bumps the extension to 2.4.0.

The repo had no working tests: jest and ts-jest were installed with no config and no test script, 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 because wordpress.cy.ts needs a dockerised WordPress that CI never starts — which is why stale (failed) screenshots were sitting in the repo.

Tooling

  • vitest replaces jest (jsdom), vite replaces webpack
  • One IIFE bundle per entry point, because an MV3 content script is injected as a classic script and cannot use ESM imports, and rollup won't emit iife for a multi-input build. scripts/build.mjs drives it; vite.config.mts explains why.
  • test / lint / format / typecheck / checks scripts, plus a CI checks job
  • checks ends with npm run build, so dist/ can't silently go stale — it did during development, because both test suites import from src/
  • wordpress.cy.ts excluded from the default e2e run, with npm run test:e2e:wordpress for it
  • TypeScript 4.4 → 5.9. Three tsconfigs now typecheck, including the Cypress specs — whose config listed "dom" as an @types package 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, and searchReplace itself. src/testing/chromeMock.ts is an in-memory chrome.* 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 that replaced never exceeds original, 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 - replaced as matches remaining, and it went negative. Two causes, both the same shape — counting and replacing used different traversals:

  1. ELEMENT_FILTER was unanchored (/(HTML|HEAD|SCRIPT|STYLE|IFRAME)/i), so "HEADER".match(...) matched HEAD. Every site's <header> was excluded from the count while still being replaced.
  2. In HTML mode the count came from a clone with hidden/filtered elements stripped, while the replacement assigned to the original element's innerHTML. The count now comes from the same string that gets rewritten.

getRemainingMatchCount clamps 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

  • listenerContentResponse fired checkPreviousResponse without awaiting it, so the background replied before merging iframe results
  • install.ts asked 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 English
  • saved.ts dereferenced result.storage.saved unguarded — threw on every tab load before the popup had been opened once
  • waitForIframeLoad had no timeout, and searchReplace awaits it, so an iframe that never completes hung the whole search
  • History was capped at 10 before deduplication, silently dropping distinct entries
  • &nbsp; is now found in text mode, where the DOM holds U+00A0 rather than the six literal characters (Cannot replace &nbsp; in HTML #1)
  • The content script is injected into already-open tabs on install and update, so no reload is needed (Inject the content script on page load #102). See Permissions below.
  • Enter runs the replacement, Shift+Enter inserts a line break, so multi-line terms can be typed (Allow replacements of newline characters #28)
  • popup.js, options.js, help.js, util.js and elements.js dropped from content_scripts — only searchreplace.js is a content script; popup.js was registering a message listener on every page you visited
  • noImplicitAny enabled and the resulting 26 errors fixed; searchReplace now takes a typed options object instead of 14 positional arguments (10 of them booleans)

UI

  • The green-and-white colouring reverts to the original greys, keeping the redesign's layout. Colours are now --sr-* custom properties.
  • Original grey magnifying-glass icons restored (byte-identical to the pre-redesign versions)
  • A spacing scale, aligned panel gutters, no fixed-height body panel, action buttons that no longer wrap to different heights, and no empty footer band on the Settings/About/History sections

Permissions

Against the published 2.0.9 — what users actually have installed — this adds one permission and removes one:

live 2.0.9 : activeTab, storage, notifications
this PR    : activeTab, storage, notifications, scripting
  • scripting added, 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.
  • tabs removed. It was added in the unreleased 2.1.0 and was never needed: in manifest v3 a tab's url is exposed to an extension holding the tabs permission or a host permission matching that tab, and http://*/* and https://*/* were already declared. The published 2.0.9 build proves it — it declares no tabs permission yet its background script already reads tab.url to 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 include file:///*. Without a host permission for file URLs those tabs cannot be queried or injected, so file:// pages still need a manual reload after install or update.

docs/permission-justifications.md holds the Chrome Web Store dashboard text for every permission requested, with a code reference for each claim.

Release packaging

Known limitations

Pinned by tests named KNOWN LIMITATION so they surface rather than lurk:

  1. Replace HTML ignores the "Hidden content" checkbox and rewrites hidden text
  2. It rewrites a <script> that shares a container with matching text (a top-level script is safe — <body> is the walk root and is never itself rewritten)
  3. Its pre-replace count under-reports, because the count honours "Hidden content" while the replacement does not

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) and npm 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 declare permissions: contents: read.

2 × js/xss-through-dom (high), at the innerHTML / outerHTML writes in setReplaceTarget. 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 innerHTML do 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 <div or display: 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 setReplaceTarget explaining 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 checks job initially failed on Node 20, where jsdom 30 calls webidl.util.markAsUncloneable in undici — absent on that release. Both workflows now use Node 24, package.json declares engines.node >= 22, and the README notes it.

CI

All five checks pass: checks, e2e, CodeQL, Analyze (actions), Analyze (javascript).

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
- &nbsp; 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>
Comment thread .github/workflows/checks.yml Fixed
Comment thread src/searchreplace.ts Dismissed
Comment thread src/searchreplace.ts Dismissed
forgetso and others added 2 commits September 21, 2026 10:42
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
forgetso merged commit 0725257 into master Sep 21, 2026
5 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants