diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 480afbb..09d5637 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,7 +147,7 @@ jobs: - id: react-npm-vite7 framework: react package-manager: npm - version-args: '--vite-version=7.3.7 --schema-peer' + version-args: '--vite-version=7.3.7 --schema-peer --issue-handoff' - id: react-pnpm-vite5 framework: react package-manager: pnpm @@ -159,7 +159,7 @@ jobs: - id: vue-pnpm-vite5 framework: vue package-manager: pnpm - version-args: '--vite-version=5.4.21 --scope=list --schema-peer --adapter-only --raw-runtime' + version-args: '--vite-version=5.4.21 --scope=list --schema-peer --adapter-only --raw-runtime --issue-handoff' - id: react-pnpm-vite8 framework: react package-manager: pnpm diff --git a/README.md b/README.md index 1c24a34..67b97c5 100644 --- a/README.md +++ b/README.md @@ -452,6 +452,8 @@ Conventions are in `.claude/rules/`. PRs welcome. For an isolated browser replay check through the Docker Node SDK, see the [Docker Cloud replay smoke test](./docs/docker-cloud-replay.md). +Portable issue-packet CLI commands are being developed on main; see the [source-only handoff guide](./docs/portable-issue-handoff.md) for availability, privacy options and fresh-checkout inspection. + --- ## License diff --git a/docs/portable-issue-handoff-verification.md b/docs/portable-issue-handoff-verification.md new file mode 100644 index 0000000..be3adfa --- /dev/null +++ b/docs/portable-issue-handoff-verification.md @@ -0,0 +1,60 @@ +# Portable issue handoff verification — October 27, 2026 + +The hypothesis is that a useful UI bug handoff preserves the selected instance, source anchor, expected/observed behavior and setup requirements across people and sessions. This slice removes manual annotation JSON/path rewriting. It does not establish demand or an advantage over a complete browser/source agent. + +The `issue export` and `issue inspect` commands are source/local-build features, absent from published npm 0.7.0. See [the usage guide](./portable-issue-handoff.md). + +## Fresh acceptance + +Both paths installed locally packed artifacts in isolated consumers. The pnpm path used explicit local-tarball overrides; these are not unmodified registry installs. Node 24.22.1 and Chromium 156.0.8078.4 were used. + +| Path | Native capture and transfer | Source evidence | Other browser assertions | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| React 18.3.1 / Vite 7.3.7 / npm 11.19.1 | Overlay-selected Orion editor Save annotation exported through the installed CLI; reporter browser and relay stopped; unchanged packet inspected offline in a separate source Git worktree; zero JSON edits | `src/main.jsx:6:458`, original source hash `1bdb30697bf58a2d`; identical checkout matched; modified receiving source reported changed | Wrong-customer locator refused; stale/ambiguous targets refused; fresh-context reordered-row replay updated the intended customer; production instrumentation absent; zero app page/console/network errors | +| Vue 3.5.44 / Vite 8.3.5 / pnpm 9.12.0 | Same native export/offline transfer flow, adapter-only installation with no top-level runtime/overlay dependency | `src/Editor.vue:13:5`, original source hash `60427786f5442eb8`; identical checkout matched; modified receiving source reported changed | Same assertions, plus recycled action-node identity retirement; production customer update correct; zero app page/console/network errors | + +These are controlled customer-editor fixtures. The annotation records an explicit acceptance scenario; it is not evidence of discovering or repairing a real customer bug. Packet integrity was checked before/after transfer, and inspection created no relay state in the receiving checkout. Captured automation durations are fixture-operation timings, not human usability or repair measurements. The headless browser driver exercised native UI controls; it did not inject a fabricated annotation. + +## Repeatable browser check + +The permanent browser harness now supports `--issue-handoff`. CI enables it on its existing React/npm/Vite7 and Vue/pnpm/Vite5 rows. Both exact configurations passed freshly with local artifacts (12 and 14 assertion groups respectively), including Vue's raw-runtime capture option. Build/synchronize first, then pack/test without concurrent builds: + +```sh +NX_DAEMON=false NX_NO_CLOUD=true pnpm exec nx run-many -t sync-dist --parallel=2 +node scripts/verify-instance-replay.mjs --framework=react --package-manager=npm \ + --vite-version=7.3.7 --schema-peer --issue-handoff --report-dir=./tmp/issue-react +node scripts/verify-instance-replay.mjs --framework=vue --package-manager=pnpm \ + --vite-version=5.4.21 --scope=list --schema-peer --adapter-only --raw-runtime \ + --issue-handoff --report-dir=./tmp/issue-vue +``` + +The report includes the native annotation, exported packet, screenshots and CLI inspection logs. An initial permanent-harness attempt correctly refused unsynchronized artifacts restored by a preceding Nx task check; synchronization was rerun before packing. The earlier Vue8 result remains a separate qualification, not a substitute for the permanent Vue5 path. + +A separate compatibility check used the actual historical native Hoppscotch Generate Token annotation from the October 26 run (upstream `63273f8`). The new installed CLI exported it with no packet edits or annotation-record mutations, retained `context_required`, and inspected it offline in a fresh source worktree as `unverified` because the legacy capture lacks original-source metadata. Synthetic private client/environment markers were absent. This was a historical-annotation check, not a fresh Hoppscotch browser or agent repair run. + +## Failures that changed the implementation + +- Initial React capture incorrectly compared transformed React Refresh code with the original source. A probe confirmed 3,722 incoming characters versus 1,766 original-source characters. A distinct optional `sourceHash` now comes only from an exact absolute source-map match. Legacy or unconfirmed hashes remain unverified. Source locations were correct; checksum provenance was wrong. +- Existing-ID manifest deduplication discarded the new original-source metadata. Real-file regressions now verify a one-time metadata upgrade, restart persistence, last-record mapping and no inherited hash when the latest record omitted it. +- Independent review found private dictionary keys escaping value-only redaction, already-redacted locators advertised as actionable, validation errors echoing private field names, and a valid large Markdown payload overflowing an argument list. Each was reproduced by a failing regression before its fix; re-review found no remaining material findings. +- The first CLI build exposed optional relay-lock host typing; an explicit readiness guard fixed it. Invalid test invocations and malformed test fixture IDs were setup failures, not product regressions. + +## Configured checks + +From the repository root, uncached: + +```sh +NX_DAEMON=false NX_NO_CLOUD=true pnpm exec nx run-many \ + -t lint,test,build,typecheck --exclude domscribe-test-fixtures \ + --parallel=2 --skipNxCache --output-style=stream +``` + +All configured targets passed across 16 projects: 2,223 unit tests. After two further source-hash cache regressions and their fix, the complete manifest lint/test/build/typecheck targets passed uncached with 180 tests. All four affected projects were checked again before task completion. New tests contain non-null assertion lint warnings; no lint errors. Existing fixture warning remains. + +After sequential artifact synchronization, `node scripts/check-dist-dependencies.mjs` passed all 497 emitted runtime/declaration files. Artifact packaging began only after building/synchronization ended. The fresh native subprocess/browser flows above then passed on these artifacts. Full-suite and affected logs preserve failed attempts as well as passing results. + +## Limits and next experiment + +Source matching checks content consistency, not report authenticity, business identity or fix correctness. Runtime and recorded replay are opt-in; known-marker filtering is not a universal detector of opaque application secrets. Main-document/light-DOM replay, explicit app prerequisites and stable dialog business context still apply. This slice's original-source metadata is qualified through Vite; integrations without a confirmed original source remain unverified. New CLI paths were not freshly qualified on Next/Nuxt in this slice. + +Next, use the native packet on a real application bug with a fresh receiving agent and no hand-edited context. Compare against a complete ordinary browser/source workflow, count capture/setup separately, and grade business behavior independently. The previous larger Hoppscotch comparison had all four repairs pass and showed no correctness advantage; this slice does not change that result. Managed orchestration remains an unproven expansion. diff --git a/docs/portable-issue-handoff.md b/docs/portable-issue-handoff.md new file mode 100644 index 0000000..01a9120 --- /dev/null +++ b/docs/portable-issue-handoff.md @@ -0,0 +1,50 @@ +# Portable UI issue handoff + +**Availability:** these `issue` CLI commands are source/local-build features. The published npm 0.7.0 release has exact-instance capture and portable replay, but does not include these commands yet. + +Use this flow when the person receiving a UI bug report has a separate source checkout or browser session. It packages one annotation as historical evidence; it does not recreate application data, authentication, backend services or a live browser selection. + +## Capture and export + +Start an instrumented development app. Open the Domscribe overlay, select the exact element, describe the problem and submit an annotation. Get its `metadata.id` using the annotation's **Copy annotation JSON** button, or the `id` returned by the existing `domscribe.annotation.list` MCP tool. + +From that app's Domscribe workspace, using a locally installed CLI build: + +```sh +domscribe issue export ann_12345678_123 \ + --title 'Collection token uses the wrong variables' \ + --expected 'Use the selected Alpha collection variables' \ + --observed 'Uses the unrelated active Beta request variables' \ + --prerequisite 'Import the attached synthetic collections in a local guest workspace' \ + --step 'Open Beta request, then Alpha collection Properties / Authorization' \ + --step 'Click Generate Token' \ + --out collection-issue.domscribe.json +``` + +`--out` creates a new file and refuses to overwrite existing files. Without it, the command writes only JSON to stdout. The annotation's relay must be running for export; no annotation status is changed. + +Add `--include-runtime` if the receiver needs captured component props/state. Add `--include-replay` only when the annotation already contains explicitly recorded steps. Neither option restores app state or turns ordinary annotations into automatic recordings. They are omitted by default because application values may be private. + +Export removes live instance handles/selectors, URL origin/query/fragment, raw DOM HTML, screenshots, environment/user-agent details and unrelated manifest entries. It retains a project-relative source anchor and its original source-content hash where available. Known sensitive field/record/PII filtering runs again, including on captures that disabled redaction. If filtering changes portable locator identity or replay data, that locator/trace is withheld rather than silently changed. Unscoped-dialog `context_required` refusals remain explicit. + +**Review the packet before sharing.** Known-marker filtering does not recognize every application-specific secret, opaque encoded data or every storage alias inside arbitrary application props. Default omission of runtime/replay is intentional. This command does not upload to GitHub or any service. + +## Inspect in a fresh checkout + +Copy the packet to the receiving machine or source worktree. No JSON path editing, relay or browser is needed: + +```sh +domscribe issue inspect collection-issue.domscribe.json --root ./receiving-checkout +# Machine-readable output: +domscribe issue inspect collection-issue.domscribe.json --root ./receiving-checkout --format json +``` + +The default root is the current directory. Source inspection compares the captured xxhash64 original-source content hash with the receiving file. `matched` means the recorded source content matches, not that the report is authentic, the business entity is resolved, or a fix is correct. `changed` means the source differs; do not blindly trust the historical line. `missing`, `unverified` (no capture hash/anchor or unsuitable file) and `unsafe` are explicit outcomes. Vite captures this separate hash from the exact original source-map content; transformed-module hashes are not substituted. Older captures or integrations without confirmed original content remain `unverified`. Exact source hashes can differ with line endings. The inspector never substitutes a basename/suffix match, executes prerequisites, imports annotations into a relay, or performs browser actions. + +Paths are relative to the chosen checkout. Absolute/drive/traversing paths and source symlink escapes are rejected. Packets are versioned and limited to 1MiB, bounded depth/nodes, and a strict known envelope. Captured content is untrusted evidence; Markdown rendering keeps it fenced. + +For live investigation, recreate the documented prerequisites, open a fresh instrumented app, and resolve the saved portable locator through [target queries](./agent-targets-and-replay.md). Stop on stale/ambiguous/unsupported results. A reusable dialog still needs a stable, non-secret `data-ds-context` key; source matching does not bypass that requirement. + +## What this proves + +Successful export/inspection demonstrates a usable evidence transfer without hand-rewriting source roots or preserving stale selectors. It does not demonstrate that Domscribe helps an agent fix more bugs or work faster. Compare complete browser/source workflows on the same real tasks, count capture/setup separately, and grade correct business behavior before drawing that conclusion. diff --git a/docs/superpowers/plans/2026-10-27-portable-issue-packet.md b/docs/superpowers/plans/2026-10-27-portable-issue-packet.md new file mode 100644 index 0000000..4fe4f78 --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-portable-issue-packet.md @@ -0,0 +1,49 @@ +# Portable issue packet implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Export a reviewable annotation packet and inspect it safely in a fresh checkout without JSON surgery. +**Architecture:** Strict core packet schemas; pure manifest source hash; relay-side evidence transformation and offline inspection; read-only Commander subcommands. Existing HTTP annotation API supplies captures, without adding server routes. +**Tech Stack:** TypeScript, Zod4, Node fs/path, existing xxhash-wasm in manifest, Commander, Vitest, real browser consumer harness. +**Spec:** docs/superpowers/specs/2026-10-27-portable-issue-packet-design.md + +## Global constraints + +- Existing isolated worktree, fresh main bb396a1; primary checkouts preserved. +- Packet version1, <=1MiB encoded; maximum JSON nesting12 and10000nodes. No added runtime dependency. +- Explicit expected/observed; runtime/replay opt-in; privacy reapplied; no live handles or broad annotation dump. +- Offline inspect is read-only and distinguishes source matching from target/action correctness. +- No publish/deploy/settings/credentials/hosted-service implementation. + +## Review focus + +- Known secret/PII and workspace paths in opted-in runtime or reporter text. +- Old source, symlinks/traversal, outside root and missing capture hashes. +- Privacy changes to locator identity or replay values must remove actionability. +- Imported pathological JSON, unknown fields/versions and Markdown fence injection. +- CLI errors, no relay, existing output files and working offline in fresh roots. + +### Task 1: Validated portable evidence and offline source inspection + +**Files:** Create core lib/types/issue.ts and issue.spec.ts; export in core index.ts. Create manifest source-hash.ts; export in manifest index.ts. Create relay issue/issue-packet.ts and issue-packet.spec.ts. +**Interfaces:** `IssuePacketSchema`, inferred `IssuePacket`, `IssueExportOptionsSchema`; `hashSource(content:string):Promise`; `createIssuePacket(annotation, root, options):Promise`; `inspectIssuePacket(packet,root):Promise<{packet,sourceStatus}>`; `renderIssuePacket(inspection):string`. Source statuses matched/changed/missing/unverified/unsafe. Export source anchor comes only from matching selected manifest ID; no fallback to unrelated entry. Source checksum uses recorded manifest sourceHash, not a new export-time blessing. + +- [x] Write failing tests for strict validation, copied-root match/changed source, no handles, no external paths, privacy and redacted actionability, and bounds. Expected: missing public API assertions fail. +- [x] Implement smallest schema, pure hash helper and bounded transformation/offline inspector that satisfy tests. Never read through unsafe source symlinks. +- [x] Run configured core/manifest/relay tests, lint/build/typecheck. Expected all pass. Commit task. + +### Task 2: CLI export/inspect and runnable usage + +**Files:** Create relay cli/commands/issue.command.ts and issue.command.spec.ts; register in cli/program.ts; add docs/portable-issue-handoff.md and README link. +**Interfaces:** Commander `createIssueCommand()` for independently instantiated tests; production IssueCommand export. Existing RelayControl/status and RelayHttpClient only for export. Offline inspect has no relay dependency. stdout JSON vs Markdown is deterministic; --out uses exclusive create. + +- [x] Write failing command tests using injected/mocked relay boundaries and real temp output files for missing relay/options, offline fresh-root report, existing output preservation and malformed/deep packet rejection. Expected missing command behavior fails. +- [x] Implement CLI wiring and error handling; document local-source availability and privacy/replay limits. Run configured affected checks plus meaningful CLI subprocess verification from packed artifacts. Expected all pass. Commit task. + +### Task 3: Native capture-to-fresh-checkout verification and delivery + +**Files:** Extend existing temporary browser harness only as needed; commit a concise product guide/evidence report in docs, no machine-specific credentials or app data. +**Interfaces:** Real selected annotation exported via actual installed CLI; offline inspect after reporter browser/relay closes in new source checkout. Preserve exact selected source hash/identity without operator rewriting packet. + +- [x] Reproduce old CLI missing command/old manual JSON gap before claiming fix. Run new React/Vue native annotation export, fresh-root match and changed-source negative, privacy marker assertions and production stripping. Expected correct business source or explicit safe refusal. +- [x] Full configured suite; independent whole-branch review, regression fix material findings, focused PR, latest-head CI and authorized merge (delivery tracked in continuity ledger). Record all failures/workarounds and exact scope. Do not publish or infer agent advantage/demand. Retain a precise next comparison step. diff --git a/docs/superpowers/specs/2026-10-27-portable-issue-packet-design.md b/docs/superpowers/specs/2026-10-27-portable-issue-packet-design.md new file mode 100644 index 0000000..1beabd8 --- /dev/null +++ b/docs/superpowers/specs/2026-10-27-portable-issue-packet-design.md @@ -0,0 +1,19 @@ +# Portable UI issue handoff + +The approved hypothesis is that precise UI capture makes an issue more useful to its next developer/agent. Previous Hoppscotch experiments required manually copying annotation JSON, rebasing absolute source paths and explaining retired browser handles. Remove that demonstrated handoff friction before claiming an agent repair advantage. + +Deliver a small CLI vertical slice, implemented inline in the current isolated worktree based on fresh main bb396a1. User standing autonomy covers implementation, review, PR and merge; no further publication, production deployment, credentials/settings changes, external messages or hosted-service work. + +## Contract + +- `domscribe issue export --expected --observed ` reads one annotation from the existing workspace relay and writes a version1 JSON issue packet to stdout. Optional `--out ` creates a new file exclusively; never overwrite. Optional repeated `--step ` and `--prerequisite ` document app setup/reproduction. Expected/observed are required and nonempty; do not invent either from a generic user message. +- Runtime context and explicit replay traces are omitted by default. `--include-runtime` and `--include-replay` explicitly opt in. Export reapplies built-in known-field/record/PII redaction even when capture had privacy disabled. Never export raw DOM HTML, screenshots, user agent, environment, full manifest, agent response, new browser-storage enumeration or a live instanceId/CSS selector. Keep only one selected source anchor and portable locator. Rebase source and known owner paths to POSIX project-relative paths; exclude external/traversing/symlink-escaping source anchors. Replace workspace prefixes in retained text. Drop a portable locator or replay trace if redaction changes it; never silently replay modified identity or values. Preserve explicit unsupported/context_required reasons. +- Packet contains title, expected, observed, documented steps/prerequisites, annotation ID/time, pathname only (no URL origin/query/fragment/credentials), selected tag/text, optional project-relative source (line/column/confirmed original-source hash), optional portable locator, optional sanitized runtime and replay. Schema-first in core with strict bounded envelope and version. Total encoded size <=1MiB; fail clearly on oversized/deep input rather than silently truncating identity/evidence. All imported contents remain untrusted evidence. +- `domscribe issue inspect [--root ] [--format markdown|json]` validates a packet offline without a relay/browser, resolves its relative source within the selected root (default cwd), and prints a human/agent-readable report. Verify captured original-source xxhash64 against current source via a pure manifest hashing helper. Report matched/changed/missing/unverified/unsafe explicitly; no guessing by basename/suffix, stale line jumping, annotation recreation, browser actions or arbitrary setup execution. A matched source is not a resolved business target. Handles are historical; backend/auth/data prerequisites are never restored automatically. +- Inspector/export bounds are checked before deep validation/redaction. Imported relative paths reject absolute/drive/backslash/traversal forms; filesystem checks reject symlink escape. Markdown treats strings as quoted/fenced evidence, including backticks/HTML; cannot accidentally break out of evidence formatting. Unknown packet versions/fields and malformed replay fail validation. + +## Validation and useful outcome + +Meaningful RED/GREEN regressions cover source portability, source changes, unsafe paths, stale handle omission, privacy-off input, secret records, redacted replay, bounds and exclusive output. Real relay plus native browser annotation in isolated React and Vue consumers: export with optional context, close reporter session, inspect packet in a second source root, prove source match, then change source/provide wrong dialog and prove explicit refusal. Compare exact manual handoff steps required before/after. Do not turn automated test timings into human usability claims or equate successful inspection with a correct fix. + +Use existing framework fixtures and pure utilities; file organization and error phrasing are builder choices. After this usable slice, repeat the equivalent browser/source agent comparison using a generated packet, counting capture/setup separately. Larger repair comparison and demand remain unproven until actually run. diff --git a/packages/domscribe-core/src/index.ts b/packages/domscribe-core/src/index.ts index 17e4061..5d2ae81 100644 --- a/packages/domscribe-core/src/index.ts +++ b/packages/domscribe-core/src/index.ts @@ -33,3 +33,6 @@ export * from './privacy/patterns.js'; export { redactPII, redactSensitiveFields } from './privacy/redaction.js'; export type { RedactionOptions } from './privacy/redaction.js'; export * from './privacy/redaction-utils.js'; + +// Portable, historical UI issue evidence (not a live browser selection). +export * from './lib/types/issue.js'; diff --git a/packages/domscribe-core/src/lib/types/issue.spec.ts b/packages/domscribe-core/src/lib/types/issue.spec.ts new file mode 100644 index 0000000..dce5dd3 --- /dev/null +++ b/packages/domscribe-core/src/lib/types/issue.spec.ts @@ -0,0 +1,106 @@ +import { IssuePacketSchema, IssueExportOptionsSchema } from './issue.js'; + +const packet = () => ({ + version: 1, + title: 'Wrong collection', + expected: 'Use Alpha variables', + observed: 'Uses Beta variables', + steps: ['Open Alpha properties'], + prerequisites: ['Import synthetic collections'], + capture: { + annotationId: 'ann_12345678_123', + timestamp: '2026-10-27T12:00:00.000Z', + pathname: '/collections', + element: { tagName: 'button', text: 'Generate' }, + }, + source: { + file: 'src/OAuth.vue', + line: 10, + column: 2, + fileHash: '44bc2cf5ad770999', + }, + portability: { locator: 'not_captured', replay: 'not_included' }, +}); + +describe('portable issue contract', () => { + it('retains required issue evidence and capture-time relative source', () => { + expect(IssuePacketSchema.parse(packet())).toEqual(packet()); + }); + it.each([ + '../private.ts', + '/private.ts', + 'C:/private.ts', + 'src\\private.ts', + 'src/../private.ts', + ])('rejects non-portable source path %s', (file) => { + expect( + IssuePacketSchema.safeParse({ + ...packet(), + source: { ...packet().source, file }, + }).success, + ).toBe(false); + }); + it('rejects live handles, unknown versions and URL state', () => { + expect( + IssuePacketSchema.safeParse({ ...packet(), version: 2 }).success, + ).toBe(false); + expect( + IssuePacketSchema.safeParse({ + ...packet(), + instanceId: 'old-browser-handle', + }).success, + ).toBe(false); + expect( + IssuePacketSchema.safeParse({ + ...packet(), + capture: { ...packet().capture, pathname: '/?token=secret' }, + }).success, + ).toBe(false); + }); + it('requires separate nonempty expected and observed evidence', () => { + expect( + IssueExportOptionsSchema.safeParse({ expected: ' ', observed: 'Broken' }) + .success, + ).toBe(false); + expect( + IssueExportOptionsSchema.safeParse({ observed: 'Broken' }).success, + ).toBe(false); + expect( + IssueExportOptionsSchema.parse({ expected: 'Works', observed: 'Broken' }), + ).toMatchObject({ includeRuntime: false, includeReplay: false }); + }); +}); + +it('rejects available locators containing existing privacy redaction markers', () => { + const value = packet(); + expect( + IssuePacketSchema.safeParse({ + ...value, + capture: { + ...value.capture, + locator: { + version: 1, + pathname: '/collections', + element: { tagName: 'button', attributes: {}, text: '[REDACTED]' }, + ancestors: [], + }, + }, + portability: { locator: 'available', replay: 'not_included' }, + }).success, + ).toBe(false); +}); + +it('retains a confirmed original-source hash separately from transformed-code identity', async () => { + const { ManifestEntrySchema } = await import('./manifest.js'); + const entry = ManifestEntrySchema.parse({ + id: 'abcd1234', + file: '/project/src/Card.jsx', + start: { line: 1, column: 0 }, + fileHash: '0011223344556677', + sourceHash: '44bc2cf5ad770999', + }); + expect(entry).toMatchObject({ + fileHash: '0011223344556677', + sourceHash: '44bc2cf5ad770999', + }); +}); diff --git a/packages/domscribe-core/src/lib/types/issue.ts b/packages/domscribe-core/src/lib/types/issue.ts new file mode 100644 index 0000000..8823712 --- /dev/null +++ b/packages/domscribe-core/src/lib/types/issue.ts @@ -0,0 +1,138 @@ +import { z } from 'zod/v4'; +import { + AnnotationIdSchema, + ComponentIdentitySchema, + RuntimeContextSchema, +} from './annotation.js'; +import { PortableLocatorSchema } from './target.js'; +import { ReplayTraceSchema } from './replay.js'; + +const text = z.string().trim().min(1).max(20000); +export const IssueSourceSchema = z + .object({ + file: z + .string() + .min(1) + .max(4096) + .refine( + (file) => + !file.startsWith('/') && + !/^[a-z]:/i.test(file) && + !/[\\\0]/.test(file) && + !file + .split('/') + .some((part) => part === '..' || part === '.' || part === ''), + 'Source must be a project-relative POSIX path', + ), + line: z.number().int().min(1), + column: z.number().int().min(0), + fileHash: z + .string() + .regex(/^[a-f0-9]{16}$/) + .optional(), + }) + .strict(); +export const IssueExportOptionsSchema = z + .object({ + expected: text, + observed: text, + title: z.string().trim().min(1).max(256).optional(), + steps: z.array(text).max(100).default([]), + prerequisites: z.array(text).max(100).default([]), + includeRuntime: z.boolean().default(false), + includeReplay: z.boolean().default(false), + }) + .strict(); +export type IssueExportOptions = z.input; +export const IssuePacketSchema = z + .object({ + version: z.literal(1), + title: z.string().trim().min(1).max(256), + expected: text, + observed: text, + steps: z.array(text).max(100), + prerequisites: z.array(text).max(100), + capture: z + .object({ + annotationId: AnnotationIdSchema, + timestamp: z.iso.datetime(), + pathname: z + .string() + .startsWith('/') + .max(2048) + .refine( + (value) => !/[?#\0]/.test(value), + 'Only a pathname is portable', + ), + element: z + .object({ + tagName: z.string().min(1).max(64), + text: z.string().max(2048).optional(), + }) + .strict(), + locator: PortableLocatorSchema.optional(), + }) + .strict(), + source: IssueSourceSchema.optional(), + runtime: RuntimeContextSchema.extend({ + componentIdentity: ComponentIdentitySchema.strict().optional(), + }) + .strict() + .optional(), + replay: ReplayTraceSchema.optional(), + portability: z + .object({ + locator: z.enum([ + 'available', + 'not_captured', + 'privacy', + 'context_required', + 'identity_too_long', + ]), + replay: z.enum([ + 'available', + 'not_included', + 'not_recorded', + 'privacy', + ]), + }) + .strict(), + }) + .strict() + .superRefine((packet, ctx) => { + const locator = packet.capture.locator; + if (locator && JSON.stringify(locator).includes('[REDACTED')) + ctx.addIssue({ + code: 'custom', + message: 'Private locator identity must be omitted', + }); + if ( + (packet.portability.locator === 'available') !== + Boolean(locator && !locator.unsupportedReason) || + (locator?.unsupportedReason && + packet.portability.locator !== locator.unsupportedReason) + ) + ctx.addIssue({ + code: 'custom', + message: 'Locator availability must agree with captured evidence', + }); + if ((packet.portability.replay === 'available') !== Boolean(packet.replay)) + ctx.addIssue({ + code: 'custom', + message: 'Replay availability must agree with recorded steps', + }); + }); +export type IssuePacket = z.infer; +export const IssueInspectionSchema = z + .object({ + packet: IssuePacketSchema, + sourceStatus: z.enum([ + 'matched', + 'changed', + 'missing', + 'unverified', + 'unsafe', + ]), + }) + .strict(); +export type IssueInspection = z.infer; diff --git a/packages/domscribe-core/src/lib/types/manifest.ts b/packages/domscribe-core/src/lib/types/manifest.ts index 606ec1c..0995fed 100644 --- a/packages/domscribe-core/src/lib/types/manifest.ts +++ b/packages/domscribe-core/src/lib/types/manifest.ts @@ -153,6 +153,13 @@ export const ManifestEntrySchema = z.object({ .string() .optional() .describe('xxhash64 hash of file content at transform time (16 hex chars)'), + sourceHash: z + .string() + .regex(/^[a-f0-9]{16}$/) + .optional() + .describe( + 'xxhash64 of exact original source-map content when confirmed; distinct from transformed fileHash', + ), styleSource: StyleSourceSchema.optional().describe( 'Build-time style attribution (className tokens + CSS-in-JS source-block location)', ), diff --git a/packages/domscribe-manifest/src/index.ts b/packages/domscribe-manifest/src/index.ts index eab82d5..326104f 100644 --- a/packages/domscribe-manifest/src/index.ts +++ b/packages/domscribe-manifest/src/index.ts @@ -35,3 +35,5 @@ export type { ManifestResolveResult } from './reader/manifest-reader.js'; // ID stabilization for consistent element IDs across builds export { IDStabilizer } from './id-stabilizer/id-stabilizer.js'; export type { IDGenerator } from './id-stabilizer/types.js'; + +export { hashSource } from './source-hash.js'; diff --git a/packages/domscribe-manifest/src/source-hash.ts b/packages/domscribe-manifest/src/source-hash.ts new file mode 100644 index 0000000..95dd979 --- /dev/null +++ b/packages/domscribe-manifest/src/source-hash.ts @@ -0,0 +1,9 @@ +import xxhash from 'xxhash-wasm'; + +let hasher: ReturnType | undefined; + +/** Same content hash used by transform-time manifest entries; no cache/filesystem writes. */ +export async function hashSource(content: string): Promise { + const api = await (hasher ??= xxhash()); + return api.h64(content).toString(16).padStart(16, '0'); +} diff --git a/packages/domscribe-manifest/src/writer/manifest-writer.ts b/packages/domscribe-manifest/src/writer/manifest-writer.ts index 9cbe866..61f7a1d 100644 --- a/packages/domscribe-manifest/src/writer/manifest-writer.ts +++ b/packages/domscribe-manifest/src/writer/manifest-writer.ts @@ -26,6 +26,7 @@ export class ManifestWriter { /** Tracks the latest fileHash per file for stale entry eviction */ private fileHashIndex: Map = new Map(); + private sourceHashIndex = new Map(); private index: ManifestIndex = { idToFile: new Map(), @@ -144,7 +145,13 @@ export class ManifestWriter { */ appendEntries(entries: ManifestEntry[]): void { // Check if entries are already in the index (deduplication) - entries = entries.filter((entry) => !this.index.idToFile.has(entry.id)); + entries = entries.filter( + (entry) => + !this.index.idToFile.has(entry.id) || + (entry.sourceHash !== undefined && + this.index.idToFile.get(entry.id) === entry.file && + this.sourceHashIndex.get(entry.id) !== entry.sourceHash), + ); if (entries.length === 0) { return; @@ -221,6 +228,7 @@ export class ManifestWriter { this.index.fileToIds.clear(); this.index.componentToIds.clear(); this.index.entryCount = 0; + this.sourceHashIndex.clear(); // Reload from compacted file const entries = this.loadEntries(manifestPath); @@ -311,10 +319,13 @@ export class ManifestWriter { this.fileHashIndex.set(file, newHash); } - const { idToFile, fileToIds, componentToIds, entryCount } = this.index; + const { idToFile, fileToIds, componentToIds } = this.index; for (const entry of entries) { const { id, file, componentName } = entry; + if (entry.sourceHash) this.sourceHashIndex.set(id, entry.sourceHash); + else this.sourceHashIndex.delete(id); + if (idToFile.get(id) === file) continue; // id → file mapping idToFile.set(id, file); @@ -332,7 +343,7 @@ export class ManifestWriter { } } - this.index.entryCount = entryCount + entries.length; + this.index.entryCount = idToFile.size; this.index.lastRebuild = new Date().toISOString(); } @@ -347,6 +358,7 @@ export class ManifestWriter { for (const id of oldIds) { this.index.idToFile.delete(id); + this.sourceHashIndex.delete(id); } this.index.fileToIds.delete(file); diff --git a/packages/domscribe-manifest/src/writer/source-hash-upgrade.spec.ts b/packages/domscribe-manifest/src/writer/source-hash-upgrade.spec.ts new file mode 100644 index 0000000..ed5840a --- /dev/null +++ b/packages/domscribe-manifest/src/writer/source-hash-upgrade.spec.ts @@ -0,0 +1,126 @@ +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { ManifestWriter } from './manifest-writer.js'; +import { ManifestReader } from '../reader/manifest-reader.js'; + +it('upgrades source-hash metadata for an existing ID once, including after restart, without duplicate index entries', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'domscribe-hash-upgrade-')); + let writer: ManifestWriter | undefined; + let reader: ManifestReader | undefined; + try { + await mkdir(path.join(root, '.domscribe')); + const file = path.join(root, '.domscribe/manifest.jsonl'); + const entry = { + id: 'abcd1234', + file: 'src/Button.tsx', + start: { line: 1, column: 0 }, + fileHash: '0011223344556677', + }; + await writeFile(file, JSON.stringify(entry) + '\n'); + writer = ManifestWriter.getInstance(root); + const updated = { ...entry, sourceHash: '44bc2cf5ad770999' }; + writer.appendEntries([updated]); + writer.appendEntries([updated]); + expect(writer.getStats().entryCount).toBe(1); + expect(writer.getEntriesByFile(entry.file)).toEqual([entry.id]); + writer.close(); + writer = undefined; + let rows = (await readFile(file, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(rows.at(-1).sourceHash).toBe('44bc2cf5ad770999'); + expect(rows).toHaveLength(2); + writer = ManifestWriter.getInstance(root); + expect(writer.getStats().entryCount).toBe(1); + writer.appendEntries([updated]); + writer.close(); + writer = undefined; + rows = (await readFile(file, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(rows).toHaveLength(2); + reader = new ManifestReader(root); + reader.initialize(); + expect(reader.resolve(entry.id).entry?.sourceHash).toBe('44bc2cf5ad770999'); + } finally { + writer?.close(); + reader?.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +it('keeps the existing last-record mapping when loading legacy duplicate IDs from different files', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'domscribe-legacy-index-')); + let writer: ManifestWriter | undefined; + try { + await mkdir(path.join(root, '.domscribe')); + const entries = ['src/First.tsx', 'src/Last.tsx'].map((file) => ({ + id: 'abcd1234', + file, + start: { line: 1, column: 0 }, + })); + await writeFile( + path.join(root, '.domscribe/manifest.jsonl'), + entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n', + ); + writer = ManifestWriter.getInstance(root); + expect(writer.resolveId('abcd1234')).toBe('src/Last.tsx'); + } finally { + writer?.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +it.each([false, true])( + 'does not inherit an earlier source hash when the winning record omitted it (same file: %s)', + async (sameFile) => { + const root = await mkdtemp( + path.join(os.tmpdir(), 'domscribe-hash-absence-'), + ); + let writer: ManifestWriter | undefined; + let reader: ManifestReader | undefined; + try { + await mkdir(path.join(root, '.domscribe')); + const file = path.join(root, '.domscribe/manifest.jsonl'); + const last = { + id: 'abcd1234', + file: 'src/Last.tsx', + start: { line: 1, column: 0 }, + fileHash: '0011223344556677', + }; + const first = { + ...last, + file: sameFile ? last.file : 'src/First.tsx', + sourceHash: '44bc2cf5ad770999', + }; + await writeFile( + file, + [first, last].map((entry) => JSON.stringify(entry)).join('\n') + '\n', + ); + writer = ManifestWriter.getInstance(root); + writer.appendEntries([{ ...last, sourceHash: '44bc2cf5ad770999' }]); + writer.close(); + writer = undefined; + reader = new ManifestReader(root); + reader.initialize(); + expect(reader.resolve(last.id).entry?.sourceHash).toBe( + '44bc2cf5ad770999', + ); + reader.close(); + reader = undefined; + const before = await readFile(file, 'utf8'); + writer = ManifestWriter.getInstance(root); + writer.appendEntries([{ ...last, sourceHash: '44bc2cf5ad770999' }]); + writer.close(); + writer = undefined; + expect(await readFile(file, 'utf8')).toBe(before); + } finally { + writer?.close(); + reader?.close(); + await rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/packages/domscribe-relay/src/cli/commands/issue.command.spec.ts b/packages/domscribe-relay/src/cli/commands/issue.command.spec.ts new file mode 100644 index 0000000..b935694 --- /dev/null +++ b/packages/domscribe-relay/src/cli/commands/issue.command.spec.ts @@ -0,0 +1,142 @@ +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { createIssuePacket } from '../../issue/issue-packet.js'; +import { createIssueAnnotation } from '../../issue/__test-utils__/issue-fixture.js'; +import { createIssueCommand } from './issue.command.js'; + +let root: string; +beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'domscribe-issue-cli-')); + await mkdir(path.join(root, 'src')); + await writeFile(path.join(root, 'src/Card.vue'), 'abc'); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); +function command(online = false) { + let output = '', + errors = ''; + const cmd = createIssueCommand(async () => { + if (!online) throw new Error('Relay is not running'); + return { root, annotation: createIssueAnnotation(root) }; + }); + for (const c of [cmd, ...cmd.commands]) { + c.exitOverride(); + c.configureOutput({ + writeOut: (text) => { + output += text; + }, + writeErr: (text) => { + errors += text; + }, + }); + } + return { cmd, output: () => output, errors: () => errors }; +} +const args = [ + 'export', + 'ann_12345678_123', + '--expected', + 'Use Alpha', + '--observed', + 'Uses Beta', +]; +const run = (cmd: ReturnType, values: string[]) => + cmd.parseAsync(values, { from: 'user' }); + +it('exports usable JSON to stdout with opt-in runtime and documented prerequisites', async () => { + const c = command(true); + await run(c.cmd, [ + ...args, + '--include-runtime', + '--step', + 'Open properties', + '--prerequisite', + 'Import Alpha', + ]); + const packet = JSON.parse(c.output()); + expect(packet.source.file).toBe('src/Card.vue'); + expect(packet.expected).toBe('Use Alpha'); + expect(packet.observed).toBe('Uses Beta'); + expect(packet.steps).toEqual(['Open properties']); + expect(packet.prerequisites).toEqual(['Import Alpha']); + expect(packet.runtime.componentProps.clientSecret).toBe('[REDACTED]'); + expect(c.output()).not.toContain(root); +}); +it('requires expected and observed before trying an unavailable relay', async () => { + const c = command(); + await expect( + run(c.cmd, ['export', 'ann_12345678_123']), + ).rejects.toMatchObject({ exitCode: 1 }); + expect(c.errors()).toContain('--expected'); +}); +it('reports an unavailable relay without creating output', async () => { + const c = command(); + await expect(run(c.cmd, args)).rejects.toMatchObject({ exitCode: 1 }); + expect(c.errors()).toContain('Relay is not running'); + expect(c.output()).toBe(''); +}); +it('never overwrites an existing output file', async () => { + const file = path.join(root, 'issue.json'); + await writeFile(file, 'keep this'); + const c = command(true); + await expect(run(c.cmd, [...args, '--out', file])).rejects.toMatchObject({ + exitCode: 1, + }); + expect(await readFile(file, 'utf8')).toBe('keep this'); +}); +it('inspects source in a second checkout offline, without any relay', async () => { + const packet = await createIssuePacket(createIssueAnnotation(root), root, { + expected: 'Use Alpha', + observed: 'Uses Beta', + }); + const file = path.join(root, 'issue.json'); + await writeFile(file, JSON.stringify(packet)); + const copy = path.join(root, 'copy'); + await mkdir(path.join(copy, 'src'), { recursive: true }); + await writeFile(path.join(copy, 'src/Card.vue'), 'abc'); + const c = command(); + await run(c.cmd, ['inspect', file, '--root', copy]); + expect(c.output()).toContain('Source: matched'); + expect(c.output()).toContain('Use Alpha'); + await writeFile(path.join(copy, 'src/Card.vue'), 'changed'); + const json = command(); + await run(json.cmd, ['inspect', file, '--root', copy, '--format', 'json']); + expect(JSON.parse(json.output()).sourceStatus).toBe('changed'); +}); +it('rejects malformed and oversized packets without echoing private input', async () => { + const file = path.join(root, 'bad.json'); + await writeFile(file, 'private-credential-fixture'); + const c = command(); + await expect(run(c.cmd, ['inspect', file])).rejects.toMatchObject({ + exitCode: 1, + }); + expect(c.errors()).not.toContain('private-credential-fixture'); + expect(c.output()).toBe(''); + await writeFile(file, 'x'.repeat(1048577)); + const large = command(); + await expect(run(large.cmd, ['inspect', file])).rejects.toMatchObject({ + exitCode: 1, + }); + expect(large.errors()).toMatch(/size|large|limit/i); +}); + +it('does not echo private dictionary keys from schema validation failures', async () => { + const packet = await createIssuePacket(createIssueAnnotation(root), root, { + expected: 'Use Alpha', + observed: 'Uses Beta', + }); + const file = path.join(root, 'private-key.json'); + await writeFile( + file, + JSON.stringify({ ...packet, 'secret-person@example.com': 'private-value' }), + ); + const c = command(); + await expect(run(c.cmd, ['inspect', file])).rejects.toMatchObject({ + exitCode: 1, + }); + expect(c.errors()).not.toContain('secret-person@example.com'); + expect(c.errors()).not.toContain('private-value'); + expect(c.errors()).toContain('Invalid issue evidence'); +}); diff --git a/packages/domscribe-relay/src/cli/commands/issue.command.ts b/packages/domscribe-relay/src/cli/commands/issue.command.ts new file mode 100644 index 0000000..7b7536b --- /dev/null +++ b/packages/domscribe-relay/src/cli/commands/issue.command.ts @@ -0,0 +1,166 @@ +import { Command } from 'commander'; +import { z } from 'zod/v4'; +import { readFile, stat, writeFile } from 'node:fs/promises'; +import { + AnnotationIdSchema, + IssueExportOptionsSchema, + type Annotation, +} from '@domscribe/core'; +import { RelayControl } from '../../lifecycle/relay-control.js'; +import { RelayHttpClient } from '../../client/relay-http-client.js'; +import { getWorkspaceRoot } from '../utils.js'; +import { + createIssuePacket, + inspectIssuePacket, + parseIssuePacket, + renderIssuePacket, +} from '../../issue/issue-packet.js'; +import { ISSUE_BYTE_LIMIT } from '../../issue/issue-input.js'; + +const ExportOptionsSchema = IssueExportOptionsSchema.omit({ + steps: true, + prerequisites: true, +}).extend({ + step: z.array(z.string()).default([]), + prerequisite: z.array(z.string()).default([]), + out: z.string().min(1).optional(), +}); +const InspectOptionsSchema = z + .object({ + root: z.string().min(1).optional(), + format: z.enum(['markdown', 'json']).default('markdown'), + }) + .strict(); +type AnnotationLoader = ( + id: string, +) => Promise<{ annotation: Annotation; root: string }>; + +async function loadWorkspaceAnnotation(id: string) { + const root = getWorkspaceRoot(); + if (!root) + throw new Error( + 'Run export from a Domscribe workspace with a running relay', + ); + const { running, lockData } = await new RelayControl(root).getStatus(); + if (!running || !lockData?.host || !lockData.port) + throw new Error( + 'Relay is not running. Start your instrumented dev server before exporting.', + ); + const annotation = await new RelayHttpClient( + lockData.host, + lockData.port, + ).getAnnotation(id); + return { root, annotation }; +} + +function fail(command: Command, error: unknown): never { + return command.error( + `[domscribe] ${error instanceof z.ZodError ? 'Invalid issue evidence: does not match the supported schema' : error instanceof Error ? error.message : 'Issue operation failed'}`, + { exitCode: 1, code: 'domscribe.issue' }, + ); +} +function output(command: Command, value: string): void { + command.configureOutput().writeOut?.(value); +} +const append = (value: string, previous: string[]) => [...previous, value]; + +/** New instances let callers configure output and exit handling without global console/process mocks. */ +export function createIssueCommand( + loadAnnotation: AnnotationLoader = loadWorkspaceAnnotation, +): Command { + const issue = new Command('issue').description( + 'Export or inspect portable, historical UI issue evidence', + ); + issue.addCommand( + new Command('export') + .argument( + '', + 'Annotation to export from the current workspace relay', + ) + .requiredOption('--expected ', 'Expected behavior') + .requiredOption('--observed ', 'Observed behavior') + .option('--title ', 'Issue title') + .option( + '--step ', + 'Document a reproduction step (repeatable)', + append, + [], + ) + .option( + '--prerequisite ', + 'Document app/data/auth prerequisites (repeatable)', + append, + [], + ) + .option( + '--include-runtime', + 'Include captured props/state with known-marker redaction', + ) + .option( + '--include-replay', + 'Include explicit recorded steps after privacy checks', + ) + .option( + '--out ', + 'Create a new packet file; existing files are never overwritten', + ) + .action(async (id: string, raw: unknown, command: Command) => { + try { + const options = ExportOptionsSchema.parse(raw); + const { annotation, root } = await loadAnnotation( + AnnotationIdSchema.parse(id), + ); + const packet = await createIssuePacket(annotation, root, { + expected: options.expected, + observed: options.observed, + title: options.title, + steps: options.step, + prerequisites: options.prerequisite, + includeRuntime: options.includeRuntime, + includeReplay: options.includeReplay, + }); + const encoded = JSON.stringify(packet) + '\n'; + if (options.out) + await writeFile(options.out, encoded, { flag: 'wx', mode: 0o600 }); + else output(command, encoded); + } catch (error) { + fail(command, error); + } + }), + ); + issue.addCommand( + new Command('inspect') + .argument('', 'JSON packet to inspect offline') + .option( + '--root ', + 'Receiving source checkout root (default current directory)', + ) + .option('--format ', 'markdown or json', 'markdown') + .action(async (file: string, raw: unknown, command: Command) => { + try { + const options = InspectOptionsSchema.parse(raw); + const info = await stat(file); + if (!info.isFile() || info.size > ISSUE_BYTE_LIMIT) + throw new Error( + 'Issue packet must be a regular file within the 1MiB size limit', + ); + const packet = parseIssuePacket(await readFile(file, 'utf8')); + const inspection = await inspectIssuePacket( + packet, + options.root ?? process.cwd(), + ); + output( + command, + options.format === 'json' + ? JSON.stringify(inspection) + '\n' + : renderIssuePacket(inspection), + ); + } catch (error) { + fail(command, error); + } + }), + ); + return issue; +} + +export const IssueCommand = createIssueCommand(); diff --git a/packages/domscribe-relay/src/cli/program.ts b/packages/domscribe-relay/src/cli/program.ts index 460eaec..e93699d 100644 --- a/packages/domscribe-relay/src/cli/program.ts +++ b/packages/domscribe-relay/src/cli/program.ts @@ -13,6 +13,7 @@ import { InitCommand } from './commands/init.command.js'; import { ServeCommand } from './commands/serve.command.js'; import { StopCommand } from './commands/stop.command.js'; import { StatusCommand } from './commands/status.command.js'; +import { IssueCommand } from './commands/issue.command.js'; import { McpCommand } from './commands/mcp.command.js'; const program = new Command(); @@ -50,3 +51,5 @@ program.addCommand(McpCommand); program.addCommand(InitCommand); export { program }; + +program.addCommand(IssueCommand); diff --git a/packages/domscribe-relay/src/issue/__test-utils__/issue-fixture.ts b/packages/domscribe-relay/src/issue/__test-utils__/issue-fixture.ts new file mode 100644 index 0000000..6e60a6c --- /dev/null +++ b/packages/domscribe-relay/src/issue/__test-utils__/issue-fixture.ts @@ -0,0 +1,81 @@ +import path from 'node:path'; +import { + AnnotationStatusEnum, + InteractionModeEnum, + InteractionTypeEnum, + type Annotation, + type PortableLocator, +} from '@domscribe/core'; +const locator = (): PortableLocator => ({ + version: 1 as const, + pathname: '/collections', + element: { tagName: 'button', attributes: {}, text: 'Generate' }, + ancestors: [], +}); +export function createIssueAnnotation(root: string): Annotation { + return { + metadata: { + id: 'ann_12345678_123', + status: AnnotationStatusEnum.QUEUED, + mode: InteractionModeEnum.ELEMENT_CLICK, + timestamp: '2026-10-27T12:00:00.000Z', + schemaVersion: 4, + }, + interaction: { + type: InteractionTypeEnum.ELEMENT_ANNOTATION, + selectedElement: { + tagName: 'button', + selector: '[data-ds-instance="stale-secret-handle"]', + dataDs: 'abcd1234', + innerText: 'Generate', + target: { + instanceId: 'stale-secret-handle', + selector: '[data-ds-instance="stale-secret-handle"]', + entryId: 'abcd1234', + locator: locator(), + disabled: false, + }, + }, + }, + context: { + pageUrl: + 'http://user:password@localhost:9999/collections?token=private#access_token=secret', + pageTitle: 'App', + userAgent: 'private-agent', + viewport: { width: 1000, height: 800 }, + domSnapshot: '', + userMessage: 'My email is person@example.com', + environment: { nodeVersion: '/private/environment' }, + manifestSnapshot: [ + { + id: 'abcd1234', + file: path.join(root, 'src/Card.vue'), + start: { line: 1, column: 1 }, + fileHash: '0011223344556677', + sourceHash: '44bc2cf5ad770999', + }, + { + id: 'wxyz1234', + file: '/outside/private.ts', + start: { line: 99, column: 1 }, + }, + ], + runtimeContext: { + componentProps: { + label: 'Alpha', + clientSecret: 'private-client', + environment: [{ key: 'value', value: 'private-env', secret: true }], + person: 'person@example.com', + location: root + '/src/Card.vue', + }, + componentIdentity: { + name: 'Card', + sourceFile: root + '/src/Card.vue', + requestedSourceFile: root + '/src/Card.vue', + sourceMatch: 'matched', + }, + }, + replay: { version: 1, steps: [{ target: locator(), action: 'click' }] }, + }, + }; +} diff --git a/packages/domscribe-relay/src/issue/issue-input.ts b/packages/domscribe-relay/src/issue/issue-input.ts new file mode 100644 index 0000000..95dcbcf --- /dev/null +++ b/packages/domscribe-relay/src/issue/issue-input.ts @@ -0,0 +1,65 @@ +import { redactPII, redactSensitiveFields } from '@domscribe/core'; + +export const ISSUE_BYTE_LIMIT = 1024 * 1024; + +/** Check before recursive schema/privacy work; JSON evidence only, not arbitrary live objects. */ +export function checkEvidenceBounds(value: unknown): void { + const pending: Array<[unknown, number]> = [[value, 0]]; + let count = 0; + while (pending.length) { + const item = pending.pop(); + if (!item) break; + const [current, depth] = item; + if (++count > 10000 || depth > 12) + throw new Error('Issue evidence exceeds the nesting/node limit'); + if (current && typeof current === 'object') { + for (const [key, child] of Object.entries(current)) { + if (key === '__proto__') throw new Error('Unsafe issue evidence key'); + pending.push([child, depth + 1]); + } + } + } + const encoded = JSON.stringify(value); + if (!encoded || Buffer.byteLength(encoded) + 1 > ISSUE_BYTE_LIMIT) + throw new Error('Issue evidence exceeds the 1MiB size limit'); +} + +export function sanitizeIssueEvidence(value: unknown, root: string): unknown { + const prefixes = [...new Set([root, root.replaceAll('\\', '/')])] + .filter(Boolean) + .sort((a, b) => b.length - a.length); + function walk(current: unknown): unknown { + if (typeof current === 'string') { + let clean = current; + for (const prefix of prefixes) + clean = clean.replaceAll(prefix, ''); + const redacted = redactPII(clean); + try { + const decoded = decodeURIComponent(clean); + if (decoded !== clean && redactPII(decoded) !== decoded) + return '[REDACTED]'; + } catch { + /* Malformed percent escapes are ordinary evidence text. */ + } + return redacted; + } + if (Array.isArray(current)) return current.map(walk); + if (current && typeof current === 'object') { + const entries = Object.entries(current); + // Renaming two private keys to the same marker would silently merge distinct evidence. + if (entries.some(([key]) => walk(key) !== key)) return '[REDACTED_KEYS]'; + return Object.fromEntries( + entries.map(([key, child]) => [key, walk(child)]), + ); + } + return current; + } + return walk( + redactSensitiveFields(value, [ + 'localStorage', + 'sessionStorage', + 'storage', + 'nativeStorage', + ]), + ); +} diff --git a/packages/domscribe-relay/src/issue/issue-packet.spec.ts b/packages/domscribe-relay/src/issue/issue-packet.spec.ts new file mode 100644 index 0000000..a132f0a --- /dev/null +++ b/packages/domscribe-relay/src/issue/issue-packet.spec.ts @@ -0,0 +1,317 @@ +import { + mkdtemp, + mkdir, + writeFile, + readFile, + symlink, + rm, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + AnnotationStatusEnum, + InteractionModeEnum, + InteractionTypeEnum, + type Annotation, +} from '@domscribe/core'; +import { + createIssuePacket, + inspectIssuePacket, + parseIssuePacket, + renderIssuePacket, +} from './issue-packet.js'; + +const locator = (): import('@domscribe/core').PortableLocator => ({ + version: 1 as const, + pathname: '/collections', + element: { tagName: 'button', attributes: {}, text: 'Generate' }, + ancestors: [], +}); +let root: string; +function annotation(): Annotation { + return { + metadata: { + id: 'ann_12345678_123', + status: AnnotationStatusEnum.QUEUED, + mode: InteractionModeEnum.ELEMENT_CLICK, + timestamp: '2026-10-27T12:00:00.000Z', + schemaVersion: 4, + }, + interaction: { + type: InteractionTypeEnum.ELEMENT_ANNOTATION, + selectedElement: { + tagName: 'button', + selector: '[data-ds-instance="stale-secret-handle"]', + dataDs: 'abcd1234', + innerText: 'Generate', + target: { + instanceId: 'stale-secret-handle', + selector: '[data-ds-instance="stale-secret-handle"]', + entryId: 'abcd1234', + locator: locator(), + disabled: false, + }, + }, + }, + context: { + pageUrl: + 'http://user:password@localhost:9999/collections?token=private#access_token=secret', + pageTitle: 'App', + userAgent: 'private-agent', + viewport: { width: 1000, height: 800 }, + domSnapshot: '', + userMessage: 'My email is person@example.com', + environment: { nodeVersion: '/private/environment' }, + manifestSnapshot: [ + { + id: 'abcd1234', + file: path.join(root, 'src/Card.vue'), + start: { line: 1, column: 1 }, + fileHash: '0011223344556677', + sourceHash: '44bc2cf5ad770999', + }, + { + id: 'wxyz1234', + file: '/outside/private.ts', + start: { line: 99, column: 1 }, + }, + ], + runtimeContext: { + componentProps: { + label: 'Alpha', + clientSecret: 'private-client', + environment: [{ key: 'value', value: 'private-env', secret: true }], + person: 'person@example.com', + location: root + '/src/Card.vue', + }, + componentIdentity: { + name: 'Card', + sourceFile: root + '/src/Card.vue', + requestedSourceFile: root + '/src/Card.vue', + sourceMatch: 'matched', + }, + }, + replay: { version: 1, steps: [{ target: locator(), action: 'click' }] }, + }, + }; +} +const options = { expected: 'Use Alpha', observed: 'Used Beta' }; +beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'domscribe-issue-')); + await mkdir(path.join(root, 'src')); + await writeFile(path.join(root, 'src/Card.vue'), 'abc'); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe('issue evidence handoff', () => { + it('exports one portable source without session handles or broad annotation data', async () => { + const packet = await createIssuePacket(annotation(), root, options); + expect(packet.source).toEqual({ + file: 'src/Card.vue', + line: 1, + column: 1, + fileHash: '44bc2cf5ad770999', + }); + expect(packet.capture.pathname).toBe('/collections'); + expect(packet.capture.locator).toEqual(locator()); + expect(packet.runtime).toBeUndefined(); + expect(packet.replay).toBeUndefined(); + for (const secret of [ + root, + 'stale-secret-handle', + 'private-agent', + 'never-export-dom', + 'private-client', + 'private-env', + 'person@example.com', + 'outside/private', + 'token=', + ]) + expect(JSON.stringify(packet)).not.toContain(secret); + expect(packet.expected).toBe('Use Alpha'); + expect(packet.observed).toBe('Used Beta'); + }); + it('redacts opted-in capture data even when the stored annotation contains private fields', async () => { + const packet = await createIssuePacket(annotation(), root, { + ...options, + includeRuntime: true, + includeReplay: true, + }); + expect(packet.runtime?.componentProps).toMatchObject({ + label: 'Alpha', + clientSecret: '[REDACTED]', + environment: ['[REDACTED]'], + person: '[REDACTED]', + location: '/src/Card.vue', + }); + expect(packet.runtime?.componentIdentity?.sourceFile).toBe('src/Card.vue'); + expect(packet.replay?.steps).toHaveLength(1); + expect(JSON.stringify(packet)).not.toContain(root); + }); + it('drops replay and locator rather than altering private identity or values', async () => { + const a = annotation(); + a.interaction.selectedElement!.target!.locator.element.text = + 'person@example.com'; + a.context.replay!.steps[0] = { + target: locator(), + action: 'fill', + value: 'person@example.com', + }; + const packet = await createIssuePacket(a, root, { + ...options, + includeReplay: true, + }); + expect(packet.capture.locator).toBeUndefined(); + expect(packet.replay).toBeUndefined(); + expect(packet.portability).toEqual({ + locator: 'privacy', + replay: 'privacy', + }); + }); + it('preserves explicit unscoped-dialog refusal', async () => { + const a = annotation(); + a.interaction.selectedElement!.target!.locator.unsupportedReason = + 'context_required'; + const packet = await createIssuePacket(a, root, options); + expect(packet.capture.locator?.unsupportedReason).toBe('context_required'); + expect(packet.portability.locator).toBe('context_required'); + }); + it('matches source in a new checkout and reports changed source without blessing export-time edits', async () => { + const packet = await createIssuePacket(annotation(), root, options); + const copy = await mkdtemp(path.join(os.tmpdir(), 'domscribe-issue-copy-')); + try { + await mkdir(path.join(copy, 'src')); + await writeFile(path.join(copy, 'src/Card.vue'), 'abc'); + expect((await inspectIssuePacket(packet, copy)).sourceStatus).toBe( + 'matched', + ); + await writeFile(path.join(copy, 'src/Card.vue'), 'changed'); + expect((await inspectIssuePacket(packet, copy)).sourceStatus).toBe( + 'changed', + ); + await writeFile(path.join(root, 'src/Card.vue'), 'edited before export'); + expect( + ( + await inspectIssuePacket( + await createIssuePacket(annotation(), root, options), + root, + ) + ).sourceStatus, + ).toBe('changed'); + } finally { + await rm(copy, { recursive: true, force: true }); + } + }); + it('excludes external/traversing anchors and refuses symlink escape during inspection', async () => { + const a = annotation(); + a.context.manifestSnapshot![0].file = '../private.ts'; + expect((await createIssuePacket(a, root, options)).source).toBeUndefined(); + const b = annotation(); + b.context.manifestSnapshot![0].file = '/outside/private.ts'; + expect((await createIssuePacket(b, root, options)).source).toBeUndefined(); + const packet = await createIssuePacket(annotation(), root, options); + await rm(path.join(root, 'src/Card.vue')); + await symlink(path.join(root, '..'), path.join(root, 'src/Card.vue')); + expect((await inspectIssuePacket(packet, root)).sourceStatus).toBe( + 'unsafe', + ); + }); + it('reports absent source and absent capture hashes honestly', async () => { + const packet = await createIssuePacket(annotation(), root, options); + await rm(path.join(root, 'src/Card.vue')); + expect((await inspectIssuePacket(packet, root)).sourceStatus).toBe( + 'missing', + ); + delete packet.source!.fileHash; + expect((await inspectIssuePacket(packet, root)).sourceStatus).toBe( + 'unverified', + ); + }); + it('rejects oversized and deeply nested imported data before processing', async () => { + expect(() => + parseIssuePacket('{"padding":"' + 'x'.repeat(1048576) + '"}'), + ).toThrow(/size|large|limit/i); + expect(() => + parseIssuePacket('['.repeat(100) + '0' + ']'.repeat(100)), + ).toThrow(/depth|nest|limit/i); + const a = annotation(); + a.context.runtimeContext = { + componentProps: { text: 'x'.repeat(1048576) }, + }; + await expect( + createIssuePacket(a, root, { ...options, includeRuntime: true }), + ).rejects.toThrow(/size|large|limit/i); + }); + it('fences hostile markup and labels source/target proof separately', async () => { + const packet = await createIssuePacket(annotation(), root, { + ...options, + observed: '```\n\n```', + }); + const markdown = renderIssuePacket(await inspectIssuePacket(packet, root)); + expect(markdown).toContain('Source: matched'); + expect(markdown).toContain('business target'); + expect(markdown).toContain('````'); + expect(markdown).toContain('untrusted'); + expect(await readFile(path.join(root, 'src/Card.vue'), 'utf8')).toBe('abc'); + }); +}); + +it('withholds nested dictionary records whose keys contain private evidence without key collisions', async () => { + const a = annotation(); + a.context.runtimeContext = { + componentProps: { + keyed: { + 'first@example.com': 'Alpha', + 'second@example.com': 'Beta', + [root + '/private']: 'Gamma', + }, + safe: 'Visible', + }, + }; + const packet = await createIssuePacket(a, root, { + ...options, + includeRuntime: true, + }); + expect(packet.runtime?.componentProps).toEqual({ + keyed: '[REDACTED_KEYS]', + safe: 'Visible', + }); + expect(JSON.stringify(packet)).not.toContain('@example.com'); + expect(JSON.stringify(packet)).not.toContain(root); +}); +it('withholds an already-redacted locator instead of advertising actionability', async () => { + const a = annotation(); + const target = a.interaction.selectedElement?.target; + if (!target) throw new Error('Invalid fixture'); + target.locator.element.text = '[REDACTED]'; + const packet = await createIssuePacket(a, root, options); + expect(packet.capture.locator).toBeUndefined(); + expect(packet.portability.locator).toBe('privacy'); +}); +it('renders a valid bounded packet with many separate Markdown backtick runs', async () => { + const a = annotation(); + a.context.runtimeContext = { + componentProps: { evidence: '`a'.repeat(150000) }, + }; + const packet = await createIssuePacket(a, root, { + ...options, + includeRuntime: true, + }); + const report = renderIssuePacket(await inspectIssuePacket(packet, root)); + expect(report).toContain('Source: matched'); + expect(report).toContain('`a'.repeat(150000)); +}); + +it('does not treat a legacy transformed-code hash as proof of original source', async () => { + const a = annotation(); + const entry = a.context.manifestSnapshot?.[0]; + if (!entry) throw new Error('Invalid fixture'); + delete entry.sourceHash; + const packet = await createIssuePacket(a, root, options); + expect(packet.source?.fileHash).toBeUndefined(); + expect((await inspectIssuePacket(packet, root)).sourceStatus).toBe( + 'unverified', + ); +}); diff --git a/packages/domscribe-relay/src/issue/issue-packet.ts b/packages/domscribe-relay/src/issue/issue-packet.ts new file mode 100644 index 0000000..13c4753 --- /dev/null +++ b/packages/domscribe-relay/src/issue/issue-packet.ts @@ -0,0 +1,200 @@ +import { readFile, realpath, stat } from 'node:fs/promises'; +import { + AnnotationSchema, + IssueExportOptionsSchema, + IssuePacketSchema, + IssueSourceSchema, + type Annotation, + type IssueExportOptions, + type IssueInspection, + type IssuePacket, +} from '@domscribe/core'; +import { hashSource } from '@domscribe/manifest'; +import { + ISSUE_BYTE_LIMIT, + checkEvidenceBounds, + sanitizeIssueEvidence, +} from './issue-input.js'; +import { issueSourcePath, portableIssueSource } from './issue-source.js'; + +export function parseIssuePacket(text: string): IssuePacket { + if (Buffer.byteLength(text) > ISSUE_BYTE_LIMIT) + throw new Error('Issue packet exceeds the 1MiB size limit'); + let value: unknown; + try { + value = JSON.parse(text); + } catch { + throw new Error('Invalid issue packet JSON'); + } + checkEvidenceBounds(value); + return IssuePacketSchema.parse(value); +} + +export async function createIssuePacket( + input: Annotation, + root: string, + inputOptions: IssueExportOptions, +): Promise { + checkEvidenceBounds(input); + const annotation = AnnotationSchema.parse(input); + const options = IssueExportOptionsSchema.parse(inputOptions); + const physicalRoot = await realpath(root); + const clean = (value: unknown) => + sanitizeIssueEvidence(sanitizeIssueEvidence(value, root), physicalRoot); + const selected = annotation.interaction.selectedElement; + if (!selected) + throw new Error( + 'Issue export requires an element annotation; recapture a selected element', + ); + const pathname = new URL( + annotation.context.pageUrl, + 'http://domscribe.invalid', + ).pathname; + const packet: IssuePacket = { + version: 1, + title: String(clean(options.title ?? `UI issue: ${selected.tagName}`)), + expected: String(clean(options.expected)), + observed: String(clean(options.observed)), + steps: options.steps.map((step) => String(clean(step))), + prerequisites: options.prerequisites.map((step) => String(clean(step))), + capture: { + annotationId: annotation.metadata.id, + timestamp: annotation.metadata.timestamp, + pathname: String(clean(pathname)), + element: { + tagName: selected.tagName, + ...(selected.innerText === undefined + ? {} + : { text: String(clean(selected.innerText)) }), + }, + }, + portability: { + locator: 'not_captured', + replay: options.includeReplay ? 'not_recorded' : 'not_included', + }, + }; + const locator = selected.target?.locator; + if (locator) { + if ( + JSON.stringify(locator).includes('[REDACTED') || + JSON.stringify(clean(locator)) !== JSON.stringify(locator) + ) + packet.portability.locator = 'privacy'; + else { + packet.capture.locator = locator; + packet.portability.locator = locator.unsupportedReason ?? 'available'; + } + } + const ids = new Set( + [selected.dataDs, selected.target?.entryId].filter(Boolean), + ); + if (ids.size === 1) { + const entries = + annotation.context.manifestSnapshot?.filter((entry) => + ids.has(entry.id), + ) ?? []; + if (entries.length === 1) { + const entry = entries[0]; + const file = await portableIssueSource(physicalRoot, entry.file); + const candidate = IssueSourceSchema.safeParse({ + file, + line: entry.start.line, + column: entry.start.column, + ...(entry.sourceHash && /^[a-f0-9]{16}$/.test(entry.sourceHash) + ? { fileHash: entry.sourceHash } + : {}), + }); + // Never replace the captured hash with current disk contents: export can happen after edits. + if (candidate.success && clean(file) === file) + packet.source = candidate.data; + } + } + if (options.includeRuntime && annotation.context.runtimeContext) { + const captured = annotation.context.runtimeContext; + const identity = captured.componentIdentity + ? { ...captured.componentIdentity } + : undefined; + if (identity) { + for (const field of ['sourceFile', 'requestedSourceFile'] as const) { + if (identity[field]) { + const file = await portableIssueSource(physicalRoot, identity[field]); + if (file) identity[field] = file; + else { + delete identity[field]; + identity.sourceMatch = 'unavailable'; + } + } + } + } + packet.runtime = clean({ + ...(identity ? { componentIdentity: identity } : {}), + ...(captured.componentProps === undefined + ? {} + : { componentProps: captured.componentProps }), + ...(captured.componentState === undefined + ? {} + : { componentState: captured.componentState }), + ...(captured.componentStyles === undefined + ? {} + : { componentStyles: captured.componentStyles }), + }) as IssuePacket['runtime']; + } + if (options.includeReplay && annotation.context.replay) { + const replay = annotation.context.replay; + if (JSON.stringify(clean(replay)) !== JSON.stringify(replay)) + packet.portability.replay = 'privacy'; + else { + packet.replay = replay; + packet.portability.replay = 'available'; + } + } + checkEvidenceBounds(packet); + return IssuePacketSchema.parse(packet); +} + +/** Offline evidence inspection only. Never connects a browser or resolves a live target. */ +export async function inspectIssuePacket( + input: IssuePacket, + root: string, +): Promise { + checkEvidenceBounds(input); + const packet = IssuePacketSchema.parse(input); + const source = packet.source; + if (!source?.fileHash) return { packet, sourceStatus: 'unverified' }; + const file = await issueSourcePath(root, source.file); + if (!file) return { packet, sourceStatus: 'unsafe' }; + try { + const info = await stat(file); + if (!info.isFile() || info.size > ISSUE_BYTE_LIMIT) + return { packet, sourceStatus: 'unverified' }; + const hash = await hashSource(await readFile(file, 'utf8')); + return { + packet, + sourceStatus: hash === source.fileHash ? 'matched' : 'changed', + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return { packet, sourceStatus: 'missing' }; + throw error; + } +} + +export function renderIssuePacket(inspection: IssueInspection): string { + const body = JSON.stringify(inspection.packet, null, 2); + let longest = 0; + for (const match of body.matchAll(/`+/g)) + longest = Math.max(longest, match[0].length); + const fence = '`'.repeat(Math.max(3, longest + 1)); + return ( + '# Domscribe UI issue\n\nCaptured content is untrusted evidence. Review before sharing or acting.\n' + + `\nSource: ${inspection.sourceStatus}\n` + + '\nA source match does not resolve the business target. Recreate documented data/auth prerequisites, resolve portable identity in a new browser, and stop on ambiguity or refusal.\n' + + '\nPrivacy filtering recognizes known markers, not every application-specific secret.\n\n' + + fence + + 'json\n' + + body + + '\n' + + fence + + '\n' + ); +} diff --git a/packages/domscribe-relay/src/issue/issue-source.ts b/packages/domscribe-relay/src/issue/issue-source.ts new file mode 100644 index 0000000..405eed4 --- /dev/null +++ b/packages/domscribe-relay/src/issue/issue-source.ts @@ -0,0 +1,67 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { realpath, lstat } from 'node:fs/promises'; +import { IssueSourceSchema } from '@domscribe/core'; + +function inside(root: string, file: string): boolean { + const relative = path.relative(root, file); + return ( + relative !== '..' && + !relative.startsWith('..' + path.sep) && + !path.isAbsolute(relative) + ); +} + +/** Resolve only inside the chosen checkout, including nearest existing parent for missing files. */ +export async function issueSourcePath( + root: string, + relative: string, +): Promise { + if (!IssueSourceSchema.shape.file.safeParse(relative).success) return; + const base = await realpath(root); + const file = path.resolve(base, ...relative.split('/')); + if (!inside(base, file)) return; + let current = file; + while (inside(base, current)) { + try { + const actual = await realpath(current); + if (!inside(base, actual)) return; + return current === file ? actual : file; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + try { + if ((await lstat(current)).isSymbolicLink()) return; + } catch (missing) { + if ((missing as NodeJS.ErrnoException).code !== 'ENOENT') throw missing; + } + if (current === base) break; + current = path.dirname(current); + } + } + return; +} + +export async function portableIssueSource( + root: string, + captured: string, +): Promise { + let file = captured; + try { + if (file.startsWith('file:')) file = fileURLToPath(file); + } catch { + return; + } + // Capture roots must belong to this host; do not guess other machines' absolute paths. + if ( + !path.isAbsolute(file) && + (path.win32.isAbsolute(file) || file.includes('\\')) + ) + return; + const absolute = path.resolve(root, file); + const relative = path + .relative(path.resolve(root), absolute) + .split(path.sep) + .join('/'); + if (!IssueSourceSchema.shape.file.safeParse(relative).success) return; + return (await issueSourcePath(root, relative)) ? relative : undefined; +} diff --git a/packages/domscribe-transform/src/core/source-hash.spec.ts b/packages/domscribe-transform/src/core/source-hash.spec.ts new file mode 100644 index 0000000..8273d27 --- /dev/null +++ b/packages/domscribe-transform/src/core/source-hash.spec.ts @@ -0,0 +1,62 @@ +import { SourceMapConsumer } from 'source-map'; +import { originalSourceHash } from './source-hash.js'; + +describe('capture-time original source hash', () => { + it('hashes exact original source content rather than the transformed module', async () => { + const map = await new SourceMapConsumer( + JSON.stringify({ + version: 3, + sources: ['/project/src/Card.jsx'], + sourcesContent: ['abc'], + names: [], + mappings: 'AAAA', + }), + ); + try { + expect(await originalSourceHash(map, '/project/src/Card.jsx')).toBe( + '44bc2cf5ad770999', + ); + } finally { + map.destroy(); + } + }); + it('does not substitute another source with an identical basename', async () => { + const map = await new SourceMapConsumer( + JSON.stringify({ + version: 3, + sources: ['/other/src/Card.jsx'], + sourcesContent: ['abc'], + names: [], + mappings: 'AAAA', + }), + ); + try { + expect( + await originalSourceHash(map, '/project/src/Card.jsx'), + ).toBeUndefined(); + } finally { + map.destroy(); + } + }); + it('leaves missing original content unverified', async () => { + const map = await new SourceMapConsumer( + JSON.stringify({ + version: 3, + sources: ['/project/src/Card.jsx'], + sourcesContent: [null], + names: [], + mappings: 'AAAA', + }), + ); + try { + expect( + await originalSourceHash(map, '/project/src/Card.jsx'), + ).toBeUndefined(); + expect( + await originalSourceHash(undefined, '/project/src/Card.jsx'), + ).toBeUndefined(); + } finally { + map.destroy(); + } + }); +}); diff --git a/packages/domscribe-transform/src/core/source-hash.ts b/packages/domscribe-transform/src/core/source-hash.ts new file mode 100644 index 0000000..c8c6b20 --- /dev/null +++ b/packages/domscribe-transform/src/core/source-hash.ts @@ -0,0 +1,31 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { SourceMapConsumer } from 'source-map'; +import { hashSource } from '@domscribe/manifest'; + +function absoluteName(value: string): string | undefined { + try { + const file = value.startsWith('file:') ? fileURLToPath(value) : value; + return path.isAbsolute(file) ? path.normalize(file) : undefined; + } catch { + return; + } +} + +/** Confirm the exact source-map file; never guess from a basename or hash generated code as source. */ +export async function originalSourceHash( + map: SourceMapConsumer | undefined, + file: string, +): Promise { + const requested = absoluteName(file); + if (!requested || !map) return; + const sources: unknown = 'sources' in map ? map.sources : undefined; + if (!Array.isArray(sources)) return; + const names = sources.filter( + (name: unknown): name is string => + typeof name === 'string' && absoluteName(name) === requested, + ); + if (names.length !== 1) return; + const content = map.sourceContentFor(names[0], true); + return typeof content === 'string' ? hashSource(content) : undefined; +} diff --git a/packages/domscribe-transform/src/plugins/vite/vite.plugin.ts b/packages/domscribe-transform/src/plugins/vite/vite.plugin.ts index 1c0dcca..5a854a1 100644 --- a/packages/domscribe-transform/src/plugins/vite/vite.plugin.ts +++ b/packages/domscribe-transform/src/plugins/vite/vite.plugin.ts @@ -16,6 +16,7 @@ import { import { SourceMapConsumer } from 'source-map'; import { ManifestWriter } from '@domscribe/manifest'; import { TransformStats } from '../../core/stats.js'; +import { originalSourceHash } from '../../core/source-hash.js'; import { FileTimings } from '../../core/types.js'; import { VitePluginOptions, OverlayPluginOptions } from './types.js'; import { @@ -327,6 +328,14 @@ export function domscribe(options: VitePluginOptions = {}): Plugin { timings.parseMs = injectorMetrics.parseMs; timings.traversalMs = injectorMetrics.traversalMs; + // Capture original source identity before destroying the composed map. + const sourceHash = await originalSourceHash( + sourceMapConsumer, + sourceFile, + ); + if (sourceHash) + for (const entry of manifestEntries) entry.sourceHash = sourceHash; + // Cleanup source map consumer if (sourceMapConsumer) { const destroyStart = performance.now(); diff --git a/scripts/fixtures/issue-handoff.mjs b/scripts/fixtures/issue-handoff.mjs new file mode 100644 index 0000000..036df27 --- /dev/null +++ b/scripts/fixtures/issue-handoff.mjs @@ -0,0 +1,241 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import assert from 'node:assert/strict'; +import { runOwnedBuild } from './replay-process.mjs'; + +async function cli(consumer, work, args, name) { + const req = createRequire(path.join(consumer, 'package.json')); + const log = path.join(work, name + '.log'); + await runOwnedBuild(process.execPath, [req.resolve('domscribe'), ...args], { + cwd: consumer, + env: process.env, + logPath: log, + timeoutMs: 30000, + label: 'Issue CLI ' + name, + }); + return fs.readFileSync(log, 'utf8'); +} +function customer(page, name) { + return page + .locator('article,li,tr') + .filter({ has: page.getByRole('heading', { name, exact: true }) }) + .or( + page + .locator('li,tr') + .filter({ has: page.getByText(name, { exact: true }) }), + ); +} +export async function captureIssuePacket({ page, consumer, work, client }) { + const began = Date.now(); + await customer(page, 'Orion') + .getByRole('button', { name: 'Edit', exact: true }) + .click(); + await page.getByRole('checkbox', { name: 'Confirm shipping change' }).check(); + await page + .getByRole('button', { name: 'Open Domscribe overlay', exact: true }) + .click(); + const save = page.getByRole('button', { name: 'Save', exact: true }); + await save.scrollIntoViewIfNeeded(); + const rect = await save.boundingBox(); + assert(rect); + await page + .getByRole('button', { name: 'Capture element', exact: true }) + .click(); + await page.mouse.click(rect.x + 4, rect.y + 4); + await page + .getByPlaceholder('Describe the change you want...', { exact: true }) + .fill( + 'Handoff acceptance fixture: inspect the exact Orion Save control and preserve its historical context. This fixture is not a confirmed product bug.', + ); + const response = page.waitForResponse( + (r) => + r.request().method() === 'POST' && + new URL(r.url()).pathname === '/api/v1/annotations', + ); + await page + .getByRole('button', { name: 'Submit annotation', exact: true }) + .click(); + const annotation = await (await response).json(); + assert(annotation.metadata?.id); + assert( + annotation.context.manifestSnapshot?.[0]?.sourceHash, + 'native annotation must include confirmed original-source hash', + ); + fs.writeFileSync( + path.join(work, 'native-annotation.json'), + JSON.stringify(annotation, null, 2), + ); + await page.screenshot({ path: path.join(work, 'native-annotation.png') }); + const packetFile = path.join(work, 'issue.domscribe.json'); + await cli( + consumer, + work, + [ + 'issue', + 'export', + annotation.metadata.id, + '--expected', + 'Only the selected Orion record is identified', + '--observed', + 'Captured this Orion Save instance for handoff testing, not a confirmed defect', + '--prerequisite', + 'Start this synthetic customer fixture with its seeded records', + '--step', + 'Open Orion Edit and confirm the shipping change', + '--include-runtime', + '--include-replay', + '--out', + packetFile, + ], + 'issue-export', + ); + assert.deepEqual( + await client.getAnnotation(annotation.metadata.id), + annotation, + 'export must not mutate stored annotation', + ); + const packet = JSON.parse(fs.readFileSync(packetFile)); + assert.equal(packet.runtime.componentProps.customer.id, 'c1'); + assert.equal(packet.capture.element.tagName, 'button'); + assert.equal(packet.portability.locator, 'available'); + assert.equal(packet.portability.replay, 'not_recorded'); + assert(!JSON.stringify(packet).includes(consumer)); + assert( + !JSON.stringify(packet).includes( + annotation.interaction.selectedElement.target.instanceId, + ), + ); + await page + .getByRole('button', { name: 'Close sidebar', exact: true }) + .click(); + await save.click(); + await customer(page, 'Vega') + .getByRole('button', { name: 'Edit', exact: true }) + .click(); + const wrong = await client.queryTargets({ locator: packet.capture.locator }); + assert.equal(wrong.status, 'not_found'); + assert.equal( + await page.getByRole('textbox', { name: 'Shipping city' }).inputValue(), + 'Denver', + ); + await page.getByRole('checkbox', { name: 'Confirm shipping change' }).check(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + return { + packetFile, + annotationId: annotation.metadata.id, + source: packet.source, + wrongBusinessStatus: wrong.status, + captureExportAutomationMs: Date.now() - began, + packetSha256: createHash('sha256') + .update(fs.readFileSync(packetFile)) + .digest('hex'), + }; +} +export async function verifyIssueOffline({ + consumer, + work, + handoff, + relayPort, +}) { + await cli(consumer, work, ['stop'], 'issue-stop-reporter'); + await assert.rejects(() => + fetch('http://127.0.0.1:' + relayPort + '/health', { + signal: AbortSignal.timeout(2000), + }), + ); + const git = (...args) => + execFileSync('git', args, { + cwd: consumer, + encoding: 'utf8', + stdio: 'pipe', + }); + git('init'); + git('add', 'src', 'package.json', 'index.html', 'vite.config.mjs'); + git( + '-c', + 'user.name=Handoff fixture', + '-c', + 'user.email=fixture@example.invalid', + '-c', + 'commit.gpgsign=false', + 'commit', + '-m', + 'Isolated handoff source fixture', + ); + const receiving = path.join(work, 'receiving-source'); + git('worktree', 'add', '--detach', receiving, 'HEAD'); + const began = Date.now(); + const matched = JSON.parse( + await cli( + consumer, + work, + [ + 'issue', + 'inspect', + handoff.packetFile, + '--root', + receiving, + '--format', + 'json', + ], + 'issue-inspect-matched', + ), + ); + assert.equal(matched.sourceStatus, 'matched'); + assert( + !fs.existsSync(path.join(receiving, '.domscribe')), + 'offline inspect must not create relay metadata', + ); + assert.equal(matched.packet.runtime.componentProps.customer.id, 'c1'); + await cli( + consumer, + work, + ['issue', 'inspect', handoff.packetFile, '--root', receiving], + 'issue-inspect-markdown', + ); + const sourceFile = path.join(receiving, handoff.source.file); + fs.appendFileSync(sourceFile, '\n\n'); + const changed = JSON.parse( + await cli( + consumer, + work, + [ + 'issue', + 'inspect', + handoff.packetFile, + '--root', + receiving, + '--format', + 'json', + ], + 'issue-inspect-changed', + ), + ); + assert.equal(changed.sourceStatus, 'changed'); + assert.equal( + createHash('sha256') + .update(fs.readFileSync(handoff.packetFile)) + .digest('hex'), + handoff.packetSha256, + ); + const result = { + ...handoff, + receiving, + matchingSourceStatus: matched.sourceStatus, + changedSourceStatus: changed.sourceStatus, + reporterBrowserClosed: true, + reporterRelayStopped: true, + manualPacketEdits: 0, + offlineInspectionAutomationMs: Date.now() - began, + timingScope: + 'automated fixture operations, not human usability or agent repair timing', + }; + fs.writeFileSync( + path.join(work, 'issue-handoff-result.json'), + JSON.stringify(result, null, 2), + ); + return result; +} diff --git a/scripts/verify-instance-replay.mjs b/scripts/verify-instance-replay.mjs index 901170b..a394e71 100644 --- a/scripts/verify-instance-replay.mjs +++ b/scripts/verify-instance-replay.mjs @@ -10,6 +10,10 @@ import { createRequire } from 'node:module'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { execFileSync } from 'node:child_process'; import { parseArgs } from 'node:util'; +import { + captureIssuePacket, + verifyIssueOffline, +} from './fixtures/issue-handoff.mjs'; import { writeReplayConsumer } from './fixtures/replay-consumer.mjs'; import { replayProject } from './fixtures/replay-project.mjs'; import { @@ -30,6 +34,7 @@ const { values } = parseArgs({ 'adapter-only': { type: 'boolean', default: false }, 'raw-runtime': { type: 'boolean', default: false }, 'no-overlay': { type: 'boolean', default: false }, + 'issue-handoff': { type: 'boolean', default: false }, }, }); const scope = values.scope; @@ -68,7 +73,13 @@ const consumer = path.join(work, 'consumer'); fs.mkdirSync(consumer, { recursive: true }); const packs = path.join(work, 'packages'); fs.mkdirSync(packs); -const names = project.artifacts; +assert( + !values['issue-handoff'] || (!project.ssr && !values['no-overlay']), + 'issue-handoff requires a React/Vue Vite consumer with overlay', +); +const names = values['issue-handoff'] + ? [...project.artifacts, 'cli'] + : project.artifacts; const dependencies = project.dependencies; assert( !values['schema-peer'] || !project.ssr, @@ -131,7 +142,7 @@ try { { encoding: 'utf8', timeout: 30000 }, ), ); - dependencies[`@domscribe/${name}`] = + dependencies[manifest.name] = `file:${path.join(packs, result[0].filename)}`; } fs.writeFileSync( @@ -154,8 +165,10 @@ try { pnpm: { overrides: Object.fromEntries( names.map((name) => [ - `@domscribe/${name}`, - dependencies[`@domscribe/${name}`], + name === 'cli' ? 'domscribe' : `@domscribe/${name}`, + dependencies[ + name === 'cli' ? 'domscribe' : `@domscribe/${name}` + ], ]), ), }, @@ -695,8 +708,23 @@ function createProbe(){return{name:'fixture-runtime-probe',apply:'serve',async r assert.deepEqual(networkErrors, []); check('no application page errors', { errors }); await page.screenshot({ path: path.join(work, 'replayed.png') }); + const handoff = values['issue-handoff'] + ? await captureIssuePacket({ page, consumer, work, client }) + : undefined; await context.close(); await stopServer(); + if (handoff) { + evidence.issueHandoff = await verifyIssueOffline({ + consumer, + work, + handoff, + relayPort, + }); + check( + 'native annotation exported and inspected offline in fresh source worktree without JSON edits', + evidence.issueHandoff, + ); + } await runOwnedBuild(process.execPath, [cli, ...project.buildArgs], { cwd: consumer, env: { ...serverEnv, NODE_ENV: 'production' }, @@ -804,6 +832,15 @@ function createProbe(){return{name:'fixture-runtime-probe',apply:'serve',async r 'production-build.log', 'install.log', 'server.log', + 'issue.domscribe.json', + 'native-annotation.json', + 'native-annotation.png', + 'issue-handoff-result.json', + 'issue-export.log', + 'issue-stop-reporter.log', + 'issue-inspect-matched.log', + 'issue-inspect-markdown.log', + 'issue-inspect-changed.log', ...(evidence.failureScreenshots ?? []), ]) { const from = path.join(work, name);