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
↓ peach-browser-54e4594f.tar.gzatlas at this save
files changed4 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 −0src/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) |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+});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+});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+});