1d32bcd94040
test: Restore clean test signal (v4.10.39)
- time
- 2026-08-29 18:49 UTC · 1 month ago
- machine
- koh2
- parent
- 90d2d69e8d21
↓ peach-browser-1d32bcd9.tar.gzatlas at this save
files changed14 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 −3manifest.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": {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",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",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 () => {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 });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 });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 });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-});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-});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-});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 cansrc/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;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+}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.