Skip to content

feat(extension): durable queue, modular ESM, and CI hardening - #10

Merged
DYAI2025 merged 24 commits into
mainfrom
feat/extension-fix-and-hardening
Apr 30, 2026
Merged

DYAI2025 merged 24 commits into
mainfrom
feat/extension-fix-and-hardening

Conversation

@DYAI2025

@DYAI2025 DYAI2025 commented Apr 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Durable send queue (chrome.storage.session) — survives MV3 service-worker suspension; messages that fail to send are no longer silently lost on worker restart
  • chrome.alarms-based retry with exponential backoff ladder [0.5, 1, 2, 5] min, replacing the old setTimeout approach that died with the worker
  • Extracted pure ESM modules under src/lib/ — each independently tested and covered
  • 89 Vitest tests (was 0); 99% statement coverage across all src/lib/** modules
  • Manifest invariants enforced in CI + minimum_chrome_version: "122" pinned
  • Health probe after Save (coloured dot), drop telemetry, diagnostic export, schema versioning visible in popup
  • @ts-nocheck removed from all entry-point files; JSDoc types complete

Module map

New module Responsibility
src/lib/url.js URL normalisation + validation
src/lib/storage.js Typed chrome.storage wrapper
src/lib/config.js Config schema + load/save
src/lib/queue.js Durable FIFO backed by chrome.storage.session
src/lib/transport.js fetch wrapper with timeout + classified outcomes
src/lib/retry.js Alarm-based exponential backoff
src/lib/heartbeat.js Parallel /api/heartbeat flush
src/lib/dedup.js Rolling-window dedup backed by chrome.storage.local
src/lib/router.js Orchestrates queue + transport + retry

Test plan

  • npm run ci passes (lint + typecheck + manifest + 89 tests + coverage ≥ 90%)
  • Load unpacked in Chrome 122+, open WhatsApp Web, verify green health dot
  • Stop radar-api, send 5 messages, wait 60s (worker suspension), restart — verify all 5 arrive in Postgres within retry window

🤖 Generated with Claude Code

Summary by Sourcery

Introduce a durable, alarm-driven message delivery pipeline for the Chrome extension, refactor background/content/popup scripts onto shared ESM lib modules, and harden configuration, manifest, and diagnostics for MV3.

New Features:

  • Add a durable FIFO send queue backed by chrome.storage.session with alarm-based exponential backoff retry orchestration.
  • Expose a health probe in the popup that validates server connectivity after saving config and surfaces status via a coloured indicator and schema version label.
  • Provide a redacted diagnostic export from the popup, including config snapshot and runtime state, for easier support and debugging.

Bug Fixes:

  • Ensure messages are not silently lost across MV3 service-worker suspension by persisting queue and retry attempt state in chrome.storage.session and alarms instead of in-memory timers.

Enhancements:

  • Refactor background, popup, and content scripts to use shared ESM modules for config, storage, transport, retry, deduplication, and routing.
  • Replace the content-script in-page queue manager with a background router that owns delivery, retry, and backoff logic.
  • Improve heartbeat handling to send per-chat counters in parallel with timeouts and to track queue and dropped-message metrics in snapshots.
  • Enforce manifest invariants such as MV3 ES module usage, WhatsApp-only content script matching, minimal permissions, and a minimum Chrome version of 122.
  • Remove @ts-nocheck from extension entry points and add JSDoc typing improvements across popup and content scripts.

CI:

  • Introduce a manifest invariants test to CI to guard MV3, module, permission, and minimum Chrome version requirements.

Documentation:

  • Add an extension README documenting architecture, module map, storage layout, schema versioning, requirements, and known limitations, and update CLAUDE.md to reflect the new modular ESM design and durable queueing.

Tests:

  • Add a Vitest-based test suite covering lib modules (config, storage, URL handling, dedup, queue, transport, retry, router, heartbeat) plus integration tests for background flow, popup behaviour, diagnostics, and content deduplication, along with manifest invariant checks.

BenPerro and others added 19 commits April 30, 2026 12:34
src/lib/url.js — first ESM module under src/lib/. Pure functions:
- normalizeServerUrl: trim, lowercase scheme/host, strip trailing
  slashes and a trailing /api suffix. Returns '' on unparseable input.
- isValidServerUrl: thin boolean predicate over normalizeServerUrl.

14 tests cover happy paths, edge cases (whitespace, null/undefined,
non-string), and rejection (ftp, malformed URLs, missing host).
100% line/function coverage; coverage threshold now ratchets active.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
createStorage(area) returns { get, set, remove, clear } over
chrome.storage.local or .session. Default-value support distinguishes
'absent' from 'present and undefined' via hasOwnProperty.

8 tests cover: roundtrip, default values, multi-key remove, clear,
local/session isolation, and unknown-area rejection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
src/lib/config.js — load/save/isConfigured over chrome.storage.local
under a versioned key (whatsorga_config_v1). Validates serverUrl via
the url module, normalises whitespace on apiKey, dedupes whitelist
case-insensitively while preserving the first-seen casing.

8 tests cover defaults, roundtrip, invalid URL rejection, partial
patch merge, whitelist edge cases, and boolean coercion. The two
@ts-expect-error markers exist on tests that deliberately exercise
invalid input — JSDoc strict mode catches those at the call site,
which is the behaviour we want for production callers.

100% line/function coverage on config.js + storage.js + url.js.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Delete the legacy MessageQueue/QueueManager module — all durability now
lives in src/lib/queue.js (chrome.storage.session) + src/lib/router.js.
Manifest updated to remove the stale script entry. Fix transport.js catch
clause typing (@type {any} cast). Add @ts-nocheck to background.js and
content.js deferring DOM null-checks to Task 3.6.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix all TypeScript errors in the three entry-point files:
- background.js: guard tab.id against undefined before sendMessage
- content.js: getAttribute ?? fallback, HTMLAudioElement cast, any-cast
  catch clause, window.radarTracker extension via (window as any)
- popup.js: typed getElementById casts, any-cast sendMessage responses,
  any-cast catch clauses; extract el() helper to avoid repetition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…diagnostics

4.1 Health probe: probeHealth() runs against /health after Save;
    result displayed as a coloured dot below the button.

4.2 Schema versioning: eventVersion surfaced in popup; transport
    test asserts payload includes the field.

4.3 Drop telemetry: droppedCount from router.snapshot() shown in
    status grid so silent message loss is visible.

4.4 Diagnostic export: collectDiagnostics() redacts apiKey,
    bundles config + snapshot + userAgent into a downloadable JSON.

All four features covered by new regression tests (84 tests total).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add extension/README.md with full module map, storage map, schema-versioning
note, and known limitations. Update CLAUDE.md extension section to reflect
the new ESM/durable-queue architecture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add 5 assertion tests that protect against accidental manifest regressions:
MV3 version, ESM service worker, ESM content script, permission set, and
Chrome 122+ minimum. Add minimum_chrome_version: "122" to the manifest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 30, 2026 14:32
@sourcery-ai

sourcery-ai Bot commented Apr 30, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Refactors the MV3 extension into a modular ES‑module architecture with a durable alarm-driven send pipeline, configuration/health UX improvements, and CI/type-safety enforcement around manifest and lib modules.

Sequence diagram for popup Save with health probe and diagnostics

sequenceDiagram
  actor User
  participant Popup
  participant Config
  participant BackgroundSW
  participant Router
  participant RadarAPI

  User->>Popup: Click Save
  Popup->>Config: saveConfig(serverUrl, apiKey)
  Config-->>Popup: Config
  Popup->>BackgroundSW: runtime.sendMessage(type:CONFIG_UPDATED)
  BackgroundSW->>BackgroundSW: forwardToContentScripts(CONFIG_UPDATED)
  BackgroundSW-->>Popup: { ok:true }
  Popup->>Config: loadConfig()
  Popup->>RadarAPI: fetch GET serverUrl/health with Authorization
  alt health ok
    RadarAPI-->>Popup: HTTP 200
    Popup->>Popup: update healthDot(success), healthText("OK (200)")
  else health error
    RadarAPI-->>Popup: non-200 or network error
    Popup->>Popup: update healthDot(error), healthText(error message)
  end

  User->>Popup: Click Export diagnostics
  Popup->>Config: loadConfig()
  Popup->>Router: snapshot()
  Router-->>Popup: { queueSize, droppedCount, configured, ... }
  Popup->>Popup: redact apiKey, build diagnostic JSON
  Popup-->>User: Trigger download whatsorga-diag-<timestamp>.json
Loading

Class diagram for new lib modules and router orchestration

classDiagram
  class UrlLib {
    +string normalizeServerUrl(raw)
    +boolean isValidServerUrl(raw)
  }

  class StorageLib {
    +createStorage(area)
    +get(key, defaultValue)
    +set(key, value)
    +remove(keys)
    +clear()
  }

  class ConfigLib {
    +string KEY
    +Config defaults()
    +loadConfig()
    +saveConfig(patch)
    +boolean isConfigured(cfg)
  }

  class QueueLib {
    +createQueue(key, opts)
    +enqueue(item)
    +size()
    +peek(n)
    +drainHead(n)
    +returnHead(items)
    +clear()
    +droppedCount()
    +resetDroppedCount()
  }

  class TransportLib {
    +sendBatch(serverUrl, apiKey, messages, timeoutMs, eventVersion)
  }

  class RetryLib {
    +string ALARM_NAME
    +number backoffMinutes(attempt)
    +scheduleRetry(minutes)
    +clearRetry()
  }

  class HeartbeatLib {
    +runHeartbeat(serverUrl, apiKey, counts, queueSize, timeoutMs)
  }

  class DedupLib {
    +createDedup(key, windowSize, area)
    +isFresh(id)
    +size()
    +clear()
  }

  class RouterLib {
    +createRouter()
    +acceptBatch(messages)
    +retryNow()
    +snapshot()
    +clear()
  }

  class BackgroundJS {
    +createMessageHandler()
    +forwardToContentScripts(msg)
    +bumpHeartbeatCount(chatId)
  }

  class PopupJS {
    +applyServerForm(serverUrl, apiKey)
    +probeHealth(cfg)
    +collectDiagnostics()
  }

  RouterLib --> ConfigLib : uses
  RouterLib --> QueueLib : owns
  RouterLib --> TransportLib : uses
  RouterLib --> RetryLib : uses

  ConfigLib --> StorageLib : uses
  QueueLib --> StorageLib : uses
  DedupLib --> StorageLib : uses

  HeartbeatLib --> TransportLib : uses HTTP

  PopupJS --> ConfigLib : loadConfig/saveConfig
  PopupJS --> RouterLib : snapshot

  BackgroundJS --> RouterLib : createRouter
  BackgroundJS --> HeartbeatLib : runHeartbeat
  BackgroundJS --> ConfigLib : loadConfig
  BackgroundJS --> DedupLib : MESSAGE_CAPTURED counts via storage
  BackgroundJS --> RetryLib : uses ALARM_NAME
Loading

File-Level Changes

Change Details Files
Replace in-memory background queue + setTimeout retries with a durable chrome.storage.session-backed queue orchestrated by a new router module and chrome.alarms-based exponential backoff.
  • Background service worker now imports createRouter, retry alarm constants, heartbeat runner, and config loader instead of maintaining its own queue, timers, and fetch logic.
  • New src/lib/router.js composes config, queue, transport, and retry modules to accept batches, drain the queue on demand, manage backoff attempts in session storage, and expose a status snapshot and clear operation.
  • New src/lib/queue.js implements a durable FIFO queue on chrome.storage.session with a max size, evicts oldest entries when full while tracking a dropped counter, and supports peek/drain/return operations.
  • New src/lib/transport.js wraps fetch to /api/ingest with a timeout and classifies results into ok/auth_error/server_error/client_error/network_error/timeout, propagating eventVersion.
  • New src/lib/retry.js defines a named retry alarm, a ladder-based backoffMinutes function, and helpers to schedule and clear chrome.alarms for retries.
extension/background.js
extension/src/lib/router.js
extension/src/lib/queue.js
extension/src/lib/transport.js
extension/src/lib/retry.js
Introduce modular configuration, URL validation, and storage abstractions to centralize extension state management and enforce manifest/runtime invariants.
  • New src/lib/storage.js wraps chrome.storage.local/session with typed async get/set/remove/clear helpers and default values, used across config, queue, dedup, and tests.
  • New src/lib/url.js provides normalizeServerUrl and isValidServerUrl to sanitize server origins and strip trailing slashes or /api suffixes.
  • New src/lib/config.js defines the persisted config schema (serverUrl, apiKey, whitelist, enabled, eventVersion), normalizes/validates fields on save, and exposes isConfigured.
  • Manifest.json now declares minimum_chrome_version 122, marks the background service worker and content script as ES modules, and drops the legacy queue-manager.js content script.
  • New tests/manifest.test.js asserts MV3 usage, ES module configuration, limited permissions, and minimum Chrome version constraints.
extension/src/lib/storage.js
extension/src/lib/url.js
extension/src/lib/config.js
extension/manifest.json
extension/tests/manifest.test.js
extension/tsconfig.json
Rework popup script and UI to use the new config API, add a health probe, diagnostics export, and surfaced queue metrics.
  • Popup.js now imports loadConfig/saveConfig and createRouter, defines applyServerForm, probeHealth, and collectDiagnostics as testable helpers, and uses them to drive form save, health-dot status, and diagnostic export.
  • Popup DOM wiring is updated to strongly type element lookups, centralize element access via a helper, and to use saveConfig for whitelist and enabled state mutations instead of raw chrome.storage.local calls.
  • Popup status polling now reads background snapshot.droppedCount and displays it, while also wiring a new diagnostics button that downloads a redacted JSON snapshot file.
  • Popup.html adds a health row with a colored dot and text, an event schema version indicator, a dropped-count row in the status section, and a diagnostics export button.
extension/popup.js
extension/popup.html
Refactor the content script to use a storage-backed dedup module and delegate queuing/retry to the background router, simplifying in-page state and removing queue-manager.js.
  • Content.js imports createDedup to manage a rolling sentMessageIds_v2 window in chrome.storage.local and uses it to gate message emission via isFresh and size, removing local Set persistence logic.
  • The internal MessageQueue and retry loop are deleted; sendToAPI now forwards messages directly to the background via NEW_MESSAGES and only separately emits MESSAGE_CAPTURED for heartbeat tracking.
  • GET_STATUS handling now awaits the dedup size for sentCount and returns async status via an IIFE to satisfy the runtime.onMessage contract, while additional typing guards are added around DOM queries and error logging.
  • The obsolete queue-manager.js file is removed from the extension bundle and from tsconfig include list, with the manifest content_scripts entry simplified to just content.js as a module.
extension/content.js
extension/queue-manager.js
extension/manifest.json
extension/tsconfig.json
Add heartbeat and diagnostics plumbing that uses shared config and router state to perform periodic /api/heartbeat calls and expose observable health indicators.
  • Background.js replaces the old heartbeat alarm handler with a new implementation that loads config, reads heartbeatCounts from local storage, queries the router snapshot for queue size, and calls runHeartbeat, persisting remaining counts.
  • New src/lib/heartbeat.js runs parallel POST /api/heartbeat calls with per-chat counters and queueSize, respecting an overall timeout and returning which chatIds were sent vs. remaining.
  • MESSAGE_CAPTURED handling in background.js is factored into bumpHeartbeatCount, which increments per-chat counters in chrome.storage.local under a new constant key.
  • Popup health probe uses /health in probeHealth to set a colored status dot and message after saving config, providing immediate operator feedback.
extension/background.js
extension/src/lib/heartbeat.js
extension/popup.js
Document the new architecture and add a focused test suite around the lib modules and integration flows to harden CI.
  • New extension/README.md documents requirements, architecture, module map, storage map, schema versioning, test commands, and known limitations, aligning with the CLAUDE.md design notes.
  • CLAUDE.md is updated to describe the new dedup, queue, transport, retry, router, and heartbeat modules, and to note the minimum Chrome version requirement and new README reference.
  • A broad set of Vitest tests is added under extension/tests for manifest invariants, lib modules (config, storage, url, queue, dedup, transport, retry, router, heartbeat), and integration behaviors (background flow, content dedup, popup health/diagnostics, dropped queue handling), targeting high coverage for src/lib.
  • CI entrypoint npm run ci is expected to run lint, typecheck, manifest tests, unit tests, and enforce coverage thresholds for src/lib modules.
extension/README.md
CLAUDE.md
extension/tests/manifest.test.js
extension/tests/lib/*.test.js
extension/tests/integration/*.test.js

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • In router.clear() you call queue.clear() but never reset the dropped counter, even though createQueue exposes resetDroppedCount(); consider invoking it there so the "Dropped" metric in the popup reflects the cleared state.
  • In background.js the alarm handler constructs a new createRouter() instance on every tick (and also inside the heartbeat branch); you could reuse the existing router created in createMessageHandler() to avoid redundant instantiation and keep all routing logic going through a single instance.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `router.clear()` you call `queue.clear()` but never reset the dropped counter, even though `createQueue` exposes `resetDroppedCount()`; consider invoking it there so the "Dropped" metric in the popup reflects the cleared state.
- In `background.js` the alarm handler constructs a new `createRouter()` instance on every tick (and also inside the heartbeat branch); you could reuse the existing router created in `createMessageHandler()` to avoid redundant instantiation and keep all routing logic going through a single instance.

## Individual Comments

### Comment 1
<location path="extension/popup.js" line_range="110-115" />
<code_context>

+  // Diagnostic export
+  el('diagBtn').addEventListener('click', async () => {
+    const diag = await collectDiagnostics();
+    const blob = new Blob([JSON.stringify(diag, null, 2)], { type: 'application/json' });
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = `whatsorga-diag-${diag.timestamp}.json`;
+    a.click();
+    URL.revokeObjectURL(url);
</code_context>
<issue_to_address>
**issue (bug_risk):** Using ISO timestamps directly in filenames will introduce illegal characters (e.g. ':' on Windows).

Consider normalizing the timestamp to a filesystem-safe form before using it in the filename, e.g.:

```js
const safeStamp = diag.timestamp.replace(/[:.]/g, '-');
a.download = `whatsorga-diag-${safeStamp}.json`;
```
This will keep the exported file usable across platforms.
</issue_to_address>

### Comment 2
<location path="extension/src/lib/router.js" line_range="94-39" />
<code_context>
+    async size() {
+      return ((await store.get(key, [])) || []).length;
+    },
+    async clear() {
+      await store.set(key, []);
+    },
</code_context>
<issue_to_address>
**suggestion (bug_risk):** CLEAR_QUEUE does not reset the droppedCount metric, which may confuse the popup’s "Dropped" indicator.

`clear()` only empties the stored queue; it doesn’t reset `queue.droppedCount()`, which the popup uses. After “Clear queue”, the UI will still show the old dropped total. Either reset the dropped counter here (e.g. `await queue.resetDroppedCount();`) so “Clear queue” truly clears state, or clarify in the UI label that the dropped count is lifetime/session-based rather than tied to the current queue contents.

Suggested implementation:

```javascript
    async size() {
      return ((await store.get(key, [])) || []).length;
    },
    async clear() {
      await store.set(key, []);
      if (typeof this.resetDroppedCount === 'function') {
        await this.resetDroppedCount();
      }
    },

```

This change assumes that the queue object already has a `resetDroppedCount()` method or that you will add one alongside `droppedCount()` (and that it resets whatever storage the popup reads from). If `resetDroppedCount()` does not exist yet, you should implement it on the same queue object so it zeroes out the metric used by `queue.droppedCount()`, ensuring the popup’s “Dropped” indicator matches the cleared state.
</issue_to_address>

### Comment 3
<location path="extension/src/lib/heartbeat.js" line_range="13-14" />
<code_context>
+  if (!serverUrl || !apiKey) return { sent: [], remaining: { ...counts }, skipped: 'not_configured' };
+
+  const entries = Object.entries(counts).filter(([, n]) => n > 0);
+  const remaining = { ...counts };
+  for (const [chatId] of entries) remaining[chatId] = counts[chatId];
+
+  const sent = [];
</code_context>
<issue_to_address>
**nitpick:** The initialization of `remaining` redundantly reassigns the same values, which can be simplified.

Because `remaining` already copies `counts`, the loop

```js
for (const [chatId] of entries) remaining[chatId] = counts[chatId];
```
never changes any values and can be removed. The later `Object.keys(remaining)` cleanup still handles zero counts, so behavior remains the same while simplifying the code.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread extension/popup.js
Comment on lines +110 to +115
const diag = await collectDiagnostics();
const blob = new Blob([JSON.stringify(diag, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `whatsorga-diag-${diag.timestamp}.json`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Using ISO timestamps directly in filenames will introduce illegal characters (e.g. ':' on Windows).

Consider normalizing the timestamp to a filesystem-safe form before using it in the filename, e.g.:

const safeStamp = diag.timestamp.replace(/[:.]/g, '-');
a.download = `whatsorga-diag-${safeStamp}.json`;

This will keep the exported file usable across platforms.

});
if (result.outcome === 'ok') return { outcome: 'ok' };
if (result.outcome === 'auth_error') {
await clearRetry();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): CLEAR_QUEUE does not reset the droppedCount metric, which may confuse the popup’s "Dropped" indicator.

clear() only empties the stored queue; it doesn’t reset queue.droppedCount(), which the popup uses. After “Clear queue”, the UI will still show the old dropped total. Either reset the dropped counter here (e.g. await queue.resetDroppedCount();) so “Clear queue” truly clears state, or clarify in the UI label that the dropped count is lifetime/session-based rather than tied to the current queue contents.

Suggested implementation:

    async size() {
      return ((await store.get(key, [])) || []).length;
    },
    async clear() {
      await store.set(key, []);
      if (typeof this.resetDroppedCount === 'function') {
        await this.resetDroppedCount();
      }
    },

This change assumes that the queue object already has a resetDroppedCount() method or that you will add one alongside droppedCount() (and that it resets whatever storage the popup reads from). If resetDroppedCount() does not exist yet, you should implement it on the same queue object so it zeroes out the metric used by queue.droppedCount(), ensuring the popup’s “Dropped” indicator matches the cleared state.

Comment on lines +13 to +14
const remaining = { ...counts };
for (const [chatId] of entries) remaining[chatId] = counts[chatId];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: The initialization of remaining redundantly reassigns the same values, which can be simplified.

Because remaining already copies counts, the loop

for (const [chatId] of entries) remaining[chatId] = counts[chatId];

never changes any values and can be removed. The later Object.keys(remaining) cleanup still handles zero counts, so behavior remains the same while simplifying the code.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d23d0adaa9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +23 to +24
const stored = await store.get(KEY, {});
return { ...defaults(), ...stored };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fall back to legacy keys when loading config

loadConfig() now reads only whatsorga_config_v1, so users upgrading from the previous release (which stored serverUrl, apiKey, whitelist, and enabled as top-level chrome.storage.local keys) are treated as unconfigured until they manually re-enter settings. In production this causes background routing to queue everything (not_configured) immediately after update, even though valid settings already exist in storage.

Useful? React with 👍 / 👎.

Comment thread extension/popup.js
Comment on lines +89 to 90
await saveConfig({ whitelist: [...list, name] });
notifyConfigUpdated();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep popup writes compatible with content config reads

The popup now saves whitelist/enabled through saveConfig() (single whatsorga_config_v1 object), but the content script still loads whitelist and enabled from legacy top-level keys in loadConfig() (content.js). After adding contacts or toggling capture in the popup, the content script continues using stale values, so fresh installs can remain effectively non-whitelisted and stop capturing expected chats.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR modernizes the Chrome extension to MV3-friendly durability by moving message delivery to a chrome.storage.session-backed queue with chrome.alarms retries, while extracting core logic into testable ESM modules and adding CI-style invariants/tests.

Changes:

  • Introduces modular src/lib/* ESM utilities (config/storage/url/queue/transport/retry/heartbeat/router) and refactors background/content/popup to use them.
  • Replaces the old in-page queue manager with an alarm-driven durable retry queue designed to survive MV3 service-worker suspension.
  • Adds a comprehensive Vitest suite (unit + integration) and manifest invariants checks; updates manifest for minimum_chrome_version and module scripts.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
extension/manifest.json Pins Chrome minimum version, converts SW + content script to ESM modules, removes legacy queue-manager script
extension/background.js New thin MV3 dispatcher using router/retry/heartbeat modules + alarms
extension/content.js Switches dedup to lib module; routes sends to background router
extension/popup.js Switches to lib-backed config + adds health probe + diagnostic export
extension/popup.html Adds health indicator, schema version, dropped counter, diagnostic export button
extension/src/lib/*.js New core modules for URL/config/storage/queue/transport/retry/heartbeat/router
extension/tests/** New tests for manifest invariants + lib modules + integration flows
extension/tsconfig.json Removes deleted legacy queue-manager entry from compilation inputs
extension/README.md / CLAUDE.md Documents new architecture, storage map, limitations

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread extension/content.js
Comment on lines 63 to 67
async loadConfig() {
const data = await chrome.storage.local.get(['whitelist', 'enabled']);
this.whitelist = data.whitelist || [];
this.enabled = data.enabled !== false;
console.log(`[Radar] Config: enabled=${this.enabled}, whitelist=[${this.whitelist.join(', ')}]`);

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadConfig() still reads chrome.storage.local keys whitelist and enabled, but the popup/background have moved config persistence to the single whatsorga_config_v1 object via src/lib/config.js. As a result the content script won’t see whitelist/enable changes from the popup (whitelist stays empty, capture never triggers). Update the content script to load these values from the new config module (or write the legacy keys as part of saveConfig for backwards compatibility).

Copilot uses AI. Check for mistakes.
Comment thread extension/src/lib/url.js Outdated
Comment thread extension/popup.js Outdated
Comment thread extension/popup.js Outdated
Comment thread extension/background.js Outdated
Comment thread extension/background.js
Comment on lines +55 to +60
async function bumpHeartbeatCount(chatId) {
if (!chatId) return;
const out = await chrome.storage.local.get([HEARTBEAT_COUNTS_KEY]);
const counts = out[HEARTBEAT_COUNTS_KEY] || {};
counts[chatId] = (counts[chatId] || 0) + 1;
await chrome.storage.local.set({ [HEARTBEAT_COUNTS_KEY]: counts });

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bumpHeartbeatCount does a non-atomic read-modify-write on chrome.storage.local. If multiple MESSAGE_CAPTURED events arrive close together, increments can race and some counts can be lost. Consider serializing updates (mutex/promise chain) or batching counts in-memory and flushing on the heartbeat alarm.

Copilot uses AI. Check for mistakes.
Comment thread extension/popup.html
Comment on lines +85 to 86
<button id="diagBtn" class="btn btn-small">Export diagnostics</button>
</section>

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

popup.js now contains ESM import statements, but popup.html still includes it as a classic script (no type="module"). That will cause a syntax error at runtime and break the popup UI. Load popup.js with type="module" (or bundle it to a non-module entry) so the imports work in Chrome.

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +40
async enqueue(item) {
const arr = await read();
arr.push(item);
let dropped = 0;
while (arr.length > maxSize) {
arr.shift();
dropped++;
}
if (dropped > 0) {
const prev = (await store.get(droppedKey, 0)) || 0;
await store.set(droppedKey, prev + dropped);
}
await write(arr);
},

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Queue operations (enqueue, drainHead, returnHead) do a read-modify-write sequence against chrome.storage.session without any locking/serialization. If multiple NEW_MESSAGES arrive concurrently (or an alarm-triggered drain overlaps an enqueue), writes can race and silently drop queued batches. Consider serializing all queue mutations through an internal mutex/promise chain (or adding a version/CAS mechanism) so storage updates are effectively atomic within a service-worker instance.

Copilot uses AI. Check for mistakes.
Comment thread extension/background.js Outdated
DYAI2025 and others added 5 commits April 30, 2026 17:10
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@DYAI2025
DYAI2025 merged commit c89005f into main Apr 30, 2026
4 checks passed
@DYAI2025

Copy link
Copy Markdown
Owner Author

Bug-fix sweep applied — addresses all 7 review findings:

  • C1 queue race: per-instance promise-chain serialization
  • C2 dedup race: same lock pattern
  • C3 retryNow auth_error: untried + previously-failed batches now returned to queue head
  • H1 manifest: dropped https://*/* wildcard host permission
  • M1 queue: 8 MB byte-cap added, README updated
  • M2 manifest: dropped unused activeTab permission
  • L1 popup: diagnostic export click handler wrapped in try/catch

3 new concurrency tests + 2 new manifest invariant tests + 1 new queue byte-cap test + 1 new router auth_error queue-state test. All 97 tests pass.

DYAI2025 pushed a commit that referenced this pull request Apr 30, 2026
Captures the implementation plan for the 7 review findings landed in
this branch (commits 7bbc007..97d5d80) for future reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@DYAI2025

Copy link
Copy Markdown
Owner Author

Connected-Races Sweep — 5 additional fixes on top of the original PR #10 bugfixes

After applying the 7 findings from the code review (C1–C3, H1, M1, M2, L1), a codebase-wide audit found five more read-modify-write races on chrome.storage that shared the same root cause as C1/C2. All five are now fixed and covered by tests.

Root cause

Every await storage.get() → mutate → await storage.set() sequence is non-atomic across concurrent callers (JS is single-threaded but chrome.storage is an IPC bridge; two continuations can both complete the read before either commits the write). The fix is a per-instance promise-chain mutex (src/lib/mutex.js) that serialises each critical section.

Commits in this sweep

Commit Fix
e31c97d Extract mutex helper — createMutex() in src/lib/mutex.js; rewire queue.js (5 sites) and dedup.js (2 sites)
4af5974 Router attempt counter — incrementAttempt / resetAttempt serialised under attemptMutex
d4a8c10 saveConfig — module-level saveMutex serialises all config writes
e3b1a43 Heartbeat counter — bumpHeartbeatCount + the alarm read-runHeartbeat-write chain share heartbeatMutex in background.js
3ee9331 acceptBatch/retryNow consistency — acceptBatch now enqueues before clearRetry() on auth_error, matching retryNow's contract

Test coverage

  • tests/lib/mutex.test.js — ordering, error-recovery, return-value forwarding
  • tests/integration/heartbeat-race.test.js — 100 concurrent bumps land exactly 100 (mutex); deliberately-unprotected version loses increments (validates mock fidelity)
  • Full CI: 106 tests, 99.17% statement coverage, 91.92% branch coverage ✅

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.

3 participants