54e4594fb2f7

draft: Priority 3 test files T-025/T-030/T-037 and Integration Contract in src/drafts/TEST-EXPANSION/

time
2026-06-12 04:50 UTC · 4 months ago
machine
koh2
agent
claude-sonnet-4-6
session
test coverage expansion
parent
1b633f5d627c
4 files src/drafts/TEST-EXPANSION/INTEGRATION-CONTRACT.md+174 −0src/drafts/TEST-EXPANSION/clipboard.double-failure.test.ts+170 −0src/drafts/TEST-EXPANSION/generate.empty-pool.test.ts+130 −0src/drafts/TEST-EXPANSION/ml.predictbatch.test.ts+185 −0
src/drafts/TEST-EXPANSION/INTEGRATION-CONTRACT.md+174 −0
@@ -0,0 +1,174 @@1+# Test Expansion Integration Contract2+3+Generated by session: `test coverage expansion`  4+Agent: `claude-sonnet-4-6`  5+Priority 1: T-001–T-010 (10 files), Priority 2: T-011–T-024 (11 files, T-011–T-014 in one file), Priority 3: T-025, T-030, T-037 (3 files)6+7+---8+9+## Test File Index10+11+| File | T-numbers | Asserts | Notes |12+|------|-----------|---------|-------|13+| `recovery-words.integrity.test.ts` | T-001 | 5 | Word list structural checks |14+| `recovery-words.checksum.test.ts` | T-002 | 6 | Round-trip, swap, order, length |15+| `recovery-words.kek.test.ts` | T-003 | 5 | HKDF round-trip, zero-pad bytes[16-31] |16+| `totp.rfc6238.test.ts` | T-004 | 6 | RFC 6238 SHA1/256/512, boundary, URI parse |17+| `peachPayload.bomb.test.ts` | T-005 | 4 | MV-4 length-bomb, decompressZstd NOT called |18+| `backup.nfc-fallback.test.ts` | T-006 | 5 | LV-1 NFD-sealed unlocked with NFC |19+| `pdf-import.sandbox.test.ts` | T-007 | 4 | MV-9 pdf.js hardened flags |20+| `messages.batch-sanitize.test.ts` | T-008 | 6 | PCH-01-016 canary/tag sanitization |21+| `license.verification.test.ts` | T-009 | 6 live + 2 todo | Ed25519 verify, structural checks |22+| `chirp-transport.probe.test.ts` | T-010 | 3 | PCH-01-018 no-msg3 identity leak |23+| `content.orchestration.test.ts` | T-011–T-014 | 0 live + 11 todo | All stubs (index.ts exports nothing) |24+| `passkey.ed25519.test.ts` | T-015 | 7 | Ed25519 COSE, sign, verify, replay |25+| `vault.rotate-sync-identity.test.ts` | T-016 | 7 | MV-8 rotation: new key, clear peers, recovery pending |26+| `codex.multistripe.test.ts` | T-017 | 5 | Multi-stripe RS round-trip + erasure |27+| `vault.corruption.test.ts` | T-018 | 5 live + 1 todo | corrupted vs wrong_key discrimination |28+| `argon2id.kat.test.ts` | T-019 | 6 (1 skipped until KAT generated) | KDF params, determinism, NFC |29+| `sync.disconnect.test.ts` | T-020 | 4 | PCH-01-026 #9 onerror/onclose |30+| `import.csv.formulainjection.test.ts` | T-021 | 6 | Formula-injection round-trip |31+| `messages.edit-origin.test.ts` | T-022 | 5 | GET_ENTRY_FOR_EDIT origin check, includeAll guard |32+| `vault.consolidate-passkeys.test.ts` | T-023 | 4 | mergePasskeyOnlyIntoLogins lastUsed sort |33+| `vault.purge-trash.test.ts` | T-024 | 5 | Retention cutoff, 0-day guard, count |34+| `generate.empty-pool.test.ts` | T-025 | 8 | Empty pool fallback, 128-char, min/max |35+| `ml.predictbatch.test.ts` | T-030 | 8 | Batch sizes, softmax stability |36+| `clipboard.double-failure.test.ts` | T-037 | 4 | alreadyWritten skip, double-fail no throw |37+38+**Total: 24 test files, ~150 live assertions, ~13 todos**39+40+---41+42+## Real Crypto vs. Mocked Crypto (Priority 1)43+44+All Priority 1 tests use **real cryptographic primitives**:45+46+| Test | Crypto used | Notes |47+|------|-------------|-------|48+| T-001 | None | Word list structural |49+| T-002 | SubtleCrypto AES-GCM, HKDF, BLAKE2s | Via `recovery-words.ts` |50+| T-003 | SubtleCrypto HKDF, AES-GCM | Via `recovery-words.ts` |51+| T-004 | HMAC-SHA1/256/512 | Via `otpauth` library |52+| T-005 | Argon2id (m=65536, t=3, p=4), AES-GCM | `vi.mock("../../crypto/zstd")` for decompressZstd spy only |53+| T-006 | Argon2id, AES-GCM | Via `backup.ts` / `derivePeachExportKey` |54+| T-007 | None | `vi.mock("pdfjs-dist")` — testing flag plumbing only |55+| T-008 | Argon2id, AES-GCM | Via full vault setup |56+| T-009 | Ed25519 sign/verify | Local `validateLicenseWithKey` re-impl; prod `validateLicense` for structural checks |57+| T-010 | Curve25519 DH (Noise XX) | Via `noiseXXInitiator` + `noiseXXResponder` |58+59+---60+61+## New Dependencies Added62+63+These dependencies are used in the draft tests but are already present in `package.json`:64+- `@stablelib/x25519` — Noise XX keypair generation (T-010, T-020)65+- `pako` — deflate/inflate for Codex tests (T-017)66+- `hash-wasm` — Argon2id in T-005, T-006 (already used by production code)67+- `otpauth` — TOTP generation (T-004, already used by production code)68+69+No new npm dependencies are required.70+71+---72+73+## Source Refactoring Recommendations74+75+### Required for T-011–T-014 (content orchestration) — HIGH PRIORITY76+`src/content/index.ts` exports nothing. All orchestration state and functions77+are module-private. To make the autofill orchestration tests runnable:78+79+```typescript80+// src/content/index.ts — add these exports81+82+export { handleFieldInteraction }       // T-01283+export { initialScan }                  // T-01384+export { wasAlreadyFilled }             // T-01185+86+export const _testSeam = {87+  getLastDismissedField(): HTMLInputElement | null { return lastDismissedField; },88+  setLastDismissedField(f: HTMLInputElement | null): void { lastDismissedField = f; },89+  isDismissLocked(): boolean { return dismissLock !== null; },90+  DISMISS_LOCK_MS,91+} as const;92+```93+94+Additionally, the vitest environment for this file must be switched to `jsdom`:95+```typescript96+// vitest.config.ts97+environmentMatchGlobs: [98+  ['src/drafts/TEST-EXPANSION/content.orchestration.test.ts', 'jsdom'],99+]100+```101+102+### Required for T-009f/g (license + chrome storage)103+Add `validateLicenseWithKey(keyString: string, pubKeyHex: string)` as an104+exported helper in `src/background/license.ts` with injectable public key.105+Currently the public key is hardcoded in a private `importPublicKey()` closure.106+107+### Required for T-018f (consumeLastUnlockFailureReason integration)108+The test needs chrome.storage mock setup identical to T-008/T-016. The109+`it.todo` can be promoted to a live test using the same mock pattern.110+111+### Recommended for T-019a (Argon2id KAT)112+Add a script at `scripts/gen-argon2id-kat.ts` to generate the expected hash113+vector and update `KAT_EXPECTED_HEX` in `argon2id.kat.test.ts`. Run once and114+commit. See the inline documentation in the test for the script body.115+116+---117+118+## Environment Requirements119+120+| Test file | Environment | Notes |121+|-----------|-------------|-------|122+| All current tests | `node` (default vitest env) | — |123+| `content.orchestration.test.ts` | `jsdom` required | Needs `environmentMatchGlobs` |124+| `sync.disconnect.test.ts` | `node` | Uses `vi.stubGlobal("WebSocket", ...)` |125+126+---127+128+## CI/Exclusion Configuration129+130+The `vitest.config.ts` already excludes `src/drafts/**` from CI runs:131+```typescript132+exclude: ["src/drafts/**"]133+```134+135+Draft tests do not run automatically. To promote a test file to CI, move it136+from `src/drafts/TEST-EXPANSION/` to the canonical location beside its source137+(e.g., `src/background/vault.rotate-sync-identity.test.ts`) and remove it from138+the drafts directory.139+140+---141+142+## T-numbers Not Implemented143+144+The following Priority 3 test numbers from the brief were not implemented in145+this session (available for follow-up):146+147+- **T-026**: timestamp-clamp + manifest-freshness boundaries148+- **T-027**: full Codex UI round-trip e2e149+- **T-028**: autofill real-page e2e150+- **T-029**: inject.ts selector keyboard nav151+- **T-031**: SW eviction mid-operation152+- **T-032**: backup.ts empty-password contradiction153+- **T-033**: Firefox passkey e2e154+- **T-034**: LV-2 homograph UI surfacing155+- **T-035**: HIBP breach-check integration156+- **T-036**: reed-solomon try/finally free157+- **T-038**: favicon canvas fallbacks158+- **T-039**: chirp pairing failure paths159+- **T-040**: sync alert queue bounds160+161+---162+163+## Summary Statistics164+165+| Category | Count |166+|----------|-------|167+| Priority 1 files | 10 |168+| Priority 2 files | 11 |169+| Priority 3 files | 3 |170+| **Total draft files** | **24** |171+| Live assertions (approx.) | ~150 |172+| `it.todo` stubs | ~13 |173+| Tests using real crypto | All P1 (except T-007 which tests flag plumbing) |174+| Tests requiring source refactoring | T-011–T-014 (index.ts exports) |

view file

src/drafts/TEST-EXPANSION/clipboard.double-failure.test.ts+170 −0
@@ -0,0 +1,170 @@1+// T-037: clipboard double-failure / alreadyWritten path2+//3+// Why these tests exist: copyToClipboard in clipboard.ts has two untested paths:4+//5+//   1. `alreadyWritten=true` skips the writeToClipboard() call.6+//      The popup/content caller may have already written to the clipboard from a7+//      focused document with a fresh user gesture (more reliable than the SW's8+//      executeScript path). When `alreadyWritten=true`, the SW should only9+//      schedule the auto-clear alarm — NOT attempt a second write that would10+//      fail or overwrite the user's clipboard.11+//12+//   2. Both clipboard write paths fail (chrome.scripting.executeScript + direct13+//      navigator.clipboard.writeText). The function must not throw; the alarm14+//      is still scheduled so the clear attempt fires on a retry.15+//16+// The existing clipboard.test.ts covers the happy path (write + clear).17+// These tests cover the edge paths that complete the audit finding HV-2.18+19+import { beforeEach, describe, expect, it, vi } from "vitest";20+21+type StorageShape = Record<string, unknown>;22+23+describe("T-037: clipboard.ts alreadyWritten and double-failure paths", () => {24+  let storage: StorageShape;25+  let sessionStorage: StorageShape;26+27+  beforeEach(async () => {28+    vi.resetModules();29+    storage = {};30+    sessionStorage = {};31+32+    const alarmListeners: Array<(alarm: { name: string }) => void> = [];33+    const createdAlarms: Array<{ name: string; opts: Record<string, unknown> }> = [];34+35+    const chromeMock = {36+      storage: {37+        local: {38+          get: vi.fn(async (key?: string | string[]) => {39+            if (!key) return { ...storage };40+            if (typeof key === "string") return { [key]: storage[key] };41+            return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(42+              (acc, k) => { acc[k] = storage[k]; return acc; },43+              {},44+            );45+          }),46+          set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),47+          remove: vi.fn(async () => {}),48+        },49+        session: {50+          get: vi.fn(async (key?: string | string[]) => {51+            if (!key) return { ...sessionStorage };52+            if (typeof key === "string") return { [key]: sessionStorage[key] };53+            return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(54+              (acc, k) => { acc[k] = sessionStorage[k]; return acc; },55+              {},56+            );57+          }),58+          set: vi.fn(async (next: StorageShape) => { Object.assign(sessionStorage, next); }),59+          remove: vi.fn(async () => {}),60+        },61+      },62+      alarms: {63+        clear: vi.fn(async () => true),64+        create: vi.fn(async (name: string, opts: Record<string, unknown>) => {65+          createdAlarms.push({ name, opts });66+        }),67+        onAlarm: {68+          addListener: vi.fn((cb: (alarm: { name: string }) => void) => {69+            alarmListeners.push(cb);70+          }),71+        },72+      },73+      tabs: {74+        query: vi.fn(async () => []),75+      },76+      scripting: {77+        executeScript: vi.fn(async () => { throw new Error("executeScript failed"); }),78+      },79+    } as unknown as typeof chrome;80+81+    (globalThis as Record<string, unknown>).chrome = chromeMock;82+    (globalThis as Record<string, unknown>)._createdAlarms = createdAlarms;83+84+    // Mock navigator.clipboard for this test suite85+    Object.defineProperty(globalThis, "navigator", {86+      value: { clipboard: { writeText: vi.fn() } },87+      configurable: true,88+    });89+  });90+91+  // T-037a: alreadyWritten=true → writeToClipboard is NOT called.92+  // The caller has already written the clipboard via a user-gesture path.93+  // The background script must only schedule the auto-clear alarm.94+  //95+  // Bug this catches: ignoring `alreadyWritten` flag would double-write,96+  // potentially overwriting the clipboard with the previous value if97+  // executeScript fails and falls through to navigator.clipboard.98+  it("T-037a: alreadyWritten=true → navigator.clipboard.writeText NOT called", async () => {99+    const { copyToClipboard } = await import("../../background/clipboard");100+101+    const writeTextMock = vi.fn(async () => {});102+    Object.defineProperty(globalThis.navigator, "clipboard", {103+      value: { writeText: writeTextMock },104+      configurable: true,105+    });106+107+    await copyToClipboard("secret-value", "Password", true /* alreadyWritten */);108+109+    // navigator.clipboard.writeText must NOT be called (the caller already wrote)110+    expect(writeTextMock).not.toHaveBeenCalled();111+  });112+113+  // T-037b: alreadyWritten=true → the auto-clear alarm IS still scheduled.114+  // Even when we skip writing, the alarm must fire to clear the clipboard later.115+  it("T-037b: alreadyWritten=true → alarm is still created for auto-clear", async () => {116+    const { copyToClipboard } = await import("../../background/clipboard");117+    const alarmsMock = chrome.alarms as { create: ReturnType<typeof vi.fn> };118+119+    await copyToClipboard("secret-value", "Password", true);120+121+    // The alarm must have been created122+    expect(alarmsMock.create).toHaveBeenCalled();123+    const alarmName = (alarmsMock.create.mock.calls[0] as [string, unknown])[0];124+    expect(alarmName).toBe("peach-clipboard-clear-current");125+  });126+127+  // T-037c: both clipboard write paths fail → copyToClipboard does NOT throw.128+  // executeScript fails (tab query returns no tabs or scripting throws) AND129+  // navigator.clipboard.writeText also throws. The alarm must still be130+  // scheduled so the secret is not silently left without a clear attempt.131+  //132+  // Bug this catches: an uncaught error in writeToClipboard propagating up133+  // through copyToClipboard would leave the alarm unscheduled — the clipboard134+  // would never be cleared.135+  it("T-037c: both write paths fail → copyToClipboard does not throw (alarm still scheduled)", async () => {136+    // Make navigator.clipboard.writeText throw137+    Object.defineProperty(globalThis.navigator, "clipboard", {138+      value: { writeText: vi.fn(async () => { throw new Error("clipboard API unavailable"); }) },139+      configurable: true,140+    });141+    // executeScript already fails (no tabs in mock)142+143+    const { copyToClipboard } = await import("../../background/clipboard");144+    const alarmsMock = chrome.alarms as { create: ReturnType<typeof vi.fn> };145+146+    // Must not throw even when both paths fail147+    await expect(copyToClipboard("secret", "Password", false)).resolves.toBeUndefined();148+149+    // Alarm must still be scheduled150+    expect(alarmsMock.create).toHaveBeenCalled();151+  });152+153+  // T-037d: alreadyWritten=false (default) → write IS attempted.154+  // Verifies the normal path still works after the alreadyWritten tests.155+  it("T-037d: alreadyWritten=false (default) → clipboard write is attempted", async () => {156+    const writeTextMock = vi.fn(async () => {});157+    Object.defineProperty(globalThis.navigator, "clipboard", {158+      value: { writeText: writeTextMock },159+      configurable: true,160+    });161+162+    const { copyToClipboard } = await import("../../background/clipboard");163+164+    await copyToClipboard("secret", "Password", false);165+166+    // Either executeScript OR navigator.clipboard was used.167+    // Since executeScript fails (no tab), fallback goes to navigator.clipboard.168+    expect(writeTextMock).toHaveBeenCalledWith("secret");169+  });170+});

view file

src/drafts/TEST-EXPANSION/generate.empty-pool.test.ts+130 −0
@@ -0,0 +1,130 @@1+// T-025: generate.ts empty-pool guard, 128-char boundary, minLength > maxLength2+//3+// Why these tests exist: generatePassword and generateSmartPassword have three4+// edge-case behaviors that are not tested:5+//6+//   1. All character classes forbidden → the pool falls back to lowercase7+//      ("abcdefghijklmnopqrstuvwxyz") rather than looping forever or throwing.8+//      (line 38: `if (chars.length === 0) chars = filter(...) || "abc..."`)9+//10+//   2. 128-character limit (raised in v1.8.0) — the generator must produce11+//      exactly 128 characters when length=128 and must handle the boundary12+//      without a performance cliff or off-by-one.13+//14+//   3. minLength > maxLength in a site policy — generate.ts clamps in15+//      declaration order: raise to minLength first, then cap to maxLength.16+//      When minLength=50 and maxLength=16, the result must be exactly 16 chars.17+//18+// These tests also serve as a regression guard against any future refactor of19+// the rejection-sampling loop: if the loop condition changes, the 128-char20+// case catches a potential performance regression (loop would run for 30+s).21+22+import { describe, expect, it } from "vitest";23+import { generatePassword, generateSmartPassword } from "../../shared/generate";24+25+describe("T-025: generatePassword edge cases", () => {26+  // T-025a: all character classes excluded via forbiddenChars → falls back to27+  // lowercase alphabet, no infinite loop, no error.28+  //29+  // Bug this catches: removing the `|| "abcde..."` fallback in the empty-pool30+  // guard would cause an infinite loop (the while loop never fills the length).31+  it("T-025a: all chars forbidden via forbiddenChars → falls back to lowercase, no hang", () => {32+    // Forbid every uppercase, digit, and symbol; force-enable all classes.33+    // Only lowercase survives.34+    const allExceptLower = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";35+    const password = generatePassword({36+      length: 10,37+      includeUppercase: true,38+      includeLowercase: true,39+      includeNumbers: true,40+      includeSymbols: true,41+      forbiddenChars: allExceptLower,42+    });43+44+    expect(typeof password).toBe("string");45+    expect(password.length).toBe(10);46+    // Remaining chars should only be lowercase47+    expect(password).toMatch(/^[a-z]+$/);48+  });49+50+  // T-025b: all character classes disabled (no uppercase, no lowercase, no51+  // numbers, no symbols) — the pool would be empty, so the fallback ensures52+  // the generator produces something usable.53+  it("T-025b: all boolean classes disabled → fallback lowercase used, no hang", () => {54+    const password = generatePassword({55+      length: 8,56+      includeUppercase: false,57+      includeLowercase: false,58+      includeNumbers: false,59+      includeSymbols: false,60+    });61+62+    expect(typeof password).toBe("string");63+    expect(password.length).toBe(8);64+    // Fallback to lowercase: output should be alphabetic65+    expect(password).toMatch(/^[a-z]+$/);66+  });67+68+  // T-025c: 128-character password generated without performance cliff.69+  // v1.8.0 raised the maximum to 128 chars. The rejection-sampling loop70+  // must complete in reasonable time (the test has a 2-second implicit timeout).71+  //72+  // Bug this catches: if the rejection-sampling limit calculation overflows73+  // or produces 0 for large char pools, every CSPRNG value is rejected and74+  // the loop runs indefinitely.75+  it("T-025c: length=128 produces exactly 128 characters without hanging", () => {76+    const password = generatePassword({77+      length: 128,78+      includeUppercase: true,79+      includeLowercase: true,80+      includeNumbers: true,81+      includeSymbols: true,82+    });83+84+    expect(password.length).toBe(128);85+  });86+87+  // T-025d: length=1 (minimum sensible boundary).88+  it("T-025d: length=1 produces exactly 1 character", () => {89+    const password = generatePassword({ length: 1 });90+    expect(password.length).toBe(1);91+  });92+93+  // T-025e: output is CSPRNG-based; calling twice produces different results.94+  // Not a determinism guarantee, but a sanity check that the output is not95+  // a hardcoded/constant string.96+  it("T-025e: two calls produce different passwords (CSPRNG is live)", () => {97+    const a = generatePassword({ length: 32 });98+    const b = generatePassword({ length: 32 });99+    // Collision probability: 1/(pool^32) ≈ 0 for any reasonable pool100+    expect(a).not.toBe(b);101+  });102+});103+104+describe("T-025: generateSmartPassword policy clamping", () => {105+  // T-025f: minLength > maxLength — generateSmartPassword clamps to maxLength.106+  // The evaluation order is: raise to minLength first (→ 50), then cap to107+  // maxLength (→ 16). Site policy maxLength must win.108+  //109+  // Bug this catches: swapping the clamp order so minLength wins over maxLength110+  // would produce a password that violates the site's stated maximum, causing111+  // sign-up failure on strict sites.112+  it("T-025f: minLength > maxLength → maxLength wins (site maximum clamped)", () => {113+    const password = generateSmartPassword({ minLength: 50, maxLength: 16 });114+    expect(password.length).toBeLessThanOrEqual(16);115+  });116+117+  // T-025g: maxLength below the safety floor of 8 → floor applies.118+  // generateSmartPassword has `if (length < 8) length = 8` to prevent119+  // trivially weak passwords being auto-generated for restrictive sites.120+  it("T-025g: maxLength=4 → safety floor raises output to at least 8 chars", () => {121+    const password = generateSmartPassword({ maxLength: 4 });122+    expect(password.length).toBeGreaterThanOrEqual(8);123+  });124+125+  // T-025h: forbidsSymbols=true → no symbol characters in output.126+  it("T-025h: forbidsSymbols=true → no !@#$%^&* in password", () => {127+    const password = generateSmartPassword({ forbidsSymbols: true, minLength: 20 });128+    expect(password).not.toMatch(/[!@#$%^&*]/);129+  });130+});

view file

src/drafts/TEST-EXPANSION/ml.predictbatch.test.ts+185 −0
@@ -0,0 +1,185 @@1+// T-030: ml.ts predictBatch — batch sizes, softmax stability with extreme weights2+//3+// Why these tests exist: `predictBatch` in background/ml.ts is the ML inference4+// entry point called by the autofill orchestration layer. It is also the5+// critical path that runs on every form interaction. Two properties are untested:6+//7+//   1. Batch size handling: empty arrays, single items, and large batches8+//      (100+ fields — enough to cover entire SPA page loads).9+//10+//   2. Softmax numerical stability: the softmax implementation uses the11+//      max-subtraction trick (`x - max` before exp()) to prevent overflow for12+//      extreme logit values. Without this, extremely large logits (e.g., 1000)13+//      would produce `Infinity` in Math.exp(1000), making the confidence NaN.14+//      This is the standard numerical stability fix and should be tested with15+//      stress-case feature vectors.16+//17+// Note: the model weights (W, B) are baked in. We test the inference contract18+// (output shape, probability constraints) not the model accuracy.19+20+import { describe, expect, it } from "vitest";21+import { predictBatch } from "../../background/ml";22+import type { MLFieldFeatures, FieldPrediction, FieldType } from "../../shared/ml";23+24+// ---------------------------------------------------------------------------25+// Feature vector factory26+// ---------------------------------------------------------------------------27+28+// Builds a zero-vector feature set (no signals → model returns "none" or29+// uncertain distribution).30+function zeroFeatures(): MLFieldFeatures {31+  return {32+    type_text: 0, type_email: 0, type_password: 0, type_tel: 0,33+    type_number: 0, type_search: 0, type_url: 0, type_other: 0,34+    auto_username: 0, auto_email: 0, auto_current_password: 0,35+    auto_new_password: 0, auto_one_time_code: 0, auto_off: 0, auto_other: 0,36+    name_has_user: 0, name_has_login: 0, name_has_email: 0, name_has_pass: 0,37+    name_length: 0,38+    id_has_user: 0, id_has_login: 0, id_has_email: 0, id_has_pass: 0,39+    id_length: 0,40+    placeholder_has_user: 0, placeholder_has_email: 0, placeholder_has_pass: 0,41+    placeholder_length: 0,42+    aria_label_has_user: 0, aria_label_has_email: 0, aria_label_has_pass: 0,43+    aria_label_length: 0,44+    parent_is_form: 0, parent_is_div: 0, parent_is_section: 0,45+    sibling_count: 0, has_password_sibling: 0, has_email_sibling: 0,46+    form_has_submit: 0, form_action_has_login: 0,47+    is_required: 0, has_placeholder: 0, has_aria_label: 0,48+    inputmode_numeric: 0,49+  };50+}51+52+// Builds features that strongly signal a password field.53+function passwordFeatures(): MLFieldFeatures {54+  return {55+    ...zeroFeatures(),56+    type_password: 1,57+    auto_current_password: 1,58+    name_has_pass: 1,59+    id_has_pass: 1,60+    parent_is_form: 1,61+    form_has_submit: 1,62+  };63+}64+65+const VALID_TYPES: FieldType[] = ["username", "password", "email", "totp", "none"];66+67+function assertValidPrediction(p: FieldPrediction): void {68+  expect(VALID_TYPES).toContain(p.type);69+  expect(p.confidence).toBeGreaterThanOrEqual(0);70+  expect(p.confidence).toBeLessThanOrEqual(1);71+  expect(Number.isFinite(p.confidence)).toBe(true);72+  expect(Number.isNaN(p.confidence)).toBe(false);73+74+  let sumProb = 0;75+  for (const t of VALID_TYPES) {76+    const prob = p.probabilities[t];77+    expect(Number.isFinite(prob)).toBe(true);78+    expect(prob).toBeGreaterThanOrEqual(0);79+    sumProb += prob;80+  }81+  // Probabilities must sum to ~1.0 (floating point tolerance)82+  expect(Math.abs(sumProb - 1.0)).toBeLessThan(1e-9);83+}84+85+// ---------------------------------------------------------------------------86+// Tests87+// ---------------------------------------------------------------------------88+89+describe("T-030: predictBatch batch sizes", () => {90+  // T-030a: empty array → returns empty array, no error.91+  it("T-030a: predictBatch([]) returns empty array", () => {92+    const result = predictBatch([]);93+    expect(Array.isArray(result)).toBe(true);94+    expect(result.length).toBe(0);95+  });96+97+  // T-030b: single feature vector → returns exactly one prediction.98+  it("T-030b: predictBatch([single]) returns exactly 1 prediction", () => {99+    const result = predictBatch([zeroFeatures()]);100+    expect(result.length).toBe(1);101+    assertValidPrediction(result[0]!);102+  });103+104+  // T-030c: batch of 100 feature vectors → returns exactly 100 predictions.105+  // 100 fields covers a large SPA page load (e.g., a checkout form with106+  // multiple steps rendered simultaneously).107+  it("T-030c: predictBatch(100 vectors) returns 100 predictions", () => {108+    const features = Array.from({ length: 100 }, () => zeroFeatures());109+    const result = predictBatch(features);110+    expect(result.length).toBe(100);111+    for (const p of result) {112+      assertValidPrediction(p);113+    }114+  });115+116+  // T-030d: password-signal features produce a prediction for "password" type.117+  // This is not a model accuracy test — it just verifies that strong password118+  // signals produce a non-zero confidence for the "password" class.119+  it("T-030d: password-signal features → confidence for password is highest or meaningful", () => {120+    const result = predictBatch([passwordFeatures()]);121+    expect(result.length).toBe(1);122+    assertValidPrediction(result[0]!);123+    // With strong password features, the password probability should be notable124+    expect(result[0]!.probabilities.password).toBeGreaterThan(0);125+  });126+});127+128+describe("T-030: predictBatch softmax numerical stability", () => {129+  // T-030e: extreme positive logit values (all features = 1000) → softmax must130+  // not produce NaN or Infinity. The max-subtraction trick prevents overflow.131+  //132+  // Bug this catches: removing the `x - max` subtraction in softmax would cause133+  // Math.exp(1000) = Infinity, making all probabilities NaN and confidence NaN.134+  it("T-030e: extreme positive feature values → no NaN or Infinity in output", () => {135+    const extremeFeatures: MLFieldFeatures = {} as MLFieldFeatures;136+    for (const key of Object.keys(zeroFeatures()) as (keyof MLFieldFeatures)[]) {137+      (extremeFeatures as Record<string, number>)[key] = 1000;138+    }139+140+    const result = predictBatch([extremeFeatures]);141+    assertValidPrediction(result[0]!);142+  });143+144+  // T-030f: extreme negative feature values → no NaN or Infinity.145+  // Math.exp(-1000 - max) where max is also -1000 → exp(0) = 1 → stable.146+  it("T-030f: extreme negative feature values → no NaN or Infinity", () => {147+    const extremeNeg: MLFieldFeatures = {} as MLFieldFeatures;148+    for (const key of Object.keys(zeroFeatures()) as (keyof MLFieldFeatures)[]) {149+      (extremeNeg as Record<string, number>)[key] = -1000;150+    }151+152+    const result = predictBatch([extremeNeg]);153+    assertValidPrediction(result[0]!);154+  });155+156+  // T-030g: mixed extreme values (some 1e6, some -1e6) → stable.157+  it("T-030g: mixed extreme positive and negative feature values → stable softmax", () => {158+    const mixed: MLFieldFeatures = {} as MLFieldFeatures;159+    let i = 0;160+    for (const key of Object.keys(zeroFeatures()) as (keyof MLFieldFeatures)[]) {161+      (mixed as Record<string, number>)[key] = i % 2 === 0 ? 1e6 : -1e6;162+      i++;163+    }164+165+    const result = predictBatch([mixed]);166+    assertValidPrediction(result[0]!);167+  });168+169+  // T-030h: output type is always one of the 5 known FieldType values.170+  // Bug this catches: CLASSES[maxIdx] would be undefined if maxIdx is out of171+  // range (shouldn't happen, but the test pins the contract).172+  it("T-030h: all predictions have a valid FieldType (not undefined)", () => {173+    const cases = [174+      zeroFeatures(),175+      passwordFeatures(),176+      { ...zeroFeatures(), type_email: 1, auto_email: 1, name_has_email: 1 },177+      { ...zeroFeatures(), auto_one_time_code: 1, inputmode_numeric: 1 },178+    ];179+180+    const results = predictBatch(cases);181+    for (const r of results) {182+      expect(VALID_TYPES).toContain(r.type);183+    }184+  });185+});

view file