diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87ce616..7b3ad52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,7 +156,7 @@ jobs: - id: vue-pnpm-vite5 framework: vue package-manager: pnpm - version-args: '--vite-version=5.4.21' + version-args: '--vite-version=5.4.21 --scope=list' - id: next15-npm-webpack framework: next package-manager: npm @@ -164,7 +164,7 @@ jobs: - id: next16-pnpm-turbopack framework: next package-manager: pnpm - version-args: '--framework-version=16.4.3' + version-args: '--framework-version=16.4.3 --scope=table' - id: nuxt3-npm framework: nuxt package-manager: npm diff --git a/docs/agent-targets-and-replay.md b/docs/agent-targets-and-replay.md index dff2493..1cfcf3b 100644 --- a/docs/agent-targets-and-replay.md +++ b/docs/agent-targets-and-replay.md @@ -20,6 +20,10 @@ Only `resolved` identifies one exact target and returns its available runtime co Handles survive DOM moves. Removed/recycled targets and changed source or identity constraints invalidate them. Never persist a handle as the only identity for a later session. Browser drivers still check visibility, enabled state, overlays and normal actionability; discovery does not imply that an element can be clicked immediately. +Semantic table rows and list items (`tr`, `li`, `[role="row"]`, and `[role="listitem"]`) also constrain actions using their non-private, non-editable text. An ordinary row with a customer name beside an unlabeled Edit button can therefore be identified without adding a test ID. Row text changes retire local handles, including when a virtualized or position-keyed list retains the same action DOM node. Text segments remain separate so differently divided cell values cannot collide by concatenation. Input values and blocked/editable descendants are excluded. Duplicate rows remain ambiguous; long or redacted identity still stops portable replay. Changes to other visible row content may conservatively require a new capture. + +PII is checked before segment encoding, including email/card/phone values split across text nodes; sensitive row identity is redacted as a whole and cannot be replayed. Local fingerprints retain complete raw ancestor constraints privately. If more than four identified ancestor scopes are needed, the portable locator is explicitly unsupported rather than silently dropping an outer business identity. The same applies when raw identity exceeds size limits even if redaction would shorten it. + ## Explicit recording In the instrumented page's JavaScript context: @@ -70,9 +74,9 @@ NX_DAEMON=false pnpm exec nx run-many -t build,sync-dist \ --exclude domscribe-test-fixtures --parallel=2 --skipNxCache node scripts/verify-instance-replay.mjs node scripts/verify-instance-replay.mjs --framework=vue --package-manager=pnpm \ - --vite-version=5.4.21 --report-dir=/tmp/vue-replay-evidence + --vite-version=5.4.21 --scope=list --report-dir=/tmp/vue-replay-evidence node scripts/verify-instance-replay.mjs --framework=next --package-manager=pnpm \ - --framework-version=16.4.3 --report-dir=/tmp/next-replay-evidence + --framework-version=16.4.3 --scope=table --report-dir=/tmp/next-replay-evidence node scripts/verify-instance-replay.mjs --framework=nuxt --package-manager=npm \ --framework-version=3.21.11 --report-dir=/tmp/nuxt-replay-evidence ``` @@ -94,6 +98,10 @@ CI runs eight representative combinations, with at most two replay jobs simultan The default remains React18/npm/Vite7. Use `--framework-version` for exact Next15/16 or Nuxt3/4 releases; use `--vite-version` for React/Vue consumers. Mixed or unsupported flags are rejected. Native SSR consumers use their framework integrations and normal hydration, not the Vite SPA integration. Nuxt excludes the runtime from Vite prebundling so its native plugin and consumer queries share one runtime on cold startup. Each run checks actual installed versions, expected framework source, and local runtime resolution. +`--scope=article` keeps the original labeled-card fixture. `--scope=table` and `--scope=list` use sibling business text with no added row label or test ID. They deliberately retain position-keyed action nodes during a customer replacement and verify that old handles/selectors retire, old portable identity does not resolve, the new runtime props belong to the replacement, and only that customer's UI data changes. CI runs the Vue/pnpm/Vite5 case as a list and the Next16/pnpm case as a table. The other six cases retain article coverage. + +The [real-application acceptance matrix](./real-app-acceptance.md) records separate maintained-application tests and their installation/actionability limits. + These are representative synthetic regression cases, not the full version/manager/router matrix or an adoption benchmark. Additional local testing on the Domscribe website (Next16.4.3 / React19.2.4) verified exact installation-tab source/state among six repeated instances, persisted replay after a fresh browser session, safe page-navigation failure, mobile navigation state/replay after reload, and controlled HMR identity changes without resurrecting retired handles. Its production build and UI also worked without instrumentation. This is one maintained app, not evidence for every router, virtualized list, framework version, or agent workflow advantage. Package installs use local tarballs, with explicit pnpm local-artifact overrides so unpublished artifacts cannot be replaced by registry copies. This is not evidence that the new APIs are in the published npm release. Logs and result JSON are retained in the printed temporary directory on success and failure. `--report-dir` copies a compact evidence bundle. Traces and screenshots are retained when available, with best-effort failure screenshots after browser startup. Server startup and builds are bounded, and the harness stops its owned server process group and relay. diff --git a/docs/real-app-acceptance.md b/docs/real-app-acceptance.md new file mode 100644 index 0000000..be16185 --- /dev/null +++ b/docs/real-app-acceptance.md @@ -0,0 +1,31 @@ +# Real-application capture and replay acceptance + +Sprint 1 tests useful workflows in maintained applications alongside controlled regressions. These checks use locally built, unpublished Domscribe tarballs. They do not imply that the new APIs are available in npm 0.6.1, demonstrate agent efficiency, or restore arbitrary application/backend state. + +| Application and task | Acceptance | +| ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Domscribe website, Next16.4.3/React19.2.4, commit `a6e1a0e8c0c6ce9995c716e7a7cf77b09e392d01`: expand manual setup and select Vue + Vite | Six repeated tab source instances remain ambiguous by source alone; an exact target exposes live state. Persist the two UI actions and replay them in a fresh browser. Verify the actual Vue installation command and committed tab state. Production UI remains functional without instrumentation. | +| [Vue TodoMVC](https://github.com/tastejs/todomvc/tree/ff43b02e59dfa604386bb382034b2cd07c2bcd8a/examples/vue): complete one task among repeated controls | Exact source/runtime correspond to the selected task. Persist a checkbox action; recreate prerequisite tasks through normal UI in a fresh browser in a different order, then replay. Only the intended task becomes completed. | +| Same TodoMVC application: edit a task and reuse its action node | Edit through the app UI. Confirm the DOM action node is retained but the old handle/selector retires and the old portable locator no longer resolves. Recapture the changed task, inspect its actual runtime props, then delete it through the UI while preserving the other task. Production deletion works without instrumentation. | + +The TodoMVC workflows passed on October26 with Vue3.5.33, Vue Router4.6.4, plugin-vue6.0.6, Vite7.3.7, Node24.22.1 and Chromium156.0.8078.4. Application components and their handlers were unchanged; test setup adds Domscribe's Vite plugin and a development-only runtime inspection hook in an isolated worktree. The app stores tasks in component memory, so the driver explicitly recreates prerequisite data through UI after a fresh session. That preparation is not evidence of automatic state restoration. + +The website's three functional checks passed on October26 with Next16.4.3/React19.2.4 and Chromium156.0.8078.4. There were no page exceptions. Local production hosting returned two metrics-script 404s (`/_vercel/insights/script.js` and `/_vercel/speed-insights/script.js`), retained separately from the functional results; do not describe this local run as having zero console/network errors. Deployment-hosted metrics are a separate website-health check. + +## Installation and browser-action limits + +The upstream TodoMVC frozen npm install succeeded with its Vite8.0.10 lockfile. Adding Domscribe then failed with `ERESOLVE`: Domscribe's Vue integration declares Vite5–7 support. The successful integration test explicitly pinned Vite7.3.7 in the isolated harness. This is a supported-version test with a workaround, **not a clean Vite8 installation success**. Vite8 support needs separate compatibility work and fresh tests before changing peer ranges. + +An initial TodoMVC replay attempted to click its hover-only delete button without revealing it and stopped with `action_failed`; the app CSS hides this button until the row is hovered. The qualified portable workflow uses the visible checkbox. The edit/delete checks explicitly hover the row through the browser driver before clicking. Domscribe does not record hover steps or turn an invisible target into an actionable one. + +The website manual setup is inside a closed native `details` element. Its trace must include the summary click before the hidden tab click. A driver that omits that prerequisite does not demonstrate a product failure or a successful replay. + +## Controlled regression coverage + +The repository consumer harness also exercises a Next16/pnpm semantic table and a Vue3/pnpm/Vite5 semantic list with genuinely recycled action nodes. It asserts fresh-session persisted replay, source/runtime correspondence, intended-business-entity updates, ambiguity refusal, and production stripping. A React18/npm/Vite7 labeled-card run preserves the original path. These fixtures make failures repeatable in CI; they are distinct from the maintained application checks above. + +Unit regressions cover native and ARIA table/list scopes, sibling-identity changes, stable moves, duplicate identities, text-segment collisions, private/editable values, redaction and over-limit identity. Five initial tests failed against the original runtime, including an old invoice handle incorrectly remaining `resolved`. A browser table run against the old artifacts also stopped instead of completing portable replay. + +Independent review reproduced two additional gaps before delivery: a new row scope displaced outer business identity at the four-scope boundary, and segment encoding allowed split PII to evade pattern matching. Regression tests now require complete local ancestor fingerprints, explicit portable overflow refusal and pre-encoding redaction of split email/card/phone values while still invalidating local handles when that raw identity changes. + +These results establish a limited acceptance matrix, not support for every framework, router, virtualizer or business workflow. Long/redacted or indistinguishable rows may require new application labels or a new capture. Shadow DOM, frames, general session restoration and native cloud repair remain outside these tests. Release readiness and real developer demand require separate evidence. diff --git a/docs/superpowers/plans/2026-10-26-semantic-row-targets.md b/docs/superpowers/plans/2026-10-26-semantic-row-targets.md new file mode 100644 index 0000000..88cfa9f --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-semantic-row-targets.md @@ -0,0 +1,20 @@ +# Semantic Row Target Safety Implementation Plan + +> Execute inline with superpowers:executing-plans; obtain one fresh independent review of the whole branch before delivery. + +Goal: prevent an exact row-action handle from silently following a recycled business entity and allow portable replay of ordinary semantic rows without requiring added test IDs. + +Diagnosis: at main bfb108d, table/list ancestors without identity attributes/headings are omitted. Changing a sibling invoice ID leaves the Retry button fingerprint unchanged. Fresh-session row actions are ambiguous. The new runtime regressions first failed in five cases (resolved instead of stale; ambiguous instead of resolved), while the original 568 runtime tests passed. + +Scope: runtime target-registry identity/matching; runtime regressions; existing browser consumer harness extended with table/list variants; accurate usage/limitations and Sprint1 real-app acceptance matrix. No public schema, dependencies, framework adapters, release or deployment changes. + +- [x] RED: retained invoice-row button must retire when sibling identity changes; reordered native/ARIA table/list locators must select the original business row in a fresh runtime. +- [x] GREEN: capture bounded privacy-filtered segmented semantic-row text in ancestor constraints and local fingerprints; use identical conjunctive matching, preserve segment boundaries and existing non-row locators. +- [x] Extend negative tests for duplicate rows, changed identity, private/editable descendants and over-limit row identity; test stable handle under moves. +- [x] Extend real-browser local-tarball consumer verification to semantic table/list rows, actual recycled DOM slots, fresh-session replay, correct business entity, source/runtime correspondence and production removal. Run representative React/Next and Vue/Nuxt cases; label fixtures versus actual apps honestly. +- [x] Establish at least three realistic acceptance workflows spanning maintained Next and an independent Vue app; document confirmed versus planned coverage. +- [ ] Run configured affected lint/test/build/typecheck uncached, broader configured suite before readiness, independent review, fix material findings with regressions, push focused PR, follow latest-head CI and merge with standing authority. + +Review focus: no wrong-entity action under DOM reuse; no private/form values in row identity; duplicate/unsupported scopes stop safely; segment collisions do not retain a stale handle; old non-row locator behavior remains compatible. The existing four-ancestor/main-document/light-DOM boundaries remain explicit. Scope text changes can conservatively require recapture. + +Review rulings: include all relevant ancestors in private local fingerprints and mark portable overflow unsupported instead of weakening prior business scope. Detect PII on contiguous/spaced filtered text before JSON segment serialization; retain raw local fingerprints. New regression tests first failed on the reviewed head. The initial fix shortened redacted long identity and changed unsupported to not_found; preserve raw-bound unsupported status before exporting redacted bounded identity. Original over-limit tests caught this and must stay green. diff --git a/packages/domscribe-runtime/src/targets/target-registry.ts b/packages/domscribe-runtime/src/targets/target-registry.ts index d25efef..bd175ed 100644 --- a/packages/domscribe-runtime/src/targets/target-registry.ts +++ b/packages/domscribe-runtime/src/targets/target-registry.ts @@ -19,6 +19,7 @@ const IDENTITY_ATTRIBUTES = [ 'role', ] as const; const INSTANCE_ATTRIBUTE = 'data-ds-instance'; +const SEMANTIC_ROW_SELECTOR = 'tr, li, [role="row"], [role="listitem"]'; const normalize = (value: string) => value.replace(/\s+/g, ' ').trim(); interface Instance { element: WeakRef; @@ -63,9 +64,12 @@ export class TargetRegistry { }); } - private fingerprint(element: HTMLElement): string { + private fingerprint( + element: HTMLElement, + locator = this.locator(element), + ): string { return JSON.stringify({ - locator: this.locator(element, false), + locator, entryId: element.getAttribute('data-ds'), }); } @@ -73,7 +77,16 @@ export class TargetRegistry { describe(element: HTMLElement): ElementTarget | null { if (!this.eligible(element)) return null; const rawLocator = this.locator(element); - const fingerprint = this.fingerprint(element); + const fingerprint = this.fingerprint(element, rawLocator); + const portableLocator = this.options.redact + ? (redactPII(this.locator(element, true)) as PortableLocator) + : rawLocator; + // Redaction may shorten identity, but cannot turn raw overflow into a + // supported locator. It must also happen before any exported truncation. + const locator = this.boundLocator({ + ...portableLocator, + unsupportedReason: this.boundLocator(rawLocator).unsupportedReason, + }); let instanceId = this.handles.get(element); if ( instanceId && @@ -95,11 +108,7 @@ export class TargetRegistry { instanceId, entryId: element.getAttribute('data-ds') ?? undefined, selector: `[${INSTANCE_ATTRIBUTE}="${instanceId}"]`, - locator: this.boundLocator( - (this.options.redact - ? redactPII(rawLocator) - : rawLocator) as PortableLocator, - ), + locator, disabled: element.matches(':disabled, [aria-disabled="true"]'), inputType: element.tagName === 'INPUT' @@ -216,7 +225,11 @@ export class TargetRegistry { ); } - private identity(element: HTMLElement, scope = false): LocatorIdentity { + private identity( + element: HTMLElement, + scope = false, + redactSegments = false, + ): LocatorIdentity { const attributes: Record = {}; for (const name of IDENTITY_ATTRIBUTES) { const value = element.getAttribute(name); @@ -227,6 +240,11 @@ export class TargetRegistry { attributes, }; if (scope) { + // Repeated row actions often have no label/ID on their container. Keep + // sibling business text as a constraint, including in local fingerprints, + // so a virtualized DOM slot cannot silently become another entity. + if (element.matches(SEMANTIC_ROW_SELECTOR)) + identity.text = this.text(element, true, redactSegments); const heading = Array.from(element.children).find((child) => /^H[1-6]$/.test(child.tagName), ); @@ -243,7 +261,11 @@ export class TargetRegistry { } /** Form/editable values and blocked descendants are never locator identity. */ - private text(element: HTMLElement): string { + private text( + element: HTMLElement, + segments = false, + redactSegments = false, + ): string { const walker = this.options.document.createTreeWalker( element, 4 /* SHOW_TEXT */, @@ -263,19 +285,35 @@ export class TargetRegistry { } node = walker.nextNode(); } - return normalize(chunks.join('')); + if (segments && redactSegments) { + // JSON punctuation can split an email/card across otherwise adjacent + // text nodes. Detect it before encoding, and never export its pieces. + for (const text of [ + normalize(chunks.join('')), + normalize(chunks.join(' ')), + ]) + if (redactPII(text) !== text) return '[REDACTED_ROW]'; + } + // Preserve segment boundaries for row identity: cells "AB"/"C" must not + // collide with "A"/"BC". Values/editable/private text remain excluded. + return segments + ? JSON.stringify(chunks.map(normalize).filter(Boolean)) + : normalize(chunks.join('')); } - private locator(element: HTMLElement, bounded = true): PortableLocator { + private locator( + element: HTMLElement, + redactSegments = false, + ): PortableLocator { const ancestors: LocatorIdentity[] = []; let ancestor = element.parentElement; - while ( - ancestor && - ancestor !== this.options.document.body && - ancestors.length < 4 - ) { - const identity = this.identity(ancestor, true); - if (Object.keys(identity.attributes).length || identity.heading) + while (ancestor && ancestor !== this.options.document.body) { + const identity = this.identity(ancestor, true, redactSegments); + if ( + Object.keys(identity.attributes).length || + identity.heading || + identity.text + ) ancestors.push(identity); ancestor = ancestor.parentElement; } @@ -285,12 +323,14 @@ export class TargetRegistry { element: this.identity(element), ancestors, }; - return bounded ? this.boundLocator(locator) : locator; + return locator; } private boundLocator(locator: PortableLocator): PortableLocator { let unsupported = - !!locator.unsupportedReason || locator.pathname.length > 2048; + !!locator.unsupportedReason || + locator.pathname.length > 2048 || + locator.ancestors.length > 4; const bound = (identity: LocatorIdentity): LocatorIdentity => { if ( identity.tagName.length > 64 || @@ -318,7 +358,7 @@ export class TargetRegistry { ...locator, pathname: locator.pathname.slice(0, 2048), element: bound(locator.element), - ancestors: locator.ancestors.map(bound), + ancestors: locator.ancestors.slice(0, 4).map(bound), }; return { ...result, @@ -329,6 +369,7 @@ export class TargetRegistry { private matchesIdentity( element: HTMLElement, identity: LocatorIdentity, + scope = false, ): boolean { if (element.tagName.toLowerCase() !== identity.tagName) return false; if ( @@ -337,7 +378,11 @@ export class TargetRegistry { ) ) return false; - if (identity.text !== undefined && this.text(element) !== identity.text) + if ( + identity.text !== undefined && + this.text(element, scope && element.matches(SEMANTIC_ROW_SELECTOR)) !== + identity.text + ) return false; if (identity.heading !== undefined) { const heading = Array.from(element.children).find((child) => @@ -353,7 +398,7 @@ export class TargetRegistry { if (!this.matchesIdentity(element, locator.element)) return false; let ancestor = element.parentElement; for (const scope of locator.ancestors) { - while (ancestor && !this.matchesIdentity(ancestor, scope)) + while (ancestor && !this.matchesIdentity(ancestor, scope, true)) ancestor = ancestor.parentElement; if (!ancestor) return false; ancestor = ancestor.parentElement; diff --git a/packages/domscribe-runtime/src/targets/targets.spec.ts b/packages/domscribe-runtime/src/targets/targets.spec.ts index 1efc499..7f71a74 100644 --- a/packages/domscribe-runtime/src/targets/targets.spec.ts +++ b/packages/domscribe-runtime/src/targets/targets.spec.ts @@ -193,6 +193,267 @@ function required(value: T | null | undefined): T { return value; } +describe('semantic row identity', () => { + afterEach(() => { + RuntimeManager.resetInstance(); + document.body.innerHTML = ''; + }); + + it('keeps complete local identity and refuses portable ancestor overflow instead of dropping business scope', async () => { + window.__DOMSCRIBE_RELAY_PORT__ = 6412; + document.body.innerHTML = + '
'; + const runtime = RuntimeManager.getInstance(); + await runtime.initialize(); + const target = required( + runtime.describeTarget(required(document.querySelector('button'))), + ); + expect( + (await runtime.queryTargets({ locator: target.locator })).status, + ).toBe('unsupported'); + required(document.querySelector('article')).setAttribute( + 'aria-label', + 'Invoice 2048', + ); + expect( + (await runtime.queryTargets({ instanceId: target.instanceId })).status, + ).toBe('stale'); + expect(document.querySelector(target.selector)).toBeNull(); + }); + + it.each([ + [ + 'email', + 'person@example.com', + 'person', + 'another@example.com', + ], + [ + 'card', + '4111111111111111', + '4111', + '5555555555554444', + ], + [ + 'phone', + '4155552671', + '2671', + '2125550199', + ], + ])( + 'redacts split %s before segment encoding while retaining raw local invalidation', + async (_, content, secretPart, replacement) => { + window.__DOMSCRIBE_RELAY_PORT__ = 6412; + document.body.innerHTML = + '
  • ' + + content + + '
'; + const runtime = RuntimeManager.getInstance(); + await runtime.initialize(); + const target = required( + runtime.describeTarget(required(document.querySelector('button'))), + ); + expect(JSON.stringify(target.locator)).not.toContain(secretPart); + expect( + (await runtime.queryTargets({ locator: target.locator })).status, + ).toBe('not_found'); + required(document.querySelector('section')).innerHTML = replacement; + expect( + (await runtime.queryTargets({ instanceId: target.instanceId })).status, + ).toBe('stale'); + }, + ); + + it('revokes a retained table button when its visible business row is recycled', async () => { + window.__DOMSCRIBE_RELAY_PORT__ = 6412; + document.body.innerHTML = + '
Invoice 1042
'; + const runtime = RuntimeManager.getInstance(); + await runtime.initialize(); + const button = required(document.querySelector('button')); + const target = required(runtime.describeTarget(button)); + required(document.querySelector('td')).textContent = 'Invoice 2048'; + expect( + (await runtime.queryTargets({ instanceId: target.instanceId })).status, + ).toBe('stale'); + expect(document.querySelector(target.selector)).toBeNull(); + }); + + it.each([ + [ + 'table', + '', + '
', + '', + '', + ], + [ + 'list', + '
    ', + '
', + '
  • ', + '
  • ', + ], + [ + 'ARIA row', + '
    ', + '
    ', + '
    ', + '
    ', + ], + [ + 'ARIA list item', + '
    ', + '
    ', + '
    ', + '
    ', + ], + ])( + 'replays a %s action using sibling business text after a fresh session and reorder', + async (_, open, close, rowOpen, rowClose) => { + window.__DOMSCRIBE_RELAY_PORT__ = 6412; + document.body.innerHTML = + open + + rowOpen + + 'Lyra' + + rowClose + + rowOpen + + 'Vega' + + rowClose + + close; + let runtime = RuntimeManager.getInstance(); + await runtime.initialize(); + const target = required( + runtime.describeTarget(required(document.querySelector('button'))), + ); + const locator = JSON.parse(JSON.stringify(target.locator)); + RuntimeManager.resetInstance(); + document.body.innerHTML = + open + + rowOpen + + 'Vega' + + rowClose + + rowOpen + + 'Lyra' + + rowClose + + close; + runtime = RuntimeManager.getInstance(); + await runtime.initialize(); + const result = await runtime.queryTargets({ locator }); + expect(result.status).toBe('resolved'); + expect(document.querySelector(result.targets[0].selector)).toBe( + document.querySelectorAll('button')[1], + ); + }, + ); + + it('preserves a row handle on moves but refuses duplicate visible row identity', async () => { + window.__DOMSCRIBE_RELAY_PORT__ = 6412; + document.body.innerHTML = + '
    • Lyra
    • Vega
    '; + const runtime = RuntimeManager.getInstance(); + await runtime.initialize(); + const button = required(document.querySelector('button')); + const target = required(runtime.describeTarget(button)); + required(document.querySelector('ul')).append( + required(button.parentElement), + ); + expect( + (await runtime.queryTargets({ instanceId: target.instanceId })).status, + ).toBe('resolved'); + required(document.querySelector('ul')).append( + required(button.parentElement).cloneNode(true), + ); + expect( + (await runtime.queryTargets({ locator: target.locator })).status, + ).toBe('ambiguous'); + }); + + it('does not confuse row cell text that has the same concatenated value', async () => { + window.__DOMSCRIBE_RELAY_PORT__ = 6412; + document.body.innerHTML = + '
    ABC
    '; + const runtime = RuntimeManager.getInstance(); + await runtime.initialize(); + const target = required( + runtime.describeTarget(required(document.querySelector('button'))), + ); + document.querySelectorAll('td')[0].textContent = 'A'; + document.querySelectorAll('td')[1].textContent = 'BC'; + expect( + (await runtime.queryTargets({ instanceId: target.instanceId })).status, + ).toBe('stale'); + expect( + (await runtime.queryTargets({ locator: target.locator })).status, + ).toBe('not_found'); + }); + + it('omits private and editable row descendants and ignores form value changes', async () => { + window.__DOMSCRIBE_RELAY_PORT__ = 6412; + document.body.innerHTML = + '
    • Invoice 1042hidden billing detaileditable secret
    '; + const runtime = RuntimeManager.getInstance(); + await runtime.initialize({ blockSelectors: ['.private'] }); + const target = required( + runtime.describeTarget(required(document.querySelector('button'))), + ); + const serialized = JSON.stringify(target.locator); + for (const secret of [ + 'hidden billing detail', + 'draft secret', + 'editable secret', + 'private value', + ]) + expect(serialized).not.toContain(secret); + required(document.querySelector('input')).value = 'new private value'; + required(document.querySelector('textarea')).value = 'another draft'; + expect( + (await runtime.queryTargets({ instanceId: target.instanceId })).status, + ).toBe('resolved'); + expect( + (await runtime.queryTargets({ locator: target.locator })).status, + ).toBe('resolved'); + }); + + it('redacts row identity and refuses portable replay of the redacted identity', async () => { + window.__DOMSCRIBE_RELAY_PORT__ = 6412; + document.body.innerHTML = + '
    • person@example.com
    '; + const runtime = RuntimeManager.getInstance(); + await runtime.initialize(); + const target = required( + runtime.describeTarget(required(document.querySelector('button'))), + ); + expect(JSON.stringify(target.locator)).not.toContain('person@example.com'); + expect( + (await runtime.queryTargets({ locator: target.locator })).status, + ).toBe('not_found'); + }); + + it('refuses over-limit row identity and invalidates changes beyond its bounded prefix', async () => { + window.__DOMSCRIBE_RELAY_PORT__ = 6412; + document.body.innerHTML = + '
    • ' + + 'x'.repeat(128) + + 'A
    '; + const runtime = RuntimeManager.getInstance(); + await runtime.initialize(); + const button = required(document.querySelector('button')); + const target = required(runtime.describeTarget(button)); + expect( + (await runtime.queryTargets({ locator: target.locator })).status, + ).toBe('unsupported'); + expect(() => + runtime.createReplayStep(button, { action: 'click' }), + ).toThrow(); + required(document.querySelector('span')).textContent = + 'x'.repeat(128) + 'B'; + expect( + (await runtime.queryTargets({ instanceId: target.instanceId })).status, + ).toBe('stale'); + }); +}); + describe('reviewed target lifecycle regressions', () => { afterEach(() => { RuntimeManager.resetInstance(); diff --git a/scripts/fixtures/replay-consumer.mjs b/scripts/fixtures/replay-consumer.mjs index d2ccb85..7a44982 100644 --- a/scripts/fixtures/replay-consumer.mjs +++ b/scripts/fixtures/replay-consumer.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -export function writeReplayConsumer(consumer, appFramework) { +export function writeReplayConsumer(consumer, appFramework, scope = 'article') { const framework = appFramework === 'next' ? 'react' @@ -102,6 +102,55 @@ createRoot(document.getElementById('root')).render(); `, ); } + if (scope !== 'article') { + const table = scope === 'table'; + if (framework === 'vue') { + const cardPath = path.join(source, 'Card.vue'); + const card = fs.readFileSync(cardPath, 'utf8'); + fs.writeFileSync( + cardPath, + card.replace( + /
    [\s\S]*?<\/article>/, + table + ? '{{ customer.name }}{{ customer.city }}' + : '
  • {{ customer.name }}{{ customer.city }}
  • ', + ), + ); + const appPath = path.join(source, 'App.vue'); + let app = fs.readFileSync(appPath, 'utf8'); + app = app.replace( + '', + (table ? '' : '
      ') + + '' + + (table ? '
    ' : ''), + ); + app = app.replace( + '', + "", + ); + fs.writeFileSync(appPath, app); + } else { + const appPath = path.join(source, 'main.jsx'); + let app = fs.readFileSync(appPath, 'utf8'); + app = app.replace( + '

    {customer.name}

    {customer.city}
    ', + table + ? '{customer.name}{customer.city}' + : '
  • {customer.name}{customer.city}
  • ', + ); + app = app.replace( + '{sorted.map(customer=>)}', + (table ? '' : '
      ') + + '{sorted.map((customer,index)=>)}' + + (table ? '
    ' : ''), + ); + app = app.replace( + '', + "", + ); + fs.writeFileSync(appPath, app); + } + } if (appFramework === 'next') { fs.rmSync(path.join(consumer, 'index.html')); fs.rmSync(path.join(consumer, 'vite.config.mjs')); diff --git a/scripts/verify-instance-replay.mjs b/scripts/verify-instance-replay.mjs index f37df06..6ae6847 100644 --- a/scripts/verify-instance-replay.mjs +++ b/scripts/verify-instance-replay.mjs @@ -21,12 +21,18 @@ import { const { values } = parseArgs({ options: { framework: { type: 'string', default: 'react' }, + scope: { type: 'string', default: 'article' }, 'package-manager': { type: 'string', default: 'npm' }, 'vite-version': { type: 'string' }, 'report-dir': { type: 'string' }, 'framework-version': { type: 'string' }, }, }); +const scope = values.scope; +assert( + ['article', 'table', 'list'].includes(scope), + 'scope must be article, table or list', +); const framework = values.framework, manager = values['package-manager'], viteVersion = values['vite-version'] ?? '7.3.7'; @@ -65,6 +71,7 @@ const evidence = { at: new Date().toISOString(), node: process.version, framework, + scope, packageManager: manager, requestedVite: project.ssr ? undefined : viteVersion, requestedFrameworkVersion: project.ssr ? project.requestedVersion : undefined, @@ -155,7 +162,7 @@ try { ); throw error; } - writeReplayConsumer(consumer, framework); + writeReplayConsumer(consumer, framework, scope); async function freePort() { const server = net.createServer(); @@ -170,7 +177,9 @@ try { try { const value = await fn(); if (value) return value; - } catch {} + } catch { + // A starting server/relay may reject requests until its next poll. + } await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error('Timed out waiting for dev server/browser readiness'); @@ -290,17 +299,26 @@ try { async () => (await client.queryTargets({ mode: 'list' })).status === 'listed', ); - const edit = page - .getByRole('article', { name: 'Lyra', exact: true }) - .getByRole('button', { name: 'Edit' }); + function customer(page, name) { + return scope === 'article' + ? page.getByRole('article', { name, exact: true }) + : page + .getByRole(scope === 'table' ? 'row' : 'listitem') + .filter({ has: page.getByText(name, { exact: true }) }); + } + const edit = customer(page, 'Lyra').getByRole('button', { name: 'Edit' }); const selected = await edit.evaluate((element) => window.__runtime.describeTarget(element), ); - const source = await ( - await fetch( + assert(selected?.entryId, 'The selected action must be instrumented'); + const source = await until(async () => { + const response = await fetch( `http://127.0.0.1:${relayPort}/api/v1/manifest/resolve?id=${selected.entryId}`, - ) - ).json(); + ); + const result = await response.json(); + // The runtime can connect before the manifest's batched write/poll lands. + return response.ok && result.entry ? result : null; + }); const bySource = await client.queryBySource({ file: source.entry.file, line: source.entry.start.line, @@ -426,24 +444,15 @@ try { assert.equal(replay.status, 'completed'); assert.equal(replay.completed, 4); assert.equal( - await page - .getByRole('article', { name: 'Lyra', exact: true }) - .locator('strong') - .innerText(), + await customer(page, 'Lyra').locator('strong').innerText(), 'Austin', ); assert.equal( - await page - .getByRole('article', { name: 'Orion', exact: true }) - .locator('strong') - .innerText(), + await customer(page, 'Orion').locator('strong').innerText(), 'Seattle', ); assert.equal( - await page - .getByRole('article', { name: 'Vega', exact: true }) - .locator('strong') - .innerText(), + await customer(page, 'Vega').locator('strong').innerText(), 'Denver', ); check( @@ -461,6 +470,64 @@ try { assert.equal(stopped.reason, 'ambiguous'); assert.equal(stopped.completed, 0); check('ambiguous replay stops before any action', {}); + if (scope !== 'article') { + const edit = customer(page, 'Lyra').getByRole('button', { name: 'Edit' }); + const retained = await edit.elementHandle(); + const before = await edit.evaluate((element) => + window.__runtime.describeTarget(element), + ); + await page + .getByRole('button', { name: 'Recycle Lyra', exact: true }) + .click(); + const replacement = customer(page, 'Aster').getByRole('button', { + name: 'Edit', + }); + assert( + await replacement.evaluate( + (element, retained) => element === retained, + retained, + ), + 'Fixture must genuinely reuse the same action DOM node', + ); + assert.equal( + (await client.queryTargets({ instanceId: before.instanceId })).status, + 'stale', + ); + assert.equal(await page.locator(before.selector).count(), 0); + assert.equal( + (await client.queryTargets({ locator: before.locator })).status, + 'not_found', + ); + const current = await replacement.evaluate((element) => + window.__runtime.describeTarget(element), + ); + const live = await client.queryTargets({ instanceId: current.instanceId }); + assert.equal(live.context.componentProps.customer.id, 'c4'); + assert.equal(live.context.componentProps.customer.name, 'Aster'); + await replacement.click(); + await page.getByRole('textbox', { name: 'Shipping city' }).fill('Tucson'); + await page + .getByRole('checkbox', { name: 'Confirm shipping change' }) + .check(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + assert.equal( + await customer(page, 'Aster').locator('strong').innerText(), + 'Tucson', + ); + assert.equal( + await customer(page, 'Orion').locator('strong').innerText(), + 'Seattle', + ); + assert.equal( + await customer(page, 'Vega').locator('strong').innerText(), + 'Denver', + ); + await retained.dispose(); + check( + 'recycled action node retires its old target; recaptured runtime and UI address only the new business entity', + { old: 'c2', current: 'c4' }, + ); + } assert.deepEqual(errors, []); assert.deepEqual(consoleErrors, []); assert.deepEqual(networkErrors, []); @@ -498,8 +565,7 @@ try { await production.locator('[data-ds], [data-ds-instance]').count(), 0, ); - await production - .getByRole('article', { name: 'Lyra', exact: true }) + await customer(production, 'Lyra') .getByRole('button', { name: 'Edit' }) .click(); await production @@ -518,17 +584,11 @@ try { .check(); await production.getByRole('button', { name: 'Save', exact: true }).click(); assert.equal( - await production - .getByRole('article', { name: 'Lyra', exact: true }) - .locator('strong') - .innerText(), + await customer(production, 'Lyra').locator('strong').innerText(), 'Dallas', ); assert.equal( - await production - .getByRole('article', { name: 'Orion', exact: true }) - .locator('strong') - .innerText(), + await customer(production, 'Orion').locator('strong').innerText(), 'Seattle', ); assert.deepEqual(errors, []); @@ -563,7 +623,9 @@ try { if (relay.workspaceRoot === consumer) { try { process.kill(relay.pid, 'SIGTERM'); - } catch {} + } catch { + // The owned server group may already have stopped its relay. + } } } fs.writeFileSync(