Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 46 additions & 2 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 `<details>`, an `<img usemap>` 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";
Expand Down Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -101,6 +102,7 @@ export function initInteractions(options: InteractionOptions): InteractionHandle
options.sink,
options.routeTemplate,
options.requestLabel,
options.testId,
];
const signature = JSON.stringify({
...options,
Expand All @@ -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();
Expand Down
9 changes: 6 additions & 3 deletions packages/browser/src/page-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 = {}) {
Expand All @@ -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 };
Loading
Loading