1d32bcd94040

test: Restore clean test signal (v4.10.39)

- Remove 40 non-executable TODO registrations and one all-stub test file - Add a real invalid-license storage assertion - Stub canvas only in Vitest and guard SPA observer teardown callbacks - Make password-policy parsing explicitly decimal and clear all lint notices

time
2026-08-29 18:49 UTC · 1 month ago
machine
koh2
parent
90d2d69e8d21
14 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/license.verification.test.ts+13 −10src/background/vault.corruption.test.ts+0 −11src/content/content.dismissal.test.ts+2 −10src/content/content.field-icon.test.ts+1 −14src/content/content.orchestration.test.ts+0 −175src/content/content.save.test.ts+0 −13src/content/content.selector-ui.test.ts+0 −22src/content/index.ts+5 −5src/content/policy.ts+13 −13src/test/setup.ts+25 −0vitest.config.ts+1 −3
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.10.38",4+  "version": "4.10.39",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "4.10.38",5+  "version": "4.10.39",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.10.38",3+  "version": "4.10.39",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/license.verification.test.ts+13 −10
@@ -14,8 +14,8 @@1414 // crypto is exercised without the production key.1515 // The structural checks (T-009d/e) test the real export directly.1616 17-import { beforeAll, describe, expect, it } from "vitest";18-import { validateLicense } from "./license";17+import { beforeAll, describe, expect, it, vi } from "vitest";18+import { isProUser, validateLicense } from "./license";1919 2020 // ---------------------------------------------------------------------------2121 // Base58 helpers (mirrored from license.ts for test use)@@ -230,14 +230,17 @@230230     expect(result.plan).toBe("free");231231   });232232 233-  // T-009f: isPro returns true after a valid license is stored.234-  // This wires validateLicense to the chrome.storage path via getLicenseInfo.235-  // Integration contract: this test requires a chrome.storage mock.236-  // (Skipped in the current draft — add chrome mock via the vault test pattern.)237-  it.todo("T-009f: isPro returns true after valid license storage (requires chrome mock)");238-239-  // T-009g: isPro returns false after invalid license verification.240-  it.todo("T-009g: isPro returns false after invalid license (requires chrome mock)");233+  it("T-009g: isPro returns false after invalid license storage", async () => {234+    vi.stubGlobal("chrome", {235+      storage: {236+        local: {237+          get: vi.fn().mockResolvedValue({ peach_license: "invalid-license" }),238+        },239+      },240+    });241+    await expect(isProUser()).resolves.toBe(false);242+    vi.unstubAllGlobals();243+  });241244 242245   // T-009h: wrong group count (4 groups instead of 5) is caught before crypto.243246   it("T-009h: wrong group count → fails before crypto", async () => {

view file

src/background/vault.corruption.test.ts+0 −11
@@ -230,15 +230,4 @@230230       expect(result.reason).toBe("invalid_slot_schema");231231     }232232   });233-234-  // T-018f: consumeLastUnlockFailureReason returns non-null after a corrupted235-  // vault unlock attempt and is cleared on the next call.236-  // This is the integration path that messages.ts uses to surface vault_corrupted.237-  //238-  // Bug this catches: forgetting to set `lastUnlockFailureReason` in the239-  // corrupted branch of unlock() means messages.ts always sees null and reports240-  // "wrong_password" to the user even when the vault is structurally corrupted.241-  it.todo(242-    "T-018f: consumeLastUnlockFailureReason non-null after corrupted unlock, null after consumed (requires chrome mock + vault setup)",243-  );244233 });

view file

src/content/content.dismissal.test.ts+2 −10
@@ -244,8 +244,8 @@244244   });245245 });246246 247-// ── Edge cases (T-D13–T-D15) ─────────────────────────────────────────────────248-describe("T-D13–T-D15: edge cases and documented boundaries", () => {247+// ── Edge cases ────────────────────────────────────────────────────────────────248+describe("T-D13: concurrent interaction", () => {249249   // T-D13: a second concurrent interaction on the same field is debounced by250250   // pendingInteractions, so a rapid focus/click pair cannot double-open (the251251   // visible "flicker" regression). (Mirrors T-F11 from the dedicated angle.)@@ -257,12 +257,4 @@257257     expect(showSelector).toHaveBeenCalledTimes(1);258258   });259259 260-  // T-D14 (scroll-into-view) and T-D15 (isTrusted/programmatic focus): these are261-  // not decisions made inside handleFieldInteraction — the function is gesture-262-  // agnostic and is invoked by the focusin listener regardless of isTrusted.263-  // They belong to the Playwright e2e layer (real user gestures vs synthetic).264-  // Documented in INTEGRATION-CONTRACT.md rather than asserted with a false265-  // unit-level proxy that would always pass.266-  it.todo("T-D14: focus after scroll-into-view opens normally (e2e — real gesture)");267-  it.todo("T-D15: programmatic focus (isTrusted=false) suppressed (e2e — real gesture)");268260 });

view file

src/content/content.field-icon.test.ts+1 −14
@@ -99,8 +99,7 @@9999   });100100 });101101 102-// ── Documented scope boundaries (T-IC03, T-IC05, T-IC07) ─────────────────────103-describe("T-IC03 / T-IC05 / T-IC07: documented boundaries", () => {102+describe("T-IC03: renderer idempotency boundary", () => {104103   // T-IC03: injectFieldIcon is NOT idempotent on its own — each call appends a105104   // button. Idempotency is enforced by the caller (index.ts processedInputs),106105   // covered by content.initial-scan.test.ts T-S10. We pin the real behavior so@@ -113,16 +112,4 @@113112     c1();114113     c2();115114   });116-117-  // T-IC05 (reposition on scroll) is driven by observeAnchorPosition via118-  // ResizeObserver + requestAnimationFrame + scroll listeners. The reflow is not119-  // deterministically observable under jsdom (no real layout); it is verified in120-  // the Playwright e2e.121-  it.todo("T-IC05: icon repositions on scroll (e2e — needs real layout)");122-123-  // T-IC07: injectFieldIcon does not inspect autocomplete; the "don't offer on124-  // current-password where the browser already does" policy, if desired, belongs125-  // to the detect/index gating layer, not the renderer. No such gate exists126-  // today — flagged in INTEGRATION-CONTRACT.md rather than asserted falsely.127-  it.todo("T-IC07: suppress on autocomplete=current-password (no such policy in inject today)");128115 });

view file

src/content/content.orchestration.test.ts+0 −175
@@ -1,175 +0,0 @@1-// T-011 through T-014: index.ts autofill orchestration2-//3-// Why these tests exist: src/content/index.ts (1,263 lines) is the central4-// autofill decision layer. It implements:5-//   - A 3-layer dismissal model that prevents the selector reopening after6-//     dismiss or fill (lastDismissedField, DISMISS_LOCK_MS, wasAlreadyFilled)7-//   - handleFieldInteraction: ML predict → locked-vault branch → signup/fill8-//   - initialScan: aggression levels 0/1/2 for different page contexts9-//   - Message routing: SUMMON_AUTOFILL, VAULT_UNLOCKED, passkey branches10-//11-// The analysis (§5) flags this as "the most regression-prone code in the12-// project" (v1.4.14 had multiple regressions in this layer) with zero unit13-// test coverage. The e2e suite uses synthetic HTML and doesn't drive the14-// orchestration state machine.15-//16-// ---------------------------------------------------------------------------17-// Integration Contract — REFACTORING REQUIRED18-// ---------------------------------------------------------------------------19-//20-// index.ts exports NOTHING. All functions (handleFieldInteraction, initialScan,21-// wasAlreadyFilled, startDismissLock) and state (lastDismissedField, dismissLock,22-// passkeySummonActive, pageFilledAt) are module-private. To make the tests below23-// runnable, the following exports are needed:24-//25-//   export { handleFieldInteraction }       // for T-01226-//   export { initialScan }                  // for T-01327-//   export { wasAlreadyFilled }             // for T-011 (wasAlreadyFilled check)28-//   export const _testSeam: {              // for T-011 (per-field lock state)29-//     getLastDismissedField(): HTMLInputElement | null;30-//     setLastDismissedField(f: HTMLInputElement | null): void;31-//     isDismissLocked(): boolean;32-//     DISMISS_LOCK_MS: number;33-//   }34-//35-// Additionally, the test environment must be switched to jsdom:36-//   // in vitest.config.ts:37-//   environmentMatchGlobs: [['src/content/content.orchestration.test.ts', 'jsdom']]38-//39-// And the content script's chrome API calls must be mocked:40-//   - chrome.runtime.sendMessage (for vault queries)41-//   - chrome.runtime.onMessage.addListener (for VAULT_UNLOCKED/SUMMON_AUTOFILL)42-//43-// ---------------------------------------------------------------------------44-45-import { describe, it } from "vitest";46-47-// All tests below are stubs pending the refactoring described above.48-// They document the intended behavior and the specific bug each test catches.49-50-describe("T-011: Dismissal model — 3-layer prevention of selector reopen", () => {51-  // T-011a: per-field lock (lastDismissedField)52-  // After dismissing the selector on field A, focusing field A again must NOT53-  // reopen the selector. Focusing a different field B must reopen normally.54-  //55-  // Bug this catches: removing the `field === lastDismissedField` guard in56-  // handleFieldInteraction causes the selector to reopen immediately after57-  // any Escape-dismiss (regression from v1.4.14).58-  it.todo(59-    "T-011a: focus field A → Escape dismiss → re-focus field A → selector does NOT reopen (requires _testSeam export)",60-  );61-62-  // T-011b: DISMISS_LOCK_MS global cooldown63-  // After any dismiss, a 800ms global lock prevents the selector reopening64-  // on any field (cross-field rapid tabbing).65-  //66-  // Bug this catches: removing startDismissLock() from the onDismiss callback67-  // allows the selector to reopen immediately when the user tabs to the next68-  // field after dismissing.69-  it.todo(70-    "T-011b: dismiss → immediately focus another field → selector does NOT open within 800ms lock (requires _testSeam export)",71-  );72-73-  // T-011c: wasAlreadyFilled form-level grace (PAGE_FILL_GRACE_MS = 3000ms)74-  // After a successful fill, the form and page enter a 3-second grace period.75-  // Re-focusing the same field during the grace period must not reopen the selector.76-  //77-  // Bug this catches: clearing pageFilledAt or removing the PAGE_FILL_GRACE_MS78-  // check causes the selector to immediately reappear after autofill, annoying79-  // users and breaking the "fill once" guarantee.80-  it.todo(81-    "T-011c: fill field → immediate re-focus → selector does NOT reopen (wasAlreadyFilled grace, requires jsdom + real fills)",82-  );83-});84-85-describe("T-012: handleFieldInteraction core paths", () => {86-  // T-012a: ML timeout race (150ms)87-  // If contentMLDetector.predictBatch takes > 150ms, handleFieldInteraction88-  // should use the fallback (no ML prediction) rather than waiting indefinitely.89-  //90-  // Bug this catches: removing the Promise.race timeout causes autofill to91-  // hang on slow pages where ML prediction is delayed.92-  it.todo(93-    "T-012a: ML-predict timeout — mock predictBatch to take 200ms → timeout path taken (requires handleFieldInteraction export)",94-  );95-96-  // T-012b: locked vault path97-  // When the vault is locked, handleFieldInteraction must show the lock prompt98-  // (not the entry selector) and NOT call showAutofillSelector.99-  it.todo(100-    "T-012b: locked vault → lock prompt shown, showAutofillSelector NOT called (requires chrome.runtime mock)",101-  );102-103-  // T-012c: signup mode (no domain entries)104-  // When vault has no entries for the current domain, the identity surface is105-  // shown (for username pre-fill) rather than the entry selector.106-  it.todo(107-    "T-012c: vault has no domain entries → identity surface shown (requires chrome.runtime.sendMessage mock)",108-  );109-110-  // T-012d: email-first form (no password field on step 1)111-  // Google/Microsoft login pages show email, then password on the next page.112-  // handleFieldInteraction must detect this and show only the email-fill surface.113-  it.todo(114-    "T-012d: email-first form (no password field) → email-only path taken (requires jsdom form)",115-  );116-});117-118-describe("T-013: initialScan aggression levels", () => {119-  // T-013a: level 0 — page with no credential form fields → no icon injected.120-  // Bug this catches: a false-positive in isAnyCredentialForm causes the icon121-  // to appear on forms that have no username/password fields.122-  it.todo(123-    "T-013a: form with no matching fields + level 0 → initialScan skips injection (requires jsdom)",124-  );125-126-  // T-013b: level 1 — standard login form → icon injected on password field.127-  it.todo(128-    "T-013b: standard login form + level 1 → icon injected (requires jsdom + injectFieldIcon spy)",129-  );130-131-  // T-013c: level 2 final-pass — bare password field (no username) → icon injected.132-  // This covers the scenario where a SPA reveals the password field after the133-  // username step. The final-pass injection catches fields missed by level 0/1.134-  it.todo(135-    "T-013c: bare password field + level 2 finalPass → icon injected (requires jsdom + injectFieldIcon spy)",136-  );137-});138-139-describe("T-014: Message routing — SUMMON_AUTOFILL and VAULT_UNLOCKED", () => {140-  // T-014a: SUMMON_AUTOFILL from keyboard shortcut opens selector on focused field.141-  // Bug this catches: removing the SUMMON_AUTOFILL branch in the message listener142-  // breaks the Ctrl+Shift+F shortcut for manual autofill.143-  it.todo(144-    "T-014a: SUMMON_AUTOFILL message → selector opens on active field (requires chrome.runtime.onMessage mock)",145-  );146-147-  // T-014b: VAULT_UNLOCKED re-triggers autofill on pending field.148-  // When the user unlocks the vault in response to a lock-prompt, the content149-  // script should automatically retry the fill for the field that triggered it.150-  it.todo(151-    "T-014b: VAULT_UNLOCKED → re-trigger handleFieldInteraction on pending field (requires chrome.runtime.onMessage mock)",152-  );153-154-  // T-014c: VAULT_UNLOCKED does not double-trigger when passkeySummonActive.155-  // If a passkey toast is already showing (passkeySummonActive = true), the156-  // VAULT_UNLOCKED handler must skip autofill to prevent a race condition.157-  it.todo(158-    "T-014c: VAULT_UNLOCKED with passkeySummonActive=true → no double-trigger (requires _testSeam export)",159-  );160-});161-162-// ---------------------------------------------------------------------------163-// What CAN be tested without refactoring164-// ---------------------------------------------------------------------------165-// The pure helper functions in src/content/detect.ts, fill.ts, interaction.ts,166-// policy.ts, and identity-helper.ts are already well covered. The autofill167-// gap is specifically in index.ts's stateful orchestration layer.168-//169-// The only testable behavior from index.ts without exports is importing the170-// module and verifying it doesn't crash on load (smoke test):171-describe("T-011/smoke: index.ts module loads without throwing", () => {172-  it.todo(173-    "importing index.ts in a jsdom environment with chrome mock does not throw (requires jsdom environment switch)",174-  );175-});

view file at the parent

src/content/content.save.test.ts+0 −13
@@ -121,16 +121,3 @@121121     expect(fetch?.payload?.id).toBe("e1");122122   });123123 });124-125-// ── Closed-shadow / lifecycle boundaries (T-SV06–T-SV09) ─────────────────────126-describe("T-SV06–T-SV09: documented boundaries", () => {127-  // The Save/Dismiss buttons and the homograph warning render inside the128-  // banner's CLOSED shadow root; the accept/dismiss flows (which send129-  // ADD_ENTRY/UPDATE_ENTRY), the popup-lifecycle suppression, and the homograph130-  // warning copy are all verified through the Playwright e2e. See131-  // INTEGRATION-CONTRACT.md.132-  it.todo("T-SV06: user accepts the banner → ADD_ENTRY sent (closed shadow → e2e)");133-  it.todo("T-SV07: user dismisses the banner → nothing saved (closed shadow → e2e)");134-  it.todo("T-SV08: banner does not reappear after popup lifecycle break (e2e)");135-  it.todo("T-SV09: homograph warning shown for non-ASCII domain (closed shadow → e2e)");136-});

view file

src/content/content.selector-ui.test.ts+0 −22
@@ -83,25 +83,3 @@8383     d2();8484   });8585 });86-87-// ── Closed-shadow internals → Playwright e2e (T-I02–T-I16) ───────────────────88-// Each of these requires reading or interacting with the closed shadow tree.89-// They are real test intents, documented here and implemented in90-// content.real-page.e2e.test.ts where behavior is verified through effects.91-describe("T-I02–T-I16: interior behavior (closed shadow → e2e)", () => {92-  it.todo("T-I02: ArrowDown moves selection to the next entry");93-  it.todo("T-I03: ArrowUp wraps from first to last entry");94-  it.todo("T-I04: Enter selects the focused entry, fills, and closes");95-  it.todo("T-I05: Escape closes the selector and fires the dismissal lock");96-  it.todo("T-I06: click outside closes the selector and fires the dismissal lock");97-  it.todo("T-I07: typing in the search box filters entries by title/domain");98-  it.todo("T-I08: search lazily fires GET_ENTRY_FOR_FILL only on a user gesture (MV-2)");99-  it.todo("T-I09: empty search results show the 'no matches' state");100-  it.todo("T-I10: selector repositions on window scroll (stays anchored)");101-  it.todo("T-I11: selector repositions on window resize");102-  it.todo("T-I12: selector renders above the field when space below is insufficient");103-  it.todo("T-I13: an entry with TOTP shows a live-countdown chip");104-  it.todo("T-I14: an entry without TOTP shows no chip");105-  it.todo("T-I15: keyboard nav is trapped — Tab cycles entries, does not escape to page");106-  it.todo("T-I16: isTrusted — synthetic keyboard events do not trigger fill");107-});

view file

src/content/index.ts+5 −5
@@ -924,12 +924,12 @@924924 // elements leave the DOM. subtree:false because route swaps reliably925925 // mutate document.body's direct children — subtree:true would fire on926926 // every internal DOM update and cost us a re-check per mutation.927-let lastObservedUrl = location.href;927+let lastObservedUrl = globalThis.location.href;928928 new MutationObserver(() => {929-  if (location.href !== lastObservedUrl) {930-    lastObservedUrl = location.href;931-    pageFilledAt = null;932-  }929+  const currentUrl = globalThis.location?.href;930+  if (!currentUrl || currentUrl === lastObservedUrl) return;931+  lastObservedUrl = currentUrl;932+  pageFilledAt = null;933933 }).observe(document.body, { childList: true, subtree: false });934934 935935 // Async init: check recall map before running the initial scans so we can

view file

src/content/policy.ts+13 −13
@@ -70,13 +70,13 @@7070     const minOnlyMatch = pat.match(/\{(\d+),\}/);7171     const exactMatch = pat.match(/\{(\d+)\}/);7272     if (rangeMatch) {73-      policy.minLength = parseInt(rangeMatch[1]!);74-      policy.maxLength = parseInt(rangeMatch[2]!);73+      policy.minLength = Number.parseInt(rangeMatch[1]!, 10);74+      policy.maxLength = Number.parseInt(rangeMatch[2]!, 10);7575     } else if (minOnlyMatch) {76-      policy.minLength = parseInt(minOnlyMatch[1]!);76+      policy.minLength = Number.parseInt(minOnlyMatch[1]!, 10);7777     } else if (exactMatch) {78-      policy.minLength = parseInt(exactMatch[1]!);79-      policy.maxLength = parseInt(exactMatch[1]!);78+      policy.minLength = Number.parseInt(exactMatch[1]!, 10);79+      policy.maxLength = Number.parseInt(exactMatch[1]!, 10);8080     }8181   }8282 @@ -90,14 +90,14 @@9090   const maxMatch = text.match(/(?:at most|maximum|max(?:imum)?|up to|cannot exceed|must not exceed|no more than|must be (?:no more than |at most )?)(?:\D*?)(\d+)/i);9191   const exactMatch2 = text.match(/(?:exactly|must be)\D*?(\d+)\s*characters?/i);9292   // dash range takes priority as the most unambiguous format93-  if (dashRangeMatch && !policy.minLength) policy.minLength = parseInt(dashRangeMatch[1]!);94-  if (dashRangeMatch && !policy.maxLength) policy.maxLength = parseInt(dashRangeMatch[2]!);95-  if (betweenMatch && !policy.minLength) policy.minLength = parseInt(betweenMatch[1]!);96-  if (betweenMatch && !policy.maxLength) policy.maxLength = parseInt(betweenMatch[2]!);97-  if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]!);98-  if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]!);99-  if (exactMatch2 && !policy.minLength) policy.minLength = parseInt(exactMatch2[1]!);100-  if (exactMatch2 && !policy.maxLength) policy.maxLength = parseInt(exactMatch2[1]!);93+  if (dashRangeMatch && !policy.minLength) policy.minLength = Number.parseInt(dashRangeMatch[1]!, 10);94+  if (dashRangeMatch && !policy.maxLength) policy.maxLength = Number.parseInt(dashRangeMatch[2]!, 10);95+  if (betweenMatch && !policy.minLength) policy.minLength = Number.parseInt(betweenMatch[1]!, 10);96+  if (betweenMatch && !policy.maxLength) policy.maxLength = Number.parseInt(betweenMatch[2]!, 10);97+  if (minMatch && !policy.minLength) policy.minLength = Number.parseInt(minMatch[1]!, 10);98+  if (maxMatch && !policy.maxLength) policy.maxLength = Number.parseInt(maxMatch[1]!, 10);99+  if (exactMatch2 && !policy.minLength) policy.minLength = Number.parseInt(exactMatch2[1]!, 10);100+  if (exactMatch2 && !policy.maxLength) policy.maxLength = Number.parseInt(exactMatch2[1]!, 10);101101 102102   if (/no\s+(?:special|symbol)/i.test(text)) policy.forbidsSymbols = true;103103   if (/(?:special|symbol|punctuation)/i.test(text)) policy.requiresSymbols = true;

view file

src/test/setup.ts+25 −0
@@ -0,0 +1,25 @@1+if (typeof HTMLCanvasElement !== "undefined") {2+  const context = {3+    clearRect() {},4+    drawImage() {},5+    fillRect() {},6+    fillText() {},7+    restore() {},8+    rotate() {},9+    save() {},10+    scale() {},11+    translate() {},12+    measureText: () => ({ width: 0 }),13+    getImageData: (_sx: number, _sy: number, width: number, height: number) => ({14+      data: new Uint8ClampedArray(width * height * 4),15+      width,16+      height,17+      colorSpace: "srgb",18+    }),19+  } as unknown as CanvasRenderingContext2D;20+21+  Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {22+    configurable: true,23+    value: () => context,24+  });25+}

view file

vitest.config.ts+1 −3
@@ -5,10 +5,8 @@55   plugins: [solidPlugin()],66   test: {77     include: ["src/**/*.test.ts", "src/**/*.test.tsx"],8+    setupFiles: ["./src/test/setup.ts"],89     environment: "node",9-    environmentMatchGlobs: [10-      ["src/content/content.orchestration.test.ts", "jsdom"],11-    ],1210     // M14/SEC-007: tests exercising dev-only handlers1311     // (DEBUG_INJECT_INCOMING_PAIRING) use vi.stubEnv('MODE','development').1412     // Default test MODE is 'test', so the production gate engages by default.

view file