|
| 1 | +import { createHash } from "node:crypto" |
| 2 | + |
| 3 | +import type { BrowserAdaptiveAction } from "./browser-adaptive-exploration.js" |
| 4 | +import { isPlainObject, stableJson, stripUndefined } from "./object-utils.js" |
| 5 | + |
| 6 | +export const BROWSER_ACCESSIBILITY_SCHEMA = "wp-codebox/browser-accessibility/v1" as const |
| 7 | +export const BROWSER_ACCESSIBILITY_RULES = [ |
| 8 | + "accessible-name", |
| 9 | + "keyboard-reachable", |
| 10 | + "tab-order", |
| 11 | + "focus-visible", |
| 12 | + "focus-loss", |
| 13 | + "dialog-focus", |
| 14 | + "aria-state", |
| 15 | +] as const |
| 16 | + |
| 17 | +export type BrowserAccessibilityRule = typeof BROWSER_ACCESSIBILITY_RULES[number] |
| 18 | +export type BrowserAccessibilityImpact = "minor" | "moderate" | "serious" | "critical" |
| 19 | +export type BrowserAccessibilityScanPhase = "initial" | "novel-state" | "final" | "replay" |
| 20 | + |
| 21 | +export interface BrowserAccessibilityContract { |
| 22 | + schema: typeof BROWSER_ACCESSIBILITY_SCHEMA |
| 23 | + ruleTags: BrowserAccessibilityRule[] |
| 24 | + includeScopes: string[] |
| 25 | + excludeScopes: string[] |
| 26 | + impactThreshold: BrowserAccessibilityImpact |
| 27 | + cadence: Array<"initial" | "novel-state" | "final"> |
| 28 | + capabilities: { |
| 29 | + rules: "required" | "optional" |
| 30 | + focus: "required" | "optional" |
| 31 | + accessibilityTree: "required" | "optional" | "disabled" |
| 32 | + } |
| 33 | + budgets: { |
| 34 | + maxScans: number |
| 35 | + maxViolationsPerScan: number |
| 36 | + maxTargetsPerViolation: number |
| 37 | + maxFocusTransitions: number |
| 38 | + maxTreeChars: number |
| 39 | + maxKeyboardActions: number |
| 40 | + maxFrames: number |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +export interface BrowserAccessibilityTargetEvidence { |
| 45 | + locator: string |
| 46 | + frameId: string |
| 47 | + tag: string |
| 48 | + role?: string |
| 49 | + states?: Partial<Record<"expanded" | "selected" | "checked" | "busy" | "pressed" | "hidden" | "inert", string>> |
| 50 | + box?: { x: number; y: number; width: number; height: number; viewportWidth: number; viewportHeight: number } |
| 51 | +} |
| 52 | + |
| 53 | +export interface BrowserAccessibilityFinding { |
| 54 | + oracle: "accessibility" | "keyboard" | "focus" |
| 55 | + rule: BrowserAccessibilityRule |
| 56 | + code: string |
| 57 | + impact: BrowserAccessibilityImpact |
| 58 | + classification: string |
| 59 | + fingerprint: string |
| 60 | + target: BrowserAccessibilityTargetEvidence |
| 61 | + stateDigest?: string |
| 62 | + transitionId?: string |
| 63 | + actionId?: string |
| 64 | + expected?: string |
| 65 | + actual?: string |
| 66 | +} |
| 67 | + |
| 68 | +export interface BrowserAccessibilityFocusTransition { |
| 69 | + index: number |
| 70 | + phase: BrowserAccessibilityScanPhase |
| 71 | + actionId?: string |
| 72 | + from?: string |
| 73 | + to: string |
| 74 | + visible: boolean |
| 75 | + insideDialog: boolean |
| 76 | +} |
| 77 | + |
| 78 | +export interface BrowserAccessibilityScan { |
| 79 | + index: number |
| 80 | + phase: BrowserAccessibilityScanPhase |
| 81 | + status: "passed" | "findings" | "unsupported" | "inconclusive" |
| 82 | + stateDigest?: string |
| 83 | + transitionId?: string |
| 84 | + actionId?: string |
| 85 | + findings: BrowserAccessibilityFinding[] |
| 86 | + accessibilityTree?: { status: "captured" | "unsupported" | "inconclusive"; snapshot?: string; reason?: string; truncated?: boolean } |
| 87 | + diagnostics: Array<{ code: string; message: string }> |
| 88 | + artifacts?: { screenshot?: string; domSnapshot?: string } |
| 89 | +} |
| 90 | + |
| 91 | +export interface BrowserAccessibilityEvidence { |
| 92 | + schema: typeof BROWSER_ACCESSIBILITY_SCHEMA |
| 93 | + collector: { name: string; version: string; capabilities: { rules: "supported" | "unsupported"; focus: "supported" | "unsupported"; accessibilityTree: "supported" | "unsupported" } } |
| 94 | + contract: BrowserAccessibilityContract |
| 95 | + scans: BrowserAccessibilityScan[] |
| 96 | + focusHistory: BrowserAccessibilityFocusTransition[] |
| 97 | + diagnostics: Array<{ code: string; message: string }> |
| 98 | + summary: { scans: number; findings: number; passed: number; unsupported: number; inconclusive: number; truncated: boolean } |
| 99 | +} |
| 100 | + |
| 101 | +export interface BrowserAccessibilityCollector { |
| 102 | + beforeAction(action: BrowserAdaptiveAction): Promise<void> |
| 103 | + scan(input: { phase: BrowserAccessibilityScanPhase; stateDigest?: string; transitionId?: string; action?: BrowserAdaptiveAction; record?: boolean }): Promise<BrowserAccessibilityScan> |
| 104 | + reset(): Promise<void> |
| 105 | + evidence(): BrowserAccessibilityEvidence |
| 106 | +} |
| 107 | + |
| 108 | +export function browserAccessibilityContract(input: unknown): BrowserAccessibilityContract | undefined { |
| 109 | + if (!isPlainObject(input) || input.enabled === false) return undefined |
| 110 | + const budgets = object(input.budgets) |
| 111 | + const capabilities = object(input.capabilities) |
| 112 | + const requestedRules = array(input.ruleTags ?? input.rule_tags).filter((value): value is BrowserAccessibilityRule => (BROWSER_ACCESSIBILITY_RULES as readonly unknown[]).includes(value)) |
| 113 | + const requestedCadence = array(input.cadence).filter((value): value is "initial" | "novel-state" | "final" => value === "initial" || value === "novel-state" || value === "final") |
| 114 | + return { |
| 115 | + schema: BROWSER_ACCESSIBILITY_SCHEMA, |
| 116 | + ruleTags: requestedRules.length > 0 ? [...new Set(requestedRules)] : [...BROWSER_ACCESSIBILITY_RULES], |
| 117 | + includeScopes: boundedStrings(input.includeScopes ?? input.include_scopes, 20), |
| 118 | + excludeScopes: boundedStrings(input.excludeScopes ?? input.exclude_scopes, 20), |
| 119 | + impactThreshold: impact(input.impactThreshold ?? input.impact_threshold), |
| 120 | + cadence: requestedCadence.length > 0 ? [...new Set(requestedCadence)] : ["initial", "novel-state", "final"], |
| 121 | + capabilities: { |
| 122 | + rules: requirement(capabilities.rules), |
| 123 | + focus: requirement(capabilities.focus), |
| 124 | + accessibilityTree: capabilities.accessibilityTree === "disabled" || capabilities.accessibility_tree === "disabled" ? "disabled" : requirement(capabilities.accessibilityTree ?? capabilities.accessibility_tree), |
| 125 | + }, |
| 126 | + budgets: { |
| 127 | + maxScans: integer(budgets.maxScans ?? budgets.max_scans, 32, 1, 500), |
| 128 | + maxViolationsPerScan: integer(budgets.maxViolationsPerScan ?? budgets.max_violations_per_scan, 25, 1, 200), |
| 129 | + maxTargetsPerViolation: integer(budgets.maxTargetsPerViolation ?? budgets.max_targets_per_violation, 3, 1, 20), |
| 130 | + maxFocusTransitions: integer(budgets.maxFocusTransitions ?? budgets.max_focus_transitions, 100, 1, 1_000), |
| 131 | + maxTreeChars: integer(budgets.maxTreeChars ?? budgets.max_tree_chars, 20_000, 256, 200_000), |
| 132 | + maxKeyboardActions: integer(budgets.maxKeyboardActions ?? budgets.max_keyboard_actions, 12, 0, 50), |
| 133 | + maxFrames: integer(budgets.maxFrames ?? budgets.max_frames, 16, 1, 100), |
| 134 | + }, |
| 135 | + } |
| 136 | +} |
| 137 | + |
| 138 | +export function browserAccessibilityFindingFingerprint(finding: Omit<BrowserAccessibilityFinding, "fingerprint">): string { |
| 139 | + const stable = stripUndefined({ |
| 140 | + oracle: finding.oracle, |
| 141 | + rule: finding.rule, |
| 142 | + code: finding.code, |
| 143 | + impact: finding.impact, |
| 144 | + classification: finding.classification, |
| 145 | + target: { |
| 146 | + locator: normalizeLocator(finding.target.locator), |
| 147 | + frameId: finding.target.frameId, |
| 148 | + tag: finding.target.tag, |
| 149 | + role: finding.target.role, |
| 150 | + states: finding.target.states, |
| 151 | + }, |
| 152 | + expected: finding.expected, |
| 153 | + actual: finding.actual, |
| 154 | + }) |
| 155 | + return createHash("sha256").update("wp-codebox/browser-accessibility-finding/v1\n").update(stableJson(stable)).digest("hex") |
| 156 | +} |
| 157 | + |
| 158 | +function normalizeLocator(value: string): string { |
| 159 | + return value.replace(/([_:-](?:[a-z]{0,4})?)[a-f0-9]{8,}/gi, "$1<generated>").slice(0, 240) |
| 160 | +} |
| 161 | + |
| 162 | +function object(value: unknown): Record<string, unknown> { return isPlainObject(value) ? value : {} } |
| 163 | +function array(value: unknown): unknown[] { return Array.isArray(value) ? value : [] } |
| 164 | +function boundedStrings(value: unknown, maximum: number): string[] { return array(value).filter((item): item is string => typeof item === "string" && item.trim().length > 0).slice(0, maximum).map((item) => item.trim().slice(0, 240)) } |
| 165 | +function requirement(value: unknown): "required" | "optional" { return value === "required" ? "required" : "optional" } |
| 166 | +function impact(value: unknown): BrowserAccessibilityImpact { return value === "minor" || value === "moderate" || value === "critical" ? value : "serious" } |
| 167 | +function integer(value: unknown, fallback: number, minimum: number, maximum: number): number { const numeric = Number(value); return Number.isFinite(numeric) ? Math.max(minimum, Math.min(maximum, Math.floor(numeric))) : fallback } |
0 commit comments