diff --git a/.claude/skills/verify-local/SKILL.md b/.claude/skills/verify-local/SKILL.md deleted file mode 100644 index 8ea94d2..0000000 --- a/.claude/skills/verify-local/SKILL.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -name: verify-local -description: Verify CodePress Interactions SDK behavior, package artifacts and optional browser-to-Django telemetry round trips. ---- - -# SDK verification - -Read the root README, changed package README and `qa/README.md` before selecting -claims. Bind evidence to a clean full commit SHA and report later source changes. -For Factory delivery, get the current workflow with `codepress factory skill`, -`codepress factory skill falsify-diff`, and `codepress factory skill falsifier`; -pre-QA falsification belongs to the delivery owner. Do not copy CLI workflow code. - -Use `.codepress/verify-local/recipe.json` for setup and required command checks. -This is a library repository: ordinary checks need no persistent application -server, database, or production credential. Run Python commands from `python/`. -Inspect packed npm tarballs and built Python wheel/sdist for public entry points, -license files, declared dependencies and absence of environment/credential files. - -Choose the execution environment before starting services. On developer devices, -run commands directly and own/stop any servers started. In a CodePress cloud -session, use the coding workspace via `start_coding` and -`coding_workspace_operation` for self-contained checks; never run customer -servers on the control-plane runner. If coding execution is absent, use only -an already authorized backend-issued verification environment with manifest-owned -check IDs. Report unsupported execution honestly; do not silently switch planes -or invoke retired app-server tools. No application Dockerfile is required here. - -Observable runtime contracts: - -- Browser: dynamic controls, excluded subtrees, bounded progress/busy traversal, - privacy, fetch/XHR request preservation, SSR, repeated init/shutdown, queue bounds, - immutable retries and denied admission. Do not equate DOM emulation with real - browser CORS/navigation behavior. -- Python: strict wire schema, immutable retries, explicit acknowledgment, bounded - failure/flush/shutdown, sync/async Django context isolation, optional Celery - propagation and fork reset. Local queue acceptance is not remote acceptance. -- Correlation: no last-click timing guess; explicit bound fetch can cross awaits. - Unrelated requests remain unknown. Missing capture is never business failure. - -For a real network/browser claim use the committed `qa/roundtrip.py` with an -explicitly provisioned test telemetry service and the bindings in `qa/README.md`. -The caller supplies service access; the SDK has no administrative provisioning -permission. Reuse healthy caller-owned services; clean up only owned browser, -Django process/thread and uploader. Record service implementation/version and -whether storage was emulated. A source-built local service can prove the protocol; -only genuinely infrastructure-dependent effects need separately supplied staging -capabilities. This repository does not deploy a hosted service. If a required -runtime binding is unavailable, declare that specific evidence gap and continue -independent supported checks. Never invent secrets or treat missing hosted access -as a reason to suppress locally provable library evidence. - -Retain new reusable probes in `qa/` or package tests before the owner freezes a new -head. During exact-head QA return proposed source changes to the owner; do not -change the candidate. Keep reports/logs/traces/credentials outside the checkout. -Return concrete per-contract observations, limitations, cleanup, and unique -Markdown/JSON/JSONL artifacts. Do not publish reports, ready/merge PRs, or publish -registry packages from verification. diff --git a/.codepress/verify-local/recipe.json b/.codepress/verify-local/recipe.json deleted file mode 100644 index 58614bb..0000000 --- a/.codepress/verify-local/recipe.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "schema_version": 1, - "setup_commands": [ - [ - "pnpm", - "install", - "--frozen-lockfile" - ], - [ - "uv", - "sync", - "--project", - "python", - "--frozen" - ] - ], - "services": [], - "checks": [ - { - "id": "browser-tests", - "executor": "native", - "working_dir": ".", - "command": [ - "pnpm", - "test" - ], - "timeout_seconds": 300, - "expected_exit_codes": [ - 0 - ], - "env_defaults": {}, - "required_bindings": [] - }, - { - "id": "typescript", - "executor": "native", - "working_dir": ".", - "command": [ - "pnpm", - "typecheck" - ], - "timeout_seconds": 300, - "expected_exit_codes": [ - 0 - ], - "env_defaults": {}, - "required_bindings": [] - }, - { - "id": "javascript-packages", - "executor": "native", - "working_dir": ".", - "command": [ - "pnpm", - "build" - ], - "timeout_seconds": 300, - "expected_exit_codes": [ - 0 - ], - "env_defaults": {}, - "required_bindings": [] - }, - { - "id": "python-tests", - "executor": "native", - "working_dir": "python", - "command": [ - "uv", - "run", - "pytest", - "-q" - ], - "timeout_seconds": 300, - "expected_exit_codes": [ - 0 - ], - "env_defaults": {}, - "required_bindings": [] - }, - { - "id": "python-lint", - "executor": "native", - "working_dir": "python", - "command": [ - "uv", - "run", - "ruff", - "check", - "src", - "tests" - ], - "timeout_seconds": 300, - "expected_exit_codes": [ - 0 - ], - "env_defaults": {}, - "required_bindings": [] - }, - { - "id": "python-packages", - "executor": "native", - "working_dir": "python", - "command": [ - "uv", - "build" - ], - "timeout_seconds": 300, - "expected_exit_codes": [ - 0 - ], - "env_defaults": {}, - "required_bindings": [] - } - ] -} diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..4c5fc2e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,48 @@ +name: Bug report +description: Something the SDK, a check or a helper does wrong +labels: [bug] +body: + - type: markdown + attributes: + value: | + Do not paste real user data, project keys or tokens. For a security or privacy problem, use "Report a vulnerability" on the Security tab instead of an issue. + - type: dropdown + id: package + attributes: + label: Package + options: + - "@codepress/interactions-browser" + - "@codepress/interactions-react" + - "@codepress/interactions-playwright" + - Python SDK + - Example or skill + validations: + required: true + - type: input + id: version + attributes: + label: Version and browser + placeholder: "1.0.0, Chrome 140 on macOS" + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Page that reproduces it + description: Minimal HTML and CSS, a small component, or a link to a repository. + render: html + validations: + required: true + - type: textarea + id: reported + attributes: + label: What was reported + description: The finding line (from formatFinding or expectNoFindings), the events, or the error. + render: text + - type: textarea + id: expected + attributes: + label: What you expected + description: For a wrong or missing finding, say what is on screen. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/check-request.yml b/.github/ISSUE_TEMPLATE/check-request.yml new file mode 100644 index 0000000..4369dac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/check-request.yml @@ -0,0 +1,22 @@ +name: Check request +description: A UI problem the checks should find, or evidence a finding should carry +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: The problem users see + description: What goes wrong on screen or when clicking, and why it matters. + validations: + required: true + - type: textarea + id: example + attributes: + label: A page that shows it + description: Minimal HTML and CSS, or a component. + render: html + - type: textarea + id: measurement + attributes: + label: How it could be measured + description: What in a snapshot (element boxes, flags, text boxes, hit tests) or in the click timeline tells it apart from intended layouts. Checks never read page text. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b9e4bea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security or privacy problem + url: https://github.com/quantfive/codepress-interactions/security/advisories/new + about: Report it privately instead of in a public issue. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c7b88c..0ab81ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,10 @@ jobs: working-directory: packages/playwright run: pnpm run build && pnpm run pack:check + - name: Check the packed files (React adapter) + working-directory: packages/react + run: pnpm run build && pnpm run pack:check + python: name: Python tests and lint runs-on: ubuntu-24.04 diff --git a/.github/workflows/publish-playwright.yml b/.github/workflows/publish-playwright.yml index 1bb1f6b..117d7e1 100644 --- a/.github/workflows/publish-playwright.yml +++ b/.github/workflows/publish-playwright.yml @@ -126,8 +126,17 @@ jobs: echo "published=false" >> "$GITHUB_OUTPUT" echo "publish=false" >> "$GITHUB_OUTPUT" elif [ "$RELEASE" = "true" ]; then - echo "published=false" >> "$GITHUB_OUTPUT" - echo "publish=true" >> "$GITHUB_OUTPUT" + # The tarball depends on the browser package's version range; do not + # publish until npm has the browser version this checkout builds against. + BROWSER_VERSION=$(node -p "require('../browser/package.json').version") + if [ -z "$(npm view "@codepress/interactions-browser@$BROWSER_VERSION" version 2>/dev/null)" ]; then + echo "::warning::@codepress/interactions-browser@$BROWSER_VERSION is not on npm yet, so this run does not publish $PACKAGE@$VERSION. Publish the browser package first (publish-browser.yml), then re-run this workflow with dry_run off." + echo "published=false" >> "$GITHUB_OUTPUT" + echo "publish=false" >> "$GITHUB_OUTPUT" + else + echo "published=false" >> "$GITHUB_OUTPUT" + echo "publish=true" >> "$GITHUB_OUTPUT" + fi else echo "published=false" >> "$GITHUB_OUTPUT" echo "publish=false" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/publish-react.yml b/.github/workflows/publish-react.yml new file mode 100644 index 0000000..9de0cb1 --- /dev/null +++ b/.github/workflows/publish-react.yml @@ -0,0 +1,189 @@ +name: Publish React adapter + +# Typechecks and publishes @codepress/interactions-react (packages/react) to +# npm with public access. A pull request runs its typecheck and a publish dry +# run; a push to main publishes the checked-in version when npm does not have +# it yet. Publishing uses npm Trusted Publishing (OIDC): no npm token is stored +# in this repository. +# +# It depends on @codepress/interactions-browser as `workspace:^`. `pnpm pack` +# rewrites that to the browser package's version range in the tarball, and npm +# publishes the tarball; publish the browser version it names first +# (publish-browser.yml). + +on: + pull_request: + paths: + - "packages/react/**" + - "packages/browser/**" + - ".github/workflows/publish-react.yml" + push: + branches: [main] + paths: + - "packages/react/**" + workflow_dispatch: + inputs: + dry_run: + description: Test and pack without publishing + type: boolean + default: true + +concurrency: + # PRs cancel superseded runs; a release run is never cancelled, so a publish + # cannot be cut off half way. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + NODE_VERSION: "24" + REGISTRY_URL: "https://registry.npmjs.org" + SCOPE: "@codepress" + PACKAGE: "@codepress/interactions-react" + +jobs: + test: + name: Test and pack + runs-on: ubuntu-24.04 + timeout-minutes: 20 + outputs: + version: ${{ steps.version.outputs.version }} + publish: ${{ steps.version.outputs.publish }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: ${{ env.REGISTRY_URL }} + scope: ${{ env.SCOPE }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build the browser package + run: pnpm --filter @codepress/interactions-browser build + + - name: Typecheck + working-directory: packages/react + run: pnpm run typecheck + + - name: Build + working-directory: packages/react + run: pnpm run build + + - name: Check the packed files + working-directory: packages/react + run: pnpm run pack:check + + - name: Decide whether to publish + id: version + working-directory: packages/react + env: + RELEASE: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && !inputs.dry_run) }} + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + set +e + PUBLISHED=$(npm view "$PACKAGE" versions --json 2>"$RUNNER_TEMP/npm-view.err") + STATUS=$? + set -e + if [ "$STATUS" -ne 0 ]; then + if grep -q "E404" "$RUNNER_TEMP/npm-view.err"; then + # Trusted Publishing can only be configured on a package that exists, + # so the first release is a one-time manual publish (see below). + EXISTS=false + PUBLISHED='[]' + else + cat "$RUNNER_TEMP/npm-view.err" >&2 + echo "::error::Could not read $PACKAGE from the npm registry." + exit 1 + fi + else + EXISTS=true + fi + if printf '%s' "$PUBLISHED" | grep -F "\"$VERSION\"" >/dev/null; then + echo "::notice::$PACKAGE@$VERSION is already on npm; bump the version in package.json to release a change." + echo "published=true" >> "$GITHUB_OUTPUT" + echo "publish=false" >> "$GITHUB_OUTPUT" + elif [ "$RELEASE" = "true" ] && [ "$EXISTS" = "false" ]; then + echo "::warning::$PACKAGE does not exist on npm yet, so this run does not publish it. An owner of the npm scope publishes $VERSION once by hand (after the browser package version it depends on is on npm: pnpm --filter @codepress/interactions-browser build, then in packages/react pnpm run build, pnpm pack, and npm publish on the tarball with --access public) and adds this repository and publish-react.yml as the package's trusted publisher on npmjs.com; later versions then publish from this workflow." + echo "published=false" >> "$GITHUB_OUTPUT" + echo "publish=false" >> "$GITHUB_OUTPUT" + elif [ "$RELEASE" = "true" ]; then + # The tarball depends on the browser package's version range; do not + # publish until npm has the browser version this checkout builds against. + BROWSER_VERSION=$(node -p "require('../browser/package.json').version") + if [ -z "$(npm view "@codepress/interactions-browser@$BROWSER_VERSION" version 2>/dev/null)" ]; then + echo "::warning::@codepress/interactions-browser@$BROWSER_VERSION is not on npm yet, so this run does not publish $PACKAGE@$VERSION. Publish the browser package first (publish-browser.yml), then re-run this workflow with dry_run off." + echo "published=false" >> "$GITHUB_OUTPUT" + echo "publish=false" >> "$GITHUB_OUTPUT" + else + echo "published=false" >> "$GITHUB_OUTPUT" + echo "publish=true" >> "$GITHUB_OUTPUT" + fi + else + echo "published=false" >> "$GITHUB_OUTPUT" + echo "publish=false" >> "$GITHUB_OUTPUT" + fi + + # npm refuses to publish over a version it already has, even as a dry + # run, so only a version npm does not have yet can be dry-run. + - name: Publish dry run + if: steps.version.outputs.published != 'true' + working-directory: packages/react + run: | + pnpm pack --pack-destination "$RUNNER_TEMP/pack" + npm publish "$RUNNER_TEMP"/pack/*.tgz --dry-run --ignore-scripts --access public + + publish: + name: Publish to npm + needs: test + if: needs.test.outputs.publish == 'true' && github.ref == 'refs/heads/main' + # npm Trusted Publishing accepts GitHub-hosted runners only. + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + # OIDC Trusted Publishing: npm's trusted publisher for + # @codepress/interactions-react must name this workflow on this repository. + id-token: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: ${{ env.REGISTRY_URL }} + scope: ${{ env.SCOPE }} + package-manager-cache: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm --filter @codepress/interactions-browser build && pnpm --filter @codepress/interactions-react build + + # Trusted Publishing needs npm >= 11.5.1. + - name: Ensure npm supports Trusted Publishing + run: npm install -g npm@^11.5.1 + + # This repository is public, so the release carries a provenance attestation. + # pnpm pack rewrites the workspace:^ dependency to a version range; npm + # publishes that tarball with the OIDC identity and provenance. + - name: Publish + working-directory: packages/react + run: | + pnpm pack --pack-destination "$RUNNER_TEMP/pack" + npm publish "$RUNNER_TEMP"/pack/*.tgz --ignore-scripts --access public --provenance diff --git a/.gitignore b/.gitignore index 092fc54..ff98b89 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,5 @@ __pycache__/ .ruff_cache/ *.egg-info/ .env -# Local delivery-tool setup; SDK consumers do not need CodePress Factory. -.codepress/* -!.codepress/verify-local/ -.codex/ test-results/ playwright-report/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b181ea4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,54 @@ +# 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 Python SDK in `python/` is versioned separately and is not covered by 1.0. + +## 1.0.0 + +First public release. + +### Added + +- `ui.click.settled` reports `selection_changed`: the click's press, or the page while the click was open, changed the text selection or moved the caret. Positions are compared; selected text is never read. +- Overlap evidence: `subject_content_covered` (whether any of the subject's text, controls, images or opaque boxes lie under the overlap), `subject_interactive` and `other_interactive`. +- `findingTier(finding)` and `involvesControl(finding)` in `@codepress/interactions-browser/detect`. +- Sinks get `context.fetch`, the page's `fetch` without the SDK's wrapper. +- `@codepress/interactions-react` is published, with its own publish workflow. +- A runnable example ([`examples/react-vite`](examples/react-vite)) that writes snapshots and findings to local JSON, and a portable agent skill ([`skills/fix-ui-findings`](skills/fix-ui-findings/SKILL.md)). +- [Protocol guide](protocol/README.md), contributing guide and issue templates. + +### Changed + +- Sorting: inside the "rest" tier, findings that involve a control come first. An overlap that covers none of the subject's content (a fixed header over a padded `
`) sorts with decorative and empty findings. It is still reported. +- `click.rage_click` treats a caret in a focused text field, or a changed selection, as an answer. Repeated clicks in one field, or a triple-click selecting a line, are no longer a rage burst; a dead button still is. +- Sent fingerprints are remembered by recency, up to 2,000. A fingerprint found again is not resent; past the limit, the one unseen for longest is forgotten. A fingerprint found in the current run is never evicted, so a screen that keeps producing more than 2,000 findings does not resend them. +- A sink's own requests are no longer recorded as the app's: requests started before the sink's first `await`, and any made with `context.fetch`. A same-origin sink previously made every flush record requests that caused the next flush. +- When hit tests cannot tell which of two overlapping elements is on top (a `pointer-events: none` cover), a positioned ancestor they share no longer counts for either side. A static button inside a `position: relative` card was treated as positioned, so a badge over it could be reported as covered by the button. +- `involvesControl` is also true when a finding blocks a control inside its subject (`subject_blocked_controls` > 0), such as `main × widget`. +- The React and Playwright publish workflows do not publish until npm has the browser version they depend on; until then they warn and skip. +- `sortFindings`, `formatFinding` and `involvesControl` accept `ui.finding` data as a sink receives it (`FindingData`), not only `Finding`. +- A field joined to a form through the `form` attribute, and any label for it, is recorded on that form. A click on such a field outside any form element was previously not recorded. + +## 0.3.0 + +Not published. + +- `@codepress/interactions-browser/detect`: pure checks over snapshots and events (overlap, spill, page horizontal scroll, offscreen controls, small tap targets, clipped text, dead, rage, slow and silently failing clicks, errors after a click, stuck busy states, layout shift, slow input, broken images), with measured evidence, fingerprints and sorting. The SDK runs them in idle time and sends `ui.finding` events. +- `@codepress/interactions-playwright`: `captureSnapshot`, `findIssues`, `expectNoFindings`. +- Snapshots record theme, coarse pointer, open popovers, ellipsis, broken images and `aria-hidden`. Clicks record `target_key`, `target_interactive`, `value_changed`, `field_focused` and `new_context`. + +## 0.2.0 + +Not published. + +- Protocol v2: screen snapshots (columnar element boxes, text boxes, hit tests), click follow-through, performance summaries, findings. +- Pluggable sinks (`sink`, `createMemorySink`, `createHttpSink`), route templates with `routeSegments`, `routeTemplate`, `requestLabel`, `propagateHeader` and `release` options. + +## 0.1.0 + +Not published. + +- Browser SDK: clicks, submits, navigation, structural UI changes and allowed-origin requests, with explicit `runAction` correlation. +- Python SDK for Django and Celery (protocol v1). +- Optional React adapter. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9fd565d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,56 @@ +# Contributing + +Thanks for helping. Bug reports with a page that reproduces them are the most useful thing you can send; pull requests are welcome too. + +## Reporting a bug + +Open an issue with the bug template. The most useful details are: + +- the package and version, and the browser; +- a minimal page (HTML and CSS, or a small component) that shows it; +- what the SDK reported (the finding line from `formatFinding` or `expectNoFindings`, or the relevant events) and what you expected. + +A wrong finding, or a missing one, is a bug: say what is on screen and what the evidence says. Never paste real user data, project keys or tokens into an issue. Batches and snapshots contain no page text by design, so they are safe to attach, but check anything else you attach. + +## Reporting a security problem + +Do not open a public issue. Use **Report a vulnerability** on the repository's Security tab, which sends a private advisory to the maintainers. Privacy problems count: anything that lets page text, input values, URLs, request bodies or user identifiers reach a batch or snapshot is a security problem here. + +## Setting up + +You need Node.js 20.19 or later (or 22.12 or later) and pnpm 10 (`corepack enable` gives you the pinned version). + +```sh +pnpm install +pnpm build # the React and Playwright packages typecheck and test against the browser package's build +pnpm typecheck +pnpm test # unit tests (Vitest, jsdom and Node) +pnpm --filter @codepress/interactions-browser exec playwright install chromium +pnpm test:browser # real-browser tests (Chromium) +``` + +The Python SDK in `python/` uses [uv](https://docs.astral.sh/uv/): `cd python && uv sync && uv run pytest && uv run ruff check src tests`. + +## Making a change + +- **Checks** live in `packages/browser/src/detect/` and are pure functions over a snapshot body and events. A new or changed check needs a unit test in `test/detect.test.ts` (build the body with the `body()` helper), and, when it depends on real layout, a browser test in `test/e2e/detect.spec.ts` that compares the browser's findings with Node's (`expectSame`). Evidence holds measured numbers and booleans only, never text, and stays within 32 keys. +- **Privacy** is the contract. A change must not let page text, input or attribute values, URLs, queries, request or response bodies, or user identifiers into a batch or snapshot. `test/e2e/privacy.spec.ts` plants such values in a page and checks they never appear; extend it when you capture something new. +- **Protocol:** edit `protocol/generate-v2.mjs`, never the generated schemas, then run `node protocol/generate-v2.mjs protocol`. Within protocol version 2 a change must be additive (new optional fields, flags or evidence keys). Say in a new field's description which version added it. When the browser output changes, regenerate the samples with `UPDATE_GOLDEN=1 pnpm --filter @codepress/interactions-browser test:browser golden` and review the diff. +- **Overhead:** if a change touches the snapshot walk, the checks or the click path, run `pnpm --filter @codepress/interactions-browser bench` before and after, and put both in the pull request. +- **Docs:** behavior is documented in the package READMEs. Change them with the code, and add a line to `CHANGELOG.md` under an "Unreleased" heading. + +Keep pull requests focused, and describe what you changed and how you checked it. CI runs the type check, the unit and browser tests and the package file checks. + +## Releasing + +The three npm packages share one version. 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`). +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. + +A package that does not exist on npm yet is skipped with a warning in the workflow run: its first version is published once by hand by an owner of the `@codepress` npm scope, who then adds the workflow as the package's trusted publisher. + +## License + +By contributing you agree that your contributions are licensed under the [Apache License 2.0](LICENSE). diff --git a/README.md b/README.md index 3f92360..9f41990 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,109 @@ # CodePress Interactions -Apache-2.0 browser and Python SDKs for machine-readable interaction evidence. -The hosted service stores searchable summaries separately from compressed event -batches. This repository contains the SDKs and their versioned wire contract. +See your UI as data, and let your own coding agent fix what's wrong with it. -The browser SDK is released to npm as `@codepress/interactions-browser` by -[its publish workflow](.github/workflows/publish-browser.yml), and the Playwright -helpers as `@codepress/interactions-playwright` by -[theirs](.github/workflows/publish-playwright.yml). The Python -package is not yet published to PyPI, and the hosted service is not publicly available. +A small browser SDK records each screen your app shows as element boxes (positions, sizes, roles, flags, never text), what followed each click, and performance signals. It runs UI checks over that data in idle time: overlapping elements, content spilling out of its box, clipped text, controls out of reach, small tap targets, dead clicks, rage clicks, stuck busy states and more. Each finding is a pointer with measured evidence, not a verdict. You send the data wherever you want: to a file on disk, your own backend, or a test assertion. The same checks run in Node and in Playwright, so a fix can be proven with a test. -## Browser +Nothing here needs a CodePress account or service. Apache-2.0. -Call once during browser application initialization: +| Package | | +| --- | --- | +| [`@codepress/interactions-browser`](packages/browser/README.md) | The SDK: capture, snapshots, checks, sinks. Framework independent, ESM and CommonJS, TypeScript types. | +| [`@codepress/interactions-react`](packages/react/README.md) | An optional `useInteractions` hook that owns the SDK's lifecycle in a component. | +| [`@codepress/interactions-playwright`](packages/playwright/README.md) | `findIssues` and `expectNoFindings` for Playwright tests. | + +## Install + +```sh +npm install @codepress/interactions-browser +``` ```ts -import { initInteractions } from "@codepress/interactions-browser"; +import { initInteractions, createMemorySink } from "@codepress/interactions-browser"; -const interactions = initInteractions({ - projectKey: "PUBLIC_PROJECT_KEY", - endpoint: "https://YOUR_TELEMETRY_HOST", - allowedApiOrigins: ["https://YOUR_API_HOST"], -}); +const sink = createMemorySink(); // or your own { sendBatch, sendSnapshot } +initInteractions({ sink }); +// sink.batches: protocol v2 batches; sink.snapshots: screen snapshot bodies ``` -It captures semantic clicks/submits, navigation, structural UI changes, busy/progress -signals, and allowed-origin fetch/XHR observations. Dynamically mounted controls use -the same recorder. React applications can call this during browser bootstrap or use -the optional [React hook](packages/react/README.md). +Call it once, early, in browser code. Under SSR, or with `enabled: false`, it returns a handle that does nothing. A sink is any object with `sendBatch(batch, serialized)` and `sendSnapshot({ sha256, body, serialized })`; [Sinks](packages/browser/README.md#sinks) has the contract. -Automatic observation does not guarantee causal attribution. For actions crossing -an asynchronous boundary, bind the request explicitly: +To see it end to end, run [`examples/react-vite`](examples/react-vite): a small React app with a planted layout bug, a sink that writes JSON to `.interactions/` through the Vite dev server, and a Playwright test that fails until the bug is fixed. -```ts -await interactions.runAction("transfer.confirm", async ({ fetch }) => { - await fetch("/api/transfer", { method: "POST" }); -}); -``` +## What is captured -The bound fetch propagates the interaction UUID only to configured API origins. -Ordinary unrelated requests stay unlinked. There is no time-window/last-click guess. -See [browser behavior and limits](packages/browser/README.md). +- **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. +- **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. +- **Requests** to origins you allow: method, a status label, duration, success or error. +- **Performance summaries**: layout shifts, long tasks, event timing, image and script errors. +- **Findings** from the checks below. -The SDK also runs UI checks (overlaps, spills, clipped text, dead and rage clicks, -stuck busy states and more) and sends what they find as `ui.finding` events with -measured evidence. The same checks run in Node and in tests: +## What is never captured + +Page text, input values, HTML, attribute values (other than roles and safe test IDs), screenshots, URLs, paths, queries, hashes, request or response bodies, headers, cookies, user identifiers, and anything inside a subtree marked `data-interactions-exclude`. Nothing is written to browser storage. The [schemas](protocol) bound every field, so a receiver can reject anything else. Details are in the [browser package README](packages/browser/README.md). + +## How the checks work + +The checks are pure functions over a snapshot body and the event timeline: no `document`, no `window`. The browser SDK runs them in idle time after each new snapshot, after each settled click and at each flush, and sends each finding once per fingerprint as a `ui.finding` event. `@codepress/interactions-browser/detect` runs the same functions in Node, and the Playwright helpers run them on a page under test, so a finding seen in the browser can be reproduced and proven fixed in a test. ```ts import { expectNoFindings } from "@codepress/interactions-playwright"; -await expectNoFindings(page); +await page.goto("/checkout/42"); +await expectNoFindings(page, { routeSegments: ["checkout"] }); ``` -See [UI checks](packages/browser/README.md#ui-checks) and the -[Playwright helpers](packages/playwright/README.md). +A finding has the check (`layout.overlap`, `layout.spill`, `layout.clipped_text`, `click.dead_click`, `click.rage_click`, ...), the elements it is about (their snapshot row, test ID, structural path and box), measured evidence (pixels covered, blocked hit-test points of a control, whether text is covered, time to feedback, ...) and a fingerprint. The fingerprint hashes the check, route template, viewport width band and element identities, never coordinates or text, so the same bug has the same fingerprint at any scroll offset, in any session, on any machine. Findings are sorted most urgent first: blocked controls, then covered or clipped text, then the rest (those involving a control first), then decorative or empty overlaps. Nothing is dropped; you decide what is a bug and accept the rest with `ignoreFingerprints` or `data-layout-ignore`. -## Django and Celery +[UI checks](packages/browser/README.md#ui-checks) lists every check, its thresholds, its evidence keys and the [measured behaviors worth knowing](packages/browser/README.md#measured-behaviors-worth-knowing). -At the end of Django settings: +## Let an agent fix findings -```python -from codepress_interactions.django import configure -configure(settings=globals()) -``` +[`skills/fix-ui-findings`](skills/fix-ui-findings/SKILL.md) is a portable skill for coding agents (Claude Code, Codex, Cursor and others that read skill files). Given an app with a sink that writes findings to disk, it has the agent read the findings, reproduce one in a Playwright test, fix the source and prove the fix with `expectNoFindings`. Copy the folder into your agent's skills directory or point the agent at the file. + +## Overhead -Set `INTERACTIONS_ENABLED=true`, `INTERACTIONS_ENDPOINT` to the service base URL, -and `INTERACTIONS_SERVER_KEY` in the server environment. Never expose the server key -to a browser. Django request middleware is installed automatically; when Celery is -installed, its publish/worker hooks are installed by the same configuration call. -See [Python installation, standalone use and limits](python/README.md). - -## Evidence contract - -[JSON Schema](protocol/v1-batch.schema.json) and [sample batch](protocol/v1-batch.json) -define the v1 transport. The browser SDK sends protocol v2 -([batch schema](protocol/v2-batch.schema.json), [sample](protocol/v2-batch.json)), which adds -screen snapshots ([body schema](protocol/v2-snapshot.schema.json), -[sample](protocol/v2-snapshot.json)), click follow-through, performance summaries and -measured findings; the schemas are generated by `node protocol/generate-v2.mjs protocol`. An interaction identifies a user attempt; backend operations -and immutable event observations form its timeline. Backend success and visible UI -completion are distinct observations. Missing evidence means unknown, not failure. - -Retries reuse identical batch bytes and IDs. Memory, batch sizes, retries and network -waits are bounded. Diagnostics distinguish queued/dropped events from server -acknowledgments. No disk/browser-storage spool is used; process/tab death can lose -pending observations. This is telemetry, not a durable business-event ledger. - -Raw HTML, UI text, input values, request/response bodies and URL queries are not sent. -`data-interactions-exclude` excludes private DOM subtrees. Explicit action/control -identifiers should be static developer labels, never customer data. Evidence is data, -not executable HTML or agent instructions. +Measured with `pnpm --filter @codepress/interactions-browser bench` ([script](packages/browser/scripts/overhead.mjs)) in headless Chromium at 1280×720 on an Apple M1 Max, median of 5 runs, without throttling and with 4× CPU throttling (closer to a mid-range phone). Your numbers will differ; the script prints its machine with them. + +- **Bundle:** 24.0 KB gzip (67.1 KB minified) for the main entry, checks included. The checks alone (`/detect`) are 9.8 KB gzip. +- **Idle page:** about 0.6 ms of extra main-thread time per 5 seconds, no long tasks, one flush timer and one structural MutationObserver. +- **Clicks:** the SDK's click listener adds 0.09 ms (0.47 ms at 4×, p95 1.1 ms) before your handler runs. +- **Snapshots:** the walk runs in idle-time slices of about 6–8 ms (`maxSliceMs`) and never caused a long task: + + | Elements | Walk CPU (1× / 4×) | Longest task (1× / 4×) | Checks, one idle task (1× / 4×) | Body (gzip) | + | --- | --- | --- | --- | --- | + | ~500 | 5.9 / 25 ms | 8.6 / 14 ms | 4.7 / 16 ms | 32 KB (3.7 KB) | + | ~2,000 | 16 / 80 ms | 7.2 / 18 ms | 8.1 / 34 ms | 134 KB (12 KB) | + | ~5,000 | 34 / 171 ms | 8.3 / 30 ms | 14 / 50 ms | 343 KB (27 KB) | + + Two steps are not sliced. Finishing a walk (serializing and hashing the body) is one task, the "longest task" column. The checks run over each new snapshot in one idle callback: on a 5,000-element page at 4× that is up to 50 ms, at the long-task threshold, and on a slower phone it would be a long task. They run only after a new snapshot, and only when the browser reports idle time; `findings: false` turns them off in the browser (the same checks still run in tests and in Node). A snapshot is taken only when a screen changes, and a body already sent in the session is not sent again. + +## Wire protocol + +Batches, snapshot bodies and findings are described by JSON Schemas in [`protocol/`](protocol), with real samples and [a guide to reading them](protocol/README.md). Within protocol version 2, changes are additive. + +## Hosted service (optional) + +CodePress, which maintains these packages, runs a hosted service that stores this data across sessions and makes it searchable. To use it, pass `endpoint` and `projectKey` instead of `sink`; the [protocol](protocol/README.md#http) documents the HTTP calls, so you can also point `endpoint` at a service of your own. None of the packages, the example or the skill need it. + +## Python + +The [Python SDK](python/README.md) in `python/` (Django and Celery request and task correlation, protocol v1) is not part of 1.0. It is not published to PyPI and its API may change. ## Development ```sh pnpm install -pnpm test +pnpm build # the React and Playwright packages typecheck and test against the browser package's build pnpm typecheck -pnpm build +pnpm test pnpm --filter @codepress/interactions-browser exec playwright install chromium -pnpm build # the Playwright helpers test against the browser package's build -pnpm test:browser # real-browser tests (Chromium), browser package and Playwright helpers -cd python -uv sync -uv run pytest -uv run ruff check src tests -uv build +pnpm test:browser # real-browser tests (Chromium) ``` -Core Python transport uses the standard library; Django and Celery are optional extras. -The browser package is framework independent, with ESM, CommonJS and TypeScript outputs. +See [CONTRIBUTING.md](CONTRIBUTING.md) for how changes are made and released, and [CHANGELOG.md](CHANGELOG.md) for what changed. ## License diff --git a/examples/react-vite/.gitignore b/examples/react-vite/.gitignore new file mode 100644 index 0000000..61a2c84 --- /dev/null +++ b/examples/react-vite/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +.interactions +test-results +playwright-report diff --git a/examples/react-vite/README.md b/examples/react-vite/README.md new file mode 100644 index 0000000..d4371d3 --- /dev/null +++ b/examples/react-vite/README.md @@ -0,0 +1,109 @@ +# React + Vite example: your UI as local JSON + +A small pricing page that records its own screens with +[`@codepress/interactions-browser`](../../packages/browser/README.md), runs the +UI checks in the browser, and writes everything to JSON files in `.interactions/` +on your machine. No hosted service, no account, no key. A Playwright test runs +the same checks with +[`@codepress/interactions-playwright`](../../packages/playwright/README.md). + +The page has one real layout bug in it. The steps below find it, hand it to a +coding agent, and prove the fix. + +## Run it + +You need Node.js 20.19+ (or 22.12+) and npm. + +```sh +npm install +npm run dev +``` + +Open http://localhost:5173 in a desktop browser and wait a few seconds: the SDK +takes a snapshot once the page settles and flushes every 2 seconds. Clicking +buttons records more (click follow-through and a snapshot after each click). + +## Look at the data + +``` +.interactions/ + batches/.json every protocol v2 batch, exactly as the SDK sent it + snapshots/.json every screen snapshot: element boxes, no text + findings.json the ui.finding data from all batches, one per fingerprint +``` + +`findings.json` is a list of `ui.finding` event data (check, elements, measured +evidence, fingerprint, viewport, route template), each with `last_seen`, the +time it was last reported. In a second terminal: + +```sh +npm run findings +``` + +prints one line per finding, most urgent first. The schemas are in +[`protocol/`](../../protocol); what each check and evidence key means is in the +[UI checks](../../packages/browser/README.md#ui-checks) section. + +To start over with an empty list, delete the `.interactions` folder (it is +recreated on the next batch) and reload the page. + +## Test it + +```sh +npx playwright install chromium +npm test +``` + +`tests/ui.spec.ts` opens the page at 1280x720 and calls `expectNoFindings(page)`. +It fails, with a message that starts `Error: 1 UI finding on /.` and lists the +finding line and its fingerprint. + +Playwright starts the dev server itself, or reuses the one from `npm run dev` +if it is running. The test closes the page before the SDK's first flush, so it +adds nothing to `.interactions/`; load the page in a browser for that. + +## Let an agent fix it + +Point your coding agent (Claude Code, Codex, Cursor, ...) at the portable skill +in [`skills/fix-ui-findings`](../../skills/fix-ui-findings/SKILL.md), for example: + +> Read ../../skills/fix-ui-findings/SKILL.md and follow it to fix the UI findings in this app. + +If you copied this example out of the repository, use +https://github.com/quantfive/codepress-interactions/blob/main/skills/fix-ui-findings/SKILL.md +or copy that folder into your agent's skills directory. The agent reads +`.interactions/findings.json`, reproduces the finding, fixes the source and +proves it. Then: + +```sh +npm test +``` + +passes. Delete `.interactions`, reload the page, wait a few seconds and run +`npm run findings` again: the fingerprint is gone. + +## How it works + +- `src/interactions.ts` is the browser half: an `InteractionSink` whose + `sendBatch` and `sendSnapshot` POST to `/__interactions/...` on the dev server + and map the status to the sink result (`2xx` accepted, `4xx` rejected, anything + else retry). They use `context.fetch`, the page's `fetch` without the SDK's + wrapper, so the uploads are not recorded as the app's own requests. Capture is + on only in development (`import.meta.env.DEV`). +- `local-sink.ts` is the dev-server half: a Vite plugin (`apply: "serve"`) that + writes the files above, checks each snapshot body against its SHA-256 and + rebuilds `findings.json` after every batch. +- `src/App.tsx` starts capture with `useInteractions` from + `@codepress/interactions-react`. The page uses `data-testid`s, so findings name + test IDs such as `plan-free-cta` rather than a tag path. +- `scripts/findings.mjs` prints `findings.json` with `formatFinding` from + `@codepress/interactions-browser/detect`, the same function the Playwright + helper uses for its failure message. + +Snapshots and events carry element boxes, roles, test IDs and counts. They never +contain page text, input values, URLs or HTML. + +## The planted bug + +It is deliberately not described here, so that an agent (or you) has to find it +from the data alone: `findings.json`, the snapshot it points at, and the source. diff --git a/examples/react-vite/index.html b/examples/react-vite/index.html new file mode 100644 index 0000000..761c687 --- /dev/null +++ b/examples/react-vite/index.html @@ -0,0 +1,12 @@ + + + + + + Example Notes pricing + + +
+ + + diff --git a/examples/react-vite/local-sink.ts b/examples/react-vite/local-sink.ts new file mode 100644 index 0000000..ac65242 --- /dev/null +++ b/examples/react-vite/local-sink.ts @@ -0,0 +1,108 @@ +// Dev-server half of the local sink (see src/interactions.ts). Writes: +// .interactions/batches/.json every protocol v2 batch, as sent +// .interactions/snapshots/.json every screen snapshot body +// .interactions/findings.json ui.finding data, one per fingerprint +import { createHash } from "node:crypto"; +import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { join, resolve } from "node:path"; +import type { Plugin } from "vite"; + +const MAX_BODY_BYTES = 5_000_000; +const SHA256 = /^[0-9a-f]{64}$/; +const BATCH_ID = /^[A-Za-z0-9-]{1,64}$/; + +export function localSink(dir = ".interactions"): Plugin { + const root = resolve(dir); + const batches = join(root, "batches"); + const snapshots = join(root, "snapshots"); + // One findings.json rewrite at a time. + let queue: Promise = Promise.resolve(); + + async function writeJson(path: string, value: unknown) { + const tmp = `${path}.${process.pid}.tmp`; + await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`); + await rename(tmp, path); + } + + // Rebuilt from every stored batch: the newest occurrence of each fingerprint + // wins, and `last_seen` says when it was last reported. + async function rebuildFindings() { + const byFingerprint = new Map>(); + const files = (await readdir(batches)).filter((name) => name.endsWith(".json")); + for (const name of files) { + const batch = JSON.parse(await readFile(join(batches, name), "utf8")); + for (const event of batch.events ?? []) { + if (event.type !== "ui.finding" || typeof event.data?.fingerprint !== "string") continue; + const seen = byFingerprint.get(event.data.fingerprint); + if (seen && String(seen.last_seen) >= event.occurred_at) continue; + byFingerprint.set(event.data.fingerprint, { ...event.data, last_seen: event.occurred_at }); + } + } + await writeJson(join(root, "findings.json"), [...byFingerprint.values()]); + } + + async function handle(req: IncomingMessage, res: ServerResponse) { + const url = req.url ?? ""; + const raw = await readBody(req); + if (raw === null) return send(res, 413); + let json: { batch_id?: unknown; events?: unknown }; + try { + json = JSON.parse(raw.toString("utf8")); + } catch { + return send(res, 400); + } + + if (url === "/batches") { + if (typeof json.batch_id !== "string" || !BATCH_ID.test(json.batch_id) || !Array.isArray(json.events)) + return send(res, 400); + await mkdir(batches, { recursive: true }); + // A retried batch has the same batch_id and payload, so rewriting it is safe. + await writeFile(join(batches, `${json.batch_id}.json`), raw); + queue = queue.then(rebuildFindings, rebuildFindings); + await queue; + return send(res, 204); + } + + const sha256 = /^\/snapshots\/([^/?]+)$/.exec(url)?.[1] ?? ""; + if (SHA256.test(sha256)) { + // The SDK hashes the exact bytes it sends; anything else is not that snapshot. + if (createHash("sha256").update(raw).digest("hex") !== sha256) return send(res, 400); + await mkdir(snapshots, { recursive: true }); + await writeFile(join(snapshots, `${sha256}.json`), raw); + return send(res, 204); + } + return send(res, 404); + } + + return { + name: "interactions-local-sink", + apply: "serve", + configureServer(server) { + server.middlewares.use("/__interactions", (req, res) => { + if (req.method !== "POST") return send(res, 405); + handle(req, res).catch((error) => { + server.config.logger.error(`[local-sink] ${error}`); + send(res, 500); // the browser sink retries + }); + }); + }, + }; +} + +function send(res: ServerResponse, status: number) { + if (!res.headersSent) res.writeHead(status).end(); +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((done, fail) => { + const chunks: Buffer[] = []; + let size = 0; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size <= MAX_BODY_BYTES) chunks.push(chunk); + }); + req.on("end", () => done(size > MAX_BODY_BYTES ? null : Buffer.concat(chunks))); + req.on("error", fail); + }); +} diff --git a/examples/react-vite/package.json b/examples/react-vite/package.json new file mode 100644 index 0000000..0cef415 --- /dev/null +++ b/examples/react-vite/package.json @@ -0,0 +1,29 @@ +{ + "name": "interactions-example-react-vite", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit", + "findings": "node scripts/findings.mjs", + "test": "playwright test" + }, + "dependencies": { + "@codepress/interactions-browser": "^1.0.0", + "@codepress/interactions-react": "^1.0.0", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@codepress/interactions-playwright": "^1.0.0", + "@playwright/test": "^1.64.0", + "@types/node": "^24.0.0", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.2.0", + "typescript": "^6.0.0", + "vite": "^8.3.0" + } +} diff --git a/examples/react-vite/playwright.config.ts b/examples/react-vite/playwright.config.ts new file mode 100644 index 0000000..9b4a820 --- /dev/null +++ b/examples/react-vite/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "tests", + use: { + ...devices["Desktop Chrome"], // 1280x720 + baseURL: "http://localhost:5173", + }, + webServer: { + command: "npm run dev", + url: "http://localhost:5173", + // Use the dev server you already have running, if any. + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/examples/react-vite/scripts/findings.mjs b/examples/react-vite/scripts/findings.mjs new file mode 100644 index 0000000..34cb0fa --- /dev/null +++ b/examples/react-vite/scripts/findings.mjs @@ -0,0 +1,19 @@ +// Prints .interactions/findings.json one line per finding, most urgent first. +import { readFile } from "node:fs/promises"; +import { formatFinding, sortFindings } from "@codepress/interactions-browser/detect"; + +const path = new URL("../.interactions/findings.json", import.meta.url); + +let findings; +try { + findings = JSON.parse(await readFile(path, "utf8")); +} catch (error) { + if (error.code !== "ENOENT") throw error; + console.log("No .interactions/findings.json yet: run `npm run dev`, open the page and wait a few seconds."); + process.exit(0); +} + +for (const finding of sortFindings(findings)) { + console.log(`${formatFinding(finding)} (last seen ${finding.last_seen})`); +} +console.log(`${findings.length} finding${findings.length === 1 ? "" : "s"}`); diff --git a/examples/react-vite/src/App.tsx b/examples/react-vite/src/App.tsx new file mode 100644 index 0000000..738682b --- /dev/null +++ b/examples/react-vite/src/App.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { useInteractions } from "@codepress/interactions-react"; +import { interactionOptions } from "./interactions"; + +interface Plan { + id: string; + name: string; + price: string; + features: string[]; + cta: string; + featured?: boolean; +} + +const PLANS: Plan[] = [ + { + id: "free", + name: "Free", + price: "$0", + features: ["3 notebooks", "Sync on 2 devices", "Markdown export"], + cta: "Get started", + }, + { + id: "pro", + name: "Pro", + price: "$8", + features: ["Unlimited notebooks", "Sync on every device", "Shared workspaces"], + cta: "Start free trial", + featured: true, + }, +]; + +export function App() { + useInteractions(interactionOptions); + const [chosen, setChosen] = useState(null); + + return ( + <> +
+ Example Notes + +
+ +
+

Pick a plan

+

Start free. Upgrade when your notes outgrow it.

+ +
+ {PLANS.map((plan) => ( +
+ {plan.featured && ( + + Most popular + + )} +

{plan.name}

+

+ {plan.price} + / month +

+
    + {plan.features.map((feature) => ( +
  • {feature}
  • + ))} +
+ +
+ ))} +
+ +

+ {chosen ? `${chosen.name} selected. We sent a confirmation link to your inbox.` : ""} +

+
+ + ); +} diff --git a/examples/react-vite/src/interactions.ts b/examples/react-vite/src/interactions.ts new file mode 100644 index 0000000..586f42a --- /dev/null +++ b/examples/react-vite/src/interactions.ts @@ -0,0 +1,50 @@ +import type { + InteractionOptions, + InteractionSink, + SinkResult, +} from "@codepress/interactions-browser"; + +// The browser cannot write files, so this sink posts each batch and snapshot +// body to the Vite dev server, where local-sink.ts writes them to .interactions/. +const BASE = "/__interactions"; + +// The sink contract: 2xx means stored, 4xx means this item will never be +// accepted (drop it), anything else is worth a bounded retry. +function result(status: number): SinkResult { + if (status >= 200 && status < 300) return "accepted"; + if (status >= 400 && status < 500 && status !== 408 && status !== 429) return "rejected"; + return "retry"; +} + +const localSink: InteractionSink = { + // context.fetch is the page's fetch without the SDK's wrapper, so these uploads + // are not recorded as the app's own requests. + async sendBatch(_batch, serialized, { keepalive, signal, fetch }) { + const response = await fetch(`${BASE}/batches`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: serialized, + keepalive, + signal, + }); + return result(response.status); + }, + async sendSnapshot({ sha256, serialized }, { signal, fetch }) { + const response = await fetch(`${BASE}/snapshots/${sha256}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: serialized, + signal, + }); + return result(response.status); + }, +}; + +// Module-level, so useInteractions gets the same object on every render. +export const interactionOptions: InteractionOptions = { + // Only the dev server has the endpoint that writes files. + enabled: import.meta.env.DEV, + sink: localSink, + // Flush sooner than the 5 s default so files show up quickly while you work. + flushIntervalMs: 2000, +}; diff --git a/examples/react-vite/src/main.tsx b/examples/react-vite/src/main.tsx new file mode 100644 index 0000000..9891129 --- /dev/null +++ b/examples/react-vite/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/react-vite/src/styles.css b/examples/react-vite/src/styles.css new file mode 100644 index 0000000..c228e52 --- /dev/null +++ b/examples/react-vite/src/styles.css @@ -0,0 +1,139 @@ +:root { + --ink: #1d2433; + --muted: #5b6475; + --line: #dde1e8; + --surface: #ffffff; + --page: #f5f6f8; + --accent: #3b5bdb; + --accent-ink: #ffffff; + --badge: #f59f00; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + color: var(--ink); + background: var(--page); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 64px; + padding: 0 32px; + background: var(--surface); + border-bottom: 1px solid var(--line); +} + +.logo { + font-weight: 700; + font-size: 18px; +} + +.link-button { + border: 0; + background: none; + color: var(--accent); + font: inherit; + padding: 8px 12px; + cursor: pointer; +} + +.pricing { + max-width: 800px; + margin: 0 auto; + padding: 40px 24px; + text-align: center; +} + +.pricing h1 { + margin: 0 0 8px; + font-size: 32px; +} + +.lede { + margin: 0 0 32px; + color: var(--muted); +} + +.plans { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; + text-align: left; +} + +.plan { + position: relative; + display: flex; + flex-direction: column; + padding: 28px 24px 24px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 12px; +} + +.plan--featured { + border-color: var(--accent); +} + +.plan h2 { + margin: 0; + font-size: 20px; +} + +.plan-price { + margin: 8px 0 16px; + font-size: 32px; + font-weight: 700; +} + +.plan-price span { + font-size: 14px; + font-weight: 400; + color: var(--muted); +} + +.plan-features { + margin: 0 0 24px; + padding-left: 20px; + line-height: 1.8; + color: var(--muted); +} + +.plan-badge { + position: absolute; + right: 24px; + bottom: 30px; + height: 28px; + padding: 0 12px; + border-radius: 14px; + background: var(--badge); + color: var(--ink); + font-size: 13px; + font-weight: 600; + line-height: 28px; +} + +.plan-cta { + margin-top: auto; + height: 44px; + border: 0; + border-radius: 8px; + background: var(--accent); + color: var(--accent-ink); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.status { + min-height: 24px; + margin: 24px 0 0; + color: var(--muted); +} diff --git a/examples/react-vite/tests/ui.spec.ts b/examples/react-vite/tests/ui.spec.ts new file mode 100644 index 0000000..b98cdd5 --- /dev/null +++ b/examples/react-vite/tests/ui.spec.ts @@ -0,0 +1,11 @@ +import { expect, test } from "@playwright/test"; +import { expectNoFindings } from "@codepress/interactions-playwright"; + +test("pricing page has no UI findings", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("plan-pro-cta")).toBeVisible(); + // Fails with one line per finding: check, elements, evidence, fingerprint. + // To accept a finding you decided is intended, pass its fingerprint in + // ignoreFingerprints (and write down why). + await expectNoFindings(page); +}); diff --git a/examples/react-vite/tsconfig.json b/examples/react-vite/tsconfig.json new file mode 100644 index 0000000..7ce764e --- /dev/null +++ b/examples/react-vite/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "types": ["vite/client", "node"] + }, + "include": ["src", "tests", "vite.config.ts", "local-sink.ts", "playwright.config.ts"] +} diff --git a/examples/react-vite/vite.config.ts b/examples/react-vite/vite.config.ts new file mode 100644 index 0000000..a242367 --- /dev/null +++ b/examples/react-vite/vite.config.ts @@ -0,0 +1,13 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import { localSink } from "./local-sink.ts"; + +export default defineConfig({ + plugins: [react(), localSink(".interactions")], + server: { + port: 5173, + strictPort: true, + // Files written by the sink are output, not source: don't reload on them. + watch: { ignored: ["**/.interactions/**"] }, + }, +}); diff --git a/packages/browser/README.md b/packages/browser/README.md index ddcb1d5..9b00bb9 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -12,7 +12,7 @@ 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](../../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. +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. @@ -54,7 +54,7 @@ Events and snapshots carry a route template, never the URL. By default every pat ## Screen snapshots -Each settled screen is recorded as element boxes in a columnar body ([schema](../../protocol/v2-snapshot.schema.json), [example](../../protocol/v2-snapshot.json)): parent, tag, role, safe test ID, `x`/`y`/`width`/`height` (viewport CSS pixels from `getBoundingClientRect`), z-index, CSS position, flags (visible, interactive, clipped, scrollable, overlay, ignored, pointer-events none, opaque, clips content, in shadow root, open popover, ellipsis or line clamp, broken image, `aria-hidden` on the element or an ancestor) and text length. "In shadow root" means the element's own tree is a shadow root; slotted light-DOM content is not flagged. The viewport records whether the primary pointer is coarse (touch) and the theme on screen (`light`, `dark` or `unknown`): the `theme()` option when given, else `prefers-color-scheme`. The `ui.snapshot` event carries the same `theme`, so screens can be grouped by route, viewport and theme without reading bodies. For elements with direct text it also records the box of the rendered text itself (from a `Range`, geometry only), so an element box can overlap something while its text does not. For visible interactive elements it probes `elementFromPoint` at the center and four inset points and stores how many land on something else and which element is on top. `elementFromPoint` is never called for other elements. No text, values, attribute values (other than role and safe test IDs), URLs or HTML are recorded. +Each settled screen is recorded as element boxes in a columnar body ([schema](https://github.com/quantfive/codepress-interactions/blob/main/protocol/v2-snapshot.schema.json), [example](https://github.com/quantfive/codepress-interactions/blob/main/protocol/v2-snapshot.json)): parent, tag, role, safe test ID, `x`/`y`/`width`/`height` (viewport CSS pixels from `getBoundingClientRect`), z-index, CSS position, flags (visible, interactive, clipped, scrollable, overlay, ignored, pointer-events none, opaque, clips content, in shadow root, open popover, ellipsis or line clamp, broken image, `aria-hidden` on the element or an ancestor) and text length. "In shadow root" means the element's own tree is a shadow root; slotted light-DOM content is not flagged. The viewport records whether the primary pointer is coarse (touch) and the theme on screen (`light`, `dark` or `unknown`): the `theme()` option when given, else `prefers-color-scheme`. The `ui.snapshot` event carries the same `theme`, so screens can be grouped by route, viewport and theme without reading bodies. For elements with direct text it also records the box of the rendered text itself (from a `Range`, geometry only), so an element box can overlap something while its text does not. For visible interactive elements it probes `elementFromPoint` at the center and four inset points and stores how many land on something else and which element is on top. `elementFromPoint` is never called for other elements. No text, values, attribute values (other than role and safe test IDs), URLs or HTML are recorded. Snapshots are taken when a route settles (including the first screen), after a click settles, after a resize settles and when the tab becomes visible again, never while it is hidden. `handle.snapshot()` takes one on demand. Each body is hashed (SHA-256 of the exact uncompressed bytes); a hash already sent in this page session is not sent again. With `endpoint`, the body is uploaded by `HEAD` then, only on 404, a gzip `PUT` to `/v2/snapshots/{sha256}`. The service must allow `HEAD`/`PUT` and the `Authorization` and `Content-Encoding` headers from your origin. @@ -62,24 +62,24 @@ The walk runs in slices of idle time: the clock is checked every 8 elements and ## Click follow-through -Every click on a control starts a follow-through window. It ends when the page has been quiet (no DOM change, no observed request in flight) for `clickSettleMs`, and is reported as one `ui.click.settled` event: time to first visible feedback (DOM change, route change, dialog or focus move), settle time, counts of mutations, requests, failed requests, uncaught errors and long tasks, layout shift score, whether the route changed, a dialog opened or focus moved, and the screen hashes before and after. Since 0.3.0 it also says whether the click landed on a control (`target_interactive`), changed a field's value (`value_changed`, for example a checkbox), put focus in a text field (`field_focused`) or opened a new tab or window (`new_context`). Focus or feedback caused by a later pointer or key input is not credited to an earlier open click. Its `interaction_id` is the click's `interaction.started` ID and its attribution is always `inferred`; requests observed in the window are counted there and never attached to the click as explicit. A second click before the first settles closes the first as `overlapped` (both are flagged) and nothing is counted for both. A click still open after `clickTimeoutMs`, or when the page is hidden or unloaded, is reported `incomplete`. Since 0.3.0 a click's `interaction.started` also carries `target_key`, an opaque per-session number for the element clicked (only equality means anything), and a click on a `