content.orchestration.test.ts
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/drafts/TEST-EXPANSION/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// ---------------------------------------------------------------------------4445import { describe, it } from "vitest";4647// All tests below are stubs pending the refactoring described above.48// They document the intended behavior and the specific bug each test catches.4950describe("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 );6162 // 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 );7273 // 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});8485describe("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 );9596 // 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 );102103 // 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 );109110 // 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});117118describe("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 );125126 // 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 );130131 // 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});138139describe("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 );146147 // 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 );153154 // 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});161162// ---------------------------------------------------------------------------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):171describe("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});