diff --git a/CHANGELOG.md b/CHANGELOG.md index b181ea4..73269b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,26 @@ # Changelog -All three npm packages (`@codepress/interactions-browser`, `@codepress/interactions-react`, `@codepress/interactions-playwright`) share one version. 1.0.0 is the first release published to npm; earlier versions were developed in this repository only. Versions follow [Semantic Versioning](https://semver.org): within 1.x, options, exports and protocol v2 fields are only added, never removed or changed in meaning. A check whose meaning changes gets a new `check_version`. +The three npm packages (`@codepress/interactions-browser`, `@codepress/interactions-react`, `@codepress/interactions-playwright`) were released together at 1.0.0; a later release bumps only the packages whose code changed. 1.0.0 is the first release published to npm; earlier versions were developed in this repository only. Versions follow [Semantic Versioning](https://semver.org): within 1.x, options, exports and protocol v2 fields are only added, never removed or changed in meaning. A check whose meaning changes gets a new `check_version`. The Python SDK in `python/` is versioned separately and is not covered by 1.0. +## 1.1.0 + +`@codepress/interactions-browser` and `@codepress/interactions-playwright`. `@codepress/interactions-react` has no code change and stays at 1.0.0; its `^1.0.0` dependency picks up this browser release, and its `InteractionOptions` type gains `testId`. + +### Added + +- `testId` option: `(value, element) => string | null` decides which `data-interaction-id` and `data-testid` values are recorded, everywhere one is read. It replaces the default. A hook that throws, or returns anything other than a string or `null`, drops the ID. See [Test IDs and privacy](packages/browser/README.md#test-ids-and-privacy). +- `defaultTestIdFilter` export, to call the default from a hook. +- `@codepress/interactions-playwright`: `testId` option on `captureSnapshot`, `findIssues` and `expectNoFindings`. It runs in the page, so it must be self-contained; a hook that uses a name missing from the page fails the capture. `globalThis.__codepressInteractions.defaultTestIdFilter` is there for a hook to call. + +### Changed + +- Test IDs that look built from data are no longer recorded. With no hook, a value is dropped when it contains a `.`, or has a segment (split on `-`, `_`, `.` or `:`) that contains a digit or is longer than 32 characters. Values such as `item-42` and `row-example.com` previously left the page in the snapshot dictionary, `control_id` and `stable_id`. The fields keep their shape and are absent more often; there is no protocol change. +- A click's tag path `control_id` is cut to the protocol's 128 characters, keeping the end nearest the control. Long custom element names could exceed it and make a batch invalid. +- `initInteractions` no longer joins a runtime from an older copy of the SDK on the same page, which would ignore options it does not know. The second caller gets an inactive handle. +- Findings on an element whose ID is now dropped use its structural path, so their fingerprint changes. Fingerprints in `ignoreFingerprints` lists for those elements need updating, and elements that shared a per-record pattern now share one finding. + ## 1.0.0 First public release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9fd565d..990c4cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,9 +43,9 @@ Keep pull requests focused, and describe what you changed and how you checked it ## Releasing -The three npm packages share one version. To release: +A release bumps the npm packages whose code changed. To release: -1. Bump `version` in `packages/browser/package.json`, `packages/react/package.json` and `packages/playwright/package.json`, and `VERSION` in `packages/browser/src/util.ts`; regenerate the golden samples (they record `sdk_version`). +1. Bump `version` in the `package.json` of each changed package, and, when the browser package changes, `VERSION` in `packages/browser/src/util.ts`; regenerate the golden samples (they record `sdk_version`). 2. Move the "Unreleased" changelog entries under the new version. 3. Merge to `main`. The publish workflows (`.github/workflows/publish-*.yml`) publish each version npm does not have yet, with provenance, through npm Trusted Publishing. No npm token is stored in the repository. The browser package goes first; the React and Playwright workflows do not publish until npm has the browser version they build against, so if they ran first they warn and skip, and you re-run them once the browser package is out. diff --git a/README.md b/README.md index 9f41990..63bd035 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ To see it end to end, run [`examples/react-vite`](examples/react-vite): a small ## What is captured -- **Clicks and submits** on semantic controls (buttons, links, fields, ARIA roles, labels), including controls mounted later. No per-control annotations. A control is identified by its `data-interaction-id` or `data-testid` when it has one, otherwise by a short tag path. +- **Clicks and submits** on semantic controls (buttons, links, fields, ARIA roles, labels), including controls mounted later. No per-control annotations. A control is identified by its `data-interaction-id` or `data-testid` when it has a static one, otherwise by a short tag path. IDs that look built from data (`item-42`, `row-example.com`) are [dropped by default](packages/browser/README.md#test-ids-and-privacy). - **What followed each click** until the page went quiet: time to first visible feedback, DOM mutation counts, requests and their outcome, route changes, dialogs, focus moves, layout shift, long tasks, errors, and hashes of the screen before and after. - **Screen snapshots** after a route settles, a click settles, a resize settles or the tab comes back: every rendered element's box, tag, role, safe test ID, z-index, CSS position, flags (visible, interactive, clipped, opaque, overlay, ...), the length of its own text, the boxes its text occupies, and hit tests for controls. - **Route templates**, never URLs: every path segment becomes `:id` unless you list it as a static word. diff --git a/packages/browser/README.md b/packages/browser/README.md index 9b00bb9..7367d3c 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -14,10 +14,53 @@ const interactions = initInteractions({ The endpoint is the service base URL; the SDK sends protocol v2 batches to `/v2/batches` and snapshot bodies to `/v2/snapshots/{sha256}` ([schemas](https://github.com/quantfive/codepress-interactions/blob/main/protocol)). Use `sink` instead of `endpoint` to send evidence anywhere else (see [Sinks](#sinks)). The public key resolves project/environment on the service. `environment` is a local diagnostic configuration label and never grants authority. Service origin admission and lease configuration must already permit this installation. API origins default to this page's origin. Add a separate API origin explicitly only if it is controlled by your application and permits `X-Interaction-Id` in its CORS configuration. -Initial setup captures clicks on semantic controls (including dynamically mounted controls), form submissions, navigation signals, bounded structural DOM changes, and allowed-origin fetch/XHR observations. It does not require per-control annotations. Control identifiers use safe `data-interaction-id`/`data-testid` values when present, otherwise an at-most-five-tag ancestry hint. This fallback is not unique and never scans sibling collections. These identifiers are structural hints and can change when layouts change. Mark private subtrees `data-interactions-exclude`. Do not put user data or secrets in explicit tracking identifiers. +Initial setup captures clicks on semantic controls (including dynamically mounted controls), form submissions, navigation signals, bounded structural DOM changes, and allowed-origin fetch/XHR observations. It does not require per-control annotations. Control identifiers use `data-interaction-id`/`data-testid` values that pass the [test ID filter](#test-ids-and-privacy), otherwise an at-most-five-tag ancestry hint. This fallback is not unique and never scans sibling collections. These identifiers are structural hints and can change when layouts change. Mark private subtrees `data-interactions-exclude`. Do not put user data or secrets in explicit tracking identifiers. No DOM text, HTML, input values, request/response bodies, URL paths, queries, cookies or user IDs are recorded. Mutation evidence reports changed-record counts, not a replay or an assertion that a spinner represents business completion. HTTP statuses are bounded `data.phase` labels such as `http_201`; browser request outcomes are `completed`/`error`, never server business outcomes. No durable browser storage is used. +## Test IDs and privacy + +A test ID is often built from data: `row-${record.id}`, `domain-row-${hostname}`. Recorded as is, it would carry that data off the page, and one per-record ID would split one finding into many. So every `data-interaction-id` and `data-testid` goes through a filter before it is used anywhere: the snapshot `test_id` dictionary, a click's `control_id`, `control_id` on UI state events, and a finding's `stable_id` and fingerprint. + +The default filter, `defaultTestIdFilter`, keeps an ID only when it looks static. It drops a value that: + +- contains a `.`, or +- has a segment (split on `-`, `_`, `.` or `:`) that contains a digit or is longer than 32 characters. + +| Value | Default | +| --- | --- | +| `save-button`, `nav:settings`, `plan_pro_cta` | kept | +| `item-42`, `step2` | dropped: a digit | +| `row-3f2a9c1e-7b4d-4e8a-9c21-5d6e7f809a1b` | dropped: a digit | +| `domain-row-example.com`, `checkout.pay` | dropped: a `.` | + +A dropped ID is treated as if the element had none. Clicks use the tag path, and findings use a structural path from the nearest ancestor with a kept ID. `data-interaction-id` follows the same rule as `data-testid`. When it is dropped, the element's `data-testid` is tried next. + +The filter cannot recognize every value. A slug of short words (`project-acme-launch`), a name (`row-JaneDoe`) or a random ID with no digit looks static and is kept, and a static ID with a digit (`step2`) is dropped. If your IDs can hold slugs or names, or you need IDs the default drops, allow only the IDs you know: + +```ts +const STATIC_TEST_IDS = new Set(["save-button", "nav:settings", "step2"]); + +initInteractions({ + sink, + testId: (value) => (STATIC_TEST_IDS.has(value) ? value : null), +}); +``` + +The `testId` option replaces the default. It runs wherever the default would, with the attribute's value and the element. Return the value, or a static replacement, to keep it, and `null` to drop it: + +```ts +import { defaultTestIdFilter, initInteractions } from "@codepress/interactions-browser"; + +initInteractions({ + sink, + // Collapse per-record rows into one ID, and use the default for the rest. + testId: (value) => (value.startsWith("order-row-") ? "order-row" : defaultTestIdFilter(value)), +}); +``` + +The hook replaces the default rather than adding to it, so `testId: (value) => value` keeps `item-42`. Call `defaultTestIdFilter(value)` from the hook to keep its protection. The result must still be a plain identifier (`[a-zA-Z][a-zA-Z0-9_.:-]*`, at most 128 characters) or it is dropped. A hook that throws, or returns anything other than a string or `null`, drops the ID; capture continues and nothing is logged. The hook runs for each element with an ID that a snapshot walks, and on clicks and UI state changes, so keep it fast and free of side effects. Pass a stable function: like `routeTemplate`, it is compared by identity when `initInteractions` is called again, and a call with a different hook gets an inactive handle. + ## Correlation Automatic clicks are separate interaction attempts. Automatic requests and mutation/navigation observations have unknown attribution: the SDK does not attach them to whichever click happened most recently. For known causal work use an optional explicit action: @@ -101,7 +144,7 @@ These are reported as measured. Each finding carries the evidence that says what - **A label click that the browser does not forward, and that changes nothing, is a dead click.** A click on label content the browser does not forward to the field (a nested button, a link, the body of a `
`, an `` with an empty map), or on a label for a hidden or disabled field, is a click of its own. When nothing happens it gives `dead_click` on the label's control (for example the form), with `target_interactive: true` because a label counts as a control. A label for a disabled field gives the same. - **A field joined to another form belongs to that form.** A field with the `form` attribute, and any label for it, is recorded on the form it joins, wherever it sits in the DOM: one label click is one `interaction.started` on that form. -The fingerprint is 32 hex characters of SHA-256 over the check, the route template, the viewport width band and the elements' `stable_id`, never coordinates, scroll offsets or text. It is the same for the same bug at another scroll offset, in another session or on another machine. `stable_id` is the test ID or, when there is none, a structural tag path from the nearest ancestor with a test ID (`#cart/div/button`); evidence `subject_structural_id` / `structural_id` says so. Structural paths change when the markup does, and siblings with the same tags share one. +The fingerprint is 32 hex characters of SHA-256 over the check, the route template, the viewport width band and the elements' `stable_id`, never coordinates, scroll offsets or text. It is the same for the same bug at another scroll offset, in another session or on another machine. `stable_id` is the test ID or, when there is none or it was [filtered out](#test-ids-and-privacy), a structural tag path from the nearest ancestor with a test ID (`#cart/div/button`); evidence `subject_structural_id` / `structural_id` says so. Structural paths change when the markup does, and siblings with the same tags share one. ```ts import { checkEvents, checkSnapshot, formatFinding } from "@codepress/interactions-browser/detect"; @@ -167,6 +210,7 @@ const sink: InteractionSink = { | `release` | none | Bounded release label sent with every batch. | | `routeSegments` | none | Static lowercase path words the default route template keeps, e.g. `["orders", "items"]`. | | `routeTemplate(url)` | every path segment `:id` unless in `routeSegments`; no query or hash | Return a bounded route label such as `/orders/[id]`. Values with a query or unsafe characters are dropped. | +| `testId(value, element)` | `defaultTestIdFilter` | Which test IDs are recorded; see [Test IDs and privacy](#test-ids-and-privacy). | | `requestLabel(url, init)` | none | Bounded static label for an observed request (`[A-Za-z0-9_.:/-]`, 64 characters). | | `propagateHeader` | `X-Interaction-Id` | Correlation header name for allowed origins; `false` sends none. A readable `X-Trace-Id` response header is recorded. | | `snapshots` | `true` | Turn screen snapshots off. | diff --git a/packages/browser/package.json b/packages/browser/package.json index 54ced63..ef1ce81 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,6 +1,6 @@ { "name": "@codepress/interactions-browser", - "version": "1.0.0", + "version": "1.1.0", "description": "Privacy-first browser interaction evidence SDK: screen snapshots, click follow-through, UI checks and pluggable sinks", "license": "Apache-2.0", "type": "module", diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index b198b50..71dda5a 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -27,7 +27,8 @@ export type { DetectOptions, ElementRef, Evidence, FindingData } from "./detect/ export type { SnapshotBody, SnapshotSummary } from "./snapshot"; export { FLAGS, HIT_POINTS, POSITIONS, THEMES } from "./snapshot"; export type { Theme } from "./format"; -export { VERSION, defaultRouteTemplate } from "./util"; +export type { TestIdFilter } from "./util"; +export { VERSION, defaultRouteTemplate, defaultTestIdFilter } from "./util"; const registryKey = Symbol.for("@codepress/interactions-browser/v1"); type RegistryWindow = Window & { [registryKey]?: Runtime }; @@ -101,6 +102,7 @@ export function initInteractions(options: InteractionOptions): InteractionHandle options.sink, options.routeTemplate, options.requestLabel, + options.testId, ]; const signature = JSON.stringify({ ...options, @@ -112,6 +114,8 @@ export function initInteractions(options: InteractionOptions): InteractionHandle if (existing) { if ( existing.signature !== signature || + // A runtime from an older copy has fewer slots: it would ignore the ones it lacks. + existing.identity.length !== identity.length || existing.identity.some((value, index) => value !== identity[index]) ) return noop(); diff --git a/packages/browser/src/page-script.ts b/packages/browser/src/page-script.ts index ec51ce6..6548ed1 100644 --- a/packages/browser/src/page-script.ts +++ b/packages/browser/src/page-script.ts @@ -2,10 +2,10 @@ * The snapshot walker as a page script, for test runners that inject it into a * page (see @codepress/interactions-playwright). Defines * `globalThis.__codepressInteractions.walk(options)`, which resolves with a - * snapshot body. Bundled to a string at build time as `PAGE_SCRIPT`. + * snapshot body, and `.defaultTestIdFilter` for a `testId` hook running in the page. Bundled to a string at build time as `PAGE_SCRIPT`. */ import { walkSnapshot } from "./snapshot"; -import { defaultRouteTemplate, safeRoute } from "./util"; +import { defaultRouteTemplate, defaultTestIdFilter, safeRoute, type TestIdFilter } from "./util"; export interface PageWalkOptions { /** CSS selector of the walk root. Default: the document element. */ @@ -17,6 +17,8 @@ export interface PageWalkOptions { routeSegments?: string[]; /** Theme to record. Default: prefers-color-scheme (Playwright's `colorScheme` / `emulateMedia`). */ theme?: string; + /** Test ID filter; replaces the default (`defaultTestIdFilter`). Must be a function created in the page. */ + testId?: TestIdFilter; } async function walk(options: PageWalkOptions = {}) { @@ -35,9 +37,10 @@ async function walk(options: PageWalkOptions = {}) { routeTemplate: route, shouldAbort: () => false, theme: options.theme === undefined ? undefined : () => options.theme, + testId: typeof options.testId === "function" ? options.testId : undefined, }); if (!result) throw new Error("snapshot walk was abandoned"); return result.body; } -(globalThis as unknown as { __codepressInteractions?: unknown }).__codepressInteractions = { walk }; +(globalThis as unknown as { __codepressInteractions?: unknown }).__codepressInteractions = { walk, defaultTestIdFilter }; diff --git a/packages/browser/src/runtime.ts b/packages/browser/src/runtime.ts index 1581458..65ed733 100644 --- a/packages/browser/src/runtime.ts +++ b/packages/browser/src/runtime.ts @@ -35,6 +35,7 @@ import { VERSION, clampMs, defaultRouteTemplate, + elementTestId, routeSegmentSet, numberOption, safeHeaderName, @@ -46,6 +47,7 @@ import { sha256Hex, validInteractionId, RecentKeys, + type TestIdFilter, } from "./util"; const ATTRIBUTION = "explicit synchronous runAction only" as const; @@ -153,6 +155,8 @@ export class Runtime { /** Per page session: the clicked element's opaque key, so rage clicks group by element. */ private targetKeys = new WeakMap(); private nextTargetKey = 1; + /** The host's `testId` hook; undefined uses `defaultTestIdFilter`. */ + private readonly testIdFilter: TestIdFilter | undefined; constructor( private options: InteractionOptions, @@ -186,6 +190,7 @@ export class Runtime { typeof crypto !== "undefined" && typeof crypto.subtle?.digest === "function"; this.findingsEnabled = options.findings !== false; + this.testIdFilter = typeof options.testId === "function" ? options.testId : undefined; } lease(): InteractionHandle { @@ -392,8 +397,7 @@ export class Runtime { ) return; const explicit = - safeIdentifier(target.getAttribute("data-interaction-id")) ?? - safeIdentifier(target.getAttribute("data-testid")); + elementTestId(target, this.testIdFilter); // Non-unique structural hint: at most five ancestor tags, never a sibling scan. const path: string[] = []; for ( @@ -403,7 +407,8 @@ export class Runtime { ) path.unshift(node.tagName.toLowerCase()); const id = crypto.randomUUID(); - const controlId = explicit ?? path.join("/"); + // The protocol bounds control_id at 128; the end of the path is nearest the control. + const controlId = explicit ?? path.join("/").slice(-128); this.record("interaction.started", id, { action: event.type, control_id: controlId, @@ -487,6 +492,7 @@ export class Runtime { let remaining = observerLimit; let truncated = false; let count = 0; + let ranHook = false; const take = () => { if (remaining > 0) { remaining--; @@ -527,6 +533,7 @@ export class Runtime { 'progress,[role="progressbar"],[role="status"],[aria-busy="true"]', ) ) { + ranHook = this.testIdFilter !== undefined; this.record("ui.state.changed", null, { feedback_type: candidate.matches('progress,[role="progressbar"]') ? "progress_indicator" @@ -534,9 +541,7 @@ export class Runtime { ? "busy" : "status_region", phase, - control_id: - safeIdentifier(candidate.getAttribute("data-interaction-id")) ?? - safeIdentifier(candidate.getAttribute("data-testid")), + control_id: elementTestId(candidate, this.testIdFilter), }); } } @@ -558,6 +563,7 @@ export class Runtime { if (record.type === "attributes") { const name = record.attributeName; if (name === "aria-busy" || name === "disabled") { + ranHook = this.testIdFilter !== undefined; this.record("ui.state.changed", null, { feedback_type: name === "aria-busy" ? "busy" : "disabled", phase: ( @@ -567,9 +573,7 @@ export class Runtime { ) ? "active" : "inactive", - control_id: - safeIdentifier(target.getAttribute("data-interaction-id")) ?? - safeIdentifier(target.getAttribute("data-testid")), + control_id: elementTestId(target, this.testIdFilter), }); } } @@ -593,6 +597,10 @@ export class Runtime { // At least one observation was omitted. Counting the rest would itself // exceed the work bound, so this is a lower bound, not a skipped-node total. if (truncated) this.dropped++; + // This callback is synchronous, so whatever is pending now was written + // during it: by the hook. Delivering that would run the hook again, and + // a hook that writes an observed attribute would never yield to the page. + if (ranHook) this.observer?.takeRecords(); }); this.observer.observe(this.root, { subtree: true, @@ -942,6 +950,7 @@ export class Runtime { routeTemplate, shouldAbort: () => !this.active || document.visibilityState === "hidden", theme: typeof this.options.theme === "function" ? this.options.theme : undefined, + testId: this.testIdFilter, }); if (!result || !this.active) { this.snapshotStats.skipped++; diff --git a/packages/browser/src/snapshot.ts b/packages/browser/src/snapshot.ts index 6d6386a..5248c64 100644 --- a/packages/browser/src/snapshot.ts +++ b/packages/browser/src/snapshot.ts @@ -5,7 +5,7 @@ */ import { FLAGS, HIT_POINTS, POSITIONS, THEMES, type SnapshotBody, type Theme } from "./format"; import type { SnapshotTrigger } from "./types"; -import { now, safeIdentifier } from "./util"; +import { elementTestId, now, type TestIdFilter } from "./util"; export { FLAGS, HIT_POINTS, POSITIONS, THEMES } from "./format"; export type { SnapshotBody } from "./format"; @@ -41,6 +41,8 @@ export interface WalkOptions { shouldAbort: () => boolean; /** The theme actually showing, when the app has its own toggle. Default: prefers-color-scheme. */ theme?: () => unknown; + /** Test ID filter (the `testId` option). Default: `defaultTestIdFilter`. */ + testId?: TestIdFilter; } export interface WalkResult { @@ -323,10 +325,7 @@ export async function walkSnapshot(options: WalkOptions): Promise Theme; + /** + * Decides which `data-interaction-id` / `data-testid` values are recorded, + * wherever one is read: the snapshot `test_id` dictionary, a click's + * `control_id`, UI state events, and so a finding's `stable_id` and + * fingerprint. Return the value (or a static replacement) to keep it, `null` + * to drop it; the element is then identified by its tag path, as if it had + * no test ID. A throw or a non-string result drops the ID. The + * result must still be a plain identifier (`[a-zA-Z][a-zA-Z0-9_.:-]*`, at + * most 128 characters). Replaces the default, `defaultTestIdFilter`, which + * drops values containing a `.` or a segment with a digit or more than 32 + * characters; call it from the hook to extend it. + */ + testId?: (value: string, element: Element) => string | null; } export interface Diagnostics { diff --git a/packages/browser/src/util.ts b/packages/browser/src/util.ts index a002155..0d80e77 100644 --- a/packages/browser/src/util.ts +++ b/packages/browser/src/util.ts @@ -1,4 +1,4 @@ -export const VERSION = "1.0.0"; +export const VERSION = "1.1.0"; export const numberOption = ( value: number | undefined, @@ -13,6 +13,52 @@ export const numberOption = ( export const safeIdentifier = (value: string | null | undefined): string | undefined => value && /^[a-zA-Z][a-zA-Z0-9_.:-]{0,127}$/.test(value) ? value : undefined; +/** + * Decides which `data-interaction-id` / `data-testid` values may be recorded: + * return the value (or a static replacement) to keep it, `null` to drop it. + */ +export type TestIdFilter = (value: string, element: Element) => string | null; + +/** + * The default test ID filter. Drops any value that contains a `.` (domains, + * file names, versions), and any value with a segment (split on `-`, `_`, `.` + * or `:`) that contains a digit or is longer than 32 characters (record IDs, + * UUIDs, hashes, counters). Keeps `save-button` and `nav:settings`; drops + * `item-42`, `row-3f2a9c1e-…` and `domain-row-example.com`. + */ +export function defaultTestIdFilter(value: string, _element?: Element): string | null { + if (typeof value !== "string" || value.includes(".")) return null; + for (const segment of value.split(/[-_:]/)) + if (segment.length > 32 || /[0-9]/.test(segment)) return null; + return value; +} + +/** Reads one ID attribute through the filter. A throw or a non-string result drops it. */ +function filteredId(element: Element, name: string, filter: TestIdFilter): string | undefined { + const value = element.getAttribute(name); + if (!value) return undefined; + let kept: unknown; + try { + kept = filter(value, element); + // An async hook is a non-string result; its rejection must not reach the page as an app error. + if (typeof (kept as PromiseLike | null)?.then === "function") + (kept as PromiseLike).then(undefined, () => {}); + } catch { + return undefined; + } + return typeof kept === "string" ? safeIdentifier(kept) : undefined; +} + +/** + * The element's recorded test ID: `data-interaction-id`, else `data-testid`, + * each passed through `filter` (default `defaultTestIdFilter`) and then the + * identifier shape check. Undefined when neither survives. + */ +export function elementTestId(element: Element, filter?: TestIdFilter): string | undefined { + const use = typeof filter === "function" ? filter : defaultTestIdFilter; + return filteredId(element, "data-interaction-id", use) ?? filteredId(element, "data-testid", use); +} + export const safeLabel = (value: unknown, max = 64): string | undefined => typeof value === "string" && value.length <= max && diff --git a/packages/browser/test/browser.test.ts b/packages/browser/test/browser.test.ts index e7a6566..6cc0e89 100644 --- a/packages/browser/test/browser.test.ts +++ b/packages/browser/test/browser.test.ts @@ -41,7 +41,7 @@ describe("initialization capture", () => { it("captures dynamically mounted semantic control without exposing private content", async () => { const handle = init(); document.body.innerHTML = - '
'; + '
'; document .querySelector("span")! .dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -56,7 +56,7 @@ describe("initialization capture", () => { expect( events().find((event) => event.type === "interaction.started").data .control_id, - ).toBe("transfer.confirm"); + ).toBe("transfer-confirm"); expect(JSON.stringify(batches())).not.toMatch(/Secret|password|innerHTML/); expect( events().some( @@ -315,7 +315,7 @@ describe("mutation callback work budget", () => { const tree = document.createElement("section"); tree.innerHTML = shape === "excluded siblings" - ? '
'.repeat( + ? '
'.repeat( 5000, ) + "" : "".repeat( @@ -411,7 +411,7 @@ describe("mutation callback work budget", () => { ).length, ).toBeLessThanOrEqual(limit); expect(JSON.stringify(batches())).not.toMatch( - /private.indicator|private text/, + /private-indicator|private text/, ); if (shape === "excluded siblings" || shape === "text siblings") { expect( diff --git a/packages/browser/test/e2e/detect.spec.ts b/packages/browser/test/e2e/detect.spec.ts index 3fc72d9..43f31d4 100644 --- a/packages/browser/test/e2e/detect.spec.ts +++ b/packages/browser/test/e2e/detect.spec.ts @@ -698,13 +698,13 @@ test("content of a closed
is not visible, so closed accordions do not const result = await scan( page, `
- ${[1, 2, 3].map((i) => `
Question ${i}
Answer ${i} with a few words
`).join("")} -
Open question
Shown answer
+ ${["one", "two", "three"].map((i) => `
Question ${i}
Answer ${i} with a few words
`).join("")} +
Open question
Shown answer
`, ); const flag = result.body.legend.flags.visible; - expect(result.body.elements.flags[rowOf(result.body, "a1")] & flag).toBe(0); - expect(result.body.elements.flags[rowOf(result.body, "a4")] & flag).toBe(flag); + expect(result.body.elements.flags[rowOf(result.body, "a-one")] & flag).toBe(0); + expect(result.body.elements.flags[rowOf(result.body, "a-four")] & flag).toBe(flag); expect(result.node.filter((f) => f.type === "overlap" || f.type === "spill")).toEqual([]); }); diff --git a/packages/browser/test/e2e/golden.spec.ts b/packages/browser/test/e2e/golden.spec.ts index 9e600c1..d7d6863 100644 --- a/packages/browser/test/e2e/golden.spec.ts +++ b/packages/browser/test/e2e/golden.spec.ts @@ -18,13 +18,13 @@ test("regenerate golden fixtures", async ({ page }) => { await openPage( page, `
- -
+ +

Checkout

Your order ships in two to three business days.

- +