From e92c9ba300b2a82df8dcc9faad494577ed9aff57 Mon Sep 17 00:00:00 2001 From: Miles Blackwood Date: Thu, 19 Feb 2026 16:48:36 -0500 Subject: [PATCH] [WIP] Fix qualification pipeline, add debug capture, human-readable conditions --- apps/browser/src/autofill/AUTOFILL_DEBUG.md | 177 ++++++++++--- .../browser/context-menu-clicked-handler.ts | 25 ++ .../browser/main-context-menu-handler.ts | 10 + .../content/bootstrap-autofill-overlay.ts | 12 +- .../autofill/enums/autofill-message.enums.ts | 2 + .../autofill-overlay-content.service.ts | 2 + ...nline-menu-field-qualifications.service.ts | 8 + .../services/autofill-debug.service.ts | 54 ++-- .../autofill-overlay-content.service.ts | 147 ++++++++++- ...inline-menu-field-qualification.service.ts | 232 +++++++++++++----- apps/browser/src/types/tab-messages.ts | 5 +- 11 files changed, 545 insertions(+), 129 deletions(-) diff --git a/apps/browser/src/autofill/AUTOFILL_DEBUG.md b/apps/browser/src/autofill/AUTOFILL_DEBUG.md index ad27ccdab80..34ed9648e58 100644 --- a/apps/browser/src/autofill/AUTOFILL_DEBUG.md +++ b/apps/browser/src/autofill/AUTOFILL_DEBUG.md @@ -5,11 +5,13 @@ This document describes how to use the autofill debug mode for troubleshooting f ## Enabling Debug Mode 1. Set the dev flag in your `.env.development` file: + ``` DEV_FLAGS={"autofillDebugMode": true} ``` 2. Rebuild the browser extension: + ```bash npm run build:watch ``` @@ -19,35 +21,67 @@ This document describes how to use the autofill debug mode for troubleshooting f ## Using Debug Mode When debug mode is enabled, the browser console will display: + ``` [Bitwarden Debug] Autofill debug mode enabled. Use window.__BITWARDEN_AUTOFILL_DEBUG__ ``` -### Available Methods +### Right-Click Context Menu (Easiest Path) + +With debug mode enabled, right-clicking any element on the page reveals a **[Debug] Copy autofill debug info** item in the Bitwarden context menu. Clicking it copies a plain-text summary of all field qualification decisions for the current page to your clipboard. + +This summary includes: + +- All fields that were evaluated (both qualified and rejected) +- Why each field passed or failed each condition +- Human-readable explanations and fix suggestions for failures + +Paste the copied text directly into a support ticket or bug report. Field values are never captured. + +### Console API #### `exportSession(format?: 'json' | 'summary' | 'console')` + Exports the current debug session in the specified format. ```javascript // Export as JSON (default) -const data = __BITWARDEN_AUTOFILL_DEBUG__.exportSession('json'); +const data = __BITWARDEN_AUTOFILL_DEBUG__.exportSession("json"); console.log(JSON.parse(data)); // Export as human-readable summary -console.log(__BITWARDEN_AUTOFILL_DEBUG__.exportSession('summary')); +console.log(__BITWARDEN_AUTOFILL_DEBUG__.exportSession("summary")); // Output to console with pretty formatting -__BITWARDEN_AUTOFILL_DEBUG__.exportSession('console'); +__BITWARDEN_AUTOFILL_DEBUG__.exportSession("console"); ``` #### `exportSummary()` + Returns a human-readable summary of the most recent session. ```javascript console.log(__BITWARDEN_AUTOFILL_DEBUG__.exportSummary()); ``` +#### `startSession(name?: string)` + +Starts a new debug session, optionally with a stable name for diffing. + +Named sessions produce deterministic, timestamp-prefixed IDs — useful for comparing the same page across different deploys or configurations: + +```javascript +// Named session: session_2026-02-19T12-00-00-000Z_login-form-test +__BITWARDEN_AUTOFILL_DEBUG__.startSession("login-form-test"); + +// Anonymous session: session_2026-02-19T12-00-00-000Z_ab3f2 +__BITWARDEN_AUTOFILL_DEBUG__.startSession(); +``` + +The ISO timestamp prefix means sessions sort naturally by date, enabling meaningful diffs between weekly deploys. + #### `setTracingDepth(depth: number)` + Configures how deep to trace precondition qualifiers. ```javascript @@ -62,6 +96,7 @@ __BITWARDEN_AUTOFILL_DEBUG__.setTracingDepth(2); ``` #### `getTracingDepth()` + Returns the current tracing depth. ```javascript @@ -70,11 +105,12 @@ console.log(`Current tracing depth: ${depth}`); ``` #### `getSessions()` + Returns an array of all session IDs in memory. ```javascript const sessions = __BITWARDEN_AUTOFILL_DEBUG__.getSessions(); -console.log('Active sessions:', sessions); +console.log("Active sessions:", sessions); ``` ## Enhanced Console Logging @@ -82,6 +118,7 @@ console.log('Active sessions:', sessions); When debug mode is enabled, the console automatically displays enhanced qualification messages: ### Field Qualified + ``` ✅ Field Qualified: opid_12345 Field: @@ -90,6 +127,7 @@ When debug mode is enabled, the console automatically displays enhanced qualific ``` ### Field Rejected + ``` ❌ Field Rejected: opid_12345 Field: @@ -101,19 +139,20 @@ When debug mode is enabled, the console automatically displays enhanced qualific ## Understanding Debug Output ### JSON Export Structure + ```json { - "sessionId": "session_1234567890_abc123", + "sessionId": "session_2026-02-19T12-00-00-000Z_abc12", "startTime": 1234567890000, "endTime": 1234567891000, "url": "https://example.com/login", "qualifications": [ { "fieldId": "opid_12345", - "elementSelector": "input[type='text']#username", + "elementSelector": "#username", "attempts": [ { - "attemptId": "attempt_1234567890_xyz789", + "attemptId": "attempt_2026-02-19T12-00-00-500Z", "timestamp": 1234567890500, "vector": "inline-menu", "result": { @@ -122,6 +161,7 @@ When debug mode is enabled, the console automatically displays enhanced qualific "pass": [ { "name": "isUsernameField", + "description": "Field is recognized as a username or email input", "functionSource": "function isUsernameField(field) { ... }" } ], @@ -138,7 +178,7 @@ When debug mode is enabled, the console automatically displays enhanced qualific "tracingDepth": 0 } }, - "triggeredBy": "page-load" + "triggeredBy": "setupOverlayListeners" } ], "finalDecision": { ... } @@ -148,30 +188,40 @@ When debug mode is enabled, the console automatically displays enhanced qualific ``` ### Summary Export Structure + ``` ================================================================================ Bitwarden Autofill Debug Summary ================================================================================ -Session ID: session_1234567890_abc123 +Session ID: session_2026-02-19T12-00-00-000Z_abc12 URL: https://example.com/login -Start Time: 2026-02-06T12:00:00.000Z -End Time: 2026-02-06T12:00:01.000Z +Start Time: 2026-02-19T12:00:00.000Z +End Time: 2026-02-19T12:00:01.000Z Duration: 1.00s Total Fields Qualified: 2 -------------------------------------------------------------------------------- Field ID: opid_12345 -Selector: input[type='text']#username +Selector: #username Attempts: 1 Final Decision: ✅ QUALIFIED Passed Conditions: - ✓ isUsernameField - ✓ notCurrentlyInSandboxedIframe + ✓ notCurrentlyInSandboxedIframe — Field is not in a sandboxed iframe + ✓ isUsernameField — Field is recognized as a username or email input -Vector: inline-menu -Timestamp: 2026-02-06T12:00:00.500Z +-------------------------------------------------------------------------------- +Field ID: opid_67890 +Selector: [name="search"] +Attempts: 1 +Final Decision: ❌ REJECTED + +Passed Conditions: + ✓ notCurrentlyInSandboxedIframe +Failed Conditions: + ✗ fieldIsForLoginForm — Field is part of a login form + → Fix: Add autocomplete="username" or autocomplete="email" to the field ================================================================================ ⚠️ WARNING: This debug data may contain sensitive information. @@ -189,32 +239,60 @@ Debug data tracks which autofill vector triggered the qualification: - **keyboard-shortcut**: Autofill triggered by keyboard shortcut (Ctrl+Shift+L) - **page-load**: Autofill triggered automatically on page load +### Architectural Reality + +All 4 entry points (inline menu, popup, keyboard shortcut, context menu) ultimately call the same fill path in the background service worker: `autofillService.doAutoFill()` → `generateFillScript()`. They use the same core logic and do not bypass each other. + +However, there are **two distinct qualification systems**: + +| System | Used by | Where it runs | +| ------------------------------------- | ------------------------ | ------------------------- | +| `InlineMenuFieldQualificationService` | Inline menu overlay only | Content script | +| `generateFillScript()` field matching | All 4 entry points | Background service worker | + +The `InlineMenuFieldQualificationService` is used exclusively to decide **whether to show the overlay icon on a given field**. The debug session records only this decision. The actual fill logic uses simpler, independent field detection in the background. + +This means: + +- A field _rejected_ by inline menu qualification (no overlay shown) may still be _filled_ by keyboard shortcut or context menu, because the two qualification systems use different criteria. +- A field _accepted_ by inline menu qualification may fail to fill if `generateFillScript()` doesn't match it. + +### Why Other Vectors Are Not Tracked + +The debug service lives in the content script inside `AutofillOverlayContentService`. The popup, keyboard shortcut, and context menu flows run entirely in the background service worker and only send the rendered fill script to the content script — they never invoke the content script's qualification service. There is no point in those paths where the current vector can be set. + +To track those vectors would require: + +1. A new message from the background to the content script announcing "autofill was triggered via [vector]" +2. The content script debug service recording it against the current session + +This is tracked as future work. + ## Precondition Tracing Some qualification functions depend on other qualifiers (preconditions). For example: + - `isNewPasswordField` depends on `isPasswordField` - `isCurrentPasswordField` depends on `isPasswordField` The tracing depth controls how deep to capture these dependencies: ### Depth = 0 (No Tracing) + Only captures the top-level condition result. Fastest, minimal data. ### Depth = 1 (Immediate Preconditions) - Default + Captures the direct preconditions of the qualification. Example: For `isNewPasswordField`, captures: + - `isNewPasswordField` (top level) - `isPasswordField` (immediate precondition) ### Depth = 2+ (Full Chain) -Captures the entire chain of preconditions recursively. -Example: For a complex qualification, captures: -- `isFieldForLoginForm` (top level) -- `isUsernameFieldForLoginForm` (precondition) -- `isUsernameField` (precondition of precondition) -- ... (and so on) +Captures the entire chain of preconditions recursively. ## Performance Impact @@ -235,48 +313,85 @@ Example: For a complex qualification, captures: ⚠️ **Never share debug output publicly or with untrusted parties** While field values are redacted, debug output still contains: + - Page URLs - Field IDs, names, and attributes - Form structure - Qualification logic (function source code) This information could be used to identify: + - Internal applications - Custom form fields - Business logic patterns +## QA / Regression Workflow + +Use named sessions for deterministic, diffable output across deployments: + +```javascript +// Before a deploy +__BITWARDEN_AUTOFILL_DEBUG__.startSession("checkout-form-baseline"); +// Focus fields on the page to trigger qualification +const before = __BITWARDEN_AUTOFILL_DEBUG__.exportSession("json"); + +// After a deploy +__BITWARDEN_AUTOFILL_DEBUG__.startSession("checkout-form-after-deploy"); +// Focus same fields +const after = __BITWARDEN_AUTOFILL_DEBUG__.exportSession("json"); + +// Diff — attempt IDs and timestamps in conditions are stable; only real changes show up +``` + +Session IDs use ISO timestamps (`session_2026-02-19T12-00-00-000Z_name`) so they sort naturally and identify when each capture was taken. + ## Troubleshooting Common Issues ### Debug API Not Available + ```javascript -typeof window.__BITWARDEN_AUTOFILL_DEBUG__ === 'undefined' +typeof window.__BITWARDEN_AUTOFILL_DEBUG__ === "undefined"; ``` **Solution**: Verify that: + 1. Dev flag is set correctly in `.env.development` 2. Extension was rebuilt after setting the flag 3. You're on a page where autofill is active (not a chrome:// or browser settings page) ### No Sessions Found + ```javascript -__BITWARDEN_AUTOFILL_DEBUG__.getSessions() // returns [] +__BITWARDEN_AUTOFILL_DEBUG__.getSessions(); // returns [] ``` **Solution**: + 1. Focus a form field to trigger qualification 2. Sessions expire after 5 minutes - check timing 3. Verify debug mode is enabled (check console for initialization message) ### Missing Precondition Data + ```javascript // result.meta.preconditions is undefined ``` **Solution**: Increase tracing depth: + ```javascript __BITWARDEN_AUTOFILL_DEBUG__.setTracingDepth(2); ``` +### Empty qualifications Array in Export + +If `exportSession('json')` shows `"qualifications": []`, the session captured no field evaluations. + +**Solution**: The session may have started after fields were already evaluated. Either: + +1. Start a named session before navigating: `startSession('my-test')` — then navigate to the page +2. Or refresh the page after enabling debug mode so all fields are re-evaluated + ## Example Workflow 1. Enable debug mode and rebuild extension @@ -296,16 +411,6 @@ __BITWARDEN_AUTOFILL_DEBUG__.setTracingDepth(2); 8. Focus the field again to capture with higher depth 9. Export and analyze: ```javascript - const json = __BITWARDEN_AUTOFILL_DEBUG__.exportSession('json'); + const json = __BITWARDEN_AUTOFILL_DEBUG__.exportSession("json"); // Save to file or analyze ``` - -## Future Enhancements - -Planned features (not yet implemented): -- Visual debug panel UI in popup/options -- Download JSON button -- Copy to clipboard functionality -- JSON-DSL representation of conditions -- Filter by field type or qualification result -- Session replay/comparison diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts index aa01ada0838..22e52f65707 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts @@ -38,6 +38,7 @@ import { openAddEditVaultItemPopout, openVaultItemPasswordRepromptPopout, } from "../../vault/popup/utils/vault-popout-window"; +import { COPY_AUTOFILL_DEBUG_ID } from "../enums/autofill-message.enums"; import { LockedVaultPendingNotificationsData } from "../background/abstractions/notification.background"; import { AutofillCipherTypeId } from "../types"; @@ -76,6 +77,13 @@ export class ContextMenuClickedHandler { this.copyToClipboard({ text: await this.getIdentifier(tab, info), tab: tab }); break; + case COPY_AUTOFILL_DEBUG_ID: + if (!tab.id) { + return; + } + + this.copyToClipboard({ text: await this.getAutofillDebugExport(tab), tab: tab }); + break; default: await this.cipherAction(info, tab); } @@ -279,4 +287,21 @@ export class ContextMenuClickedHandler { ); }); } + + private async getAutofillDebugExport(tab: chrome.tabs.Tab): Promise { + const tabId = tab.id!; + return new Promise((resolve) => { + BrowserApi.sendTabsMessage( + tabId, + { command: "getAutofillDebugExport" }, + undefined, + (debugText: string) => { + resolve( + debugText || + "No autofill debug data available. Enable debug mode and focus a form field first.", + ); + }, + ); + }); + } } diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.ts index 5a47975684c..d5566f309ce 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.ts @@ -27,6 +27,8 @@ import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { COPY_AUTOFILL_DEBUG_ID } from "../enums/autofill-message.enums"; +import { devFlagEnabled } from "../../platform/flags"; import { InitContextMenuItems } from "./abstractions/main-context-menu-handler"; export class MainContextMenuHandler { @@ -204,6 +206,14 @@ export class MainContextMenuHandler { await MainContextMenuHandler.create({ ...otherOptions, contexts: ["all"] }); } + if (devFlagEnabled("autofillDebugMode")) { + await MainContextMenuHandler.create({ + id: COPY_AUTOFILL_DEBUG_ID, + parentId: ROOT_ID, + title: "[Debug] Copy autofill debug info", + contexts: ["all"], + }); + } } catch (error) { if (error instanceof Error) { this.logService.warning(error.message); diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts index 839f61d551a..02002971678 100644 --- a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts +++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts @@ -1,3 +1,4 @@ +import { devFlagEnabled } from "../../platform/flags"; import { DebugExportFormat } from "../models/autofill-debug-data"; import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service"; import { OverlayNotificationsContentService } from "../overlay/notifications/content/overlay-notifications-content.service"; @@ -7,7 +8,6 @@ import DomElementVisibilityService from "../services/dom-element-visibility.serv import { DomQueryService } from "../services/dom-query.service"; import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service"; import { setupAutofillInitDisconnectAction } from "../utils"; -import { devFlagEnabled } from "../../platform/flags"; import AutofillInit from "./autofill-init"; @@ -60,8 +60,15 @@ import AutofillInit from "./autofill-init"; Array.from(debugService.sessionStore.keys())[0] || "", ); }, + startSession: (name?: string) => { + const sessionId = debugService.startSession(globalThis.location.href, name); + // eslint-disable-next-line no-console + console.log(`[Bitwarden Debug] Session started: ${sessionId}`); + return sessionId; + }, setTracingDepth: (depth: number) => { debugService.setTracingDepth(depth); + // eslint-disable-next-line no-console console.log(`[Bitwarden Debug] Precondition tracing depth set to ${depth}`); }, getTracingDepth: () => { @@ -72,6 +79,7 @@ import AutofillInit from "./autofill-init"; }, }; + /* eslint-disable no-console */ console.log( "%c[Bitwarden Debug] Autofill debug mode enabled. Use window.__BITWARDEN_AUTOFILL_DEBUG__", "color: #175DDC; font-weight: bold; font-size: 12px", @@ -79,9 +87,11 @@ import AutofillInit from "./autofill-init"; console.log("Available methods:"); console.log(" - exportSession(format?: 'json' | 'summary' | 'console')"); console.log(" - exportSummary()"); + console.log(" - startSession(name?: string) — named sessions are diffable across deploys"); console.log(" - setTracingDepth(depth: number)"); console.log(" - getTracingDepth()"); console.log(" - getSessions()"); + /* eslint-enable no-console */ } } })(window); diff --git a/apps/browser/src/autofill/enums/autofill-message.enums.ts b/apps/browser/src/autofill/enums/autofill-message.enums.ts index 4fdae2d9146..de17358ea50 100644 --- a/apps/browser/src/autofill/enums/autofill-message.enums.ts +++ b/apps/browser/src/autofill/enums/autofill-message.enums.ts @@ -3,6 +3,8 @@ export const AutofillMessageCommand = { collectPageDetailsResponse: "collectPageDetailsResponse", } as const; +export const COPY_AUTOFILL_DEBUG_ID = "copy-autofill-debug"; + export type AutofillMessageCommandType = (typeof AutofillMessageCommand)[keyof typeof AutofillMessageCommand]; diff --git a/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts index e1d24159664..2cf70bfce7f 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts @@ -27,6 +27,8 @@ export type AutofillOverlayContentExtensionMessageHandlers = { getInlineMenuFormFieldData: ({ message, }: AutofillExtensionMessageParam) => Promise; + generatedPasswordModifyLogin: () => Promise; + getAutofillDebugExport: () => string; }; export interface AutofillOverlayContentService { diff --git a/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts b/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts index 8f99f994b61..d2428f566f3 100644 --- a/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts +++ b/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts @@ -20,6 +20,8 @@ export type AutofillVector = export type QualificationCondition = { name: string; + description?: string; + suggestion?: string; functionSource?: string; }; @@ -53,11 +55,17 @@ export interface InlineMenuFieldQualificationService { isFieldForAccountCreationForm(field: AutofillField, pageDetails: AutofillPageDetails): boolean; isFieldForIdentityForm(field: AutofillField, pageDetails: AutofillPageDetails): boolean; isFieldForCardholderName(field: AutofillField): boolean; + isFieldForCardholderNameWithResult(field: AutofillField): QualificationResult; isFieldForCardNumber(field: AutofillField): boolean; + isFieldForCardNumberWithResult(field: AutofillField): QualificationResult; isFieldForCardExpirationDate(field: AutofillField): boolean; + isFieldForCardExpirationDateWithResult(field: AutofillField): QualificationResult; isFieldForCardExpirationMonth(field: AutofillField): boolean; + isFieldForCardExpirationMonthWithResult(field: AutofillField): QualificationResult; isFieldForCardExpirationYear(field: AutofillField): boolean; + isFieldForCardExpirationYearWithResult(field: AutofillField): QualificationResult; isFieldForCardCvv(field: AutofillField): boolean; + isFieldForCardCvvWithResult(field: AutofillField): QualificationResult; isFieldForIdentityTitle(field: AutofillField): boolean; isFieldForIdentityFirstName(field: AutofillField): boolean; isFieldForIdentityMiddleName(field: AutofillField): boolean; diff --git a/apps/browser/src/autofill/services/autofill-debug.service.ts b/apps/browser/src/autofill/services/autofill-debug.service.ts index b205e0d1bdd..23b987517a6 100644 --- a/apps/browser/src/autofill/services/autofill-debug.service.ts +++ b/apps/browser/src/autofill/services/autofill-debug.service.ts @@ -1,12 +1,13 @@ -import AutofillField from "../models/autofill-field"; -import AutofillPageDetails from "../models/autofill-page-details"; +/* eslint-disable no-console */ +import { devFlagEnabled } from "../../platform/flags"; import { AutofillDebugSession, DebugExportFormat, - FieldQualificationRecord, QualificationAttempt, } from "../models/autofill-debug-data"; -import { devFlagEnabled } from "../../platform/flags"; +import AutofillField from "../models/autofill-field"; +import AutofillPageDetails from "../models/autofill-page-details"; + import { AutofillVector, QualificationResult, @@ -40,8 +41,11 @@ export class AutofillDebugService { this.tracingDepth = depth; } - startSession(url: string): string { - const sessionId = `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + startSession(url: string, sessionName?: string): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const sessionId = sessionName + ? `session_${timestamp}_${sessionName}` + : `session_${timestamp}_${Math.random().toString(36).substring(2, 7)}`; this.currentSession = { sessionId, startTime: Date.now(), @@ -92,7 +96,7 @@ export class AutofillDebugService { } const attempt: QualificationAttempt = { - attemptId: `attempt_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + attemptId: `attempt_${new Date().toISOString().replace(/[:.]/g, "-")}`, timestamp: Date.now(), vector, result, @@ -105,7 +109,9 @@ export class AutofillDebugService { exportCurrentSession(format: DebugExportFormat = "json"): string { if (!this.currentSession) { - return format === "json" ? JSON.stringify({ error: "No active session" }) : "No active session"; + return format === "json" + ? JSON.stringify({ error: "No active session" }) + : "No active session"; } return this.exportSession(this.currentSession.sessionId, format); @@ -115,7 +121,9 @@ export class AutofillDebugService { const session = this.sessionStore.get(sessionId); if (!session) { - return format === "json" ? JSON.stringify({ error: "Session not found" }) : "Session not found"; + return format === "json" + ? JSON.stringify({ error: "Session not found" }) + : "Session not found"; } switch (format) { @@ -168,7 +176,8 @@ export class AutofillDebugService { lines.push(""); lines.push("Passed Conditions:"); for (const condition of fieldRecord.finalDecision.conditions.pass) { - lines.push(` ✓ ${condition.name}`); + const desc = condition.description ? ` — ${condition.description}` : ""; + lines.push(` ✓ ${condition.name}${desc}`); } } @@ -176,14 +185,20 @@ export class AutofillDebugService { lines.push(""); lines.push("Failed Conditions:"); for (const condition of fieldRecord.finalDecision.conditions.fail) { - lines.push(` ✗ ${condition.name}`); + const desc = condition.description ? ` — ${condition.description}` : ""; + lines.push(` ✗ ${condition.name}${desc}`); + if (condition.suggestion) { + lines.push(` → Fix: ${condition.suggestion}`); + } } } if (fieldRecord.finalDecision.meta) { lines.push(""); lines.push(`Vector: ${fieldRecord.finalDecision.meta.vector}`); - lines.push(`Timestamp: ${new Date(fieldRecord.finalDecision.meta.timestamp).toISOString()}`); + lines.push( + `Timestamp: ${new Date(fieldRecord.finalDecision.meta.timestamp).toISOString()}`, + ); } } @@ -230,7 +245,9 @@ export class AutofillDebugService { if (fieldRecord.finalDecision.conditions.pass.length > 0) { console.group("✓ Passed Conditions"); for (const condition of fieldRecord.finalDecision.conditions.pass) { - console.log(`${condition.name}`); + console.log( + `${condition.name}${condition.description ? ` — ${condition.description}` : ""}`, + ); if (condition.functionSource) { console.log(`Function:`, condition.functionSource); } @@ -241,7 +258,12 @@ export class AutofillDebugService { if (fieldRecord.finalDecision.conditions.fail.length > 0) { console.group("✗ Failed Conditions"); for (const condition of fieldRecord.finalDecision.conditions.fail) { - console.log(`${condition.name}`); + console.log( + `${condition.name}${condition.description ? ` — ${condition.description}` : ""}`, + ); + if (condition.suggestion) { + console.log(` → Fix: ${condition.suggestion}`); + } if (condition.functionSource) { console.log(`Function:`, condition.functionSource); } @@ -252,7 +274,9 @@ export class AutofillDebugService { if (fieldRecord.finalDecision.meta) { console.group("Metadata"); console.log(`Vector: ${fieldRecord.finalDecision.meta.vector}`); - console.log(`Timestamp: ${new Date(fieldRecord.finalDecision.meta.timestamp).toISOString()}`); + console.log( + `Timestamp: ${new Date(fieldRecord.finalDecision.meta.timestamp).toISOString()}`, + ); console.log(`Field Snapshot:`, fieldRecord.finalDecision.meta.fieldSnapshot); if (fieldRecord.finalDecision.meta.pageSnapshot) { console.log(`Page Snapshot:`, fieldRecord.finalDecision.meta.pageSnapshot); diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index ad0c4b742b6..b63c06bafce 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -54,7 +54,11 @@ import { } from "./abstractions/autofill-overlay-content.service"; import { DomElementVisibilityService } from "./abstractions/dom-element-visibility.service"; import { DomQueryService } from "./abstractions/dom-query.service"; -import { InlineMenuFieldQualificationService } from "./abstractions/inline-menu-field-qualifications.service"; +import { + AutofillVector, + InlineMenuFieldQualificationService, + QualificationResult, +} from "./abstractions/inline-menu-field-qualifications.service"; import { AutoFillConstants } from "./autofill-constants"; import { AutofillDebugService } from "./autofill-debug.service"; @@ -123,6 +127,8 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ getInlineMenuFormFieldData: ({ message }) => this.handleGetInlineMenuFormFieldDataMessage(message), generatedPasswordModifyLogin: () => this.sendGeneratedPasswordModifyLogin(), + getAutofillDebugExport: () => + this.debugService?.exportCurrentSession("summary") ?? "Debug mode not enabled", }; private readonly loginFieldQualifiers: Record = { [AutofillFieldQualifier.username]: this.inlineMenuFieldQualificationService.isUsernameField, @@ -263,16 +269,6 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ failure: "field previously qualified", }, }, - // Function should be entirely deconstructed first. - // { - // qualifier: ({ autofillFieldData, pageDetails }: QualificationCriteria) => - // !this.isIgnoredField(autofillFieldData, pageDetails), - // alias: "isNotIgnoredField", - // message: { - // success: "field is not ignored", - // failure: "field is ignored", - // }, - // }, { qualifier: ({ autofillFieldData }: QualificationCriteria) => !this.ignoredFieldTypes.has(autofillFieldData.type), @@ -297,6 +293,88 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ effect: ({ autofillFieldData }: QualificationCriteria) => this.setQualifiedLoginFillType(autofillFieldData), }, + { + qualifier: ({ autofillFieldData, pageDetails }: QualificationCriteria) => + this.showInlineMenuCards && + this.inlineMenuFieldQualificationService.isFieldForCreditCardForm( + autofillFieldData, + pageDetails, + ), + alias: "fieldIsForCreditCardForm", + message: { + success: "field is for credit card form", + failure: "field is not for credit card form", + }, + blocking: false, + effect: ({ autofillFieldData }: QualificationCriteria) => { + if (!autofillFieldData.inlineMenuFillType) { + autofillFieldData.inlineMenuFillType = CipherType.Card; + } + }, + }, + { + qualifier: ({ autofillFieldData, pageDetails }: QualificationCriteria) => + this.inlineMenuFieldQualificationService.isFieldForAccountCreationForm( + autofillFieldData, + pageDetails, + ), + alias: "fieldIsForAccountCreationForm", + message: { + success: "field is for account creation form", + failure: "field is not for account creation form", + }, + blocking: false, + effect: ({ autofillFieldData }: QualificationCriteria) => { + if (!autofillFieldData.inlineMenuFillType) { + this.setQualifiedAccountCreationFillType(autofillFieldData); + } + }, + }, + { + qualifier: ({ autofillFieldData, pageDetails }: QualificationCriteria) => + this.showInlineMenuIdentities && + this.inlineMenuFieldQualificationService.isFieldForIdentityForm( + autofillFieldData, + pageDetails, + ), + alias: "fieldIsForIdentityForm", + message: { + success: "field is for identity form", + failure: "field is not for identity form", + }, + blocking: false, + effect: ({ autofillFieldData }: QualificationCriteria) => { + if (!autofillFieldData.inlineMenuFillType) { + autofillFieldData.inlineMenuFillType = CipherType.Identity; + } + }, + }, + { + qualifier: ({ autofillFieldData, pageDetails }: QualificationCriteria) => + this.inlineMenuFieldQualificationService.isFieldForLoginForm( + autofillFieldData, + pageDetails, + ) || + this.inlineMenuFieldQualificationService.isFieldForAccountCreationForm( + autofillFieldData, + pageDetails, + ) || + (this.showInlineMenuCards && + this.inlineMenuFieldQualificationService.isFieldForCreditCardForm( + autofillFieldData, + pageDetails, + )) || + (this.showInlineMenuIdentities && + this.inlineMenuFieldQualificationService.isFieldForIdentityForm( + autofillFieldData, + pageDetails, + )), + alias: "fieldIsForKnownFormType", + message: { + success: "field is for a recognized form type", + failure: "field is not for any recognized form type", + }, + }, { qualifier: ({ formFieldElement, autofillFieldData }: QualificationCriteria) => !this.isHiddenField(formFieldElement, autofillFieldData), @@ -324,6 +402,13 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ isQualifiedField(criteria: QualificationCriteria) { const debugEnabled = this.debugService?.isDebugEnabled() ?? false; const responses: QualificationResponse[] = []; + const { autofillFieldData } = criteria; + + const elementSelector = autofillFieldData.htmlID + ? `#${autofillFieldData.htmlID}` + : autofillFieldData.htmlName + ? `[name="${autofillFieldData.htmlName}"]` + : autofillFieldData.opid; for (const definition of this.qualifiers) { const response = this.qualify(definition, criteria); @@ -331,8 +416,25 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ if (response.result === false && definition?.blocking !== false) { if (debugEnabled) { + const qualificationResult: QualificationResult = { + result: false, + conditions: { + pass: responses + .filter((r) => r !== response && r.result) + .map((r) => ({ name: r.alias })), + fail: [{ name: response.alias }], + }, + }; + this.debugService?.recordQualification( + autofillFieldData.opid, + elementSelector, + "inline-menu" as AutofillVector, + qualificationResult, + "setupOverlayListeners", + ); + /* eslint-disable no-console */ console.group( - `%c❌ Field Rejected: ${criteria.autofillFieldData.opid}`, + `%c❌ Field Rejected: ${autofillFieldData.opid}`, "color: #ef4444; font-weight: bold", ); console.log("Field:", criteria.formFieldElement); @@ -340,7 +442,9 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ console.log("Message:", response.message); console.log("All responses:", responses); console.groupEnd(); + /* eslint-enable no-console */ } else { + // eslint-disable-next-line no-console console.log({ element: criteria.formFieldElement, responses }); } return false; @@ -352,8 +456,23 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ } if (debugEnabled) { + const qualificationResult: QualificationResult = { + result: true, + conditions: { + pass: responses.map((r) => ({ name: r.alias })), + fail: [], + }, + }; + this.debugService?.recordQualification( + autofillFieldData.opid, + elementSelector, + "inline-menu" as AutofillVector, + qualificationResult, + "setupOverlayListeners", + ); + /* eslint-disable no-console */ console.group( - `%c✅ Field Qualified: ${criteria.autofillFieldData.opid}`, + `%c✅ Field Qualified: ${autofillFieldData.opid}`, "color: #10b981; font-weight: bold", ); console.log("Field:", criteria.formFieldElement); @@ -363,7 +482,9 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ ); console.log("All responses:", responses); console.groupEnd(); + /* eslint-enable no-console */ } else { + // eslint-disable-next-line no-console console.log({ element: criteria.formFieldElement, responses }); } diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index c2bf2dd805f..579e0b26678 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -16,8 +16,8 @@ import { SubmitChangePasswordButtonNames, SubmitLoginButtonNames, } from "./autofill-constants"; -import AutofillService from "./autofill.service"; import { AutofillDebugService } from "./autofill-debug.service"; +import AutofillService from "./autofill.service"; export class InlineMenuFieldQualificationService implements InlineMenuFieldQualificationServiceInterface { private searchFieldNamesSet = new Set(AutoFillConstants.SearchFieldNames); @@ -159,6 +159,116 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali return this.debugService?.isDebugEnabled() ?? false; } + private static readonly conditionDescriptions: Record< + string, + { description?: string; suggestion?: string } + > = { + isUsernameField: { + description: "Field is recognized as a username or email input", + suggestion: 'Add autocomplete="username" or autocomplete="email" to the field', + }, + isCurrentPasswordField: { + description: "Field is recognized as an existing (login) password field", + suggestion: 'Add autocomplete="current-password" to the field', + }, + isNewPasswordField: { + description: "Field is recognized as a new password field (account creation)", + suggestion: 'Add autocomplete="new-password" to the field', + }, + isPasswordField: { + description: "Field has type='password' or matches password heuristics", + suggestion: "Set type='password' on the field", + }, + isFieldForCardholderName: { + description: "Field is recognized as a cardholder name field", + suggestion: 'Add autocomplete="cc-name" to the field', + }, + isFieldForCardNumber: { + description: "Field is recognized as a credit card number field", + suggestion: 'Add autocomplete="cc-number" to the field', + }, + isFieldForCardExpirationDate: { + description: "Field is recognized as a card expiration date field", + suggestion: 'Add autocomplete="cc-exp" to the field', + }, + isFieldForCardExpirationMonth: { + description: "Field is recognized as a card expiration month field", + suggestion: 'Add autocomplete="cc-exp-month" to the field', + }, + isFieldForCardExpirationYear: { + description: "Field is recognized as a card expiration year field", + suggestion: 'Add autocomplete="cc-exp-year" to the field', + }, + isFieldForCardCvv: { + description: "Field is recognized as a card CVV/security code field", + suggestion: 'Add autocomplete="cc-csc" to the field', + }, + isFieldForIdentityTitle: { + description: "Field is recognized as an honorific title field", + suggestion: 'Add autocomplete="honorific-prefix" to the field', + }, + isFieldForIdentityFirstName: { + description: "Field is recognized as a first name field", + suggestion: 'Add autocomplete="given-name" to the field', + }, + isFieldForIdentityMiddleName: { + description: "Field is recognized as a middle name field", + suggestion: 'Add autocomplete="additional-name" to the field', + }, + isFieldForIdentityLastName: { + description: "Field is recognized as a last name field", + suggestion: 'Add autocomplete="family-name" to the field', + }, + isFieldForIdentityFullName: { + description: "Field is recognized as a full name field", + suggestion: 'Add autocomplete="name" to the field', + }, + isFieldForIdentityAddress1: { + description: "Field is recognized as a primary address line field", + suggestion: 'Add autocomplete="address-line1" to the field', + }, + isFieldForIdentityAddress2: { + description: "Field is recognized as a secondary address line field", + suggestion: 'Add autocomplete="address-line2" to the field', + }, + isFieldForIdentityAddress3: { + description: "Field is recognized as a third address line field", + suggestion: 'Add autocomplete="address-line3" to the field', + }, + isFieldForIdentityCity: { + description: "Field is recognized as a city field", + suggestion: 'Add autocomplete="address-level2" to the field', + }, + isFieldForIdentityState: { + description: "Field is recognized as a state or province field", + suggestion: 'Add autocomplete="address-level1" to the field', + }, + isFieldForIdentityPostalCode: { + description: "Field is recognized as a postal or ZIP code field", + suggestion: 'Add autocomplete="postal-code" to the field', + }, + isFieldForIdentityCountry: { + description: "Field is recognized as a country field", + suggestion: 'Add autocomplete="country" to the field', + }, + isFieldForIdentityCompany: { + description: "Field is recognized as a company or organization field", + suggestion: 'Add autocomplete="organization" to the field', + }, + isFieldForIdentityPhone: { + description: "Field is recognized as a phone number field", + suggestion: 'Add autocomplete="tel" to the field', + }, + isFieldForIdentityEmail: { + description: "Field is recognized as an email address field", + suggestion: 'Add autocomplete="email" to the field', + }, + isFieldForIdentityUsername: { + description: "Field is recognized as a username field for identity", + suggestion: 'Add autocomplete="username" to the field', + }, + }; + setCurrentVector(vector: AutofillVector): void { this.currentVector = vector; } @@ -192,8 +302,11 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali const maxDepth = this.debugService?.getTracingDepth() ?? 1; const shouldTrace = currentDepth < maxDepth; + const descMeta = InlineMenuFieldQualificationService.conditionDescriptions[name]; const condition = { name, + description: descMeta?.description, + suggestion: result ? undefined : descMeta?.suggestion, functionSource: shouldTrace ? fn.toString() : undefined, }; @@ -640,104 +753,97 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali * * @param field - The field to validate */ - isFieldForCardholderName = (field: AutofillField): boolean => { - if (this.fieldContainsAutocompleteValues(field, this.creditCardNameAutocompleteValues)) { - return true; - } + isFieldForCardholderName = (field: AutofillField): boolean => + this.isFieldForCardholderNameWithResult(field).result; - return this.keywordsFoundInFieldData( - field, - CreditCardAutoFillConstants.CardHolderFieldNames, - false, - ); - }; + isFieldForCardholderNameWithResult(field: AutofillField): QualificationResult { + const result = + this.fieldContainsAutocompleteValues(field, this.creditCardNameAutocompleteValues) || + this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CardHolderFieldNames, false); + return this.wrapQualifier("isFieldForCardholderName", () => result, field); + } /** * Validates the provided field as a credit card number field. * * @param field - The field to validate */ - isFieldForCardNumber = (field: AutofillField): boolean => { - if (this.fieldContainsAutocompleteValues(field, this.creditCardNumberAutocompleteValue)) { - return true; - } + isFieldForCardNumber = (field: AutofillField): boolean => + this.isFieldForCardNumberWithResult(field).result; - return this.keywordsFoundInFieldData( - field, - CreditCardAutoFillConstants.CardNumberFieldNames, - false, - ); - }; + isFieldForCardNumberWithResult(field: AutofillField): QualificationResult { + const result = + this.fieldContainsAutocompleteValues(field, this.creditCardNumberAutocompleteValue) || + this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CardNumberFieldNames, false); + return this.wrapQualifier("isFieldForCardNumber", () => result, field); + } /** * Validates the provided field as a credit card expiration date field. * * @param field - The field to validate */ - isFieldForCardExpirationDate = (field: AutofillField): boolean => { - if ( - this.fieldContainsAutocompleteValues(field, this.creditCardExpirationDateAutocompleteValue) - ) { - return true; - } + isFieldForCardExpirationDate = (field: AutofillField): boolean => + this.isFieldForCardExpirationDateWithResult(field).result; - return this.keywordsFoundInFieldData( - field, - CreditCardAutoFillConstants.CardExpiryFieldNames, - false, - ); - }; + isFieldForCardExpirationDateWithResult(field: AutofillField): QualificationResult { + const result = + this.fieldContainsAutocompleteValues(field, this.creditCardExpirationDateAutocompleteValue) || + this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CardExpiryFieldNames, false); + return this.wrapQualifier("isFieldForCardExpirationDate", () => result, field); + } /** * Validates the provided field as a credit card expiration month field. * * @param field - The field to validate */ - isFieldForCardExpirationMonth = (field: AutofillField): boolean => { - if ( - this.fieldContainsAutocompleteValues(field, this.creditCardExpirationMonthAutocompleteValue) - ) { - return true; - } + isFieldForCardExpirationMonth = (field: AutofillField): boolean => + this.isFieldForCardExpirationMonthWithResult(field).result; - return this.keywordsFoundInFieldData( - field, - CreditCardAutoFillConstants.ExpiryMonthFieldNames, - false, - ); - }; + isFieldForCardExpirationMonthWithResult(field: AutofillField): QualificationResult { + const result = + this.fieldContainsAutocompleteValues( + field, + this.creditCardExpirationMonthAutocompleteValue, + ) || + this.keywordsFoundInFieldData( + field, + CreditCardAutoFillConstants.ExpiryMonthFieldNames, + false, + ); + return this.wrapQualifier("isFieldForCardExpirationMonth", () => result, field); + } /** * Validates the provided field as a credit card expiration year field. * * @param field - The field to validate */ - isFieldForCardExpirationYear = (field: AutofillField): boolean => { - if ( - this.fieldContainsAutocompleteValues(field, this.creditCardExpirationYearAutocompleteValue) - ) { - return true; - } + isFieldForCardExpirationYear = (field: AutofillField): boolean => + this.isFieldForCardExpirationYearWithResult(field).result; - return this.keywordsFoundInFieldData( - field, - CreditCardAutoFillConstants.ExpiryYearFieldNames, - false, - ); - }; + isFieldForCardExpirationYearWithResult(field: AutofillField): QualificationResult { + const result = + this.fieldContainsAutocompleteValues(field, this.creditCardExpirationYearAutocompleteValue) || + this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.ExpiryYearFieldNames, false); + return this.wrapQualifier("isFieldForCardExpirationYear", () => result, field); + } /** * Validates the provided field as a credit card CVV field. * * @param field - The field to validate */ - isFieldForCardCvv = (field: AutofillField): boolean => { - if (this.fieldContainsAutocompleteValues(field, this.creditCardCvvAutocompleteValue)) { - return true; - } + isFieldForCardCvv = (field: AutofillField): boolean => + this.isFieldForCardCvvWithResult(field).result; - return this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CVVFieldNames, false); - }; + isFieldForCardCvvWithResult(field: AutofillField): QualificationResult { + const result = + this.fieldContainsAutocompleteValues(field, this.creditCardCvvAutocompleteValue) || + this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CVVFieldNames, false); + return this.wrapQualifier("isFieldForCardCvv", () => result, field); + } /** * Validates the provided field as an identity title type field. diff --git a/apps/browser/src/types/tab-messages.ts b/apps/browser/src/types/tab-messages.ts index dbedb3c4a55..e573b142fd7 100644 --- a/apps/browser/src/types/tab-messages.ts +++ b/apps/browser/src/types/tab-messages.ts @@ -1,7 +1,8 @@ export type TabMessage = | CopyTextTabMessage | ClearClipboardTabMessage - | GetClickedElementTabMessage; + | GetClickedElementTabMessage + | GetAutofillDebugExportTabMessage; export type TabMessageBase = { command: T; @@ -14,3 +15,5 @@ type CopyTextTabMessage = TabMessageBase<"copyText"> & { type ClearClipboardTabMessage = TabMessageBase<"clearClipboard">; type GetClickedElementTabMessage = TabMessageBase<"getClickedElement">; + +type GetAutofillDebugExportTabMessage = TabMessageBase<"getAutofillDebugExport">;