From 6aea2fa0de3d85a95d40d52b299271376d644bbe Mon Sep 17 00:00:00 2001 From: HUANG <15866338256@163.com> Date: Sun, 27 Sep 2026 01:12:03 +0800 Subject: [PATCH 1/4] fix(desktop): unify deterministic preview source editing --- .../trace-generated-text-definitions.md | 12 + SOURCE_EDITING.md | 137 ++-- ...ource-edit-engine.source-selection.test.ts | 77 +- .../src/main/source-edit-engine.test.ts | 96 ++- .../source-edit-engine.text-coverage.test.ts | 225 ++++++ apps/desktop/src/main/source-edit-engine.ts | 601 +++++----------- .../src/main/source-edit-provenance.test.ts | 367 ++++++++++ .../src/main/source-edit-provenance.ts | 612 ++++++++++++++++ .../desktop/src/main/source-edits-ipc.test.ts | 41 +- apps/desktop/src/main/source-edits-ipc.ts | 10 + .../FilesTabView.source-edit.test.tsx | 407 ++++++++++- .../src/components/FilesTabView.test.ts | 61 +- .../renderer/src/components/FilesTabView.tsx | 136 +++- .../SourceEditPanel.browser.test.ts | 660 +++++++++++++++++- .../src/components/SourceEditPanel.test.tsx | 150 +++- .../src/components/SourceEditPanel.tsx | 187 +++-- .../__fixtures__/source-edit-browser.tsx | 2 +- .../src/preview/helpers.source-edit.test.ts | 130 +++- .../src/renderer/src/preview/helpers.ts | 136 +++- .../src/preview/source-edit-messages.test.ts | 72 ++ .../src/preview/source-edit-messages.ts | 58 ++ .../preview/useWorkspaceSourceEdit.test.tsx | 172 ++++- .../src/preview/useWorkspaceSourceEdit.ts | 183 +++-- packages/i18n/src/locales/en.json | 64 +- packages/i18n/src/locales/zh-CN.json | 64 +- packages/runtime/src/index.ts | 11 +- packages/runtime/src/overlay.test.ts | 385 +++++++++- packages/runtime/src/overlay.ts | 256 ++++++- .../runtime/src/source-edit-binding.test.ts | 173 +++++ packages/runtime/src/source-edit-binding.ts | 187 +++++ .../src/source-edit-instrumentation.test.ts | 39 ++ .../src/source-edit-instrumentation.ts | 55 +- packages/shared/src/source-edits.test.ts | 52 ++ packages/shared/src/source-edits.ts | 58 +- 34 files changed, 5039 insertions(+), 837 deletions(-) create mode 100644 .changeset/trace-generated-text-definitions.md create mode 100644 apps/desktop/src/main/source-edit-engine.text-coverage.test.ts create mode 100644 apps/desktop/src/main/source-edit-provenance.test.ts create mode 100644 apps/desktop/src/main/source-edit-provenance.ts create mode 100644 apps/desktop/src/renderer/src/preview/source-edit-messages.test.ts create mode 100644 apps/desktop/src/renderer/src/preview/source-edit-messages.ts create mode 100644 packages/runtime/src/source-edit-binding.test.ts create mode 100644 packages/runtime/src/source-edit-binding.ts diff --git a/.changeset/trace-generated-text-definitions.md b/.changeset/trace-generated-text-definitions.md new file mode 100644 index 00000000..e6311ca0 --- /dev/null +++ b/.changeset/trace-generated-text-definitions.md @@ -0,0 +1,12 @@ +--- +"@open-codesign/desktop": patch +"@open-codesign/runtime": patch +"@open-codesign/shared": patch +"@open-codesign/i18n": patch +--- + +Improve deterministic text editing for generated JSX/TSX: edit independent mixed-child segments, shared local component literals, and lexically traced const/object/static-array string definitions. Display exact source origins and shared-definition scope, bind individual preview fields, and refuse unresolved instance data rather than guessing. Preserve hash conflicts, path safety, and atomic span writes without a model call. + +Unify editing into preview selection and one field panel, removing the separate source picker. Support static segments beside dynamic content, disabled control selection, explicit containing-element navigation, and fresh field validation before saving. Retired source-mode IPC requests are explicitly rejected rather than acting as a bypass. Localize editability reasons, source-definition explanations and inspect/save/refresh messages for Chinese and English interfaces, keeping backend reason codes in collapsed diagnostics. + +Bind resolved HTML-to-JSX previews to the requested file/design/workspace and refresh sources outside the expanded file tree. Add data-driven browser persistence, shared-reference, mutation/escape, mixed-text, ambiguous-mapping and source-conflict regressions. diff --git a/SOURCE_EDITING.md b/SOURCE_EDITING.md index c2a7db94..6ee3819b 100644 --- a/SOURCE_EDITING.md +++ b/SOURCE_EDITING.md @@ -4,105 +4,122 @@ Open CoDesign can edit a small, explicitly supported part of a React design dire Here, **deterministic** means that the same source and supported operation produce a reproducible source patch or refusal. It does not prove arbitrary JavaScript safe, establish that a source definition has only one rendered instance, or guarantee visual correctness in every application state. -## Use it +## One editing flow 1. Open a design bound to a real local workspace. -2. In the **Files** tab, open an integrated preview of an actual `.jsx` or `.tsx` workspace file. -3. Choose **Edit source**. The displayed source must match the current workspace file before editing is enabled; a snapshot or fallback preview is not a writable source. -4. Select an element in the preview. The panel identifies its source file and shows supported existing fields, or explains why a target or field is unsupported. If opaque execution prevents preview selection, choose **Choose a static source field**, then select by source line, native tag and current value. Review the displayed source fragment before saving. -5. Change one field and use its **Save source definition** button. -6. Check the refreshed preview and the source file. A save can reload the preview and reset in-memory component state; state-preserving HMR is not promised. Select again when the preview or source revision changes. +2. In **Files**, open an integrated preview of an actual `.jsx` or `.tsx` workspace file. +3. Choose **Edit source**. The displayed source must match the current workspace file; a snapshot or fallback preview is not writable source. +4. Select an element in the preview. The panel shows supported fields, their source origins and any field-specific refusal. If you hit a nested icon, explicitly select a containing element from the breadcrumb buttons. +5. Change one field and use its **Save source definition** button. Text segments are labeled **Text 1**, **Text 2**, etc., rather than source offsets. +6. Check the refreshed preview and source. A save can reload the preview and reset component state; state-preserving HMR is not promised. Select again after the preview or source revision changes. -The operation has **source-definition** scope. It changes a literal in the source, not a private copy of whichever DOM instance happened to be clicked. Preview selection deliberately refuses cases where its supported source boundary cannot be established. Explicit source selection instead identifies a literal in the parsed source without claiming a mapping to a live DOM element; page behavior can still affect the rendered result. +There is no separate source-selection mode or whole-file static-field picker. Source locations remain read-only explanations in the same panel. Content in an unrendered branch must first be made visible; a source definition alone does not create a clickable runtime element. -The source editor toolbar and panel reserve their own layout space outside the preview iframe, so they do not intercept clicks on artifact controls. The panel reduces the available preview width while open, which can trigger the artifact's responsive layout. +The operation has **source-definition** scope. It changes the actual literal, not a private copy of the clicked DOM instance. A component/map literal or shared variable can therefore change every use of that definition. No single-instance override is offered. -Ordinary comment and tweak behavior remains separate. Tweaks are temporarily hidden while source editing is active so the two editing paths do not compete. Leaving a tab, file, or design suppresses late UI acknowledgements for the old view; it does **not** promise to undo a save that has already been dispatched to the main process. Generation in the same design or workspace disables source editing. +The toolbar and panel reserve layout space outside the iframe; the panel can change the available responsive viewport width. While editing is active, a temporary hit layer intercepts artifact actions and allows selection of disabled controls. It does not wrap source text or insert children into the artifact's React layout. Leaving editing removes the hit layer and restores ordinary preview interaction. + +Comments remain separate. Tweaks are hidden while source editing is active so the paths do not compete. Leaving a tab, file or design suppresses late UI acknowledgements for the old view; it does **not** undo a save already dispatched to main. Generation in the same design or workspace disables editing. ## Supported fields -Both selection modes require one directly declared `App` or `_App` script entry with a direct JSX return and directly owned native JSX elements and fragments. Preview selection additionally requires the supported auto-mounted execution boundary. Explicit source selection permits effects, refs, opaque handlers and explicit mounting elsewhere in the source without asserting that a selected definition has a unique or unchanged rendered instance. It is not an arbitrary imported React application. +Inspection requires one directly declared `App` or `_App` script entry with a direct JSX return. Text targets are **native JSX source definitions**, including definitions inside local components and callback/conditional JSX. This is not arbitrary imported React application support. | Field | Supported form | | --- | --- | -| Text | An existing static text child or supported string-literal expression. | -| Attributes | Existing static `title`, `placeholder`, or `alt` literals. | -| Inline style | Existing literal properties in a directly owned inline style object: `color`, `backgroundColor`, `fontSize`, `gap`, `padding`, `borderRadius`, `maxWidth`. | +| Text segments | Existing JSX text or string expressions, including segments beside icons, `
`, emphasis and unresolved dynamic siblings. Each save changes one segment and preserves child elements. | +| Traced text | Lexically resolved `const` strings/aliases and static object/array paths such as `resume.name` or `cards[1].title`, ending at a same-file string literal. The actual literal, line and origin chain are shown. | +| Shared text | Literal JSX inside a local component or map callback. Saving edits the definition and all its uses, not one rendered instance. | +| Attributes | Existing static `title`, `placeholder` or `alt` literals with directly owned native App targets. | +| Inline style | Existing literal `color`, `backgroundColor`, `fontSize`, `gap`, `padding`, `borderRadius` or `maxWidth` in a directly owned inline style object. | + +Effects, refs, opaque handlers and explicit mounting elsewhere do not blanket-disable candidates. Attribute/style ownership and literal allowlists still apply. Each candidate must independently match the current preview before the UI offers saving. + +For example, `` exposes the static prefix and suffix through preview selection; it does not make `count` editable. There is no need to switch modes. + +### Field-level preview mapping + +Inspection provides ordered direct-child descriptors: static text segments, marked native child boundaries and unknown dynamic positions. The overlay aligns these with the selected element's actual direct text nodes and children. It does not flatten nested element text, evaluate generated expressions, inspect React private internals, or search the page for the first equal string. -The editor does not add absent attributes or style properties. It does not rewrite an entire file or infer which shared variable should change. +- A field is enabled only when its rendered position is uniquely established. Matching preserves complete React scalar Text-node slots; it never accepts a substring of a surviving sibling as a removed field. A dynamic sibling alone is not grounds for rejecting static text. +- An ambiguous or changed field is disabled with a reason. Independent fields, including matching attributes/styles, can remain available. +- Attribute values and normalized **inline** style values are checked independently; computed/inherited styles are not source locations. +- Matching is bounded (256 layout descriptors, 128 fields for text mapping, 1,024 child nodes, 50,000 text units and 30,000 search steps per host). Large or ambiguous structures can be refused conservatively. +- A preview-only observer starts before generated code executes. It remembers field Text-node identities and records removals/character changes; replacing a static node with an equal-valued node does not silently restore readiness. Before initial binding, removing matching text is conservatively refused. Pending records are flushed synchronously before selection and save validation. Observation is bounded (4,096 records / 10,000 visited nodes per batch); overflow disables text mapping until reload. +- Selection readiness is refreshed while editing, and the parent requests a fresh check of the pinned DOM instance immediately before dispatching a save. Replies are tied to the current iframe, request and preview revision. Imperative changes or React replacing scalar nodes can require reloading rather than automatically rebinding an uncertain origin. -CSS values follow a narrow allowlist, not arbitrary CSS. Supported forms include literal color forms and finite nonnegative sizes with supported units; `gap`, `padding`, and `borderRadius` permit their supported short forms, and `maxWidth` also accepts `none`. CSS variables, URLs, expressions, arbitrary declarations, and unsupported value syntax are refused. An accepted source literal is not a guarantee that every browser interprets every value identically. +These checks improve source/display correspondence; they are not proof against arbitrary same-frame script tampering, ambient prototype changes or future imperative overwrites. The main process never treats preview metadata as write authorization. -Directly owned `useState` state and supported pure inline state-setter handlers can coexist with static editable fields. This does not make a state-derived label or arbitrary event handler editable. +### Source resolution and limitations -### Explicit refusals +The resolver parses source; it never executes it. It checks lexical shadowing, writes, aliases, container escapes, duplicate keys, getters and spreads. A bounded read-only inline array `map` may coexist with traced fields, but does **not** make its callback parameter an editable data instance. The complete object/array must be static. Unsupported uses such as `flatMap`, `filter`, destructured callbacks or passing containers to unknown code conservatively disable indirect edits through that connected container. + +The editor does not add absent properties, hard-code generated expressions, replace equal strings throughout a file or rewrite the whole file. A fixed array path edits that exact field and any uses of it; an unrelated equal-valued field remains unchanged. + +CSS follows a narrow allowlist: supported literal colors and finite nonnegative sizes/units; `gap`, `padding` and `borderRadius` permit supported short forms, and `maxWidth` accepts `none`. CSS variables, URLs, expressions and arbitrary declarations are refused. Browser acceptance of every proposed value is not guaranteed merely by source parsing. Unsupported cases include: -- Dynamic text or attribute expressions and mapped/repeated targets. Preview selection also refuses reused entry references and ambiguous runtime ownership. -- Custom-component targets, component prop forwarding, computed or shared style objects, and unsupported spreads. Dynamic children do not necessarily prevent editing a static parent's own supported layout fields. -- Import/export module syntax and unsupported entry structure in both modes. Preview selection additionally refuses refs, effect or other non-state hooks, DOM/global mutation, timers or other scheduling, reflection, and unknown or imperative handlers outside the supported pure setter form. -- Runtime-control text in a proposed value, including certain preview/document/tweak markers, mount controls, or `App`/`_App` declaration patterns that the preview runtime currently recognizes in raw source. -- Unsupported files, unavailable workspaces, hidden or escaping paths, symlinked child paths, hard-linked source files, stale source, and saves attempted during generation. +- Computed text (calls, interpolation, concatenation and conditional expressions), state-derived labels, mutable/escaping data, dynamic keys, destructuring bindings, TS expression wrappers and optional access. In particular, `items.map(item =>

{item.title}

)` and component parameter text remain unsupported. +- Custom-component targets, forwarded JSX children/props, shared/computed style objects and ambiguous spreads. Literal text inside a component's native JSX is a shared definition, not a prop override. +- Import/export module syntax and unsupported entry structures. +- Unrendered source branches, ambiguous DOM correspondence, removed text and fields whose displayed values no longer match their source definitions. +- Runtime-control text in proposed values, including preview/document/tweak markers, mount controls and entry declaration patterns recognized by the runtime. +- Unsupported files, unavailable workspaces, hidden/escaping paths, symlinked child paths, hard-linked source files, stale source and saves during generation. -Refusal is intentional. Switching to source selection is an explicit user action. It does not enable dynamic fields or indirect definitions, and the editor does not silently fall back to DOM mutation, broad string replacement, whole-file regeneration, or an LLM request. +A refusal stays in the same preview editing interface. There is no fallback to a source picker, DOM-only mutation, whole-file regeneration or an LLM request. ## Source identity and preview metadata -Inspection binds the exact displayed source to the workspace file and returns a SHA-256 source hash and source targets. Selection and save acknowledgements are also associated with a preview revision. The source hash and preview revision serve different purposes: source bytes determine the patch baseline; the preview revision prevents a late selection or acknowledgement from being presented as belonging to a different rendered view. +Inspection binds exact displayed bytes to the workspace file and returns a SHA-256 hash and source targets. The source hash determines the patch baseline; a separate preview revision prevents old selections and acknowledgements being reused for a different rendered view. -Temporary preview instrumentation identifies source definitions for selection. Those markers are inserted only into the preview document, not saved into the workspace source. Ordinary exports do not add this source-edit provenance. Generated-page metadata, target IDs, and the preview revision are **not authorization**: the main process validates the request and independently reads and analyzes the current workspace source. +Instrumentation adds temporary source markers only to the preview document. They are not saved in workspace source or added as editing metadata to ordinary exports. Both source-bound and ancestor-only edit selections carry the preview revision. Clicking a breadcrumb requests an actual ancestor selection in the current preview rather than selecting an arbitrary source ID locally. -In explicit source selection mode the preview is not instrumented, and preview selection messages cannot change the selected source field. The request carries `selectionMode: "source"`; omission retains the strict preview mode. Both inspection and save use the selected mode, while source hashes, field validation and atomic conflict checks remain mandatory. Source-mode analysis results must never be used as preview provenance. The revision still correlates save acknowledgements with the current editor context. +HTML placeholders can resolve to nested JSX/TSX files. Loaded bytes are bound to the **requested file, design and workspace**; writes use the resolved source path. Changes outside expanded file-tree directories also invalidate preview inspection. -For JSX/TSX, workspace reads preserve a UTF-8 BOM so preview content, offsets, hashes, and saved bytes remain aligned. Other text readers retain their existing decoding behavior; this does not change BOM handling for JSON files. Invalid UTF-8 source is rejected rather than silently transcoded. +Mixed/indirect operations carry `textId`, identifying a parsed child segment rather than a caller-supplied write range. Actual literal-definition ranges remain separate. Main re-resolves the target at save time. -## Save and conflict behavior +The IPC contract remains schema version 1 with additive field-layout metadata. Omitted `selectionMode` and legacy `preview` use the same unified inspector. Legacy `source` requests receive `source-mode-removed` instead of activating a permissive alternate path. Older clients omitting a segment ID cannot accidentally save a mixed field. -The main process: +JSX/TSX reads preserve a UTF-8 BOM so bytes, hashes and offsets remain aligned. Invalid UTF-8 is rejected rather than silently transcoded; unrelated reader formats are unchanged. + +## Save and conflict behavior -1. Validates the versioned request, source-definition scope, operation allowlist, workspace path, and live generation state. -2. Holds the existing stable-workspace-path and canonical-file-writer leases, rereads the source, and checks the expected SHA-256 hash. -3. Parses the source locally, resolves the target independently, plans one source-span replacement, and reparses the result before saving. -4. Writes an exclusive, same-directory temporary stage, syncs it, verifies staged bytes and the current source/path, and atomically replaces the source with `rename`. -5. Returns the committed source, hash, patch, and matching preview revision, and requests a preview refresh. +Before invoking main, the renderer verifies the selected field against the live preview. Main independently: -Detected source conflicts are refused without overwriting the newer file. Pre-commit failures do not replace the source; cleanup of the temporary stage is attempted without overwriting the current file. There is no new session history, undo/version UI, or database-backed snapshot state for this feature. +1. Validates the versioned request, scope, operation allowlist, workspace path and generation state. +2. Holds stable-workspace-path and canonical-file-writer leases, rereads source and checks its SHA-256 hash. +3. Parses source, resolves the target, plans one span replacement and reparses the result. +4. Writes an exclusive same-directory temporary stage, syncs it, verifies bytes/path/source and atomically replaces the file with `rename`. +5. Returns committed content, hash, patch and matching preview revision, and requests refresh. -**Concurrency boundary:** the writer lease serializes participating app writers, including different designs using the same canonical file. It is not an operating-system compare-and-swap or a lock respected by an external editor. An external process can still race the final validation and rename (TOCTOU). The implementation does not claim otherwise. +Conflicts are refused without overwriting newer files. Pre-commit failures do not replace source. There is no new history/undo UI or database snapshot state. -If saving has committed but the refresh notification fails, the result remains **saved**, with a warning to reload the preview. It is not reported as an unsaved edit, and the app does not blindly restore old bytes over a later external version. Inspect the workspace file when a warning or stale preview makes the visual result uncertain. +**Concurrency boundary:** app writer leases serialize participating writers to the same canonical file, not external editors. They are not an OS compare-and-swap; an external process can still race final validation and rename (TOCTOU). -## Verification and manual checks +If commit succeeds but refresh notification fails, the result remains **saved** with a reload warning. The app does not restore old bytes over later changes. Inspect the workspace file if a stale preview makes the result uncertain. -Relevant tests are kept alongside the implementation: +## Verification -- [AST support and refusal tests](apps/desktop/src/main/source-edit-engine.test.ts) -- [Explicit source selection and execution-boundary tests](apps/desktop/src/main/source-edit-engine.source-selection.test.ts) -- [Atomic commit and failure tests](apps/desktop/src/main/source-edit-atomic.test.ts) -- [Main IPC, conflicts, and BOM round-trip tests](apps/desktop/src/main/source-edits-ipc.test.ts) -- [Workspace reader compatibility tests](apps/desktop/src/main/workspace-reader.test.ts) -- [Generation registry and workspace lifecycle tests](apps/desktop/src/main/ipc/generate.workspace-rename.test.ts) -- [Preload channel tests](apps/desktop/src/preload/source-edits.test.ts) -- [Files tab integration tests](apps/desktop/src/renderer/src/components/FilesTabView.source-edit.test.tsx) -- [Source edit persistence tests](apps/desktop/src/renderer/src/preview/source-edit-persistence.test.ts) -- [Preview lifecycle tests](apps/desktop/src/renderer/src/preview/useWorkspaceSourceEdit.test.tsx) -- [Runtime overlay tests](packages/runtime/src/overlay.test.ts) +Relevant tests: -From the repository root, using the supported Node version and installed workspace dependencies: +- [AST supported fields and refusals](apps/desktop/src/main/source-edit-engine.test.ts) +- [Unified inspection and retired-mode migration](apps/desktop/src/main/source-edit-engine.source-selection.test.ts) +- [Generated data and mixed/shared text](apps/desktop/src/main/source-edit-engine.text-coverage.test.ts) +- [Lexical provenance, mutations and escapes](apps/desktop/src/main/source-edit-provenance.test.ts) +- [Atomic writes](apps/desktop/src/main/source-edit-atomic.test.ts) and [main IPC](apps/desktop/src/main/source-edits-ipc.test.ts) +- [Files tab integration](apps/desktop/src/renderer/src/components/FilesTabView.source-edit.test.tsx) and [editing lifecycle](apps/desktop/src/renderer/src/preview/useWorkspaceSourceEdit.test.tsx) +- [Bounded field mapping](packages/runtime/src/source-edit-binding.test.ts) and [overlay selection/validation](packages/runtime/src/overlay.test.ts) +- [Browser selection, filesystem writes, refresh and reopen](apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts) -```sh -corepack pnpm --filter @open-codesign/desktop exec vitest run src/main/source-edit-engine.test.ts src/main/source-edit-atomic.test.ts src/main/source-edits-ipc.test.ts src/main/workspace-reader.test.ts src/main/ipc/generate.workspace-rename.test.ts src/preload/source-edits.test.ts -corepack pnpm --filter @open-codesign/desktop exec vitest run src/renderer/src/components/FilesTabView.source-edit.test.tsx src/renderer/src/preview/source-edit-persistence.test.ts src/renderer/src/preview/useWorkspaceSourceEdit.test.tsx -corepack pnpm --filter @open-codesign/runtime test -``` +Run scoped desktop suites with `corepack pnpm --filter @open-codesign/desktop exec vitest run ` and the runtime package with `corepack pnpm --filter @open-codesign/runtime test`. -These are test entry points, not a claim that a full browser or packaged Electron end-to-end run has passed on a particular platform. Unit tests and Node IPC fixtures do not replace real preview verification. +Run the browser suite from the desktop directory with `corepack pnpm exec vitest run src/renderer/src/components/SourceEditPanel.browser.test.ts`. It uses installed system Chromium, production handlers and the atomic writer through an HTTP bridge in a temporary workspace, with external requests blocked. This verifies browser-to-filesystem persistence, **not packaged Electron/preload E2E**. No browser is bundled/downloaded and no model credentials are required. Separate tests cover path, hash, generation, BOM, version and atomic-write protections. -In the existing desktop app, manually check a self-contained supported static sample with no model credentials or network access: compare the exact source diff, save, and verify the refreshed preview. Change the file externally between selection and save and verify a conflict leaves the external content intact. Check a BOM-prefixed JSX/TSX file, an unsupported mapped or dynamic target, switching tabs during a save, and an active generation. Confirm that no generation turn, undo/history UI, or persistent preview marker appears. A successful source edit may reset preview state; test that behavior rather than assuming HMR. +Reproduction used six actual local generated designs (resume, product-page and slide structures). Their originals remain read-only and uncommitted; portable tests contain representative structures, not private copies. AST candidate counts are not a claim that every runtime state or visible field has been clicked. -Also check a script containing effects or explicit mounting: preview selection should remain refused, while explicitly choosing an eligible source field can save a static literal. Confirm the source-mode preview has no source-selection markers, exact before/after bytes differ only at the selected field, and edits survive a full application restart. Use actual mouse input to check artifact controls near the preview's top-right edge; DOM-triggered clicks cannot detect shell overlays intercepting the same screen coordinates. +In the desktop app, check a mixed static/dynamic label, an icon/text button, disabled controls, shared component definitions, fixed object/array fields and refused map parameters. Verify exact source differences and persistence after reopening. Change source externally between selection and save; change a displayed field after selection; switch files/designs during a save. Confirm source conflicts and stale/ambiguous preview mappings do not enable incorrect writes. Use actual pointer input, not only DOM-triggered clicks, and verify leaving editing restores normal controls. ## Parser dependency review -The desktop app explicitly depends on `@babel/parser` **7.29.2**, under the **MIT** license; the package license was reviewed for this addition. A measured local installation contains 8 files totaling 1,995,536 bytes, including its source map. This is installed package size, **not** an installer-size delta. The same parser version was already present transitively in the lockfile; making it a direct desktop dependency adds three lockfile lines rather than introducing a new parser version. The main-process AST engine is imported on demand. Regex is unsuitable for reliably locating and validating JSX/TSX syntax; Acorn plus JSX would require an additional TypeScript strategy. Reusing the larger browser Babel bundle (roughly 3 MB) would couple Node-side analysis to a browser compilation bundle, and the main process should parse, not execute, generated source. A peer dependency is inappropriate for this internal desktop capability because the app must reliably ship the parser it requires. +The desktop app explicitly depends on `@babel/parser` **7.29.2**, under **MIT**. A measured local installation contains 8 files totaling 1,995,536 bytes including its source map; this is installed package size, not installer delta. The version was already transitive; making it direct added three lockfile lines rather than another parser version. Main loads the AST engine on demand. Regex cannot reliably locate JSX/TSX spans; Acorn plus JSX would need an additional TypeScript strategy. Reusing browser Babel would couple Node analysis to a browser compilation bundle. A peer dependency is inappropriate for a shipped internal desktop capability. Preview-only unification adds no runtime dependency. diff --git a/apps/desktop/src/main/source-edit-engine.source-selection.test.ts b/apps/desktop/src/main/source-edit-engine.source-selection.test.ts index a0b29dcc..02bc1ad2 100644 --- a/apps/desktop/src/main/source-edit-engine.source-selection.test.ts +++ b/apps/desktop/src/main/source-edit-engine.source-selection.test.ts @@ -6,7 +6,7 @@ const source = `function App() { const dialog = React.useRef(null); const label = React.useMemo(() => String(count), [count]); React.useEffect(() => { document.title = label; }, [label]); - return

Welcome

+ return

Welcome

{[1, 2].map(n =>

Repeated

)}
Dialog
@@ -15,7 +15,7 @@ const source = `function App() { ReactDOM.createRoot(document.getElementById('root')).render();`; function inspect() { - const result = analyzeSourceEdit({ path: 'App.jsx', source, selectionMode: 'source' }); + const result = analyzeSourceEdit({ path: 'App.jsx', source }); if (result.status !== 'ready') throw new Error(result.message); return result; } @@ -26,7 +26,6 @@ function request(tag: string) { return { path: 'App.jsx', source, - selectionMode: 'source' as const, expectedSourceHash: result.sourceHash, targetId: target.id, scope: 'source-definition' as const, @@ -34,32 +33,69 @@ function request(tag: string) { }; } -describe('explicit source selection beside opaque execution', () => { - it('keeps preview inspection and preview apply closed', () => { - expect(analyzeSourceEdit({ path: 'App.jsx', source })).toMatchObject({ +describe('one preview-only source edit path', () => { + it('rejects legacy source mode for both inspection and patch planning', () => { + expect(analyzeSourceEdit({ path: 'App.jsx', source, selectionMode: 'source' })).toMatchObject({ status: 'rejected', - reason: 'unsafe-source', + reason: 'source-mode-removed', }); - expect(planSourceEdit({ ...request('h1'), selectionMode: 'preview' })).toMatchObject({ + expect(planSourceEdit({ ...request('h1'), selectionMode: 'source' })).toMatchObject({ status: 'rejected', - reason: 'unsafe-source', + reason: 'source-mode-removed', }); + expect( + planSourceEdit({ ...request('h1'), source: `${source}\n// stale`, selectionMode: 'source' }), + ).toMatchObject({ status: 'rejected', reason: 'source-mode-removed' }); + }); + it('treats omitted and explicit preview modes identically', () => { + expect(analyzeSourceEdit({ path: 'App.jsx', source, selectionMode: 'preview' })).toEqual( + inspect(), + ); + expect(planSourceEdit({ ...request('h1'), selectionMode: 'preview' })).toEqual( + planSourceEdit(request('h1')), + ); + }); + it('keeps existing text, attribute and style candidates beside effects and explicit mounting', () => { + expect(inspect().targets.find((t) => t.tagName === 'h1')?.editableFields).toEqual([ + { kind: 'set-text', value: 'Welcome' }, + { kind: 'set-attribute', name: 'title', value: 'Original' }, + { kind: 'set-style', property: 'gap', value: '2' }, + ]); + expect( + planSourceEdit({ + ...request('h1'), + operation: { kind: 'set-attribute', name: 'title', value: 'Changed' }, + }), + ).toMatchObject({ + status: 'applied', + content: source.replace('title="Original"', 'title={"Changed"}'), + }); + expect( + planSourceEdit({ + ...request('h1'), + operation: { kind: 'set-style', property: 'gap', value: '8' }, + }), + ).toMatchObject({ status: 'applied', content: source.replace('gap:2', 'gap:8') }); }); - it('replaces only the explicitly selected static literal without executing effects', () => { + it('replaces only the selected static literal without executing effects', () => { const result = planSourceEdit(request('h1')); expect(result.status).toBe('applied'); if (result.status !== 'applied') throw new Error(result.message); expect(result.content).toBe(source.replace('>Welcome<', '>{"Edited"}<')); expect(result.content).not.toContain('data-codesign-source-id'); }); - it('keeps dynamic and repeated targets unsupported', () => { - for (const tag of ['button', 'p']) - expect(planSourceEdit(request(tag))).toMatchObject({ - status: 'rejected', - reason: 'unsupported-field', - }); + it('refuses dynamic data but edits repeated literal definitions with shared scope', () => { + expect(planSourceEdit(request('button'))).toMatchObject({ + status: 'rejected', + reason: 'unsupported-field', + }); + expect(planSourceEdit(request('p'))).toMatchObject({ + status: 'applied', + scope: 'source-definition', + content: source.replace('>Repeated<', '>{"Edited"}<'), + }); }); - it('does not overwrite an external edit or accept a forged target', () => { + it('does not overwrite external edits or accept forged targets', () => { expect(planSourceEdit({ ...request('h1'), source: `${source}\n// external` })).toMatchObject({ status: 'rejected', reason: 'stale-source', @@ -77,17 +113,12 @@ describe('explicit source selection beside opaque execution', () => { }), ).toMatchObject({ status: 'rejected', reason: 'unsupported-field' }); expect( - analyzeSourceEdit({ - path: 'App.jsx', - source: `import x from "x";${source}`, - selectionMode: 'source', - }), + analyzeSourceEdit({ path: 'App.jsx', source: `import x from "x";${source}` }), ).toMatchObject({ status: 'rejected', reason: 'unsupported-module' }); expect( analyzeSourceEdit({ path: 'App.jsx', source: 'function App(){return flag ?

A

:

B

}', - selectionMode: 'source', }), ).toMatchObject({ status: 'rejected', reason: 'unsupported-entry' }); }); diff --git a/apps/desktop/src/main/source-edit-engine.test.ts b/apps/desktop/src/main/source-edit-engine.test.ts index 447db7b8..71c1ce75 100644 --- a/apps/desktop/src/main/source-edit-engine.test.ts +++ b/apps/desktop/src/main/source-edit-engine.test.ts @@ -203,6 +203,35 @@ describe('source-definition span editing support matrix', () => { }); }); +function expectLiteralCandidates(source: string, path = 'App.jsx') { + const withLiterals = source.replace(' { ])('rejects TSX module shapes too: %s', (source) => { expectRejectedByInspectAndPlan(source, 'unsupported-module', 'App.tsx'); }); - it('rejects an effect mutating through self without relying on an append blacklist', () => { + it('keeps direct literal candidates beside effects for independent preview verification', () => { const source = `function App(){React.useEffect(()=>{self['document'].getElementsByTagName('header')[0].append(' injected')},[]);return
Hello
}`; - expectRejectedByInspectAndPlan(source, 'unsafe-source'); + expectLiteralCandidates(source); }); it.each([ 'React.useEffect(()=>{},[])', @@ -298,11 +327,8 @@ describe('public review regressions: script entries and effects', () => { "const R=React; R['use'+'Effect'](()=>{},[])", `React['useEffect'](()=>{},[])`, `React['use'+'Effect'](()=>{},[])`, - ])('fails closed on non-state or reflected hook execution: %s', (effect) => { - expectRejectedByInspectAndPlan( - `function App(){${effect};return
Hello
}`, - 'unsafe-source', - ); + ])('keeps direct literal candidates beside non-state or reflected hooks: %s', (effect) => { + expectLiteralCandidates(`function App(){${effect};return
Hello
}`); }); it.each([ 'self', @@ -311,24 +337,20 @@ describe('public review regressions: script entries and effects', () => { 'top', 'parent', 'this', - ])('rejects the %s global/reflection entry independently of hooks', (entry) => { - expectRejectedByInspectAndPlan( + ])('keeps direct literal candidates beside the %s global/reflection entry', (entry) => { + expectLiteralCandidates( `function App(){${entry}['document'].getElementsByTagName('header')[0].append(' injected');return
Hello
}`, - 'unsafe-source', ); }); }); -describe('public review regressions: scheduling is outside the source boundary', () => { - it('rejects string-evaluating timers without executing the candidate', () => { +describe('preview candidates do not execute or globally reject scheduling code', () => { + it('exposes existing literal fields without executing string timers', () => { const source = `function App(){setTimeout("document.querySelector('header').textContent='injected'",0);return
Hello
}`; - expectRejectedByInspectAndPlan(source, 'unsafe-source'); + expectLiteralCandidates(source); }); - it('also rejects callback timers instead of inspecting only string arguments', () => { - expectRejectedByInspectAndPlan( - 'function App(){setTimeout(()=>{},0);return
Hello
}', - 'unsafe-source', - ); + it('keeps direct literal candidates for callback timers as well', () => { + expectLiteralCandidates('function App(){setTimeout(()=>{},0);return
Hello
}'); }); it.each([ 'setTimeout', @@ -346,10 +368,9 @@ describe('public review regressions: scheduling is outside the source boundary', 'postMessage', 'MessageChannel', 'MessagePort', - ])('rejects scheduling global %s even through a captured alias', (reference) => { - expectRejectedByInspectAndPlan( + ])('keeps direct literal candidates for scheduling global %s through aliases', (reference) => { + expectLiteralCandidates( `function App(){const schedule=${reference};return
Hello
}`, - 'unsafe-source', ); }); }); @@ -394,21 +415,21 @@ describe('conservative rejection and independent revalidation', () => { 'function App(){return
A
} ReactDOM.createRoot(root).render();', 'unsafe-source', ], - ])('rejects unsupported ownership: %s', (source, reason) => { - expect(analyzeSourceEdit({ path: 'App.jsx', source })).toMatchObject({ - status: 'rejected', - reason, - }); + ])('separates structural refusal from globally opaque execution: %s', (source, reason) => { + if (reason === 'unsafe-source' || reason === 'reused-entry') { + expectLiteralCandidates(source); + } else + expect(analyzeSourceEdit({ path: 'App.jsx', source })).toMatchObject({ + status: 'rejected', + reason, + }); }); it.each([ 'function useState(){return [0,evil]} function App(){const [count,setCount]=useState(0);return
setCount(0)}>Static
}', 'function App(){const [count,setCount]=React.useState(0);return
{items.map(setCount=>)}
}', 'function App(){const [count,setCount]=React.useState(0);return
setCount(count)}>Static
}', - ])('rejects shadowed hook/setter/value bindings: %s', (source) => { - expect(analyzeSourceEdit({ path: 'App.jsx', source })).toMatchObject({ - status: 'rejected', - reason: 'unsafe-source', - }); + ])('does not let opaque hook/setter/value bindings hide direct literal candidates: %s', (source) => { + expectLiteralCandidates(source); }); it('rejects stale hashes before locating targets', () => { const inspected = inspect(simple); @@ -465,11 +486,18 @@ describe('conservative rejection and independent revalidation', () => { expect(targets.map((target) => target.tagName)).toEqual(['my-widget', 'script', 'style']); expect(targets.every((target) => target.editableFields.length === 0)).toBe(true); }); - it('rejects shared definitions even if currently rendered once', () => { + it('edits a shared text definition without asserting instance uniqueness', () => { const source = 'function Card(){return
Shared
} function App(){return }'; - expect(inspect(source).targets[0]?.editableFields).toEqual([]); + expect(inspect(source).targets[0]?.editableFields).toEqual([ + { kind: 'set-text', value: 'Shared' }, + ]); expect(apply(source, { kind: 'set-text', value: 'x' })).toMatchObject({ + status: 'applied', + scope: 'source-definition', + content: source.replace('>Shared<', '>{"x"}<'), + }); + expect(apply(source, { kind: 'set-attribute', name: 'title', value: 'x' })).toMatchObject({ status: 'rejected', reason: 'unsupported-field', }); @@ -479,7 +507,7 @@ describe('conservative rejection and independent revalidation', () => { '
Hello child
', '
{`template`}
', '
{"A"}{"B"}
', - ])('rejects non-sole-static text %s', (jsx) => { + ])('rejects unresolved text or a missing mixed-segment ID: %s', (jsx) => { expect(apply(`function App(){return ${jsx}}`, { kind: 'set-text', value: 'x' })).toMatchObject({ status: 'rejected', reason: 'unsupported-field', diff --git a/apps/desktop/src/main/source-edit-engine.text-coverage.test.ts b/apps/desktop/src/main/source-edit-engine.text-coverage.test.ts new file mode 100644 index 00000000..ebd8be42 --- /dev/null +++ b/apps/desktop/src/main/source-edit-engine.text-coverage.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest'; +import { analyzeSourceEdit, planSourceEdit } from './source-edit-engine'; + +// Reduced from local generated resume/product/deck structures; no private originals. +const generated = `const resume = {name:'Alex', role:'Design engineer', jobs:[{title:'Engineer'},{title:'Designer'}]}; +const label = 'Download'; +function Badge(){return Shared badge} +function Icon(){return } +function App(){ + React.useEffect(()=>{document.title='Resume'},[]); + return

{resume.name}

{resume.role}

+ +

Profile
and craft.

+
{resume.jobs.map(job=>

{job.title}

)}
+
{resume.name}
Alex +
; +} +ReactDOM.createRoot(document.getElementById('root')).render();`; + +function inspect(source: string) { + const result = analyzeSourceEdit({ path: 'App.jsx', source }); + if (result.status !== 'ready') throw new Error(result.message); + return result; +} +function change(source: string, tag: string, value: string, segment = 0) { + const result = inspect(source); + const target = result.targets.find((t) => t.tagName === tag); + const operation = target?.editableFields.filter((f) => f.kind === 'set-text')[segment]; + if (!target || !operation) throw new Error(`No text for ${tag}`); + return planSourceEdit({ + path: 'App.jsx', + source, + expectedSourceHash: result.sourceHash, + targetId: target.id, + scope: 'source-definition', + operation: { ...operation, value }, + }); +} + +describe('generated page text-definition coverage', () => { + it('maps an object field to its literal, never replacing equal text elsewhere', () => { + const result = change(generated, 'h1', 'Morgan'); + expect(result.status).toBe('applied'); + if (result.status !== 'applied') throw new Error(result.message); + expect(result.patch.expectedText).toBe("'Alex'"); + expect(result.content).toBe(generated.replace("name:'Alex'", 'name:"Morgan"')); + const after = inspect(result.content); + for (const tag of ['h1', 'footer']) + expect(after.targets.find((t) => t.tagName === tag)?.editableFields[0]?.value).toBe('Morgan'); + expect(after.targets.find((t) => t.tagName === 'span')?.editableFields[0]?.value).toBe('Alex'); + expect(after.targets.find((t) => t.tagName === 'h1')?.textSources?.[0]?.origin).toContain( + 'resume', + ); + }); + it('traces a fixed array record independently of map callback parameters', () => { + const result = change(generated, 'aside', 'Architect'); + expect(result.status).toBe('applied'); + if (result.status !== 'applied') throw new Error(result.message); + expect(result.content).toBe(generated.replace("title:'Designer'", 'title:"Architect"')); + const mapped = inspect(generated).targets.find((t) => t.tagName === 'h3'); + expect(mapped?.editableFields).toEqual([]); + expect(mapped?.unsupported).toEqual( + expect.arrayContaining([expect.objectContaining({ reason: 'unresolved-text-source' })]), + ); + }); + it('edits only one mixed child and preserves icons, elements and sibling segments', () => { + const button = change(generated, 'button', 'Export'); + expect(button.status).toBe('applied'); + if (button.status !== 'applied') throw new Error(button.message); + expect(button.content).toBe( + generated.replace("const label = 'Download'", 'const label = "Export"'), + ); + const result = change(generated, 'p', 'plus ', 1); + expect(result.status).toBe('applied'); + if (result.status !== 'applied') throw new Error(result.message); + expect(result.content).toBe(generated.replace('and ', '{"plus "}')); + expect(inspect(result.content).targets.find((t) => t.tagName === 'p')?.directText).toBe( + 'Profile plus .', + ); + }); + it('makes shared component literals explicit source definitions', () => { + const result = change(generated, 'small', 'Shared replacement'); + expect(result.status).toBe('applied'); + if (result.status !== 'applied') throw new Error(result.message); + expect(result.content).toBe(generated.replace('>Shared badge<', '>{"Shared replacement"}<')); + const target = inspect(generated).targets.find((t) => t.tagName === 'small'); + expect(target?.scope).toBe('source-definition'); + expect(target?.textSources?.[0]?.origin).toContain('all uses'); + expect(target?.editableFields.map((f) => f.kind)).toEqual(['set-text']); + }); + it('does not authorize a forged text segment or instance scope', () => { + const before = inspect(generated); + const target = before.targets.find((t) => t.tagName === 'p'); + if (!target) throw new Error('No paragraph'); + const request = { + path: 'App.jsx', + source: generated, + expectedSourceHash: before.sourceHash, + targetId: target.id, + scope: 'source-definition' as const, + operation: { kind: 'set-text' as const, value: 'bad', textId: '0:1' }, + }; + expect(planSourceEdit(request)).toMatchObject({ + status: 'rejected', + reason: 'unsupported-field', + }); + expect( + planSourceEdit({ ...request, operation: { kind: 'set-text', value: 'bad' } }), + ).toMatchObject({ status: 'rejected', reason: 'unsupported-field' }); + expect(planSourceEdit({ ...request, source: `${generated}\n// external` })).toMatchObject({ + status: 'rejected', + reason: 'stale-source', + }); + }); + it.each([ + 'name.toUpperCase()', + // biome-ignore lint/suspicious/noTemplateCurlyInString: this is generated JSX source under test + '`Hello ${name}`', + 'flag ? name : "Other"', + 'items[index]', + 'Date.now()', + ])('refuses computed text %s without running code', (expression) => { + const source = `const name='Alex'; const items=['one']; function App(){return

{${expression}}

}`; + const target = inspect(source).targets[0]; + expect(target?.editableFields).toEqual([]); + expect(target?.unsupported.some((p) => p.reason === 'unresolved-text-source')).toBe(true); + }); + it('refuses component props and transformed/forwarded children instead of guessing an instance', () => { + const source = `function Label({text}){return

{text}

} function App(){return
}`; + for (const tag of ['h1', 'b']) + expect(inspect(source).targets.find((t) => t.tagName === tag)?.editableFields).toEqual([]); + }); + it('keeps a Cart static segment candidate beside an unresolved count', () => { + const source = 'function App(){return }'; + const target = inspect(source).targets[0]; + expect(target?.directText).toBeUndefined(); + expect(target?.editableFields).toEqual([ + expect.objectContaining({ kind: 'set-text', value: 'Cart ', textId: expect.any(String) }), + ]); + const operation = target?.editableFields[0]; + if (operation?.kind !== 'set-text') throw new Error('Missing cart text'); + expect(target?.textLayout).toEqual([ + { kind: 'text', value: 'Cart ', textId: operation.textId }, + { kind: 'dynamic' }, + ]); + expect(change(source, 'button', 'Basket ')).toMatchObject({ + status: 'applied', + content: source.replace('Cart ', '{"Basket "}'), + }); + }); + it('describes native boundaries, opaque children, comments and actual literal origins in order', () => { + const source = `const label='Download'; function App(){return

\n {/* comment */}\n {label}<>{'fragment'}{count} tail\n

}`; + const result = inspect(source); + const target = result.targets.find((t) => t.tagName === 'p'); + const svg = result.targets.find((t) => t.tagName === 'svg'); + if (!target || !svg) throw new Error('Missing layout targets'); + const text = target.editableFields.filter((f) => f.kind === 'set-text'); + expect(text.map((f) => f.value)).toEqual(['Download', ' tail']); + expect(target.textLayout).toEqual([ + { kind: 'element', targetId: svg.id }, + { kind: 'dynamic' }, + { kind: 'text', textId: text[0]?.textId, value: 'Download' }, + { kind: 'dynamic' }, + { kind: 'dynamic' }, + { kind: 'text', textId: text[1]?.textId, value: ' tail' }, + ]); + const origin = target.textSources?.[0]; + expect(source.slice(origin?.start, origin?.end)).toBe("'Download'"); + const [childStart, childEnd] = text[0]?.textId?.split(':').map(Number) ?? []; + expect(source.slice(childStart, childEnd)).toBe('{label}'); + }); + it('preserves optional IDs for legacy single literal text', () => { + const target = inspect('function App(){return

Title

}').targets[0]; + expect(target?.editableFields).toEqual([{ kind: 'set-text', value: 'Title' }]); + expect(target?.textLayout).toEqual([{ kind: 'text', value: 'Title' }]); + }); + it('turns oversized text into dynamic metadata instead of an invalid text descriptor', () => { + const result = inspect(`function App(){return

${'x'.repeat(100_001)}

}`); + expect(result.targets[0]?.editableFields).toEqual([]); + expect(result.targets[0]?.textSources).toEqual([]); + expect(result.targets[0]?.textLayout).toEqual([{ kind: 'dynamic' }]); + expect(result.targets[0]?.unsupported).toContainEqual( + expect.objectContaining({ reason: 'literal-too-large' }), + ); + }); + it('bounds native targets and flat text-layout descriptors', () => { + for (const children of [''.repeat(10_000), '{"x"}'.repeat(10_001)]) { + expect( + analyzeSourceEdit({ path: 'App.jsx', source: `function App(){return

${children}

}` }), + ).toMatchObject({ status: 'rejected', reason: 'unsupported-targets' }); + } + }); + it.each([ + `(data.title as string) = 'A'`, + `data.title! = 'A'`, + `[].__proto__.map = function(){data.title='A';return []}; data.rows.map(row=>row.title)`, + `[].__proto__.map = function(){this[0].title='A';return []}; data.rows.map(row=>row.title)`, + `[].constructor.prototype.map = function(){this[0].title='A';return []}; data.rows.map(row=>row.title)`, + ])('refuses overwritten TSX/data initializers even when displayed text initially matches: %s', (mutation) => { + const source = `const data={title:'A',rows:[{title:'A'}]}; ${mutation}; function App(){return <>

{data.title}

{data.rows[0].title}

}`; + const result = analyzeSourceEdit({ path: 'App.tsx', source }); + if (result.status !== 'ready') throw new Error(result.message); + for (const target of result.targets) { + expect(target.editableFields).toEqual([]); + expect( + planSourceEdit({ + path: 'App.tsx', + source, + expectedSourceHash: result.sourceHash, + targetId: target.id, + scope: 'source-definition', + operation: { kind: 'set-text', textId: target.textSources?.[0]?.textId, value: 'B' }, + }), + ).toMatchObject({ status: 'rejected', reason: 'unsupported-field' }); + } + }); + it('keeps existing attribute and style candidates beside opaque effects', () => { + const source = `function App(){React.useEffect(()=>{},[]);return

Text

}`; + expect(inspect(source).targets[0]?.editableFields).toEqual([ + { kind: 'set-text', value: 'Text' }, + { kind: 'set-attribute', name: 'title', value: 'Title' }, + { kind: 'set-style', property: 'gap', value: '2' }, + ]); + }); +}); diff --git a/apps/desktop/src/main/source-edit-engine.ts b/apps/desktop/src/main/source-edit-engine.ts index e3413eec..52ffb753 100644 --- a/apps/desktop/src/main/source-edit-engine.ts +++ b/apps/desktop/src/main/source-edit-engine.ts @@ -13,46 +13,15 @@ import { type SourceEditTarget, type SourceEditUnsupported, } from '@open-codesign/shared'; +import { + type AstNode, + children, + createTextResolver, + nameOf, + node, + walk, +} from './source-edit-provenance'; -// A structural view keeps parser implementation types out of the wire contract. -interface AstNode { - type: string; - start: number; - end: number; - name?: unknown; - value?: unknown; - openingElement?: unknown; - expression?: unknown; - children?: unknown; - attributes?: unknown; - computed?: unknown; - shorthand?: unknown; - properties?: unknown; - key?: unknown; - program?: unknown; - body?: unknown; - declaration?: unknown; - id?: unknown; - kind?: unknown; - declarations?: unknown; - init?: unknown; - argument?: unknown; - params?: unknown; - async?: unknown; - generator?: unknown; - property?: unknown; - left?: unknown; - selfClosing?: unknown; - object?: unknown; - callee?: unknown; - arguments?: unknown; - elements?: unknown; - operator?: unknown; - right?: unknown; - test?: unknown; - consequent?: unknown; - alternate?: unknown; -} interface FieldSpan { operation: SourceEditOperation; start: number; @@ -68,47 +37,6 @@ interface Analysis { targets: LocatedTarget[]; } -function node(value: unknown): AstNode | undefined { - if (typeof value !== 'object' || value === null) return undefined; - const candidate = value as Record; - return typeof candidate['type'] === 'string' && - typeof candidate['start'] === 'number' && - typeof candidate['end'] === 'number' - ? (value as AstNode) - : undefined; -} -function children(value: unknown): AstNode[] { - if (!Array.isArray(value)) return []; - return value.flatMap((item: unknown) => { - const child = node(item); - return child ? [child] : []; - }); -} -function walk(root: AstNode, visit: (current: AstNode) => void): void { - visit(root); - for (const [key, value] of Object.entries(root)) { - if ( - key === 'loc' || - key === 'extra' || - key.endsWith('Comments') || - key === 'comments' || - key === 'tokens' - ) - continue; - const child = node(value); - if (child) walk(child, visit); - else for (const item of children(value)) walk(item, visit); - } -} -function nameOf(value: unknown): string | undefined { - const current = node(value); - if (current?.type === 'Identifier' || current?.type === 'JSXIdentifier') { - return typeof current.name === 'string' ? current.name : undefined; - } - if (current?.type === 'StringLiteral') - return typeof current.value === 'string' ? current.value : undefined; - return undefined; -} function reject(reason: string, message: string): SourceEditRejectedV1 { return { schemaVersion: 1, status: 'rejected', reason, message }; } @@ -199,7 +127,7 @@ function openingProblem(opening: AstNode): SourceEditUnsupported | undefined { 'reserved-provenance', 'Reserved preview provenance attributes cannot be authored or edited.', ); - if (name === 'children') + if (name === 'children' || name === 'dangerouslySetInnerHTML') return problem( 'target', 'children-prop', @@ -215,9 +143,15 @@ function openingProblem(opening: AstNode): SourceEditUnsupported | undefined { return undefined; } -function fieldsFor(element: AstNode): { +function fieldsFor( + element: AstNode, + resolveText: ReturnType, +): { fields: FieldSpan[]; unsupported: SourceEditUnsupported[]; + directText?: string; + textSources: NonNullable; + textLayout: NonNullable; } { const fields: FieldSpan[] = []; const unsupported: SourceEditUnsupported[] = []; @@ -228,26 +162,65 @@ function fieldsFor(element: AstNode): { node(child.expression)?.type === 'JSXEmptyExpression' ); }); - const only = meaningful.length === 1 ? meaningful[0] : undefined; - const text = - only?.type === 'JSXText' - ? jsxText(String(only.value)) - : only?.type === 'JSXExpressionContainer' - ? staticString(node(only.expression)) - : undefined; - if (only && text !== undefined) { + const textSources: NonNullable = []; + const textLayout: NonNullable = []; + let directText: string | undefined = ''; + for (const child of meaningful) { + if (child.type === 'JSXElement' || child.type === 'JSXFragment') { + textLayout.push( + child.type === 'JSXElement' && isHost(child) + ? { kind: 'element', targetId: `${child.start}:${child.end}` } + : { kind: 'dynamic' }, + ); + continue; + } + const expression = child.type === 'JSXExpressionContainer' ? node(child.expression) : undefined; + const resolved = expression ? resolveText(expression) : undefined; + const text = child.type === 'JSXText' ? jsxText(String(child.value)) : resolved?.value; + if (text === undefined) { + textLayout.push({ kind: 'dynamic' }); + directText = undefined; + unsupported.push( + problem( + 'text', + 'unresolved-text-source', + 'This text is computed, mutable, or supplied by a map/component parameter. Its literal source cannot be determined; edit the source manually.', + ), + ); + continue; + } + if (directText !== undefined) directText += text; + const indirect = expression?.type !== 'StringLiteral' && child.type !== 'JSXText'; + const textId = meaningful.length === 1 && !indirect ? undefined : `${child.start}:${child.end}`; + const start = indirect && resolved ? resolved.start : child.start; + const end = indirect && resolved ? resolved.end : child.end; + textLayout.push( + text.length <= 100_000 + ? { kind: 'text', value: text, ...(textId ? { textId } : {}) } + : { kind: 'dynamic' }, + ); fields.push({ - operation: { kind: 'set-text', value: text }, - start: only.start, - end: only.end, - expressionContainer: true, + operation: { kind: 'set-text', value: text, ...(textId ? { textId } : {}) }, + start, + end, + expressionContainer: !indirect, + }); + textSources.push({ + ...(textId ? { textId } : {}), + start, + end, + origin: + indirect && resolved + ? resolved.origin + : 'JSX text definition (all uses of this definition)', }); - } else + } + if (!fields.length && !unsupported.length) unsupported.push( problem( 'text', 'non-static-text', - 'Text requires one direct static text child or string expression.', + 'This element has no directly owned text. Select its text-bearing child.', ), ); @@ -375,135 +348,17 @@ function fieldsFor(element: AstNode): { ); return false; }); - return { fields: boundedFields, unsupported }; -} - -function stateBindings(body: AstNode | undefined): { - setters: Set; - values: Set; - definitions: Set; -} { - const setters = new Set(); - const values = new Set(); - const definitions = new Set(); - for (const statement of children(body?.body)) { - if (statement.type !== 'VariableDeclaration' || statement.kind !== 'const') continue; - for (const binding of children(statement.declarations)) { - const id = node(binding.id); - const init = node(binding.init); - const callee = node(init?.callee); - const isUseState = - nameOf(callee) === 'useState' || - (callee?.type === 'MemberExpression' && - callee.computed !== true && - nameOf(callee.object) === 'React' && - nameOf(callee.property) === 'useState'); - if ( - id?.type !== 'ArrayPattern' || - init?.type !== 'CallExpression' || - !isUseState || - !Array.isArray(id.elements) - ) - continue; - const setter = nameOf(id.elements[1]); - const value = nameOf(id.elements[0]); - if (setter) { - setters.add(setter); - const declaration = node(id.elements[1]); - if (declaration) definitions.add(declaration.start); - } - if (value) values.add(value); - } - } - return { setters, values, definitions }; -} - -function pureStateValue(expression: AstNode | undefined, values: Set): boolean { - if (!expression) return false; - if ( - ['StringLiteral', 'NumericLiteral', 'BooleanLiteral', 'NullLiteral'].includes(expression.type) - ) - return true; - if (expression.type === 'Identifier') return values.has(nameOf(expression) ?? ''); - if (expression.type === 'UnaryExpression') - return ( - ['!', '+', '-', '~', 'typeof'].includes(String(expression.operator)) && - pureStateValue(node(expression.argument), values) - ); - if (expression.type === 'BinaryExpression' || expression.type === 'LogicalExpression') - return ( - pureStateValue(node(expression.left), values) && - pureStateValue(node(expression.right), values) - ); - if (expression.type === 'ConditionalExpression') - return ( - pureStateValue(node(expression.test), values) && - pureStateValue(node(expression.consequent), values) && - pureStateValue(node(expression.alternate), values) - ); - if ( - expression.type === 'ArrowFunctionExpression' && - expression.async !== true && - children(expression.params).every((param) => param.type === 'Identifier') - ) { - const locals = new Set(values); - for (const param of children(expression.params)) locals.add(nameOf(param) ?? ''); - const body = node(expression.body); - const statements = children(body?.body); - const returned = - body?.type === 'BlockStatement' && - statements.length === 1 && - statements[0]?.type === 'ReturnStatement' - ? node(statements[0].argument) - : body; - return pureStateValue(returned, locals); - } - return false; -} - -function isStateHandler(attribute: AstNode, bindings: ReturnType): boolean { - const value = node(attribute.value); - const handler = value?.type === 'JSXExpressionContainer' ? node(value.expression) : undefined; - if ( - !handler || - !['ArrowFunctionExpression', 'FunctionExpression'].includes(handler.type) || - handler.async === true || - handler.generator === true - ) - return false; - const params = children(handler.params); - if ( - params.some( - (param) => - param.type !== 'Identifier' || - bindings.setters.has(nameOf(param) ?? '') || - bindings.values.has(nameOf(param) ?? ''), - ) - ) - return false; - const body = node(handler.body); - const expressions = - body?.type === 'BlockStatement' - ? children(body.body).map((statement) => - statement.type === 'ExpressionStatement' - ? node(statement.expression) - : statement.type === 'ReturnStatement' - ? node(statement.argument) - : undefined, - ) - : [body]; - return ( - expressions.length > 0 && - expressions.every((expression) => { - if ( - expression?.type !== 'CallExpression' || - !bindings.setters.has(nameOf(expression.callee) ?? '') - ) - return false; - const args = children(expression.arguments); - return args.length === 1 && pureStateValue(args[0], bindings.values); - }) - ); + return { + fields: boundedFields, + unsupported, + textSources: textSources.filter((origin) => + boundedFields.some( + (field) => field.operation.kind === 'set-text' && field.operation.textId === origin.textId, + ), + ), + textLayout, + ...(directText !== undefined ? { directText } : {}), + }; } function analyze( @@ -511,6 +366,11 @@ function analyze( source: string, selectionMode: SourceEditSelectionMode = 'preview', ): Analysis | SourceEditRejectedV1 { + if (selectionMode === 'source') + return reject( + 'source-mode-removed', + 'Source-list editing has been removed. Select the element in the current preview.', + ); if (!/\.(jsx|tsx)$/i.test(path)) return reject('unsupported-path', 'Source editing requires a real JSX or TSX file.'); let ast: AstNode; @@ -600,201 +460,41 @@ function analyze( 'App/_App must directly return a static JSX structure without props forwarding or conditional entry returns.', ); } - const bindings = stateBindings(body); - const directStateReactReferences = new Set(); + // Candidates identify source definitions. The preview independently checks + // the selected DOM instance; opaque execution elsewhere does not remove fields. + const resolveText = createTextResolver(ast); + const parents = new Map(); walk(ast, (current) => { - if ( - current.type === 'MemberExpression' && - current.computed !== true && - nameOf(current.object) === 'React' && - nameOf(current.property) === 'useState' - ) { - const object = node(current.object); - if (object) directStateReactReferences.add(object.start); + for (const value of Object.values(current)) { + const child = node(value); + if (child) parents.set(child, current); + else for (const item of children(value)) parents.set(item, current); } }); - let unsafe: SourceEditRejectedV1 | undefined; - function flag(reason: string, message: string): void { - if ( - !unsafe || - reason === 'unsafe-source' || - (reason === 'cross-file-source' && unsafe.reason !== 'unsafe-source') - ) - unsafe = reject(reason, message); - } - walk(ast, (current) => { - const declarations = - current.type === 'VariableDeclarator' - ? [node(current.id)] - : current.type === 'FunctionDeclaration' || - current.type === 'FunctionExpression' || - current.type === 'ArrowFunctionExpression' - ? [node(current.id), ...children(current.params)] - : []; - for (const declaration of declarations) { - if (!declaration) continue; - walk(declaration, (binding) => { - const name = nameOf(binding); - if ( - binding.type === 'Identifier' && - (name === 'React' || - name === 'useState' || - (bindings.setters.has(name ?? '') && !bindings.definitions.has(binding.start))) - ) - flag( - 'unsafe-source', - 'Shadowed React hooks or state setters have ambiguous execution ownership.', + function textBoundary(current: AstNode): SourceEditUnsupported | undefined { + let ancestor: AstNode | undefined = current; + const visited = new Set(); + while (ancestor && !visited.has(ancestor)) { + visited.add(ancestor); + if (ancestor.type === 'JSXElement') { + const opening = node(ancestor.openingElement); + if (!isHost(ancestor)) + return problem( + 'target', + 'custom-component-ancestor', + 'Forwarded children may be transformed by a component. Select a directly owned text definition instead.', ); - }); + if (opening) { + const issue = openingProblem(opening); + if (issue) return issue; + } + } + ancestor = parents.get(ancestor); } - if ( - current.type === 'ImportDeclaration' || - current.type === 'ImportExpression' || - current.type === 'Import' - ) - flag( - 'cross-file-source', - 'Imported execution and cross-file ownership are outside this source-definition MVP.', - ); - if ( - (current.type === 'Identifier' || current.type === 'JSXIdentifier') && - current.name === entry.id.name && - current.start !== entry.id.start - ) - flag( - 'reused-entry', - 'A referenced or reused entry cannot establish the direct auto-mounted source boundary.', - ); - if ( - current.type === 'Identifier' && - current.name === 'React' && - !directStateReactReferences.has(current.start) - ) - flag( - 'unsafe-source', - 'Only direct React.useState access is supported; aliased React execution cannot establish the state-only hook boundary.', - ); - const referencedHook = - current.type === 'Identifier' - ? nameOf(current) - : current.type === 'MemberExpression' || current.type === 'OptionalMemberExpression' - ? nameOf(current.property) - : undefined; - if (referencedHook && /^use[A-Z]/.test(referencedHook) && referencedHook !== 'useState') - flag( - 'unsafe-source', - 'Only directly owned useState hooks are supported; effect and other hook execution is outside the static editing boundary.', - ); - if ( - (current.type === 'MemberExpression' || current.type === 'OptionalMemberExpression') && - nameOf(current.object) === 'React' && - current.computed === true - ) - flag( - 'unsafe-source', - 'Dynamic or reflective React access cannot establish the supported state-only hook boundary.', - ); - if (current.type === 'ThisExpression' || current.type === 'MetaProperty') - flag( - 'unsafe-source', - 'Implicit global or module reflection is outside the static editing boundary.', - ); - if ( - current.type === 'Identifier' && - [ - 'document', - 'window', - 'globalThis', - 'self', - 'global', - 'top', - 'parent', - 'frames', - 'ReactDOM', - 'eval', - 'Function', - 'Reflect', - 'Proxy', - 'setTimeout', - 'setInterval', - 'setImmediate', - 'clearTimeout', - 'clearInterval', - 'clearImmediate', - 'queueMicrotask', - 'requestAnimationFrame', - 'cancelAnimationFrame', - 'requestIdleCallback', - 'cancelIdleCallback', - 'scheduler', - 'postMessage', - 'MessageChannel', - 'MessagePort', - 'jQuery', - '$', - ].includes(String(current.name)) - ) - flag( - 'unsafe-source', - 'Imperative DOM/global execution is outside the supported ownership boundary.', - ); - if ( - (current.type === 'MemberExpression' || current.type === 'OptionalMemberExpression') && - [ - 'innerHTML', - 'outerHTML', - 'textContent', - 'insertAdjacentHTML', - 'appendChild', - 'replaceChildren', - 'createElement', - 'cloneElement', - 'createPortal', - 'getElementById', - 'querySelector', - 'querySelectorAll', - 'setAttribute', - 'removeAttribute', - 'setProperty', - 'constructor', - '__proto__', - 'defineProperty', - 'defineProperties', - 'setPrototypeOf', - 'assign', - ].includes(nameOf(current.property) ?? '') - ) - flag('unsafe-source', 'Imperative element creation or mutation obscures source ownership.'); - if ( - current.type === 'JSXAttribute' && - ['ref', 'dangerouslySetInnerHTML'].includes(nameOf(current.name) ?? '') - ) - flag('unsafe-source', 'Refs and raw HTML may imperatively mutate the rendered definition.'); - if ( - current.type === 'JSXAttribute' && - /^on/i.test(nameOf(current.name) ?? '') && - !isStateHandler(current, bindings) - ) - flag( - 'unsafe-source', - 'Only inline handlers calling directly owned state setters with pure values are supported.', - ); - if (current.type === 'UnaryExpression' && current.operator === 'delete') - flag('unsafe-source', 'Imperative deletion is outside the static source ownership boundary.'); - if ( - (current.type === 'AssignmentExpression' && - node(current.left)?.type === 'MemberExpression') || - (current.type === 'UpdateExpression' && node(current.argument)?.type === 'MemberExpression') - ) - flag('unsafe-source', 'Member mutation is outside the static source ownership boundary.'); - }); - // A source-list selection names an AST definition, not a live DOM node. - // Opaque execution prevents preview ownership claims but cannot change the - // exact literal span selected in the current, hash-checked source. - if (unsafe && selectionMode !== 'source') return unsafe; + return undefined; + } const allowed = new Set(); - const excluded = new Map(); function mark(current: AstNode, inherited?: SourceEditUnsupported): void { if (current.type === 'JSXFragment') { for (const child of children(current.children)) @@ -814,8 +514,7 @@ function analyze( : opening ? openingProblem(opening) : undefined); - if (own) excluded.set(current.start, own); - else allowed.add(current.start); + if (!own) allowed.add(current.start); for (const child of children(current.children)) if (child.type === 'JSXElement' || child.type === 'JSXFragment') mark(child, own); } @@ -826,19 +525,29 @@ function analyze( const opening = node(current.openingElement); const tagName = nameOf(opening?.name); if (!opening || !tagName) return; - const details = allowed.has(current.start) - ? fieldsFor(current) - : { - fields: [], - unsupported: [ - excluded.get(current.start) ?? - problem( - 'target', - 'non-direct-source', - 'Only direct static App/_App host structure is editable; callbacks, maps, expressions and reused definitions are not.', - ), - ], - }; + const boundary = textBoundary(current); + const details = fieldsFor(current, resolveText); + const layoutAllowed = allowed.has(current.start); + if (boundary) { + details.fields = []; + details.textSources = []; + details.textLayout = details.textLayout.map((part) => + part.kind === 'text' ? { kind: 'dynamic' } : part, + ); + details.unsupported = [boundary]; + } else { + details.fields = details.fields.filter( + (field) => field.operation.kind === 'set-text' || layoutAllowed, + ); + if (!layoutAllowed) + details.unsupported.push( + problem( + 'target', + 'text-definition-only', + 'Only traced text definitions are editable here. Layout/attribute ownership cannot be established. Shared definitions change every use, not just the clicked instance.', + ), + ); + } targets.push({ target: { id: `${current.start}:${current.end}`, @@ -848,28 +557,31 @@ function analyze( insertionOffset: opening.end - (opening.selfClosing === true ? 2 : 1), scope: 'source-definition', editableFields: details.fields.map((field) => field.operation), + ...(details.directText !== undefined ? { directText: details.directText } : {}), + textSources: details.textSources, + textLayout: details.textLayout, unsupported: details.unsupported, }, fields: details.fields, }); }); + if ( + targets.length > 10_000 || + targets.some(({ target }) => (target.textLayout?.length ?? 0) > 10_000) + ) + return reject( + 'unsupported-targets', + 'This source exceeds the supported preview target or text-layout limit.', + ); return { sourceHash: hash(source), targets }; } /** - * MVP support matrix (always source-definition scope, never instance uniqueness): - * - One auto-mounted App/_App; direct host/fragment structure only. - * - One static JSXText/string-expression child; existing static title/placeholder/alt. - * - Existing string/number properties in a directly owned inline style object. - * - Dynamic/map/custom-component descendants remain annotated, but are not editable. - * - Pure inline state-setter handlers are supported; opaque/imperative handlers are not. - * - Imports/exports, non-state hooks, global/DOM/ref mutation, reused entries, - * computed/shared styles and executable/customized hosts are unsupported. - * Explicit source-list mode retains the structural/field constraints but makes - * no live ownership claim, so opaque execution does not reject the whole file. - * Source-list results must never be used to instrument a selectable preview. - * Dynamic children alone do not invalidate a static parent's own layout fields. - * Scheduling/global rejection bounds this MVP; it is not a general JavaScript safety proof. + * Candidates identify exact source definitions, never private DOM instances. + * Indirect fields need lexical literal provenance; ordered child layouts let the + * preview verify individual static segments beside dynamic content. Existing + * attribute/style literals retain direct native App ownership. No source executes + * here, and legacy source-list mode is refused rather than providing a bypass. */ export function analyzeSourceEdit(input: { path: string; @@ -891,7 +603,7 @@ function sameField(a: SourceEditOperation, b: SourceEditOperation): boolean { if (a.kind !== b.kind) return false; if (a.kind === 'set-attribute' && b.kind === 'set-attribute') return a.name === b.name; if (a.kind === 'set-style' && b.kind === 'set-style') return a.property === b.property; - return a.kind === 'set-text'; + return a.kind === 'set-text' && b.kind === 'set-text' && a.textId === b.textId; } function quote(value: string): string { return JSON.stringify(value) @@ -956,6 +668,11 @@ export function planSourceEdit(input: { scope: SourceEditScope; selectionMode?: SourceEditSelectionMode | undefined; }): SourceEditApplyResultV1 { + if (input.selectionMode === 'source') + return reject( + 'source-mode-removed', + 'Source-list editing has been removed. Select the element in the current preview.', + ); if (input.scope !== 'source-definition') return reject( 'invalid-scope', diff --git a/apps/desktop/src/main/source-edit-provenance.test.ts b/apps/desktop/src/main/source-edit-provenance.test.ts new file mode 100644 index 00000000..ad344c1c --- /dev/null +++ b/apps/desktop/src/main/source-edit-provenance.test.ts @@ -0,0 +1,367 @@ +import { parse } from '@babel/parser'; +import { describe, expect, it } from 'vitest'; +import { + type AstNode, + children, + createTextResolver, + nameOf, + node, + walk, +} from './source-edit-provenance'; + +function fixture(source: string) { + const ast = node(parse(source, { sourceType: 'module', plugins: ['jsx', 'typescript'] })); + if (!ast) throw new Error('Expected positioned AST'); + const expressions: AstNode[] = []; + walk(ast, (current) => { + if (current.type === 'JSXExpressionContainer') { + const expression = node(current.expression); + if (expression) expressions.push(expression); + } + }); + const resolve = createTextResolver(ast); + return { ast, expressions, resolve, results: expressions.map(resolve) }; +} + +function text(source: string) { + return fixture(source).results.at(-1); +} + +describe('structural AST helpers', () => { + it('narrows positioned nodes without depending on Babel implementation types', () => { + expect(node(null)).toBeUndefined(); + expect(node({ type: 'Identifier' })).toBeUndefined(); + expect(node({ type: 'Identifier', start: -1, end: 2 })).toBeUndefined(); + expect(node({ type: 'Identifier', start: 2, end: 1 })).toBeUndefined(); + const identifier = { type: 'Identifier', start: 0, end: 3, name: 'foo' }; + expect(children([null, identifier])).toEqual([identifier]); + expect(children(identifier)).toEqual([]); + expect(nameOf(identifier)).toBe('foo'); + expect(nameOf({ type: 'StringLiteral', start: 0, end: 3, value: 'x' })).toBe('x'); + }); + + it('ignores metadata and does not loop on malformed cyclic structural trees', () => { + const root: AstNode = { type: 'Program', start: 0, end: 3 }; + root.body = [root]; + root.extra = { type: 'StringLiteral', start: 0, end: 3, value: 'fake' }; + const visited: AstNode[] = []; + walk(root, (current) => visited.push(current)); + expect(visited).toEqual([root]); + expect(createTextResolver(root)(root)).toBeUndefined(); + }); +}); + +describe('createTextResolver', () => { + it('returns exact quoted UTF-16 spans and decoded literal values', () => { + const source = `const emoji = '😀'; const view =

{'A\\nB & C'}

`; + const result = text(source); + expect(result).toEqual({ + start: source.indexOf("'A"), + end: source.indexOf("'A") + "'A\\nB & C'".length, + value: 'A\nB & C', + origin: 'string literal', + }); + expect(source.slice(result?.start, result?.end)).toBe("'A\\nB & C'"); + }); + + it('traces const aliases to the original shared definition', () => { + const source = `const title = 'Engineer'; const alias = title; const view = <>

{alias}

{title}

`; + const { results } = fixture(source); + expect(results.map((result) => result?.value)).toEqual(['Engineer', 'Engineer']); + expect(results[0]?.start).toBe(results[1]?.start); + expect(results[0]?.origin).toBe('const alias → const title → string literal'); + }); + + it('supports the resume object and nested static array/member paths', () => { + const source = `const resume = { name: 'Ada Lovelace', experience: [{ company: 'Analytical Engines', current: true, year: 1843, note: null }] }; function App() { return <>

{resume.name}

{resume.experience[0]['company']}

; }`; + const { results } = fixture(source); + expect(results.map((result) => result?.value)).toEqual(['Ada Lovelace', 'Analytical Engines']); + expect(results[0]?.origin).toBe('resume.name → const resume → string literal'); + expect(results[1]?.origin).toContain('resume.experience["0"]["company"]'); + expect(source.slice(results[1]?.start, results[1]?.end)).toBe("'Analytical Engines'"); + }); + + it.each([ + [`const name = 'Ada'; const data = { name };

{data.name}

`, 'Ada'], + [`const data = { ['name']: 'Ada' };

{data['name']}

`, 'Ada'], + [`const data = { 0: 'Ada' };

{data[0]}

`, 'Ada'], + [`const data = ['Ada']; const alias = data;

{alias[0]}

`, 'Ada'], + [ + `const inner = { name: 'Ada' }; const data = { inner }; const alias = data.inner;

{alias.name}

`, + 'Ada', + ], + [`const data = { name: 'Ada' }; const title = data.name;

{title}

`, 'Ada'], + [`

{{ name: 'Ada' }.name}

`, 'Ada'], + [`

{['Ada'][0]}

`, 'Ada'], + [`function App() { const title = 'Local'; return

{title}

; }`, 'Local'], + [`const App = () => { const title = 'Arrow'; return

{title}

; };`, 'Arrow'], + [`const title = 'Outer'; { const title = 'Block';

{title}

; }`, 'Block'], + [`const title = 'Outer'; { const title = 'Block'; }

{title}

`, 'Outer'], + [ + `const data = { name: 'Ada' }; function other(data) { data.name = 'Else'; }

{data.name}

`, + 'Ada', + ], + [`const title = 'Ada'; consume(title);

{title}

`, 'Ada'], + [`const data = { name: 'Ada' }; consume(data.name);

{data.name}

`, 'Ada'], + ])('resolves safe static provenance: %s', (source, expected) => { + expect(text(source)?.value).toBe(expected); + }); + + it.each([ + `let title = 'Mutable';

{title}

`, + `var title = 'Mutable';

{title}

`, + `

{unknown}

`, + `import title from 'elsewhere';

{title}

`, + `const title = getTitle();

{title}

`, + `const title = 'A' + 'B';

{title}

`, + 'const title = `Template`;

{title}

', + `const title = 'Outer'; function App(title) { return

{title}

; }`, + `const title = 'Outer'; function App({ title }) { return

{title}

; }`, + `const title = 'Outer'; function App(title = 'Default') { return

{title}

; }`, + `const title = 'Outer'; function App(...title) { return

{title}

; }`, + `const title = 'Outer'; function App() { return

{title}

; var title = 'Local'; }`, + `const title = 'Outer'; {

{title}

; const title = 'Later'; }`, + `const title = 'Outer'; { let title = 'Local';

{title}

; }`, + `const title = 'Outer'; try {} catch (title) {

{title}

; }`, + `const title = 'Outer'; for (let title of values) {

{title}

; }`, + `const title = 'Outer'; const App = function title() { return

{title}

; };`, + `const title = 'Outer'; { function title() {}

{title}

; }`, + `const title = 'Outer'; { class title {}

{title}

; }`, + `const title = 'Outer'; class Other { static { let title;

{title}

; } }`, + `const title = 'Outer'; { const { title } = props;

{title}

; }`, + `const { title } = { title: 'A' };

{title}

`, + `const title = alias; const alias = title;

{title}

`, + `const title = title;

{title}

`, + `const title = 'A'; title = 'B';

{title}

`, + `const title = 'A'; title++;

{title}

`, + `const title = 'A'; ({ title } = other);

{title}

`, + `const title = 'A'; [title] = other;

{title}

`, + `const title = 'A'; for (title of values) {}

{title}

`, + `const title = 'A'; eval(code);

{title}

`, + `const title = 'A'; (eval as Function)(code);

{title}

`, + `const title = 'A'; (title as string) = 'B';

{title}

`, + `const title = 'A'; title!++;

{title}

`, + `const data = { name: 'A' };

{data[unknown]}

`, + `const key = 'name'; const data = { name: 'A' };

{data[key]}

`, + `const data = { name: 'A' };

{data?.name}

`, + `const data = { name: 'A' };

{data.missing}

`, + `const data = { name: 'A' };

{data.toString()}

`, + `const data = { get name() { return 'A'; } };

{data.name}

`, + `const data = { name: 'A', set other(value) {} };

{data.name}

`, + `const data = { name: 'A', method() {} };

{data.name}

`, + `const data = { name: 'A', ...other };

{data.name}

`, + `const data = { name: 'A', name: 'B' };

{data.name}

`, + `const data = { name: 'A', ['name']: 'B' };

{data.name}

`, + `const data = { name: 'A', [dynamic]: 'B' };

{data.name}

`, + `const data = { name: 'A', __proto__: other };

{data.name}

`, + `const data = { name: 'A', other: call() };

{data.name}

`, + `const data = { name: 'A', self: data };

{data.name}

`, + `const data = ['A', ...other];

{data[0]}

`, + `const data = ['A', , 'B'];

{data[0]}

`, + `const data = ['A'];

{data[-1]}

`, + `const data = ['A'];

{data[1]}

`, + `const data = { name: 1 };

{data.name}

`, + `function Title({ title }) { return

{title}

; } `, + ])('rejects unknown, mutable, shadowed, or dynamic provenance: %s', (source) => { + expect(text(source)).toBeUndefined(); + }); + + it.each([ + `data.name = 'B'`, + `data.name++`, + `(data.name as string) = 'A'`, + `data.name! = 'A'`, + `(data.name as string)++`, + `for (data.name! of values) {}`, + `({ name: data.name! } = other)`, + `delete data.name`, + `data['name'] = 'B'`, + `data[dynamic] = 'B'`, + `({ name: data.name } = other)`, + `[data.name] = other`, + `for (data.name of values) {}`, + `consume(data)`, + `new Consumer(data)`, + `data.method()`, + `Object.assign(data, { name: 'B' })`, + `Object.defineProperty(data, 'name', { get: () => 'B' })`, + `const alias = data; alias.name = 'B'`, + `let alias = data`, + `const { name } = data`, + `const alias = data; consume(alias)`, + `const wrapper = { data }; consume(wrapper)`, + `const wrapper = [data]; wrapper[0].name = 'B'`, + `const copied = { ...data }`, + `const copied = [...data]`, + `function expose() { return data; }`, + `const expose = () => data`, + `const view = <Other data={data} />`, + `const view = <Other {...data} />`, + `export { data }`, + `export default data`, + `data[dynamic]`, + ])('rejects container escape/mutation across aliases: %s', (use) => { + expect(text(`const data = { name: 'A' }; ${use}; <h1>{data.name}</h1>`)).toBeUndefined(); + }); + + it('rejects mutation after the selected expression and inside closures', () => { + expect( + text( + `const data = { name: 'A' }; <h1>{data.name}</h1>; function mutate() { data.name = 'B'; }`, + ), + ).toBeUndefined(); + }); + + it.each([ + `function mutate() { data.name = 'B'; }`, + `const mutate = () => { data.name = 'B'; };`, + `function mutate() { const alias = data; consume(alias); }`, + `class Mutator { run() { consume(data); } }`, + ])('detects deferred captures even before the const declaration: %s', (prefix) => { + expect(text(`${prefix} const data = { name: 'A' }; <h1>{data.name}</h1>`)).toBeUndefined(); + }); + + it('resolves safe captured constants declared after the component function', () => { + expect( + text(`function App() { return <h1>{data.name}</h1>; } const data = { name: 'A' };`)?.value, + ).toBe('A'); + }); + + it.each([ + `const title = 'Outer'; class App { constructor(public title: string) { <h1>{title}</h1>; } }`, + `const title = 'Outer'; namespace App { import title = require('unknown'); <h1>{title}</h1>; }`, + `const title = 'Outer'; namespace App { enum title { Value } <h1>{title}</h1>; }`, + `const title = 'Outer'; namespace App { namespace title {} <h1>{title}</h1>; }`, + ])('does not bypass TypeScript runtime binding shadows: %s', (source) => { + expect(text(source)).toBeUndefined(); + }); + + it('rejects exported const containers but permits exported immutable strings', () => { + expect(text(`export const data = { name: 'A' }; <h1>{data.name}</h1>`)).toBeUndefined(); + expect(text(`export const title = 'A'; <h1>{title}</h1>`)?.value).toBe('A'); + }); + + it('propagates nested aliases, array mutation, and selected-field snapshots conservatively', () => { + expect( + text( + `const data = { name: 'A', experience: [{ company: 'B' }] }; const alias = data.experience[0]; consume(alias); <h1>{data.name}</h1>`, + ), + ).toBeUndefined(); + expect( + text( + `const data = { name: 'A', experience: [{ company: 'B' }] }; data.experience.push(other); <h1>{data.name}</h1>`, + ), + ).toBeUndefined(); + expect( + text( + `const data = { name: 'A' }; const title = data.name; data.name = 'B'; <h1>{title}</h1>`, + ), + ).toBeUndefined(); + }); + + it('keeps unrelated equal text and safe independent objects separate', () => { + const { results } = fixture( + `const good = { name: 'Same' }; const bad = { name: 'Same' }; consume(bad); <><h1>{good.name}</h1><h2>{bad.name}</h2></>`, + ); + expect(results[0]?.value).toBe('Same'); + expect(results[1]).toBeUndefined(); + }); + + it('allows readonly resume maps without exposing callback parameters as editable definitions', () => { + const { results } = fixture( + `const resume = { name: 'Alex', jobs: [{ title: 'Engineer', skills: ['JS', 'TS'] }, { title: 'Lead', skills: ['Design'] }] }; function App() { return <><h1>{resume.name}</h1>{resume.jobs.map((job, index) => <section key={index}><h3>{job.title}</h3>{job.skills.map(skill => <p>{skill}</p>)}</section>)}<p>{resume.jobs[1].title}</p></>; }`, + ); + expect(results[0]?.value).toBe('Alex'); + expect(results.at(-1)?.value).toBe('Lead'); + expect(results.slice(1, -1).every((result) => result === undefined)).toBe(true); + }); + + it.each([ + `job => <h3>{job.title}</h3>`, + `(job, index) => <h3 key={index}>{job.title.toUpperCase()}</h3>`, + `job => { const title = job.title; return <h3>{title}</h3>; }`, + `job => format(job.title)`, + `job => job.skills.map(skill => <span>{skill}</span>)`, + `function (job) { return <h3>{job.title}</h3>; }`, + `() => <h3>Static title</h3>`, + ])('permits verified readonly inline map callbacks: %s', (callback) => { + expect( + text( + `const resume = { name: 'Alex', jobs: [{ title: 'Engineer', skills: ['JS'] }] }; resume.jobs.map(${callback}); <h1>{resume.name}</h1>`, + )?.value, + ).toBe('Alex'); + }); + + it.each([ + `job => { job.title = 'Changed'; return null; }`, + `job => { (job.title as string) = 'Changed'; return null; }`, + `job => { job.title! = 'Changed'; return null; }`, + `job => { job.title++; return null; }`, + `job => { delete job.title; return null; }`, + `job => { consume(job); return null; }`, + `job => job`, + `job => <Other job={job} />`, + `job => <Other {...job} />`, + `job => { const alias = job; alias.title = 'Changed'; }`, + `job => job.skills`, + `job => consume(job.skills)`, + `job => job.skills.push('New')`, + `job => job.skills.map(skill => { consume(job); return skill; })`, + `job => job.skills.map(skill => { skill = 'Changed'; return skill; })`, + `job => job[dynamic]`, + `job => job.method()`, + `job => { job = other; return null; }`, + `(job, index) => { index++; return job.title; }`, + `(job, index, jobs) => jobs.pop()`, + `({ title }) => title`, + `function (job) { arguments[0].title = 'Changed'; return job.title; }`, + `job => this.mutate(job)`, + `externalCallback`, + `async job => job.title`, + ])('rejects unverified, mutating, or escaping map callbacks: %s', (callback) => { + expect( + text( + `const resume = { name: 'Alex', jobs: [{ title: 'Engineer', skills: ['JS'] }] }; resume.jobs.map(${callback}); <h1>{resume.name}</h1>`, + ), + ).toBeUndefined(); + }); + + it.each([ + `resume.jobs.map = unknown`, + `Array.prototype.map = unknown`, + `[].__proto__.map = unknown`, + `[]['__proto__'].map = unknown`, + `[]['__' + 'proto__'].map = unknown`, + `[].constructor.prototype.map = unknown`, + `Reflect.getPrototypeOf([]).map = unknown`, + `const prototype = Array.prototype; consume(prototype)`, + `Object.defineProperty(Array.prototype, 'map', { value: unknown })`, + `Object.setPrototypeOf(resume.jobs, other)`, + ])('does not trust overridden array maps: %s', (mutation) => { + expect( + text( + `const resume = { name: 'Alex', jobs: [{ title: 'Engineer' }] }; ${mutation}; resume.jobs.map(job => job.title); <h1>{resume.name}</h1>`, + ), + ).toBeUndefined(); + }); + + it('does not trust custom object map methods or callback this arguments', () => { + expect( + text( + `const resume = { name: 'Alex', jobs: { map: unknown } }; resume.jobs.map(job => job.title); <h1>{resume.name}</h1>`, + ), + ).toBeUndefined(); + expect( + text( + `const resume = { name: 'Alex', jobs: [{ title: 'Engineer' }] }; resume.jobs.map(job => job.title, context); <h1>{resume.name}</h1>`, + ), + ).toBeUndefined(); + }); + + it('requires expression node identity from its own tree and handles invalid roots', () => { + const { resolve, expressions } = fixture(`<h1>{'A'}</h1>`); + expect(resolve({ ...expressions[0] })).toBeUndefined(); + expect(resolve(undefined)).toBeUndefined(); + expect(createTextResolver(null)(expressions[0])).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/main/source-edit-provenance.ts b/apps/desktop/src/main/source-edit-provenance.ts new file mode 100644 index 00000000..63047160 --- /dev/null +++ b/apps/desktop/src/main/source-edit-provenance.ts @@ -0,0 +1,612 @@ +// Structural parser views avoid coupling source editing to Babel's implementation types. +export interface AstNode { + type: string; + start: number; + end: number; + name?: unknown; + value?: unknown; + property?: unknown; + key?: unknown; + computed?: unknown; + object?: unknown; + left?: unknown; + argument?: unknown; + elements?: unknown; + properties?: unknown; + id?: unknown; + params?: unknown; + param?: unknown; + specifiers?: unknown; + local?: unknown; + kind?: unknown; + declarations?: unknown; + init?: unknown; + callee?: unknown; + optional?: unknown; + method?: unknown; + operator?: unknown; + body?: unknown; + extra?: unknown; + expression?: unknown; + openingElement?: unknown; + children?: unknown; + attributes?: unknown; + shorthand?: unknown; + program?: unknown; + declaration?: unknown; + async?: unknown; + generator?: unknown; + right?: unknown; + test?: unknown; + consequent?: unknown; + alternate?: unknown; + selfClosing?: unknown; + arguments?: unknown; + parameter?: unknown; + [key: string]: unknown; +} + +export interface TextProvenance { + start: number; + end: number; + value: string; + /** A shared source definition, not a guarantee of one rendered instance. */ + origin: string; +} + +export function node(value: unknown): AstNode | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const candidate = value as Record<string, unknown>; + return typeof candidate['type'] === 'string' && + typeof candidate['start'] === 'number' && + typeof candidate['end'] === 'number' && + Number.isInteger(candidate['start']) && + Number.isInteger(candidate['end']) && + candidate['start'] >= 0 && + candidate['end'] >= candidate['start'] + ? (value as AstNode) + : undefined; +} + +export function children(value: unknown): AstNode[] { + return Array.isArray(value) + ? value.flatMap((item: unknown) => { + const child = node(item); + return child ? [child] : []; + }) + : []; +} + +export function nameOf(value: unknown): string | undefined { + const current = node(value); + if (current?.type === 'Identifier' || current?.type === 'JSXIdentifier') { + return typeof current.name === 'string' ? current.name : undefined; + } + return current?.type === 'StringLiteral' && typeof current.value === 'string' + ? current.value + : undefined; +} + +function edges(current: AstNode): { child: AstNode; key: string }[] { + return Object.entries(current).flatMap(([key, value]) => { + if ( + key === 'loc' || + key === 'extra' || + key === 'tokens' || + key === 'comments' || + key.endsWith('Comments') + ) + return []; + const child = node(value); + return (child ? [child] : children(value)).map((item) => ({ child: item, key })); + }); +} + +export function walk(root: AstNode, visit: (current: AstNode) => void): void { + const seen = new Set<AstNode>(); + function visitNode(current: AstNode): void { + if (seen.has(current)) return; + seen.add(current); + visit(current); + for (const { child } of edges(current)) visitNode(child); + } + visitNode(root); +} + +interface Scope { + parent?: Scope; + functionScope: boolean; + bindings: Map<string, Binding>; +} +interface Binding { + name: string; + scope: Scope; + init: AstNode | undefined; + immutable: boolean; + written: boolean; +} +interface Parent { + node: AstNode; + key: string; +} +interface StaticValue { + source: AstNode; + fields?: Map<string, Resolved>; + neighbors: Set<StaticValue>; +} +interface Resolved { + value: StaticValue; + trace: string[]; + owners: Set<StaticValue>; +} + +function isFunction(current: AstNode): boolean { + return [ + 'FunctionDeclaration', + 'FunctionExpression', + 'ArrowFunctionExpression', + 'ObjectMethod', + 'ClassMethod', + 'ClassPrivateMethod', + 'TSDeclareFunction', + ].includes(current.type); +} + +function patternNames(pattern: AstNode | undefined): AstNode[] { + if (!pattern) return []; + switch (pattern.type) { + case 'Identifier': + return [pattern]; + case 'AssignmentPattern': + return patternNames(node(pattern.left)); + case 'RestElement': + return patternNames(node(pattern.argument)); + case 'TSParameterProperty': + return patternNames(node(pattern.parameter)); + case 'TSAsExpression': + case 'TSTypeAssertion': + case 'TSNonNullExpression': + case 'ParenthesizedExpression': + return patternNames(node(pattern.expression)); + case 'ArrayPattern': + return children(pattern.elements).flatMap(patternNames); + case 'ObjectPattern': + return children(pattern.properties).flatMap((property) => + patternNames(node(property.type === 'RestElement' ? property.argument : property.value)), + ); + default: + return []; + } +} + +function staticKey(current: AstNode, member: boolean): string | undefined { + const key = node(member ? current.property : current.key); + if (!key) return undefined; + if (!current.computed && key.type === 'Identifier') return nameOf(key); + if (key.type === 'StringLiteral' && typeof key.value === 'string') return key.value; + if (key.type === 'NumericLiteral' && typeof key.value === 'number' && Number.isFinite(key.value)) + return String(key.value); + return undefined; +} + +function label(current: AstNode): string { + if (current.type === 'Identifier') return nameOf(current) ?? 'binding'; + if (current.type === 'MemberExpression') { + const object = node(current.object); + const key = staticKey(current, true); + if (object && key !== undefined) + return `${label(object)}${current.computed ? `[${JSON.stringify(key)}]` : `.${key}`}`; + } + return current.type === 'ArrayExpression' ? 'array literal' : 'object literal'; +} + +/** + * Resolve only strings whose complete lexical provenance is statically known. + * Pass nodes from this same AST; spans include the literal's quote delimiters. + * Container escape/mutation checks are whole-tree and intentionally conservative. + */ +export function createTextResolver( + ast: unknown, +): (expression: unknown) => TextProvenance | undefined { + const root = node(ast); + if (!root) return () => undefined; + const scopes = new Map<AstNode, Scope>(); + const parents = new Map<AstNode, Parent>(); + const declarations = new Set<AstNode>(); + const all: AstNode[] = []; + let dynamicScope = false; + const top: Scope = { functionScope: true, bindings: new Map() }; + + function declare( + scope: Scope, + pattern: AstNode | undefined, + declaration: AstNode, + init?: AstNode, + ) { + for (const identifier of patternNames(pattern)) { + const name = nameOf(identifier); + if (!name) continue; + declarations.add(identifier); + const existing = scope.bindings.get(name); + if (existing) { + existing.immutable = false; + continue; + } + scope.bindings.set(name, { + name, + scope, + init, + immutable: + declaration.type === 'VariableDeclarator' && pattern?.type === 'Identifier' && !!init, + written: false, + }); + } + } + + function index(current: AstNode, inherited: Scope): void { + if (scopes.has(current)) return; + let scope = inherited; + if ( + current.type === 'FunctionDeclaration' || + current.type === 'ClassDeclaration' || + current.type === 'TSDeclareFunction' || + current.type === 'TSEnumDeclaration' || + current.type === 'TSModuleDeclaration' || + current.type === 'TSImportEqualsDeclaration' + ) + declare(inherited, node(current.id), current); + if ( + isFunction(current) || + [ + 'Program', + 'BlockStatement', + 'CatchClause', + 'ForStatement', + 'ForInStatement', + 'ForOfStatement', + 'SwitchStatement', + 'ClassDeclaration', + 'ClassExpression', + 'StaticBlock', + 'TSModuleBlock', + ].includes(current.type) + ) { + scope = { + parent: inherited, + functionScope: + isFunction(current) || current.type === 'Program' || current.type === 'StaticBlock', + bindings: new Map(), + }; + } + scopes.set(current, scope); + all.push(current); + if (isFunction(current)) { + declare(scope, node(current.id), current); + for (const param of children(current.params)) declare(scope, param, current); + } + if (current.type === 'ClassExpression' || current.type === 'ClassDeclaration') + declare(scope, node(current.id), current); + if (current.type === 'CatchClause') declare(scope, node(current.param), current); + if (current.type === 'ImportDeclaration') { + for (const specifier of children(current.specifiers)) + declare(scope, node(specifier.local), specifier); + } + if (current.type === 'VariableDeclaration') { + let target = scope; + if (current.kind === 'var') { + while (!target.functionScope && target.parent) target = target.parent; + } + for (const declaration of children(current.declarations)) { + declare(target, node(declaration.id), declaration, node(declaration.init)); + if (current.kind !== 'const') { + for (const identifier of patternNames(node(declaration.id))) { + const binding = target.bindings.get(nameOf(identifier) ?? ''); + if (binding) binding.immutable = false; + } + } + } + } + if ( + current.type === 'WithStatement' || + (current.type === 'Identifier' && nameOf(current) === 'eval') + ) + dynamicScope = true; + for (const { child, key } of edges(current)) { + parents.set(child, { node: current, key }); + index(child, scope); + } + } + index(root, top); + + function bindingOf(identifier: AstNode): Binding | undefined { + const name = nameOf(identifier); + if (!name) return undefined; + let scope = scopes.get(identifier); + while (scope) { + const binding = scope.bindings.get(name); + if (binding) return binding; + scope = scope.parent; + } + return undefined; + } + for (const current of all) { + const target = + current.type === 'AssignmentExpression' || + current.type === 'ForInStatement' || + current.type === 'ForOfStatement' + ? node(current.left) + : current.type === 'UpdateExpression' + ? node(current.argument) + : undefined; + for (const identifier of patternNames(target)) { + const binding = bindingOf(identifier); + if (binding) binding.written = true; + } + } + + const cache = new Map<AstNode, Resolved | undefined>(); + const resolving = new Set<AstNode>(); + function resolve(current: AstNode | undefined): Resolved | undefined { + if (!current || !scopes.has(current)) return undefined; + if (cache.has(current)) return cache.get(current); + if (resolving.has(current)) return undefined; + resolving.add(current); + const result = resolveUncached(current); + resolving.delete(current); + cache.set(current, result); + return result; + } + function isDeferredCapture(current: AstNode, binding: Binding): boolean { + let scope = scopes.get(current); + while (scope && scope !== binding.scope) { + if (scope.functionScope) return true; + scope = scope.parent; + } + return false; + } + function resolveUncached(current: AstNode): Resolved | undefined { + if (current.type === 'Identifier') { + const binding = bindingOf(current); + if ( + dynamicScope || + !binding?.immutable || + binding.written || + !binding.init || + (binding.init.end > current.start && !isDeferredCapture(current, binding)) + ) + return undefined; + const result = resolve(binding.init); + return result ? { ...result, trace: [`const ${binding.name}`, ...result.trace] } : undefined; + } + if (current.type === 'MemberExpression' && !current.optional) { + const object = resolve(node(current.object)); + const key = staticKey(current, true); + const field = key === undefined ? undefined : object?.value.fields?.get(key); + if (!object || !field) return undefined; + return { + value: field.value, + trace: [label(current), ...object.trace, ...field.trace], + owners: new Set([...object.owners, object.value, ...field.owners]), + }; + } + const value: StaticValue = { source: current, neighbors: new Set() }; + if (['StringLiteral', 'NumericLiteral', 'BooleanLiteral', 'NullLiteral'].includes(current.type)) + return { value, trace: [], owners: new Set() }; + if (current.type !== 'ObjectExpression' && current.type !== 'ArrayExpression') return undefined; + value.fields = new Map(); + const entries: { key: string; expression: AstNode }[] = []; + if (current.type === 'ObjectExpression') { + for (const property of children(current.properties)) { + const key = staticKey(property, false); + const expression = node(property.value); + if ( + property.type !== 'ObjectProperty' || + property.method || + (property.kind !== undefined && property.kind !== 'init') || + key === undefined || + key === '__proto__' || + !expression || + entries.some((entry) => entry.key === key) + ) + return undefined; + entries.push({ key, expression }); + } + } else { + if (!Array.isArray(current.elements)) return undefined; + for (const [index, element] of current.elements.entries()) { + const expression = node(element); + if (!expression || expression.type === 'SpreadElement') return undefined; + entries.push({ key: String(index), expression }); + } + } + for (const { key, expression } of entries) { + const field = resolve(expression); + if (!field) return undefined; + value.fields.set(key, field); + // Aliases and containing objects share risk, even through nested arrays. + for (const related of [...field.owners, ...(field.value.fields ? [field.value] : [])]) { + value.neighbors.add(related); + related.neighbors.add(value); + } + } + return { value, trace: [], owners: new Set() }; + } + + function isWriteTarget(current: AstNode): boolean { + const parent = parents.get(current); + if (!parent) return false; + if ( + (parent.node.type === 'AssignmentExpression' && parent.key === 'left') || + (parent.node.type === 'UpdateExpression' && parent.key === 'argument') || + (parent.node.type === 'UnaryExpression' && parent.node.operator === 'delete') || + ((parent.node.type === 'ForInStatement' || parent.node.type === 'ForOfStatement') && + parent.key === 'left') + ) + return true; + if ( + (parent.key === 'expression' && + [ + 'TSAsExpression', + 'TSTypeAssertion', + 'TSNonNullExpression', + 'TSSatisfiesExpression', + 'ParenthesizedExpression', + ].includes(parent.node.type)) || + ['ObjectPattern', 'ArrayPattern', 'RestElement', 'AssignmentPattern'].includes( + parent.node.type, + ) || + (parent.node.type === 'ObjectProperty' && parent.key === 'value') + ) + return isWriteTarget(parent.node); + return false; + } + function isExported(declaration: AstNode): boolean { + const statement = parents.get(declaration)?.node; + return parents.get(statement ?? declaration)?.node.type === 'ExportNamedDeclaration'; + } + // A literal-array prototype alias can replace map without naming Array at all. + const standardArrayPrototype = !all.some((current) => { + if ( + current.type === 'Identifier' && + ['Array', 'Object', 'Reflect', 'Proxy', 'Function', 'eval'].includes(nameOf(current) ?? '') + ) + return true; + if (current.type !== 'MemberExpression' && current.type !== 'OptionalMemberExpression') + return false; + const key = staticKey(current, true); + return key === undefined || ['__proto__', 'constructor', 'prototype'].includes(key); + }); + function isPropertyName(current: AstNode): boolean { + const parent = parents.get(current); + return ( + !!parent && + !parent.node.computed && + ((parent.key === 'property' && parent.node.type === 'MemberExpression') || + (parent.key === 'key' && parent.node.type === 'ObjectProperty')) + ); + } + function safeParameterUse(current: AstNode, values: StaticValue[]): boolean { + if (isWriteTarget(current)) return false; + if (values.every((value) => !value.fields)) return true; + const parent = parents.get(current); + if (parent?.node.type !== 'MemberExpression' || parent.key !== 'object') return false; + const member = parent.node; + const key = staticKey(member, true); + if (key === undefined || member.optional) return false; + if (key === 'map' && values.every((value) => value.source.type === 'ArrayExpression')) + return safeMap(member, values); + const fields = values.map((value) => value.fields?.get(key)?.value); + if (fields.some((field) => !field)) return false; + return safeParameterUse( + member, + fields.filter((field): field is StaticValue => !!field), + ); + } + function safeMap(member: AstNode, arrays: StaticValue[]): boolean { + if (!standardArrayPrototype || arrays.some((value) => value.source.type !== 'ArrayExpression')) + return false; + const parent = parents.get(member); + if (parent?.node.type !== 'CallExpression' || parent.key !== 'callee' || parent.node.optional) + return false; + const args = children(parent.node.arguments); + const callback = args[0]; + if ( + args.length !== 1 || + !callback || + !['ArrowFunctionExpression', 'FunctionExpression'].includes(callback.type) || + callback.async || + callback.generator + ) + return false; + const params = children(callback.params); + if ( + params.length > 2 || + params.some((param) => param.type !== 'Identifier' || bindingOf(param)?.written) + ) + return false; + const parameter = params[0]; + const binding = parameter ? bindingOf(parameter) : undefined; + const elements = arrays.flatMap((array) => + [...(array.fields?.values() ?? [])].map((field) => field.value), + ); + let safe = true; + walk(callback, (current) => { + if (current.type === 'ThisExpression' || nameOf(current) === 'arguments') safe = false; + if ( + binding && + !declarations.has(current) && + !isPropertyName(current) && + (current.type === 'Identifier' || current.type === 'JSXIdentifier') && + bindingOf(current) === binding && + !safeParameterUse(current, elements) + ) + safe = false; + }); + return safe; + } + function safeContainerUse(current: AstNode): boolean { + const parent = parents.get(current); + if (!parent) return false; + const owner = parent.node; + if (owner.type === 'MemberExpression' && parent.key === 'object') { + if (staticKey(owner, true) === 'map') { + const receiver = resolve(current); + if (receiver && safeMap(owner, [receiver.value])) return true; + } + return staticKey(owner, true) !== undefined && !isWriteTarget(owner) && !!resolve(owner); + } + if (owner.type === 'VariableDeclarator' && parent.key === 'init') { + const id = node(owner.id); + const binding = id?.type === 'Identifier' ? bindingOf(id) : undefined; + return !!binding?.immutable && !binding.written && !isExported(owner); + } + if (owner.type === 'ObjectProperty' && parent.key === 'value') { + const container = parents.get(owner)?.node; + return container?.type === 'ObjectExpression' && !!resolve(container); + } + return owner.type === 'ArrayExpression' && parent.key === 'elements' && !!resolve(owner); + } + + const unsafe = new Set<StaticValue>(); + function invalidate(value: StaticValue): void { + if (unsafe.has(value)) return; + unsafe.add(value); + for (const neighbor of value.neighbors) invalidate(neighbor); + } + // Build every container graph first so later escape discovery reaches earlier aliases. + for (const current of all) resolve(current); + for (const current of all) { + if (declarations.has(current)) continue; + const parent = parents.get(current); + if ( + current.type === 'Identifier' && + parent && + ((parent.key === 'property' && + parent.node.type === 'MemberExpression' && + !parent.node.computed) || + (parent.key === 'key' && parent.node.type === 'ObjectProperty' && !parent.node.computed)) + ) + continue; + const result = resolve(current); + if (result?.value.fields && !safeContainerUse(current)) invalidate(result.value); + } + + return (expression) => { + const current = node(expression); + const result = resolve(current); + if ( + !result || + result.value.source.type !== 'StringLiteral' || + typeof result.value.source.value !== 'string' || + [...result.owners].some((owner) => unsafe.has(owner)) + ) + return undefined; + const literal = result.value.source; + return { + start: literal.start, + end: literal.end, + value: String(literal.value), + origin: [...result.trace, 'string literal'].join(' → '), + }; + }; +} diff --git a/apps/desktop/src/main/source-edits-ipc.test.ts b/apps/desktop/src/main/source-edits-ipc.test.ts index e85e3fe6..148e35b0 100644 --- a/apps/desktop/src/main/source-edits-ipc.test.ts +++ b/apps/desktop/src/main/source-edits-ipc.test.ts @@ -68,23 +68,26 @@ afterEach(async () => { }); describe('deterministic source edits IPC', () => { - it('persists explicit source selection beside effects while retaining stale protection', async () => { + it('persists preview-selected literals beside effects while retaining stale protection', async () => { const content = original.replace( 'return <main>', 'React.useEffect(() => { document.title = "Page"; }, []); return <main>', ); await writeFile(path.join(root, 'App.jsx'), content); - expect(await inspect(designId, 'App.jsx', content)).toMatchObject({ - status: 'rejected', - reason: 'unsafe-source', - }); + const preview = await inspect(designId, 'App.jsx', content); + expect(preview.status).toBe('ready'); + if (preview.status !== 'ready') throw new Error(preview.message); + expect(preview.targets.find((item) => item.tagName === 'h1')?.editableFields).toEqual([ + { kind: 'set-text', value: 'Original' }, + { kind: 'set-attribute', name: 'title', value: 'hello' }, + ]); const result = SourceEditInspectResultV1.parse( await invoke('inspect', { schemaVersion: 1, designId, path: 'App.jsx', expectedContent: content, - selectionMode: 'source', + selectionMode: 'preview', }), ); if (result.status !== 'ready') throw new Error(result.message); @@ -94,22 +97,40 @@ describe('deterministic source edits IPC', () => { schemaVersion: 1, designId, path: 'App.jsx', - selectionMode: 'source', + selectionMode: 'preview', expectedSourceHash: result.sourceHash, targetId: target.id, - previewRevision: 'source-list-1', + previewRevision: 'preview-1', scope: 'source-definition', - operation: { kind: 'set-text', value: 'Source selected' }, + operation: { kind: 'set-text', value: 'Preview selected' }, }; expect(await invoke('apply', input)).toMatchObject({ status: 'applied' }); expect(await readFile(path.join(root, 'App.jsx'), 'utf8')).toBe( - content.replace('>Original<', '>{"Source selected"}<'), + content.replace('>Original<', '>{"Preview selected"}<'), ); expect(await invoke('apply', input)).toMatchObject({ status: 'rejected', reason: 'stale-source', }); }); + it('explicitly rejects legacy source-mode inspect and apply without touching disk', async () => { + const input = await request(); + expect( + await invoke('inspect', { + schemaVersion: 1, + designId, + path: 'App.jsx', + expectedContent: original, + selectionMode: 'source', + }), + ).toMatchObject({ status: 'rejected', reason: 'source-mode-removed' }); + expect(await invoke('apply', { ...input, selectionMode: 'source' })).toMatchObject({ + status: 'rejected', + reason: 'source-mode-removed', + }); + expect(await readFile(path.join(root, 'App.jsx'), 'utf8')).toBe(original); + expect(await readdir(root)).toEqual(['App.jsx']); + }); it('reads actual workspace, applies one AST patch and creates no snapshots', async () => { const input = await request(); const result = SourceEditApplyResultV1.parse(await invoke('apply', input)); diff --git a/apps/desktop/src/main/source-edits-ipc.ts b/apps/desktop/src/main/source-edits-ipc.ts index 8372dec2..d1f4f474 100644 --- a/apps/desktop/src/main/source-edits-ipc.ts +++ b/apps/desktop/src/main/source-edits-ipc.ts @@ -136,6 +136,11 @@ export function registerSourceEditsIpc(db: Database, getWin: () => BrowserWindow async (event: unknown, raw: unknown): Promise<SourceEditInspectResultV1> => { const input = SourceEditInspectRequestV1.safeParse(raw); if (!input.success) return rejected('invalid-input', 'Invalid source inspection request.'); + if (input.data.selectionMode === 'source') + return rejected( + 'source-mode-removed', + 'Source-list editing has been removed. Select the element in the current preview.', + ); try { return await inSource(input.data, event, async (source) => { if (source.content !== input.data.expectedContent) @@ -159,6 +164,11 @@ export function registerSourceEditsIpc(db: Database, getWin: () => BrowserWindow async (event: unknown, raw: unknown): Promise<SourceEditApplyResultV1> => { const input = SourceEditApplyRequestV1.safeParse(raw); if (!input.success) return rejected('invalid-input', 'Invalid source edit request.'); + if (input.data.selectionMode === 'source') + return rejected( + 'source-mode-removed', + 'Source-list editing has been removed. Select the element in the current preview.', + ); try { return await inSource(input.data, event, async (source) => { const { planSourceEdit } = await import('./source-edit-engine'); diff --git a/apps/desktop/src/renderer/src/components/FilesTabView.source-edit.test.tsx b/apps/desktop/src/renderer/src/components/FilesTabView.source-edit.test.tsx index 48aa9524..8aa082d4 100644 --- a/apps/desktop/src/renderer/src/components/FilesTabView.source-edit.test.tsx +++ b/apps/desktop/src/renderer/src/components/FilesTabView.source-edit.test.tsx @@ -4,6 +4,9 @@ import type { SourceEditApplyRequestV1, SourceEditTarget } from '@open-codesign/ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DesignFileEntry } from '../hooks/useDesignFiles'; +import { requestSourceEditValidation } from '../preview/helpers'; +import type { WorkspacePreviewReadResult } from '../preview/workspace-source'; import { useCodesignStore } from '../store'; import { createWorkspaceFilePreviewMessageHandlers, WorkspaceFilePreview } from './FilesTabView'; @@ -24,6 +27,10 @@ vi.mock('@open-codesign/runtime', async (original) => ({ `<html><body>${source}${options.sourceEdit?.previewRevision ?? ''}</body></html>`, ), })); +vi.mock('../preview/helpers', async (original) => ({ + ...(await original<typeof import('../preview/helpers')>()), + requestSourceEditValidation: vi.fn(), +})); const initial = useCodesignStore.getState(); const hash = 'a'.repeat(64); const target: SourceEditTarget = { @@ -39,6 +46,25 @@ const target: SourceEditTarget = { const inspect = vi.fn(); const apply = vi.fn(); const read = vi.fn(); +const changeListeners = new Set<(event: { schemaVersion: 1; designId: string }) => void>(); +const onChanged = vi.fn((listener: (event: { schemaVersion: 1; designId: string }) => void) => { + changeListeners.add(listener); + return () => changeListeners.delete(listener); +}); +const subscribe = vi.fn(); +const unsubscribe = vi.fn(); +function deferred<T>() { + let resolve!: (value: T) => void; + const promise = new Promise<T>((done) => { + resolve = done; + }); + return { promise, resolve }; +} +async function changeFiles(designId: string) { + await act(async () => { + for (const listener of changeListeners) listener({ schemaVersion: 1, designId }); + }); +} const openCommentBubble = vi.fn(); const selectCanvasElement = vi.fn(); let container: HTMLDivElement; @@ -69,8 +95,8 @@ async function emit(meta?: Parameters<typeof selection>[0]) { ), ); } -async function mount() { - await act(async () => root.render(<WorkspaceFilePreview path="App.jsx" files={[]} />)); +async function mount(path = 'App.jsx', files: DesignFileEntry[] = []) { + await act(async () => root.render(<WorkspaceFilePreview path={path} files={files} />)); } async function toggle() { const button = container.querySelector<HTMLButtonElement>('button[aria-pressed]'); @@ -79,13 +105,13 @@ async function toggle() { } beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); - inspect.mockReset().mockResolvedValue({ + inspect.mockReset().mockImplementation(async ({ path }: { path: string }) => ({ schemaVersion: 1, status: 'ready', - path: 'App.jsx', + path, sourceHash: hash, targets: [target], - }); + })); apply.mockReset().mockImplementation(async (request: SourceEditApplyRequestV1) => ({ schemaVersion: 1, status: 'applied', @@ -97,12 +123,28 @@ beforeEach(() => { patch: { start: 3, end: 6, expectedText: 'Old', replacement: 'New' }, })); read.mockReset().mockResolvedValue({ path: 'App.jsx', content: '<p>Old</p>' }); + changeListeners.clear(); + onChanged.mockClear(); + subscribe.mockReset(); + unsubscribe.mockReset(); openCommentBubble.mockReset(); selectCanvasElement.mockReset(); vi.mocked(buildInteractivePreviewDocument).mockClear(); + vi.mocked(requestSourceEditValidation) + .mockReset() + .mockImplementation(async (_win, request) => ({ + targetId: request.targetId, + sourceHash: request.sourceHash, + previewRevision: request.previewRevision, + fieldStates: [{ key: request.fieldKey, status: 'ready' }], + })); Object.defineProperty(window, 'codesign', { configurable: true, - value: { files: { read }, sourceEdits: { inspect, apply }, snapshots: {} }, + value: { + files: { read, onChanged, subscribe, unsubscribe }, + sourceEdits: { inspect, apply }, + snapshots: {}, + }, }); useCodesignStore.setState({ currentDesignId: 'a', @@ -129,7 +171,199 @@ afterEach(async () => { vi.unstubAllGlobals(); }); describe('FilesTab source edit integration', () => { - it('uses explicit source selection without instrumenting or trusting the preview', async () => { + it.each([ + 'App.jsx', + 'Screen.jsx', + 'Screen.tsx', + ])('inspects the resolved sibling %s for a nested HTML selection', async (sourceName) => { + const sourcePath = `pages/${sourceName}`; + read.mockImplementation(async (_designId: string, path: string) => ({ + path, + content: + path === 'pages/preview.html' + ? `<!doctype html><!-- artifact source lives in ${sourceName} -->` + : '<p>Old</p>', + })); + await mount('pages/preview.html'); + expect(read).toHaveBeenCalledWith('a', sourcePath); + expect(container.querySelector('iframe')?.srcdoc).toContain('<p>Old</p>'); + await toggle(); + expect(inspect).toHaveBeenLastCalledWith({ + schemaVersion: 1, + designId: 'a', + path: sourcePath, + expectedContent: '<p>Old</p>', + }); + await emit({ + targetId: target.id, + sourceHash: hash, + previewRevision: lastRevision().previewRevision, + }); + expect(container.querySelector('textarea')?.value).toBe('Old'); + }); + + it('does not reuse a previous file while another read is pending or accept its delayed result', async () => { + const pending = deferred<WorkspacePreviewReadResult>(); + read.mockImplementation(async (_designId: string, path: string) => { + if (path === 'second.html') return pending.promise; + return { path, content: path === 'third.jsx' ? '<p>Third</p>' : '<p>Old</p>' }; + }); + await mount(); + await toggle(); + const oldRevision = lastRevision(); + await mount('second.html'); + expect(container.querySelector('iframe')).toBeNull(); + expect(container.querySelector('textarea')).toBeNull(); + await mount('third.jsx'); + await toggle(); + expect(inspect).toHaveBeenLastCalledWith(expect.objectContaining({ path: 'third.jsx' })); + await act(async () => pending.resolve({ path: 'App.jsx', content: '<p>Delayed</p>' })); + expect(container.querySelector('iframe')?.srcdoc).toContain('Third'); + expect(container.querySelector('iframe')?.srcdoc).not.toContain('Delayed'); + await emit({ + targetId: target.id, + sourceHash: hash, + previewRevision: oldRevision.previewRevision, + }); + expect(container.querySelector('textarea')).toBeNull(); + expect(apply).not.toHaveBeenCalled(); + }); + + it('does not show or inspect another design source during delayed same-path switches', async () => { + const pending = deferred<WorkspacePreviewReadResult>(); + read.mockImplementation(async (designId: string, path: string) => { + if (designId === 'b') return pending.promise; + return { path, content: designId === 'c' ? '<p>Current</p>' : '<p>Old</p>' }; + }); + await mount(); + await toggle(); + const oldRevision = lastRevision(); + await act(async () => useCodesignStore.setState({ currentDesignId: 'b' })); + expect(container.querySelector('iframe')).toBeNull(); + expect(inspect).not.toHaveBeenCalledWith(expect.objectContaining({ designId: 'b' })); + await act(async () => useCodesignStore.setState({ currentDesignId: 'c' })); + await toggle(); + expect(inspect).toHaveBeenLastCalledWith( + expect.objectContaining({ designId: 'c', expectedContent: '<p>Current</p>' }), + ); + await act(async () => pending.resolve({ path: 'App.jsx', content: '<p>Delayed</p>' })); + expect(container.querySelector('iframe')?.srcdoc).toContain('Current'); + await emit({ + targetId: target.id, + sourceHash: hash, + previewRevision: oldRevision.previewRevision, + }); + expect(container.querySelector('textarea')).toBeNull(); + }); + + it('invalidates a loaded source when the same design changes workspace', async () => { + const design = { + schemaVersion: 1 as const, + id: 'a', + name: 'Preview fixture', + createdAt: '2026-09-17', + updatedAt: '2026-09-17', + thumbnailText: null, + deletedAt: null, + workspacePath: '/workspace/old', + }; + useCodesignStore.setState({ designs: [design] }); + await mount(); + await toggle(); + const pending = deferred<WorkspacePreviewReadResult>(); + read.mockReturnValueOnce(pending.promise); + await act(async () => + useCodesignStore.setState({ designs: [{ ...design, workspacePath: '/workspace/new' }] }), + ); + expect(container.querySelector('iframe')).toBeNull(); + expect(container.querySelector('textarea')).toBeNull(); + await act(async () => pending.resolve({ path: 'App.jsx', content: '<p>New workspace</p>' })); + expect(container.querySelector('iframe')?.srcdoc).toContain('New workspace'); + await toggle(); + expect(inspect).toHaveBeenLastCalledWith( + expect.objectContaining({ expectedContent: '<p>New workspace</p>' }), + ); + }); + + it('refreshes unlisted nested sources on current-design changes and rejects stale selections', async () => { + const files: DesignFileEntry[] = [ + { path: 'index.html', kind: 'html', updatedAt: 'unchanged', size: 70, source: 'workspace' }, + ]; + const pending = deferred<WorkspacePreviewReadResult>(); + let changed = false; + read.mockImplementation(async (_designId: string, path: string) => { + if (path === 'index.html') { + return { path, content: '<!doctype html><!-- artifact source lives in src/App.jsx -->' }; + } + return changed ? pending.promise : { path, content: '<p>Old</p>' }; + }); + await mount('index.html', files); + await toggle(); + const oldRevision = lastRevision(); + await emit({ + targetId: target.id, + sourceHash: hash, + previewRevision: oldRevision.previewRevision, + }); + expect(container.querySelector('textarea')?.value).toBe('Old'); + const readsBeforeChange = read.mock.calls.length; + await changeFiles('other-design'); + expect(read).toHaveBeenCalledTimes(readsBeforeChange); + changed = true; + await changeFiles('a'); + expect(read).toHaveBeenCalledTimes(readsBeforeChange + 2); + expect(container.querySelector('textarea')).toBeNull(); + expect(container.querySelector<HTMLButtonElement>('button[aria-pressed]')?.disabled).toBe(true); + await emit({ + targetId: target.id, + sourceHash: hash, + previewRevision: oldRevision.previewRevision, + }); + expect(container.querySelector('textarea')).toBeNull(); + inspect.mockResolvedValueOnce({ + schemaVersion: 1, + status: 'ready', + path: 'src/App.jsx', + sourceHash: 'b'.repeat(64), + targets: [{ ...target, editableFields: [{ kind: 'set-text', value: 'External' }] }], + }); + await act(async () => pending.resolve({ path: 'src/App.jsx', content: '<p>External</p>' })); + expect(inspect).toHaveBeenLastCalledWith( + expect.objectContaining({ path: 'src/App.jsx', expectedContent: '<p>External</p>' }), + ); + expect(container.querySelector('iframe')?.srcdoc).toContain('External'); + const revision = lastRevision(); + expect(revision.previewRevision).not.toBe(oldRevision.previewRevision); + await emit({ + targetId: target.id, + sourceHash: hash, + previewRevision: revision.previewRevision, + }); + expect(container.querySelector('textarea')).toBeNull(); + await emit({ + targetId: target.id, + sourceHash: 'b'.repeat(64), + previewRevision: oldRevision.previewRevision, + }); + expect(container.querySelector('textarea')).toBeNull(); + await emit({ + targetId: target.id, + sourceHash: 'b'.repeat(64), + previewRevision: revision.previewRevision, + }); + expect(container.querySelector('textarea')?.value).toBe('External'); + expect(apply).not.toHaveBeenCalled(); + expect(subscribe).not.toHaveBeenCalled(); + expect(unsubscribe).not.toHaveBeenCalled(); + expect(changeListeners.size).toBe(1); + // Once the directory is loaded, file metadata owns invalidation again. + await mount('index.html', [ + ...files, + { path: 'src/App.jsx', kind: 'jsx', updatedAt: 'new', source: 'workspace' }, + ]); + expect(changeListeners.size).toBe(0); + }); + it('shows inspection refusal without a source-picker bypass', async () => { inspect.mockResolvedValueOnce({ schemaVersion: 1, status: 'rejected', @@ -138,33 +372,75 @@ describe('FilesTab source edit integration', () => { }); await mount(); await toggle(); - const fallback = [...container.querySelectorAll('button')].find( - (button) => button.textContent === 'canvas.sourceEdit.chooseSource', + expect(container.textContent).toContain('canvas.sourceEdit.reasons.unknown'); + expect(container.textContent).not.toContain('Opaque execution'); + expect(container.textContent).not.toContain('canvas.sourceEdit.chooseSource'); + expect(container.querySelector('aside select')).toBeNull(); + expect(inspect).toHaveBeenCalledOnce(); + expect(inspect.mock.calls[0]?.[0]).not.toHaveProperty('selectionMode'); + expect(apply).not.toHaveBeenCalled(); + }); + it('navigates unmapped-node ancestors with an explicit current-preview request', async () => { + await mount(); + await toggle(); + const frame = container.querySelector('iframe'); + if (!frame?.contentWindow) throw new Error('no frame'); + const post = vi.spyOn(frame.contentWindow, 'postMessage'); + await act(async () => frame.dispatchEvent(new Event('load'))); + expect(post).toHaveBeenCalledWith( + { __codesign: true, type: 'SET_MODE', mode: 'source-edit' }, + '*', + ); + const revision = lastRevision(); + const data = { + ...selection(), + sourceEditRevision: { + sourceHash: revision.sourceHash, + previewRevision: revision.previewRevision, + }, + sourceEditAncestors: [{ selector: '/main[1]/button[1]', tagName: 'button' }], + }; + await act(async () => + window.dispatchEvent(new MessageEvent('message', { source: frame.contentWindow, data })), + ); + const crumb = container.querySelector<HTMLButtonElement>( + 'nav[aria-label="canvas.sourceEdit.ancestors"] button', + ); + expect(crumb).not.toBeNull(); + expect(container.querySelector('textarea')).toBeNull(); + await act(async () => crumb?.click()); + expect(post).toHaveBeenCalledWith( + { + __codesign: true, + type: 'SOURCE_EDIT_SELECT', + selector: '/main[1]/button[1]', + sourceHash: revision.sourceHash, + previewRevision: revision.previewRevision, + }, + '*', ); - expect(fallback).toBeDefined(); - await act(async () => fallback?.click()); - expect(inspect).toHaveBeenLastCalledWith(expect.objectContaining({ selectionMode: 'source' })); - const select = container.querySelector('select'); - expect(select).not.toBeNull(); - await act(async () => { - if (!select) throw new Error('No source list'); - select.value = target.id; - select.dispatchEvent(new Event('change', { bubbles: true })); + expect(container.querySelector('textarea')).toBeNull(); + expect(apply).not.toHaveBeenCalled(); + expect(openCommentBubble).not.toHaveBeenCalled(); + }); + it('does not save when the iframe cannot revalidate the current field', async () => { + await mount(); + await toggle(); + const revision = lastRevision(); + await emit({ + targetId: target.id, + sourceHash: hash, + previewRevision: revision.previewRevision, }); - expect(container.querySelector('textarea')?.value).toBe('Old'); - await emit({ targetId: '9:30', sourceHash: hash, previewRevision: 'forged' }); - expect(container.querySelector('textarea')?.value).toBe('Old'); - expect( - vi.mocked(buildInteractivePreviewDocument).mock.calls.at(-1)?.[1]?.sourceEdit, - ).toBeUndefined(); + vi.mocked(requestSourceEditValidation).mockResolvedValue(null); await act(async () => container .querySelector('form') ?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })), ); - expect(apply).toHaveBeenLastCalledWith( - expect.objectContaining({ selectionMode: 'source', targetId: target.id }), - ); + expect(requestSourceEditValidation).toHaveBeenCalledOnce(); + expect(apply).not.toHaveBeenCalled(); + expect(container.textContent).toContain('canvas.sourceEdit.validationFailed'); }); it('keeps comment routing by default but never opens comments in source edit mode', async () => { await mount(); @@ -212,6 +488,81 @@ describe('FilesTab source edit integration', () => { useCodesignStore.getState().toasts.some((item) => item.title === 'canvas.sourceEdit.saved'), ).toBe(true); }); + it('exits edit mode on iframe Escape without changing the stored comment interaction mode', async () => { + await mount(); + await toggle(); + const frame = container.querySelector('iframe'); + if (!frame) throw new Error('no frame'); + expect(container.querySelector('aside')).not.toBeNull(); + await act(async () => + window.dispatchEvent( + new MessageEvent('message', { + source: frame.contentWindow, + data: { __codesign: true, type: 'PREVIEW_ESCAPE' }, + }), + ), + ); + expect(container.querySelector('aside')).toBeNull(); + expect(container.querySelector('button[aria-pressed]')?.getAttribute('aria-pressed')).toBe( + 'false', + ); + expect(useCodesignStore.getState().interactionMode).toBe('comment'); + expect(apply).not.toHaveBeenCalled(); + expect(openCommentBubble).not.toHaveBeenCalled(); + }); + it('exits on toolbar-focused Escape after preview selection while ignoring IME and unrelated focus', async () => { + await mount(); + const toolbar = container.querySelector<HTMLButtonElement>('button[aria-pressed]'); + if (!toolbar) throw new Error('No edit toolbar'); + toolbar.focus(); + await act(async () => toolbar.click()); + const revision = lastRevision(); + await emit({ + targetId: target.id, + sourceHash: hash, + previewRevision: revision.previewRevision, + }); + expect(document.activeElement).toBe(toolbar); + expect(container.querySelector('textarea')).not.toBeNull(); + const unrelated = document.createElement('button'); + document.body.append(unrelated); + await act(async () => + unrelated.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }), + ), + ); + expect(toolbar.getAttribute('aria-pressed')).toBe('true'); + unrelated.remove(); + await act(async () => + toolbar.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + isComposing: true, + bubbles: true, + cancelable: true, + }), + ), + ); + expect(toolbar.getAttribute('aria-pressed')).toBe('true'); + const escapeEvent = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + await act(async () => toolbar.dispatchEvent(escapeEvent)); + expect(escapeEvent.defaultPrevented).toBe(true); + expect(toolbar.getAttribute('aria-pressed')).toBe('false'); + expect(container.querySelector('aside')).toBeNull(); + expect(useCodesignStore.getState().interactionMode).toBe('comment'); + expect(apply).not.toHaveBeenCalled(); + const ordinaryEscape = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + await act(async () => toolbar.dispatchEvent(ordinaryEscape)); + expect(ordinaryEscape.defaultPrevented).toBe(false); + }); it('disables the real UI toggle during generation and clears the source panel', async () => { await mount(); await toggle(); @@ -232,7 +583,7 @@ describe('FilesTab source edit integration', () => { pushIframeError: vi.fn(), }); handlers.onElementSelected(selection()); - expect(onSourceEditSelected).toHaveBeenCalledWith(undefined); + expect(onSourceEditSelected).toHaveBeenCalledWith(undefined, []); expect(openCommentBubble).not.toHaveBeenCalled(); expect(selectCanvasElement).not.toHaveBeenCalled(); }); diff --git a/apps/desktop/src/renderer/src/components/FilesTabView.test.ts b/apps/desktop/src/renderer/src/components/FilesTabView.test.ts index aedeea52..57a07f9f 100644 --- a/apps/desktop/src/renderer/src/components/FilesTabView.test.ts +++ b/apps/desktop/src/renderer/src/components/FilesTabView.test.ts @@ -1,6 +1,7 @@ import type { CommentRow } from '@open-codesign/shared'; import { describe, expect, it, vi } from 'vitest'; import type { DesignFileEntry } from '../hooks/useDesignFiles'; +import { handlePreviewFullscreenEscape } from '../preview/fullscreen'; import { openFileTab } from '../store/slices/tabs'; import { chooseWorkspacePreviewSourceMode, @@ -184,6 +185,26 @@ describe('FilesTabView preview helpers', () => { ).toBe(false); }); + it('routes Escape to explicit source-edit exit and otherwise preserves fullscreen behavior', () => { + const onSourceEditExit = vi.fn(); + const onSelectionCleared = vi.fn(); + const input = { + onSourceEditExit, + onSelectionCleared, + selectCanvasElement: vi.fn(), + openCommentBubble: vi.fn(), + applyLiveRects: vi.fn(), + pushIframeError: vi.fn(), + }; + const editing = createWorkspaceFilePreviewMessageHandlers({ ...input, sourceEditMode: true }); + editing.onPreviewEscape?.(); + expect(onSourceEditExit).toHaveBeenCalledOnce(); + expect(onSelectionCleared).not.toHaveBeenCalled(); + expect(createWorkspaceFilePreviewMessageHandlers(input).onPreviewEscape).toBe( + handlePreviewFullscreenEscape, + ); + }); + it('forwards element selection messages from file preview iframes into comment state', () => { const selectCanvasElement = vi.fn(); const openCommentBubble = vi.fn(); @@ -372,28 +393,24 @@ describe('FilesTabView preview helpers', () => { ).toBe(false); }); - it('rejects stale preview sources from another selected file', () => { - expect( - isPreviewSourceUsableForSelectedPath({ - selectedPath: 'App.jsx', - previewSourcePath: 'DESIGN.md', - selectedPreviewKind: 'runtime', - }), - ).toBe(false); - expect( - isPreviewSourceUsableForSelectedPath({ - selectedPath: 'App.jsx', - previewSourcePath: 'App.jsx', - selectedPreviewKind: 'runtime', - }), - ).toBe(true); - expect( - isPreviewSourceUsableForSelectedPath({ - selectedPath: 'index.html', - previewSourcePath: 'src/App.jsx', - selectedPreviewKind: 'runtime', - }), - ).toBe(true); + it('binds resolved preview sources to the requested file, design, and workspace', () => { + const request = { path: 'pages/preview.html', designId: 'a', workspacePath: '/workspace/a' }; + expect(isPreviewSourceUsableForSelectedPath({ request, loadedRequest: null })).toBe(false); + expect(isPreviewSourceUsableForSelectedPath({ request, loadedRequest: { ...request } })).toBe( + true, + ); + for (const change of [ + { path: 'pages/other.html' }, + { designId: 'b' }, + { workspacePath: '/workspace/b' }, + ]) { + expect( + isPreviewSourceUsableForSelectedPath({ + request, + loadedRequest: { ...request, ...change }, + }), + ).toBe(false); + } }); it('splits YAML frontmatter before rendering markdown previews', () => { diff --git a/apps/desktop/src/renderer/src/components/FilesTabView.tsx b/apps/desktop/src/renderer/src/components/FilesTabView.tsx index 6a0f8185..0bbab9ea 100644 --- a/apps/desktop/src/renderer/src/components/FilesTabView.tsx +++ b/apps/desktop/src/renderer/src/components/FilesTabView.tsx @@ -61,6 +61,9 @@ import { postClearPinToPreviewWindow, postModeToPreviewWindow, postPinSelectorToPreviewWindow, + postSourceEditAncestorToPreviewWindow, + requestSourceEditValidation, + type SourceEditAncestor, stablePreviewSourceKey, } from '../preview/helpers'; import { useWorkspaceSourceEdit } from '../preview/useWorkspaceSourceEdit'; @@ -989,18 +992,21 @@ export function workspacePreviewDependencyKey( return [selected, source].filter((part): part is string => part !== null).join('|') || null; } +interface WorkspacePreviewRequest { + path: string; + designId: string | null; + workspacePath: string | null | undefined; +} + export function isPreviewSourceUsableForSelectedPath(input: { - selectedPath: string; - previewSourcePath: string | null | undefined; - selectedPreviewKind: FilePreviewKind; + request: WorkspacePreviewRequest; + loadedRequest: WorkspacePreviewRequest | null | undefined; }): boolean { - const previewSourcePath = input.previewSourcePath; - if (!previewSourcePath) return false; - if (previewSourcePath === input.selectedPath) return true; return ( - input.selectedPreviewKind === 'runtime' && - isMainDesignSourcePath(input.selectedPath) && - previewKindForFile(previewSourcePath, undefined) === 'runtime' + input.loadedRequest != null && + input.loadedRequest.path === input.request.path && + input.loadedRequest.designId === input.request.designId && + input.loadedRequest.workspacePath === input.request.workspacePath ); } @@ -1014,7 +1020,11 @@ interface WorkspaceFilePreviewProps { interface WorkspaceFilePreviewMessageHandlerInput { sourceEditMode?: boolean; - onSourceEditSelected?: (selection?: SourceEditSelection) => void; + onSourceEditExit?: () => void; + onSourceEditSelected?: ( + selection?: SourceEditSelection, + ancestors?: SourceEditAncestor[], + ) => void; onSelectionCleared?: (() => void) | undefined; sourcePath?: string | undefined; comments?: CommentRow[] | undefined; @@ -1051,6 +1061,7 @@ export function findReusableWorkspaceFileCommentForSelector(input: { export function createWorkspaceFilePreviewMessageHandlers({ sourceEditMode = false, + onSourceEditExit, onSourceEditSelected, onSelectionCleared, sourcePath, @@ -1062,11 +1073,11 @@ export function createWorkspaceFilePreviewMessageHandlers({ pushIframeError, }: WorkspaceFilePreviewMessageHandlerInput): PreviewMessageHandlers { return { - onPreviewEscape: handlePreviewFullscreenEscape, + onPreviewEscape: sourceEditMode ? () => onSourceEditExit?.() : handlePreviewFullscreenEscape, onSelectionCleared: () => onSelectionCleared?.(), onElementSelected: (msg) => { if (sourceEditMode) { - onSourceEditSelected?.(msg.sourceEdit); + onSourceEditSelected?.(msg.sourceEdit, msg.sourceEditAncestors ?? []); return; } selectCanvasElement({ @@ -1600,7 +1611,21 @@ export function WorkspaceFilePreview({ previewKind === 'video' || previewKind === 'audio' || previewKind === 'pdf'; - const [previewSource, setPreviewSource] = useState<WorkspacePreviewSource | null>(null); + const previewRequest = useMemo( + () => ({ path, designId: currentDesignId, workspacePath: currentDesign?.workspacePath }), + [path, currentDesignId, currentDesign?.workspacePath], + ); + const [loadedPreview, setLoadedPreview] = useState<{ + source: WorkspacePreviewSource; + request: WorkspacePreviewRequest; + } | null>(null); + const setPreviewSource = useCallback( + (source: WorkspacePreviewSource | null) => + setLoadedPreview(source ? { source, request: previewRequest } : null), + [previewRequest], + ); + const previewSource = loadedPreview?.source ?? null; + const [sourceReadEpoch, setSourceReadEpoch] = useState(0); const showTweakPanel = interactive && shouldShowTweakPanelForFile({ @@ -1622,12 +1647,31 @@ export function WorkspaceFilePreview({ srcDoc: string | null; } | null>(null); const activePreviewSource = isPreviewSourceUsableForSelectedPath({ - selectedPath: path, - previewSourcePath: previewSource?.path, - selectedPreviewKind: previewKind, + request: previewRequest, + loadedRequest: loadedPreview?.request, }) ? previewSource : null; + const unlistedSourcePath = + activePreviewSource?.workspaceDesignId === currentDesignId && + activePreviewSource && + !workspaceFiles.some((entry) => entry.path === activePreviewSource.path) + ? activePreviewSource.path + : null; + + useEffect(() => { + if (!currentDesignId || !unlistedSourcePath) return; + // The lazy tree owns the watcher, but unopened directories have no metadata + // dependency to refresh a resolved preview source. + const off = window.codesign?.files?.onChanged?.((event) => { + if (event.designId !== currentDesignId) return; + setSourceReadPending(true); + setSourceReadEpoch((epoch) => epoch + 1); + }); + return () => { + off?.(); + }; + }, [currentDesignId, unlistedSourcePath]); const sourceEdit = useWorkspaceSourceEdit({ designId: currentDesignId, @@ -1636,6 +1680,11 @@ export function WorkspaceFilePreview({ available: interactive && renderable, loading: sourceReadPending, generating: isGenerating && generatingDesignId === currentDesignId, + validate: async (request, signal) => { + const win = iframeRef.current?.contentWindow; + const result = await requestSourceEditValidation(win, request, { signal }); + return iframeRef.current?.contentWindow === win ? result : null; + }, onPersist: setPreviewSource, onSaved: (warnings) => useCodesignStore.getState().pushToast({ @@ -1645,9 +1694,7 @@ export function WorkspaceFilePreview({ }), }); const previewInteractionMode = sourceEdit.active - ? sourceEdit.sourceMode - ? 'default' - : 'comment' + ? 'source-edit' : interactive ? interactionMode : 'default'; @@ -1659,6 +1706,7 @@ export function WorkspaceFilePreview({ event.data, createWorkspaceFilePreviewMessageHandlers({ sourceEditMode: sourceEdit.active, + onSourceEditExit: sourceEdit.exit, onSourceEditSelected: sourceEdit.select, onSelectionCleared: () => { sourceEdit.clearSelection(); @@ -1688,6 +1736,7 @@ export function WorkspaceFilePreview({ return () => window.removeEventListener('message', onMessage); }, [ sourceEdit.active, + sourceEdit.exit, sourceEdit.select, sourceEdit.clearSelection, sourceEdit.inspection, @@ -1740,6 +1789,7 @@ export function WorkspaceFilePreview({ void currentDesignUpdatedAt; void currentDesign?.workspacePath; void previewDependencyKey; + void sourceReadEpoch; if ((!renderable && !textPreview) || !currentDesignId) { setPreviewSource(null); setReadError(null); @@ -1772,18 +1822,18 @@ export function WorkspaceFilePreview({ cancelled = true; }; } - const sourceMode = chooseWorkspacePreviewSourceMode({ + const previewSourceMode = chooseWorkspacePreviewSourceMode({ path, hasReadApi: typeof read === 'function', hasPreviewSource: Boolean(currentPreviewSource), preferPreviewSource: prefersPreviewSource, }); - if (sourceMode === 'preview-source-fallback' && currentPreviewSource) { + if (previewSourceMode === 'preview-source-fallback' && currentPreviewSource) { setPreviewSource({ content: currentPreviewSource, path }); setReadError(null); return; } - if (sourceMode === 'unavailable' || !read) { + if (previewSourceMode === 'unavailable' || !read) { setPreviewSource(null); setReadError(t('canvas.filesTabEmpty')); return; @@ -1811,6 +1861,8 @@ export function WorkspaceFilePreview({ currentDesignUpdatedAt, currentDesign?.workspacePath, previewDependencyKey, + sourceReadEpoch, + setPreviewSource, path, currentPreviewSource, renderable, @@ -1922,7 +1974,22 @@ export function WorkspaceFilePreview({ } return ( - <div className="relative flex h-full min-h-0 flex-col"> + <div + className="relative flex h-full min-h-0 flex-col" + onKeyDown={(event) => { + // Preview hit-testing preserves parent focus, which may still be on the toolbar. + if ( + !sourceEdit.active || + event.key !== 'Escape' || + event.nativeEvent.isComposing || + event.nativeEvent.keyCode === 229 + ) + return; + event.preventDefault(); + event.stopPropagation(); + sourceEdit.exit(); + }} + > {interactive && !previewFullscreen ? ( <div className="flex shrink-0 justify-end border-b border-[var(--color-border)] bg-[var(--color-surface)] px-[var(--space-3)] py-[var(--space-1)]"> <button @@ -1983,13 +2050,24 @@ export function WorkspaceFilePreview({ busy={sourceEdit.busy} message={sourceEdit.message} onApply={sourceEdit.apply} - onClose={sourceEdit.toggle} - sourceMode={sourceEdit.sourceMode} - canSelectSource={sourceEdit.canSelectSource} - onSourceMode={sourceEdit.enableSourceSelection} - sourceTargets={sourceEdit.sourceTargets} + onClose={sourceEdit.exit} + fieldStates={sourceEdit.fieldStates} + ancestors={sourceEdit.ancestors} + onSelectAncestor={(selector) => { + if ( + !sourceEdit.inspection || + sourceEdit.busy || + !sourceEdit.ancestors.some((item) => item.selector === selector) + ) + return; + postSourceEditAncestorToPreviewWindow( + iframeRef.current?.contentWindow, + selector, + sourceEdit.inspection, + pushIframeError, + ); + }} source={activePreviewSource.content} - onSelectSource={sourceEdit.selectSource} /> ) : null} </div> diff --git a/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts b/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts index 4759a529..b0dc9ffe 100644 --- a/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts +++ b/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts @@ -23,6 +23,37 @@ vi.mock('../../../main/logger', () => ({ })); const original = `function App(){const [count,setCount]=React.useState(0);return <main><h1 title="Original title">Original heading</h1><button onClick={()=>setCount(c=>c+1)}>Count {count}</button><section aria-label="Cards" style={{display:'flex',gap:12}}><span>A</span><span>B</span></section><div>{[1,2].map(n=><p key={n}>Repeated {n}</p>)}</div></main>}`; +const resumeFixture = `const resume = { name: 'Alex', city: 'City' }; +const role = 'Engineer'; +const labels = ['Available', 'City']; +function Badge(){return <p className="helper" title="Helper title"><span aria-hidden="true">★</span>Team <strong>design</strong>{'ready'}</p>} +function PropLabel({label}){return <p className="prop-label">{label}</p>} +function App(){ + React.useEffect(()=>{document.documentElement.dataset.fixture='mounted'},[]); + return <main> + <h1 title="Resume title">{resume.name}</h1> + <p id="city">{resume.city}</p><p id="role">{role}</p><p id="availability">{labels[0]}</p> + <footer>{resume.name}</footer><p id="independent-name">Alex</p> + <Badge/><Badge/><p id="independent-helper">Team ready</p> + <div>{['one','two'].map(key=><p className="static-map" key={key}>Shared map</p>)}</div> + <p id="independent-map">Shared map</p> + <div>{[{name:'Map Alex'},{name:'Map Casey'}].map(person=><p className="dynamic-map" key={person.name}>{person.name}</p>)}</div> + <PropLabel label="Prop Alex"/><p id="computed">{String(7)}</p> + </main> +} +ReactDOM.createRoot(document.getElementById('root')).render(<App/>);`; +const previewOnlyFixture = `function App(){ + const [count,setCount]=React.useState(0); + return <main><h1>Preview only</h1> + <button id="cart" onClick={()=>setCount(c=>c+1)}><svg id="cart-icon" width="20" height="20" viewBox="0 0 20 20"><rect x="2" y="2" width="16" height="16" /></svg>Cart ({count})</button> + <button id="disabled" disabled title="Disabled action">Unavailable</button> + <p id="independent">Left <strong>anchor</strong>Right</p> + <p id="ambiguous">{String('same')}{'same'}{String('same')}</p> + <p id="between" title="Stable title">Stable text</p> + <p id="removed-duplicate">{'same'}{'same'}</p> + <p id="remaining-substring">{String('prefix same')}{'same'}</p> + </main>; +}`; const evidence = resolve( process.cwd(), '../../.Codex/workspace/validation/source-edit-browser', @@ -158,8 +189,21 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = await writeFile(join(workspace, 'App.jsx'), original); records.length = 0; errors = []; + await openPage(); + }, 60_000); + + async function openPage() { page = await browser.newPage(); await page.setViewport({ width: 1500, height: 1000 }); + await page.evaluateOnNewDocument(() => { + Reflect.set(window, 'sourceEditValidationReplies', []); + window.addEventListener('message', (event: MessageEvent) => { + if (event.data?.__codesign === true && event.data?.type === 'SOURCE_EDIT_VALIDATED') { + const replies = Reflect.get(window, 'sourceEditValidationReplies') as unknown[]; + replies.push(event.data.sourceEdit); + } + }); + }); page.on('pageerror', (error) => errors.push(String(error))); await page.setRequestInterception(true); page.on('request', (request) => { @@ -175,7 +219,7 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = await page.waitForSelector('iframe'); const frame = await artifact(); await frame.waitForSelector('h1'); - }, 60_000); + } afterEach(async (context) => { await Promise.allSettled(active); @@ -267,26 +311,72 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = style.display === 'none' ) issues.push(`Invisible ${node.tagName}`); - if (rect.left < 0 || rect.top < 0 || rect.right > innerWidth || rect.bottom > innerHeight) + // The panel may scroll to reveal later per-field controls; its shell must fit. + if ( + node === panel && + (rect.left < 0 || rect.top < 0 || rect.right > innerWidth || rect.bottom > innerHeight) + ) issues.push(`Outside viewport: ${node.tagName}`); } for (const input of panel.querySelectorAll('textarea')) { if (!input.labels?.length || !input.labels[0]?.textContent?.trim()) issues.push('Unlabelled textarea'); - if (input.disabled) issues.push('Disabled textarea'); + if (input.disabled && !input.getAttribute('aria-describedby')) + issues.push('Disabled textarea without an explanation'); } for (const button of panel.querySelectorAll('button')) { if (!button.getAttribute('aria-label')) issues.push('Unlabelled button'); - if (button.disabled) issues.push('Disabled button'); + if ( + button.disabled && + !button.closest('form')?.querySelector('textarea[disabled][aria-describedby]') + ) + issues.push('Disabled button without a refused-field explanation'); } return issues; }); expect(issues).toEqual([]); } + it('shows editability and source explanations in Chinese without changing source', async () => { + await page.goto(`${endpoint}?locale=zh-CN`); + await page.waitForSelector('iframe'); + await (await artifact()).waitForSelector('h1'); + await page.click('button::-p-text(编辑源码)'); + await page.waitForSelector('aside[aria-busy="false"]'); + await (await artifact('[data-codesign-source-id]')).click('button'); + await page.waitForFunction(() => + document.querySelector('aside[aria-busy]')?.textContent?.includes('循环/组件参数'), + ); + const primary = await page.$eval('aside[aria-busy]', (node) => { + const clone = node.cloneNode(true) as HTMLElement; + clone.querySelectorAll('details').forEach((detail) => detail.remove()); + return clone.textContent ?? ''; + }); + expect(primary).toContain('暂时无法确定要修改的源码位置'); + expect(primary).toContain('没有可修改的静态属性'); + expect(primary).toContain('此元素没有直接声明的内联样式'); + expect(primary).toContain('文字来源'); + expect(primary).not.toContain('This text'); + expect(primary).not.toContain('string literal'); + expect(primary).not.toContain('unresolved-text-source'); + expect( + await page.$$eval('aside details', (nodes) => + nodes.every((node) => !node.hasAttribute('open')), + ), + ).toBe(true); + expect(await disk()).toBe(original); + expect(records.filter((record) => record.method === 'apply')).toHaveLength(0); + verified.push( + 'Chinese primary editability/source explanations, collapsed diagnostic codes, original code preserved', + ); + }, 60_000); + async function enterEdit() { await page.click('button::-p-text(Edit source)'); await page.waitForFunction(() => document.body.innerText.includes('Local source edit')); await artifact('[data-codesign-source-id]'); + await page.waitForSelector('aside[aria-busy="false"]'); + expect(await page.$('button[aria-label="Choose a static source field"]')).toBeNull(); + expect(await page.$('aside select')).toBeNull(); await assertPanelLayout(); } async function save(label: string, value: string) { @@ -294,7 +384,10 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = const button = await page.waitForSelector(selector); if (!button) throw new Error('Missing save control'); await assertPanelLayout(); - await page.screenshot({ path: join(evidence, `edit-${label}.png`), fullPage: true }); + await page.screenshot({ + path: join(evidence, `edit-${label.replace(/[^a-z0-9]+/gi, '-')}.png`), + fullPage: true, + }); const input = await button.evaluateHandle((node) => node.closest('form')?.querySelector('textarea'), ); @@ -309,6 +402,86 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = await page.keyboard.type(value); await page.click(selector); } + async function loadPreviewOnlyFixture() { + await writeFile(join(workspace, 'App.jsx'), previewOnlyFixture); + await page.reload(); + await assertPreviewText('h1', 'Preview only'); + } + async function validationReplies() { + return page.evaluate(() => Reflect.get(window, 'sourceEditValidationReplies') as unknown[]); + } + async function fieldDisabled(value: string) { + const label = await textLabel(value); + return page.$eval( + `[aria-label="Save source definition: ${label}"]`, + (node) => node instanceof HTMLButtonElement && node.disabled, + ); + } + async function loadResumeFixture() { + await writeFile(join(workspace, 'App.jsx'), resumeFixture); + await page.reload(); + const frame = await artifact(); + await frame.waitForFunction(() => document.documentElement.dataset['fixture'] === 'mounted'); + expect(await frame.$eval('h1', (node) => node.textContent)).toBe('Alex'); + } + async function selectText(selector: string, value: string) { + await (await artifact('[data-codesign-source-id]')).click(selector); + await page.waitForFunction( + (expected) => + Array.from(document.querySelectorAll('aside[aria-busy] textarea')).some( + (node) => node instanceof HTMLTextAreaElement && node.value === expected, + ), + {}, + value, + ); + } + async function textLabel(value: string) { + const labels = await page.$$eval( + 'aside[aria-busy] form', + (forms, expected) => + forms.flatMap((form) => { + const input = form.querySelector('textarea'); + const label = form.querySelector('label')?.textContent; + return input?.value === expected && label ? [label] : []; + }), + value, + ); + expect(labels).toHaveLength(1); + const label = labels[0]; + if (!label) throw new Error(`Missing text field for ${value}`); + return label; + } + async function saveText(previous: string, value: string) { + await save(await textLabel(previous), value); + } + async function assertPreviewText(selector: string, value: string) { + await expect + .poll(async () => + (await artifact(selector)).$$eval(selector, (nodes) => + nodes.map((node) => node.textContent), + ), + ) + .toEqual([value]); + } + function assertExactPatch( + before: string, + after: string, + from: string, + to: string, + start = before.indexOf(from), + ) { + expect(after).toBe(before.slice(0, start) + to + before.slice(start + from.length)); + const applied = records.filter((record) => record.method === 'apply').at(-1); + const result = SourceEditApplyResultV1.parse(applied?.result); + if (result.status !== 'applied') throw new Error('Expected atomic writer acknowledgment'); + expect(result.content).toBe(after); + expect(result.patch).toEqual({ + start, + end: start + from.length, + expectedText: from, + replacement: to, + }); + } it.each([ [ 'title', @@ -341,12 +514,16 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = 60_000, ); - it('rejects dynamic and repeated selections without offering writes', async () => { + it('rejects dynamic-only and repeated parameter selections without offering writes', async () => { + const dynamicOnly = original.replace('Count {count}', '{count}').replace('Repeated {n}', '{n}'); + await writeFile(join(workspace, 'App.jsx'), dynamicOnly); + await page.reload(); + await artifact(); await enterEdit(); const frame = await artifact('[data-codesign-source-id]'); await frame.click('button'); await page.waitForFunction(() => - document.querySelector('aside[aria-busy]')?.textContent?.includes('non-static-text'), + document.querySelector('aside[aria-busy]')?.textContent?.includes('unresolved-text-source'), ); expect(await page.$$('aside[aria-busy] form')).toHaveLength(0); const repeatedMarkers = await frame.$$eval('p', (nodes) => @@ -356,26 +533,29 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = expect(repeatedMarkers[0]).toBeTruthy(); expect(repeatedMarkers[0]).toBe(repeatedMarkers[1]); await frame.click('p'); - await page.waitForFunction(() => - document.querySelector('aside[aria-busy]')?.textContent?.includes('non-direct-source'), - ); + await page.waitForFunction(() => { + const text = document.querySelector('aside[aria-busy]')?.textContent; + return text?.includes('<p>') && text.includes('unresolved-text-source'); + }); expect(await page.$$('aside[aria-busy] form')).toHaveLength(0); expect(records.filter((record) => record.method === 'apply')).toHaveLength(0); - expect(await disk()).toBe(original); + expect(await disk()).toBe(dynamicOnly); verified.push( - 'dynamic count and repeated map output: no editable controls, no apply request, unchanged disk', + 'dynamic-only count and repeated map parameters: no editable controls, no apply request, unchanged disk', ); }, 60_000); it('rejects stale disk changes without overwriting external content', async () => { await enterEdit(); await (await artifact('[data-codesign-source-id]')).click('h1'); - await page.waitForSelector('[aria-label="Save source definition: Text"]'); + await page.waitForSelector('[aria-label="Save source definition: Text 1"]'); const external = original.replace('Original heading', 'External edit wins'); await writeFile(join(workspace, 'App.jsx'), external); - await save('Text', 'Must not overwrite'); + await save('Text 1', 'Must not overwrite'); await page.waitForFunction(() => - document.querySelector('aside[aria-busy]')?.textContent?.includes('stale-source'), + document + .querySelector('aside[aria-busy]') + ?.textContent?.includes('The source changed; no edit was written.'), ); expect(await disk()).toBe(external); expect( @@ -424,6 +604,10 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = tag: 'section', outerHTML: '<section>Old selection</section>', rect: { top: 0, left: 0, width: 20, height: 20 }, + sourceEditRevision: { + sourceHash: meta.sourceHash, + previewRevision: meta.previewRevision, + }, sourceEdit: meta, }, '*', @@ -444,12 +628,456 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = 'old revision ELEMENT_SELECTED from current iframe observed but did not change current h1 selection or disk', ); }, 60_000); + it('traces a resume object definition across intentional uses, reopening, and a source conflict', async () => { + await loadResumeFixture(); + await enterEdit(); + await selectText('h1', 'Alex'); + expect(await textLabel('Alex')).toMatch(/^Text \d+$/); + const panel = await page.$eval('aside[aria-busy]', (node) => node.textContent); + expect(panel).toContain('resume.name'); + expect(panel).toContain("'Alex'"); + expect(panel).toContain('App.jsx:1'); + expect(await page.$('[aria-label="Save source definition: title"]')).not.toBeNull(); + await saveText('Alex', 'Alex Rivera'); + const expected = resumeFixture.replace("'Alex'", '"Alex Rivera"'); + await expect.poll(disk).toBe(expected); + assertExactPatch(resumeFixture, await disk(), "'Alex'", '"Alex Rivera"'); + await assertPreviewText('h1', 'Alex Rivera'); + await assertPreviewText('footer', 'Alex Rivera'); + await assertPreviewText('#independent-name', 'Alex'); + await assertPreviewText('#city', 'City'); + + await page.reload(); + await assertPreviewText('h1', 'Alex Rivera'); + await page.close(); + await openPage(); + await assertPreviewText('footer', 'Alex Rivera'); + await enterEdit(); + await selectText('h1', 'Alex Rivera'); + const external = expected.replace("city: 'City'", "city: 'External City'"); + await writeFile(join(workspace, 'App.jsx'), external); + await saveText('Alex Rivera', 'Must not overwrite'); + await page.waitForFunction(() => + document + .querySelector('aside[aria-busy]') + ?.textContent?.includes('The source changed; no edit was written.'), + ); + expect(await disk()).toBe(external); + expect( + records.filter((record) => record.method === 'apply').map((record) => record.result), + ).toEqual([ + expect.objectContaining({ status: 'applied' }), + expect.objectContaining({ status: 'rejected', reason: 'stale-source' }), + ]); + verified.push( + 'resume.name: shared literal origin, exact atomic patch, intentional footer reuse, independent same-value literal and city unchanged, refreshed/reloaded/new-page persistence, stale conflict preserves external bytes', + ); + }, 60_000); + + it.each([ + ['local string', '#role', 'Engineer', 'Designer', "'Engineer'", '"Designer"'], + [ + 'static array index', + '#availability', + 'Available', + 'Open to work', + "'Available'", + '"Open to work"', + ], + ])( + 'persists a traced %s despite effects and explicit mount', + async (_kind, selector, previous, value, from, to) => { + await loadResumeFixture(); + await enterEdit(); + await selectText(selector, previous); + expect(await textLabel(previous)).toMatch(/^Text \d+$/); + await saveText(previous, value); + await expect.poll(disk).toBe(resumeFixture.replace(from, to)); + assertExactPatch(resumeFixture, await disk(), from, to); + await assertPreviewText(selector, value); + await assertPreviewText('h1', 'Alex'); + await assertPreviewText('#city', 'City'); + await page.reload(); + await assertPreviewText(selector, value); + await enterEdit(); + await selectText(selector, value); + verified.push( + `${_kind}: actual source literal only, mount/effect fixture, refreshed preview, reload and reopened panel`, + ); + }, + 60_000, + ); + + it('edits mixed helper segments and a shared map definition without touching same-value siblings', async () => { + await loadResumeFixture(); + await enterEdit(); + await selectText('.helper', 'Team '); + const firstLabel = await textLabel('Team '); + const secondLabel = await textLabel('ready'); + expect(firstLabel).toMatch(/^Text \d+$/); + expect(secondLabel).toMatch(/^Text \d+$/); + expect(firstLabel).not.toBe(secondLabel); + expect(await page.$eval('aside[aria-busy]', (node) => node.textContent)).toContain('every use'); + expect(await page.$('[aria-label="Save source definition: title"]')).toBeNull(); + await save(firstLabel, 'Group '); + let expected = resumeFixture.replace('Team <strong>', '{"Group "}<strong>'); + await expect.poll(disk).toBe(expected); + assertExactPatch(resumeFixture, await disk(), 'Team ', '{"Group "}'); + await expect + .poll(async () => + (await artifact()).$$eval('.helper', (nodes) => nodes.map((node) => node.textContent)), + ) + .toEqual(['★Group designready', '★Group designready']); + await assertPreviewText('#independent-helper', 'Team ready'); + await selectText('.helper', 'ready'); + await saveText('ready', 'set'); + const beforeSecond = expected; + expected = expected.replace("{'ready'}", '{"set"}'); + await expect.poll(disk).toBe(expected); + assertExactPatch(beforeSecond, await disk(), "{'ready'}", '{"set"}'); + await expect + .poll(async () => + (await artifact()).$$eval('.helper', (nodes) => nodes.map((node) => node.textContent)), + ) + .toEqual(['★Group designset', '★Group designset']); + expect( + await (await artifact()).$$eval('.helper strong', (nodes) => + nodes.map((node) => node.textContent), + ), + ).toEqual(['design', 'design']); + await selectText('.static-map', 'Shared map'); + await saveText('Shared map', 'Shared definition'); + const beforeMap = expected; + expected = expected.replace('>Shared map</p>)', '>{"Shared definition"}</p>)'); + await expect.poll(disk).toBe(expected); + assertExactPatch(beforeMap, await disk(), 'Shared map', '{"Shared definition"}'); + await expect + .poll(async () => + (await artifact()).$$eval('.static-map', (nodes) => nodes.map((node) => node.textContent)), + ) + .toEqual(['Shared definition', 'Shared definition']); + await assertPreviewText('#independent-map', 'Shared map'); + await page.reload(); + await expect + .poll(async () => + (await artifact()).$$eval('.helper', (nodes) => nodes.map((node) => node.textContent)), + ) + .toEqual(['★Group designset', '★Group designset']); + await enterEdit(); + await selectText('.static-map', 'Shared definition'); + verified.push( + 'mixed helper: distinct segment labels, exact per-segment patches, nested icon/strong untouched, both intentional component/map uses updated, independent same-value siblings untouched, reload and reopened panel', + ); + }, 60_000); + + it('refuses map parameters, component props, computed text, and exact direct-text mismatches', async () => { + await loadResumeFixture(); + await enterEdit(); + for (const selector of ['.dynamic-map', '.prop-label', '#computed']) { + // Start from an editable target so each refusal must replace a real form. + await selectText('#city', 'City'); + await (await artifact()).click(selector); + await page.waitForFunction(() => + document.querySelector('aside[aria-busy]')?.textContent?.includes('unresolved-text-source'), + ); + expect(await page.$$('aside[aria-busy] form')).toHaveLength(0); + } + await selectText('#city', 'City'); + const frame = await artifact(); + // A whitespace-only runtime mismatch must not be normalized into a source match. + await frame.$eval('#city', (node) => { + node.textContent = ' City'; + }); + await frame.click('#city'); + await page.waitForFunction(() => + document.querySelector('aside[aria-busy]')?.textContent?.includes('changed from its source'), + ); + expect(await fieldDisabled('City')).toBe(true); + expect(records.filter((record) => record.method === 'apply')).toHaveLength(0); + expect(await disk()).toBe(resumeFixture); + expect(await page.$('button[aria-label="Choose a static source field"]')).toBeNull(); + expect(await page.$('aside select')).toBeNull(); + verified.push( + 'unresolved map parameters/props/computed text: no fields or writes; exact whitespace mismatch disables only that field with an explanation; no source-picker bypass exists', + ); + }, 60_000); + + it('edits Cart prefix and suffix without activating the artifact, then restores normal interaction', async () => { + await loadPreviewOnlyFixture(); + await enterEdit(); + await selectText('#cart', 'Cart ('); + expect(await textLabel('Cart (')).toBe('Text 1'); + expect(await textLabel(')')).toBe('Text 2'); + expect(await fieldDisabled('Cart (')).toBe(false); + expect(await fieldDisabled(')')).toBe(false); + await assertPreviewText('#cart', 'Cart (0)'); + expect(await (await artifact()).$('[data-codesign-edit-hit-layer]')).not.toBeNull(); + await saveText('Cart (', 'Basket ('); + let expected = previewOnlyFixture.replace('Cart (', '{"Basket ("}'); + await expect.poll(disk).toBe(expected); + assertExactPatch(previewOnlyFixture, await disk(), 'Cart (', '{"Basket ("}'); + await assertPreviewText('#cart', 'Basket (0)'); + await selectText('#cart', ')'); + await saveText(')', ' items)'); + const beforeSuffix = expected; + expected = expected.replace('{count})</button>', '{count}{" items)"}</button>'); + await expect.poll(disk).toBe(expected); + assertExactPatch( + beforeSuffix, + await disk(), + ')', + '{" items)"}', + beforeSuffix.indexOf('{count})') + '{count}'.length, + ); + await assertPreviewText('#cart', 'Basket (0 items)'); + expect((await validationReplies()).length).toBeGreaterThanOrEqual(2); + await page.click('button::-p-text(Edit source)'); + await page.waitForFunction(() => !document.querySelector('aside[aria-busy]')); + const frame = await artifact('#cart'); + expect(await frame.$('[data-codesign-edit-hit-layer]')).toBeNull(); + await frame.click('#cart'); + await assertPreviewText('#cart', 'Basket (1 items)'); + await page.reload(); + await assertPreviewText('#cart', 'Basket (0 items)'); + await (await artifact()).click('#cart'); + await assertPreviewText('#cart', 'Basket (1 items)'); + await enterEdit(); + await selectText('#cart', 'Basket ('); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => !document.querySelector('aside[aria-busy]')); + const afterEscape = await artifact('#cart'); + expect(await afterEscape.$('[data-codesign-edit-hit-layer]')).toBeNull(); + await afterEscape.click('#cart'); + await assertPreviewText('#cart', 'Basket (1 items)'); + verified.push( + 'Cart dynamic count: separate static prefix/suffix exact patches, real validation replies, pointer selection does not click artifact, hit layer removed on toggle/Escape and normal counter works after toggle/reload/Escape', + ); + }, 60_000); + + it('selects a disabled button through the real pointer hit layer and persists its text', async () => { + await loadPreviewOnlyFixture(); + await enterEdit(); + await selectText('#disabled', 'Unavailable'); + expect( + await (await artifact()).$eval( + '#disabled', + (node) => node instanceof HTMLButtonElement && node.disabled, + ), + ).toBe(true); + expect(await fieldDisabled('Unavailable')).toBe(false); + await saveText('Unavailable', 'Coming soon'); + const expected = previewOnlyFixture.replace('Unavailable', '{"Coming soon"}'); + await expect.poll(disk).toBe(expected); + assertExactPatch(previewOnlyFixture, await disk(), 'Unavailable', '{"Coming soon"}'); + await assertPreviewText('#disabled', 'Coming soon'); + await page.reload(); + await assertPreviewText('#disabled', 'Coming soon'); + await assertPreviewText('#cart', 'Cart (0)'); + await page.close(); + await openPage(); + await assertPreviewText('#disabled', 'Coming soon'); + verified.push( + 'disabled button: actual pointer hit-layer selection, real IPC atomic write, disabled attribute retained, refresh/reload/new-page persisted text, independent cart unchanged', + ); + }, 60_000); + + it('selects an icon itself before an explicit containing-button breadcrumb', async () => { + await loadPreviewOnlyFixture(); + await enterEdit(); + await (await artifact()).click('#cart-icon'); + await page.waitForSelector('button[aria-label="Select containing element: button"]'); + expect(await page.$$('aside form')).toHaveLength(0); + expect(await page.$eval('aside[aria-busy]', (node) => node.textContent)).not.toContain( + '· <button>', + ); + await assertPreviewText('#cart', 'Cart (0)'); + await page.click('button[aria-label="Select containing element: button"]'); + await page.waitForFunction(() => + Array.from(document.querySelectorAll('aside textarea')).some( + (node) => node instanceof HTMLTextAreaElement && node.value === 'Cart (', + ), + ); + expect(await page.$eval('aside[aria-busy]', (node) => node.textContent)).toContain( + '· <button>', + ); + await saveText('Cart (', 'Bag ('); + await expect.poll(disk).toBe(previewOnlyFixture.replace('Cart (', '{"Bag ("}')); + assertExactPatch(previewOnlyFixture, await disk(), 'Cart (', '{"Bag ("}'); + await assertPreviewText('#cart', 'Bag (0)'); + expect( + await (await artifact()).$eval('#cart-icon rect', (node) => node.getAttribute('width')), + ).toBe('16'); + verified.push( + 'icon selection never silently promotes to parent; explicit visible breadcrumb reselects button through real runtime, icon markup unchanged and click handler never invoked', + ); + }, 60_000); + + it('keeps an independently anchored field editable after a sibling text changes', async () => { + await loadPreviewOnlyFixture(); + await enterEdit(); + await (await artifact()).$eval('#independent', (node) => { + if (node.firstChild) node.firstChild.textContent = 'Runtime left '; + }); + await selectText('#independent', 'Right'); + expect(await fieldDisabled('Left ')).toBe(true); + expect(await fieldDisabled('Right')).toBe(false); + expect(await page.$eval('aside[aria-busy]', (node) => node.textContent)).toContain( + 'changed from its source', + ); + await saveText('Right', 'Independent'); + await expect.poll(disk).toBe(previewOnlyFixture.replace('Right', '{"Independent"}')); + assertExactPatch(previewOnlyFixture, await disk(), 'Right', '{"Independent"}'); + await assertPreviewText('#independent', 'Left anchorIndependent'); + await assertPreviewText('#between', 'Stable text'); + verified.push( + 'per-field states: changed left segment disabled with explanation while native-anchor-separated right segment validates and patches independently', + ); + }, 60_000); + + it('refuses ambiguous identical text without a source-list bypass', async () => { + await loadPreviewOnlyFixture(); + await enterEdit(); + await selectText('#ambiguous', 'same'); + expect(await fieldDisabled('same')).toBe(true); + expect(await page.$eval('aside[aria-busy]', (node) => node.textContent)).toContain( + 'more than one possible', + ); + expect(await page.$('aside select')).toBeNull(); + expect(await page.$('button[aria-label="Choose a static source field"]')).toBeNull(); + expect(records.filter((record) => record.method === 'apply')).toHaveLength(0); + expect(await disk()).toBe(previewOnlyFixture); + verified.push( + 'identical static/dynamic text ambiguity: disabled explained field, no source picker, no apply request and disk unchanged', + ); + }, 60_000); + + it('never rebinds a removed static text node to a duplicate or dynamic substring', async () => { + await loadPreviewOnlyFixture(); + await enterEdit(); + const frame = await artifact(); + for (const selector of ['#removed-duplicate', '#remaining-substring']) { + await frame.$eval(selector, (node) => { + if (node.lastChild) node.removeChild(node.lastChild); + }); + await frame.click(selector); + const expectedFields = selector === '#removed-duplicate' ? 2 : 1; + await page.waitForFunction( + (count) => { + const inputs = Array.from(document.querySelectorAll('aside textarea')); + return ( + inputs.length === count && + inputs.every((input) => input instanceof HTMLTextAreaElement && input.disabled) + ); + }, + {}, + expectedFields, + ); + const saves = await page.$$eval('aside form button', (buttons) => + buttons.map((button) => button instanceof HTMLButtonElement && button.disabled), + ); + expect(saves).toEqual(Array.from({ length: expectedFields }, () => true)); + } + await assertPreviewText('#removed-duplicate', 'same'); + await assertPreviewText('#remaining-substring', 'prefix same'); + expect(records.filter((record) => record.method === 'apply')).toHaveLength(0); + expect(await disk()).toBe(previewOnlyFixture); + verified.push( + 'removed literal nodes: no duplicate-definition rebinding and no substring match inside a dynamic Text node; all affected controls disabled, no IPC writes', + ); + }, 60_000); + + it.each([ + 'before initial selection', + 'after initial selection', + 'changed and moved before initial selection', + ])('never binds a removed static A to the dynamic A survivor %s', async (timing) => { + const effect = + timing === 'before initial selection' + ? `React.useLayoutEffect(()=>{const host=document.getElementById('history');if(host?.firstChild)host.removeChild(host.firstChild);document.documentElement.dataset.removed='yes'},[]);` + : timing === 'changed and moved before initial selection' + ? `React.useLayoutEffect(()=>{const host=document.getElementById('history');const first=host?.firstChild;if(first){first.textContent='Z';document.getElementById('destination').appendChild(first)}document.documentElement.dataset.removed='yes'},[]);` + : ''; + const source = `function App(){${effect}return <main><h1>Mutation history</h1><p id="history">{'A'}{String('A')}{'B'}</p><div id="destination"/></main>}`; + await writeFile(join(workspace, 'App.jsx'), source); + await page.reload(); + await artifact(); + await enterEdit(); + const frame = await artifact('#history'); + if (timing !== 'after initial selection') { + await frame.waitForFunction(() => document.documentElement.dataset['removed'] === 'yes'); + } else { + await selectText('#history', 'A'); + expect(await fieldDisabled('A')).toBe(false); + await frame.$eval('#history', (node) => { + if (node.firstChild) node.removeChild(node.firstChild); + }); + } + await frame.click('#history'); + await page.waitForFunction(() => + Array.from(document.querySelectorAll('aside textarea')).some( + (node) => node instanceof HTMLTextAreaElement && node.value === 'A' && node.disabled, + ), + ); + expect(await fieldDisabled('A')).toBe(true); + await assertPreviewText('#history', 'AB'); + expect(records.filter((record) => record.method === 'apply')).toHaveLength(0); + expect(await disk()).toBe(source); + verified.push( + `mutation history ${timing}: source static A/dynamic A/static B loses first node; remaining dynamic A never authorizes removed static definition, no disk write`, + ); + }, 60_000); + + it('revalidates a selected field before save and refuses an intervening runtime mutation', async () => { + await loadPreviewOnlyFixture(); + await enterEdit(); + await selectText('#between', 'Stable text'); + expect(await fieldDisabled('Stable text')).toBe(false); + const frame = await artifact(); + await frame.evaluate(() => { + const mutateBeforeValidation = (event: MessageEvent) => { + if (event.data?.type !== 'TEST_MUTATE_BEFORE_SAVE') return; + const node = document.querySelector('#between'); + if (node) node.textContent = 'Runtime changed'; + window.removeEventListener('message', mutateBeforeValidation); + }; + window.addEventListener('message', mutateBeforeValidation); + }); + const iframe = await frame.frameElement(); + if (!iframe) throw new Error('Missing selected preview iframe'); + await iframe.evaluate((node) => { + const save = document.querySelector('[aria-label="Save source definition: Text 1"]'); + // Same-window FIFO delivers this mutation before the real click/submit + // validation request, without intercepting or replacing either protocol message. + save?.addEventListener( + 'pointerdown', + () => { + (node as HTMLIFrameElement).contentWindow?.postMessage( + { type: 'TEST_MUTATE_BEFORE_SAVE' }, + '*', + ); + }, + { capture: true, once: true }, + ); + }); + await saveText('Stable text', 'Must not save'); + await page.waitForFunction(() => + document.querySelector('aside[aria-busy]')?.textContent?.includes('could not be verified'), + ); + expect((await validationReplies()).length).toBeGreaterThan(0); + expect(records.filter((record) => record.method === 'apply')).toHaveLength(0); + expect(await disk()).toBe(previewOnlyFixture); + await assertPreviewText('#between', 'Runtime changed'); + verified.push( + 'commit-time SOURCE_EDIT_VALIDATE real iframe roundtrip rejects mutation after selection before any apply IPC; no source bytes changed', + ); + }, 60_000); + it('persists an exact text patch through the full FilesTab UI', async () => { await (await artifact()).click('button'); expect(await (await artifact()).$eval('button', (node) => node.textContent)).toBe('Count 1'); await enterEdit(); await (await artifact()).click('h1'); - await save('Text', 'Edited heading'); + await save('Text 1', 'Edited heading'); await expect.poll(disk).toBe(original.replace('Original heading', '{"Edited heading"}')); verified.push('text exact single replacement via UI -> HTTP -> real IPC -> atomic disk write'); await page.reload(); diff --git a/apps/desktop/src/renderer/src/components/SourceEditPanel.test.tsx b/apps/desktop/src/renderer/src/components/SourceEditPanel.test.tsx index c5df1782..aaebe0a2 100644 --- a/apps/desktop/src/renderer/src/components/SourceEditPanel.test.tsx +++ b/apps/desktop/src/renderer/src/components/SourceEditPanel.test.tsx @@ -3,9 +3,10 @@ import type { SourceEditTarget } from '@open-codesign/shared'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SourceEditPanel } from './SourceEditPanel'; +import { SourceEditPanel, type SourceEditPanelProps } from './SourceEditPanel'; -vi.mock('@open-codesign/i18n', () => ({ useT: () => (key: string) => key })); +vi.mock('@open-codesign/i18n', () => ({ useT: () => translate })); +let translate = (key: string) => key; let root: Root; let container: HTMLDivElement; const target: SourceEditTarget = { @@ -25,6 +26,7 @@ const target: SourceEditTarget = { ], }; beforeEach(() => { + translate = (key: string) => key; vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); container = document.createElement('div'); document.body.append(container); @@ -35,7 +37,7 @@ afterEach(async () => { container.remove(); vi.unstubAllGlobals(); }); -async function mount(busy = false) { +async function mount(busy = false, overrides: Partial<SourceEditPanelProps> = {}) { const onApply = vi.fn(async () => {}); const onClose = vi.fn(); await act(async () => @@ -47,13 +49,14 @@ async function mount(busy = false) { message={null} onApply={onApply} onClose={onClose} + {...overrides} />, ), ); return { onApply, onClose }; } describe('SourceEditPanel', () => { - it('has all eleven real English and Chinese labels under canvas.sourceEdit', async () => { + it('has real English and Chinese preview-only labels under canvas.sourceEdit', async () => { const { i18n, initI18n, setLocale } = await vi.importActual<typeof import('@open-codesign/i18n')>('@open-codesign/i18n'); await initI18n('en'); @@ -69,6 +72,12 @@ describe('SourceEditPanel', () => { 'unsupportedSelection', 'saved', 'unavailable', + 'ancestors', + 'selectAncestor', + 'validationFailed', + 'fieldState.changed', + 'fieldState.ambiguous', + 'fieldState.unmapped', ]; for (const locale of ['en', 'zh-CN'] as const) { await setLocale(locale); @@ -77,7 +86,7 @@ describe('SourceEditPanel', () => { true, ); } - expect(i18n.t('canvas.sourceEdit.scope')).toContain('源定义'); + expect(i18n.t('canvas.sourceEdit.scope')).toContain('所有引用'); await setLocale('en'); expect(i18n.t('canvas.sourceEdit.reloadWarning')).toContain('reset'); }); @@ -85,7 +94,12 @@ describe('SourceEditPanel', () => { await mount(); expect(container.textContent).toContain('canvas.sourceEdit.scope'); expect(container.textContent).toContain('canvas.sourceEdit.reloadWarning'); - expect(container.textContent).toContain('Computed styles are not editable (dynamic-style)'); + expect(container.textContent).not.toContain('Computed styles are not editable'); + expect(container.querySelector('details summary')?.textContent).toBe( + 'canvas.sourceEdit.diagnostics', + ); + expect(container.querySelector('details code')?.textContent).toBe('dynamic-style'); + expect(container.querySelector('details')?.open).toBe(false); expect(container.querySelectorAll('form')).toHaveLength(3); }); it.each([ @@ -103,6 +117,130 @@ describe('SourceEditPanel', () => { ); expect(onApply).toHaveBeenCalledWith(operation); }); + it('shows candidate text fields with friendly labels and independently blocks ambiguous fields', async () => { + const mixed: SourceEditTarget = { + ...target, + textLayout: [{ kind: 'text', textId: '10:15', value: 'Static' }, { kind: 'dynamic' }], + editableFields: [ + { kind: 'set-text', textId: '10:15', value: 'Static' }, + { kind: 'set-text', textId: '20:25', value: 'Ambiguous' }, + ], + }; + const { onApply } = await mount(false, { + target: mixed, + fieldStates: [ + { key: 'text:10:15', status: 'ready' }, + { key: 'text:20:25', status: 'ambiguous' }, + ], + }); + expect([...container.querySelectorAll('label')].map((label) => label.textContent)).toEqual([ + 'canvas.sourceEdit.text 1', + 'canvas.sourceEdit.text 2', + ]); + const fields = container.querySelectorAll('textarea'); + expect(fields[0]?.disabled).toBe(false); + expect(fields[1]?.disabled).toBe(true); + expect(container.textContent).toContain('canvas.sourceEdit.fieldState.ambiguous'); + expect(fields[1]?.getAttribute('aria-describedby')).toBeTruthy(); + await act(async () => + container + .querySelectorAll('form')[1] + ?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })), + ); + expect(onApply).not.toHaveBeenCalled(); + expect(container.querySelector('select')).toBeNull(); + expect(container.textContent).not.toContain('canvas.sourceEdit.chooseSource'); + }); + it('fails closed for production targets lacking runtime states while retaining candidate fields', async () => { + await mount(false, { target: { ...target, textLayout: [{ kind: 'text', value: 'Old' }] } }); + expect(container.querySelectorAll('form')).toHaveLength(3); + expect([...container.querySelectorAll('textarea')].every((field) => field.disabled)).toBe(true); + expect(container.textContent).toContain('canvas.sourceEdit.fieldState.unmapped'); + }); + it('shows readonly source origin and shared-definition warning without a source picker', async () => { + await mount(false, { + source: "const title='Old';", + target: { ...target, textSources: [{ start: 12, end: 17, origin: 'title' }] }, + }); + expect(container.querySelector('pre')?.textContent).toBe("'Old'"); + expect(container.textContent).toContain('canvas.sourceEdit.origin · App.jsx:1'); + expect(container.textContent).not.toContain('App.jsx:1 · title'); + expect(container.textContent).toContain('canvas.sourceEdit.scope'); + expect(container.querySelector('[contenteditable]')).toBeNull(); + expect(container.querySelector('select')).toBeNull(); + }); + it.each([ + 'en', + 'zh-CN', + ] as const)('localizes unsupported fields and source origins in %s without translating code', async (locale) => { + const { i18n, initI18n } = + await vi.importActual<typeof import('@open-codesign/i18n')>('@open-codesign/i18n'); + await initI18n(locale); + translate = (key: string) => String(i18n.t(key, { lng: locale })); + const rawOrigin = 'const title → string literal (all uses of this definition)'; + await mount(false, { + source: "const title='Old';", + target: { ...target, textSources: [{ start: 12, end: 17, origin: rawOrigin }] }, + }); + expect(container.textContent).toContain(translate('canvas.sourceEdit.origin')); + expect(container.textContent).toContain('App.jsx:1'); + expect(container.querySelector('pre')?.textContent).toBe("'Old'"); + const originDetails = container.querySelectorAll('details')[0]; + expect(originDetails?.open).toBe(false); + expect(originDetails?.querySelector('code')?.textContent).toBe(rawOrigin); + const primary = container.cloneNode(true) as HTMLDivElement; + for (const detail of primary.querySelectorAll('details')) detail.remove(); + expect(primary.textContent).not.toContain(rawOrigin); + expect(primary.textContent).not.toContain('dynamic-style'); + expect(container.textContent).not.toContain('Computed styles are not editable'); + const details = container.querySelectorAll('details')[1]; + expect(details?.open).toBe(false); + expect(details?.querySelector('summary')?.textContent).toBe( + translate('canvas.sourceEdit.diagnostics'), + ); + expect(details?.querySelector('code')?.textContent).toBe('dynamic-style'); + expect(details?.previousElementSibling?.textContent).toMatch( + locale === 'zh-CN' ? /[\u4e00-\u9fff]/ : /[A-Za-z]/, + ); + expect(details?.previousElementSibling?.textContent).not.toContain('dynamic-style'); + expect(container.textContent).not.toContain('canvas.sourceEdit.'); + }); + it('shows ancestor navigation even when the selected element has no source target', async () => { + const onSelectAncestor = vi.fn(); + await mount(false, { + target: null, + ancestors: [{ selector: '/main[1]/button[1]', tagName: 'button' }], + onSelectAncestor, + }); + const button = container.querySelector<HTMLButtonElement>('nav button'); + expect(button?.textContent).toBe('<button>'); + await act(async () => button?.click()); + expect(onSelectAncestor).toHaveBeenCalledExactlyOnceWith('/main[1]/button[1]'); + expect(container.querySelector('textarea')).toBeNull(); + }); + it('exits on Escape from a text field but leaves IME composition alone', async () => { + const { onClose, onApply } = await mount(); + const input = container.querySelector('textarea'); + if (!input) throw new Error('Missing field'); + await act(async () => + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + isComposing: true, + bubbles: true, + cancelable: true, + }), + ), + ); + expect(onClose).not.toHaveBeenCalled(); + await act(async () => + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }), + ), + ); + expect(onClose).toHaveBeenCalledOnce(); + expect(onApply).not.toHaveBeenCalled(); + }); it('disables saving during a request but allows closing', async () => { const { onApply, onClose } = await mount(true); expect([...container.querySelectorAll('textarea')].every((input) => input.disabled)).toBe(true); diff --git a/apps/desktop/src/renderer/src/components/SourceEditPanel.tsx b/apps/desktop/src/renderer/src/components/SourceEditPanel.tsx index abe6cfa3..fb98cd99 100644 --- a/apps/desktop/src/renderer/src/components/SourceEditPanel.tsx +++ b/apps/desktop/src/renderer/src/components/SourceEditPanel.tsx @@ -1,7 +1,14 @@ import { useT } from '@open-codesign/i18n'; -import type { SourceEditOperation, SourceEditTarget } from '@open-codesign/shared'; +import type { SourceEditSelection } from '@open-codesign/runtime'; +import { + type SourceEditOperation, + type SourceEditTarget, + sourceEditFieldKey, +} from '@open-codesign/shared'; import { Save, X } from 'lucide-react'; import { type FormEvent, useId } from 'react'; +import { type SourceEditAncestor, sourceEditFieldState } from '../preview/helpers'; +import { sourceEditOriginMessage, sourceEditReasonMessage } from '../preview/source-edit-messages'; export interface SourceEditPanelProps { path: string; @@ -10,19 +17,19 @@ export interface SourceEditPanelProps { message: string | null; onApply: (operation: SourceEditOperation) => Promise<void>; onClose: () => void; - sourceMode?: boolean; - canSelectSource?: boolean; - onSourceMode?: () => void; - sourceTargets?: SourceEditTarget[]; + fieldStates?: SourceEditSelection['fieldStates']; + ancestors?: SourceEditAncestor[]; + onSelectAncestor?: (selector: string) => void; source?: string; - onSelectSource?: (id: string) => void; } export function sourceEditFieldLabel( operation: SourceEditOperation, t: (key: string) => string, + index?: number, ): string { - if (operation.kind === 'set-text') return t('canvas.sourceEdit.text'); + if (operation.kind === 'set-text') + return `${t('canvas.sourceEdit.text')}${index === undefined ? '' : ` ${index + 1}`}`; if (operation.kind === 'set-attribute') return operation.name; return operation.property; } @@ -30,10 +37,14 @@ export function sourceEditFieldLabel( function SourceEditField({ operation, disabled, + label, + reason, onApply, }: { operation: SourceEditOperation; disabled: boolean; + label: string; + reason: string | undefined; onApply: SourceEditPanelProps['onApply']; }) { const t = useT(); @@ -46,7 +57,7 @@ function SourceEditField({ return ( <form onSubmit={submit} className="grid gap-[var(--space-1)]"> <label htmlFor={id} className="text-[var(--text-sm)] text-[var(--color-text-secondary)]"> - {sourceEditFieldLabel(operation, t)} + {label} </label> <div className="flex items-start gap-[var(--space-1)]"> <textarea @@ -54,6 +65,7 @@ function SourceEditField({ name="value" defaultValue={operation.value} disabled={disabled} + aria-describedby={reason ? `${id}-reason` : undefined} rows={operation.kind === 'set-text' ? 3 : 1} maxLength={operation.kind === 'set-style' ? 1000 : 100000} className="min-w-0 flex-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] p-[var(--space-2)] text-[var(--text-sm)] text-[var(--color-text-primary)] focus:border-[var(--color-accent)] disabled:opacity-50" @@ -62,12 +74,17 @@ function SourceEditField({ type="submit" disabled={disabled} title={t('canvas.sourceEdit.apply')} - aria-label={`${t('canvas.sourceEdit.apply')}: ${sourceEditFieldLabel(operation, t)}`} + aria-label={`${t('canvas.sourceEdit.apply')}: ${label}`} className="inline-flex size-[var(--size-control-md)] shrink-0 items-center justify-center rounded-[var(--radius-sm)] border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50" > <Save className="size-[var(--space-4)]" aria-hidden /> </button> </div> + {reason ? ( + <p id={`${id}-reason`} className="m-0 text-[var(--text-sm)] text-[var(--color-text-muted)]"> + {reason} + </p> + ) : null} </form> ); } @@ -79,20 +96,29 @@ export function SourceEditPanel({ message, onApply, onClose, - sourceMode = false, - canSelectSource = false, - onSourceMode, - sourceTargets = [], + fieldStates, + ancestors = [], + onSelectAncestor, source = '', - onSelectSource, }: SourceEditPanelProps) { const t = useT(); const heading = useId(); - const sourceSelect = useId(); + let textIndex = 0; return ( <aside aria-labelledby={heading} aria-busy={busy} + onKeyDown={(event) => { + if ( + event.key !== 'Escape' || + event.nativeEvent.isComposing || + event.nativeEvent.keyCode === 229 + ) + return; + event.preventDefault(); + event.stopPropagation(); + onClose(); + }} className="flex min-h-0 flex-1 flex-col gap-[var(--space-3)] overflow-auto rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[var(--space-4)] shadow-[var(--shadow-soft)]" > <div className="flex items-center justify-between gap-[var(--space-2)]"> @@ -121,6 +147,26 @@ export function SourceEditPanel({ <p className="m-0 text-[var(--text-sm)] text-[var(--color-text-muted)]"> {t('canvas.sourceEdit.reloadWarning')} </p> + {ancestors.length ? ( + <nav + aria-label={t('canvas.sourceEdit.ancestors')} + className="flex flex-wrap gap-[var(--space-1)]" + > + {ancestors.map((ancestor) => ( + <button + key={ancestor.selector} + type="button" + disabled={busy || !onSelectAncestor} + title={ancestor.selector} + aria-label={`${t('canvas.sourceEdit.selectAncestor')}: ${ancestor.tagName}`} + onClick={() => onSelectAncestor?.(ancestor.selector)} + className="rounded-[var(--radius-sm)] border border-[var(--color-border)] p-[var(--space-1)] text-[var(--text-sm)] disabled:opacity-50" + > + <{ancestor.tagName}> + </button> + ))} + </nav> + ) : null} {message ? ( <p role="status" className="m-0 text-[var(--text-sm)] text-[var(--color-text-secondary)]"> {message} @@ -131,68 +177,65 @@ export function SourceEditPanel({ {t('common.loading')} </p> ) : null} - {canSelectSource && !busy ? ( - <button - type="button" - onClick={onSourceMode} - className="rounded-[var(--radius-sm)] border border-[var(--color-border)] p-[var(--space-2)] text-[var(--text-sm)]" - > - {t('canvas.sourceEdit.chooseSource')} - </button> - ) : null} - {sourceMode ? ( - <div className="grid gap-[var(--space-2)] text-[var(--text-sm)]"> - <p>{t('canvas.sourceEdit.sourceHint')}</p> - <label htmlFor={sourceSelect}>{t('canvas.sourceEdit.sourceField')}</label> - <select - id={sourceSelect} - value={target?.id ?? ''} - disabled={busy} - onChange={(event) => onSelectSource?.(event.target.value)} - className="min-w-0 w-full border border-[var(--color-border)] bg-[var(--color-background)] p-[var(--space-2)]" - > - <option value="">{t('canvas.sourceEdit.chooseSource')}</option> - {sourceTargets.map((item) => ( - <option key={item.id} value={item.id}> - {source.slice(0, item.start).split('\n').length}: <{item.tagName}>{' '} - {item.editableFields - .map((field) => field.value) - .join(' · ') - .replace(/\s+/g, ' ') - .slice(0, 100)} - </option> - ))} - </select> - {!busy && sourceTargets.length === 0 ? ( - <p>{t('canvas.sourceEdit.noSourceFields')}</p> - ) : null} - {target ? ( - <pre className="m-0 max-h-40 overflow-auto whitespace-pre-wrap break-all"> - {source.slice(target.start, Math.min(target.end, target.start + 1000))} - </pre> - ) : null} - </div> - ) : null} - {!sourceMode && !canSelectSource && !target && !busy ? ( + {!target && !busy ? ( <p className="m-0 text-[var(--text-sm)] text-[var(--color-text-muted)]"> {t('canvas.sourceEdit.selectHint')} </p> ) : null} - {target?.editableFields.map((operation) => ( - <SourceEditField - key={`${target.id}:${operation.kind}:${operation.kind === 'set-style' ? operation.property : operation.kind === 'set-attribute' ? operation.name : 'text'}`} - operation={operation} - disabled={busy} - onApply={onApply} - /> - ))} - {target?.unsupported.map((item) => ( - <p - key={`${item.field}:${item.reason}`} - className="m-0 text-[var(--text-sm)] text-[var(--color-text-muted)]" + {target?.editableFields.map((operation) => { + const origin = + operation.kind === 'set-text' + ? target.textSources?.find((item) => item.textId === operation.textId) + : undefined; + const state = sourceEditFieldState(target, operation, fieldStates); + const label = sourceEditFieldLabel( + operation, + t, + operation.kind === 'set-text' ? textIndex++ : undefined, + ); + const reason = + state.status === 'ready' ? undefined : t(`canvas.sourceEdit.fieldState.${state.status}`); + return ( + <div + key={`${target.id}:${sourceEditFieldKey(operation)}`} + className="grid gap-[var(--space-2)]" + > + {origin ? ( + <div className="text-[var(--text-sm)] text-[var(--color-text-muted)]"> + <p> + {sourceEditOriginMessage(t)} · {path}: + {source.slice(0, origin.start).split('\n').length} + </p> + <pre className="max-h-24 overflow-auto whitespace-pre-wrap break-all"> + {source.slice(origin.start, origin.end)} + </pre> + <details> + <summary>{t('canvas.sourceEdit.diagnostics')}</summary> + <code className="break-all">{origin.origin}</code> + </details> + </div> + ) : null} + <SourceEditField + operation={operation} + disabled={busy || state.status !== 'ready'} + label={label} + reason={reason} + onApply={onApply} + /> + </div> + ); + })} + {target?.unsupported.map((item, index) => ( + <div + key={`${item.field}:${item.reason}:${index}`} + className="text-[var(--text-sm)] text-[var(--color-text-muted)]" > - {item.message} ({item.reason}) - </p> + <p className="m-0">{sourceEditReasonMessage(item.reason, t)}</p> + <details> + <summary>{t('canvas.sourceEdit.diagnostics')}</summary> + <code className="break-all">{item.reason}</code> + </details> + </div> ))} </aside> ); diff --git a/apps/desktop/src/renderer/src/components/__fixtures__/source-edit-browser.tsx b/apps/desktop/src/renderer/src/components/__fixtures__/source-edit-browser.tsx index 189808e3..8a6e08c6 100644 --- a/apps/desktop/src/renderer/src/components/__fixtures__/source-edit-browser.tsx +++ b/apps/desktop/src/renderer/src/components/__fixtures__/source-edit-browser.tsx @@ -28,7 +28,7 @@ Object.defineProperty(window, 'codesign', { } satisfies CodesignApi['sourceEdits'], }, }); -await initI18n('en'); +await initI18n(new URLSearchParams(window.location.search).get('locale') ?? 'en'); const design = await rpc<Design>('design'); useCodesignStore.setState({ currentDesignId: design.id, diff --git a/apps/desktop/src/renderer/src/preview/helpers.source-edit.test.ts b/apps/desktop/src/renderer/src/preview/helpers.source-edit.test.ts index d30007ef..fe06484f 100644 --- a/apps/desktop/src/renderer/src/preview/helpers.source-edit.test.ts +++ b/apps/desktop/src/renderer/src/preview/helpers.source-edit.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, it, vi } from 'vitest'; -import { handlePreviewMessage, type PreviewMessageHandlers } from './helpers'; +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + handlePreviewMessage, + type PreviewMessageHandlers, + postSourceEditAncestorToPreviewWindow, + requestSourceEditValidation, +} from './helpers'; +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); const current = { sourceHash: 'a'.repeat(64), previewRevision: 'preview-2' }; const message = { __codesign: true, @@ -14,6 +24,102 @@ function handlers(): PreviewMessageHandlers { return { onElementSelected: vi.fn(), onElementRects: vi.fn(), onIframeError: vi.fn() }; } +describe('preview-only commit validation', () => { + const request = { ...current, targetId: '10:50', fieldKey: 'text:only' }; + function fixture() { + const post = vi.fn<(data: unknown, origin: string) => void>(); + const win = { postMessage: post } as unknown as Window; + const controller = new AbortController(); + const pending = requestSourceEditValidation(win, request, { + signal: controller.signal, + timeoutMs: 50, + }); + const payload = post.mock.calls[0]?.[0] as { requestId: string }; + const selected = { + ...current, + targetId: '10:50', + fieldStates: [{ key: 'text:only', status: 'ready' }], + }; + const emit = ( + sourceEdit: unknown = selected, + source: Window = win, + requestId = payload.requestId, + ) => + window.dispatchEvent( + new MessageEvent('message', { + source, + data: { __codesign: true, type: 'SOURCE_EDIT_VALIDATED', requestId, sourceEdit }, + }), + ); + return { post, win, controller, pending, selected, emit }; + } + it('checks current iframe, request id, target, hash and revision before resolving', async () => { + vi.useFakeTimers(); + const f = fixture(); + const done = vi.fn(); + void f.pending.then(done); + f.emit(f.selected, {} as Window); + f.emit(f.selected, f.win, 'another-request'); + f.emit({ ...f.selected, sourceHash: 'b'.repeat(64) }); + f.emit({ ...f.selected, previewRevision: 'old' }); + f.emit({ ...f.selected, targetId: '99:100' }); + f.emit({ ...f.selected, fieldStates: [{ key: 'text:only', status: 'forged' }] }); + await Promise.resolve(); + expect(done).not.toHaveBeenCalled(); + f.emit(); + expect(await f.pending).toEqual(f.selected); + expect(f.post).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'SOURCE_EDIT_VALIDATE', + ...request, + requestId: expect.any(String), + }), + '*', + ); + expect(vi.getTimerCount()).toBe(0); + }); + it('returns null and removes listeners on timeout or abort', async () => { + vi.useFakeTimers(); + const remove = vi.spyOn(window, 'removeEventListener'); + const timed = fixture(); + await vi.advanceTimersByTimeAsync(51); + expect(await timed.pending).toBeNull(); + const aborted = fixture(); + aborted.controller.abort(); + expect(await aborted.pending).toBeNull(); + expect(remove.mock.calls.filter(([type]) => type === 'message')).toHaveLength(2); + expect(vi.getTimerCount()).toBe(0); + }); + it('accepts explicit no-mapping replies and never dispatches them as normal selections', async () => { + const f = fixture(); + f.emit(null); + expect(await f.pending).toBeNull(); + const callbacks = handlers(); + expect( + handlePreviewMessage({ __codesign: true, type: 'SOURCE_EDIT_VALIDATED' }, callbacks), + ).toEqual({ status: 'handled', type: 'SOURCE_EDIT_VALIDATED' }); + expect(callbacks.onElementSelected).not.toHaveBeenCalled(); + expect(callbacks.onIframeError).not.toHaveBeenCalled(); + }); + it('posts explicit ancestor selection with revision rather than selecting source ids', () => { + const postMessage = vi.fn(); + const error = vi.fn(); + expect( + postSourceEditAncestorToPreviewWindow( + { postMessage } as unknown as Window, + '/main[1]/button[1]', + current, + error, + ), + ).toBe(true); + expect(postMessage).toHaveBeenCalledExactlyOnceWith( + { __codesign: true, type: 'SOURCE_EDIT_SELECT', selector: '/main[1]/button[1]', ...current }, + '*', + ); + expect(error).not.toHaveBeenCalled(); + }); +}); + describe('source-edit preview message revisions', () => { it('forwards current source hints without converting them into write authorization', () => { const callbacks = handlers(); @@ -42,6 +148,26 @@ describe('source-edit preview message revisions', () => { }); expect(callbacks.onElementSelected).not.toHaveBeenCalled(); }); + it('requires current top-level revision for ancestor-only selections', () => { + const callbacks = handlers(); + const selected = { + ...message, + sourceEditAncestors: [{ selector: '/main[1]', tagName: 'main' }], + }; + expect(handlePreviewMessage(selected, callbacks, current).status).toBe('rejected'); + expect( + handlePreviewMessage( + { ...selected, sourceEditRevision: { ...current, previewRevision: 'old' } }, + callbacks, + current, + ).status, + ).toBe('rejected'); + expect(callbacks.onElementSelected).not.toHaveBeenCalled(); + expect( + handlePreviewMessage({ ...selected, sourceEditRevision: current }, callbacks, current).status, + ).toBe('handled'); + expect(callbacks.onElementSelected).toHaveBeenCalledOnce(); + }); it('keeps existing no-metadata comment selection compatible', () => { const callbacks = handlers(); expect(handlePreviewMessage(message, callbacks, current)).toEqual({ diff --git a/apps/desktop/src/renderer/src/preview/helpers.ts b/apps/desktop/src/renderer/src/preview/helpers.ts index beb59633..45ccd5a5 100644 --- a/apps/desktop/src/renderer/src/preview/helpers.ts +++ b/apps/desktop/src/renderer/src/preview/helpers.ts @@ -4,9 +4,128 @@ import { isElementRectsMessage, isIframeErrorMessage, isOverlayMessage, + isSourceEditSelection, type OverlayMessage, type SourceEditSelection, } from '@open-codesign/runtime'; +import { + type SourceEditOperation, + type SourceEditTarget, + sourceEditFieldKey, +} from '@open-codesign/shared'; + +export interface SourceEditAncestor { + selector: string; + tagName: string; +} +export interface SourceEditValidationRequest { + targetId: string; + sourceHash: string; + previewRevision: string; + fieldKey: string; +} + +export function sourceEditFieldState( + target: SourceEditTarget, + operation: SourceEditOperation, + states: SourceEditSelection['fieldStates'], +): NonNullable<SourceEditSelection['fieldStates']>[number] { + const key = sourceEditFieldKey(operation); + const matching = states?.filter((state) => state.key === key); + if (matching?.length === 1 && matching[0]) return matching[0]; + return { + key, + status: states === undefined && target.textLayout === undefined ? 'ready' : 'unmapped', + }; +} + +/** A live DOM check is a UI hint, not authorization for the main-process source write. */ +export function requestSourceEditValidation( + win: Window | null | undefined, + request: SourceEditValidationRequest, + options: { signal: AbortSignal; timeoutMs?: number }, +): Promise<SourceEditSelection | null> { + if (!win || options.signal.aborted) return Promise.resolve(null); + return new Promise((resolve) => { + const requestId = crypto.randomUUID(); + let settled = false; + const finish = (selection: SourceEditSelection | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + window.removeEventListener('message', onMessage); + options.signal.removeEventListener('abort', onAbort); + resolve(selection); + }; + const onAbort = () => finish(null); + const onMessage = (event: MessageEvent) => { + if ( + !isTrustedPreviewMessageSource(event.source, win) || + typeof event.data !== 'object' || + event.data === null + ) + return; + const data = event.data as Record<string, unknown>; + if ( + data['__codesign'] !== true || + data['type'] !== 'SOURCE_EDIT_VALIDATED' || + data['requestId'] !== requestId + ) + return; + const selection: unknown = data['sourceEdit']; + if (selection === null) { + finish(null); + return; + } + if ( + !isSourceEditSelection(selection) || + selection.targetId !== request.targetId || + selection.sourceHash !== request.sourceHash || + selection.previewRevision !== request.previewRevision + ) + return; + finish(selection); + }; + const timer = setTimeout(() => finish(null), options.timeoutMs ?? 3000); + window.addEventListener('message', onMessage); + options.signal.addEventListener('abort', onAbort, { once: true }); + try { + win.postMessage( + { __codesign: true, type: 'SOURCE_EDIT_VALIDATE', requestId, ...request }, + '*', + ); + } catch { + finish(null); + } + }); +} + +export function postSourceEditAncestorToPreviewWindow( + win: Window | null | undefined, + selector: string, + revision: Pick<SourceEditSelection, 'sourceHash' | 'previewRevision'>, + onError: (message: string) => void, +): boolean { + if (!win) return false; + try { + win.postMessage( + { + __codesign: true, + type: 'SOURCE_EDIT_SELECT', + selector, + sourceHash: revision.sourceHash, + previewRevision: revision.previewRevision, + }, + '*', + ); + return true; + } catch (error) { + onError( + `SOURCE_EDIT_SELECT postMessage failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } +} export function formatIframeError( kind: string, @@ -106,7 +225,8 @@ export type AllowedPreviewMessageType = | 'ELEMENT_SELECTION_CLEARED' | 'IFRAME_ERROR' | 'ELEMENT_RECTS' - | 'PREVIEW_ESCAPE'; + | 'PREVIEW_ESCAPE' + | 'SOURCE_EDIT_VALIDATED'; export interface PreviewMessageHandlers { onPreviewEscape?: () => void; @@ -138,6 +258,9 @@ export function handlePreviewMessage( } switch (envelope.type) { + case 'SOURCE_EDIT_VALIDATED': + // A request-scoped listener validates these; never turn them into comments/errors. + return { status: 'handled', type: envelope.type }; case 'PREVIEW_ESCAPE': handlers.onPreviewEscape?.(); return { status: 'handled', type: envelope.type }; @@ -149,10 +272,15 @@ export function handlePreviewMessage( // WindowProxy survives document navigation. Same-frame messages are still // candidate hints, and the main process must independently validate edits. if ( - data.sourceEdit && expectedSourceEditRevision && - (data.sourceEdit.sourceHash !== expectedSourceEditRevision.sourceHash || - data.sourceEdit.previewRevision !== expectedSourceEditRevision.previewRevision) + ((data.sourceEditAncestors !== undefined && !data.sourceEditRevision) || + (data.sourceEditRevision && + (data.sourceEditRevision.sourceHash !== expectedSourceEditRevision.sourceHash || + data.sourceEditRevision.previewRevision !== + expectedSourceEditRevision.previewRevision)) || + (data.sourceEdit && + (data.sourceEdit.sourceHash !== expectedSourceEditRevision.sourceHash || + data.sourceEdit.previewRevision !== expectedSourceEditRevision.previewRevision))) ) { return { status: 'rejected', reason: 'stale-source-edit', type: envelope.type }; } diff --git a/apps/desktop/src/renderer/src/preview/source-edit-messages.test.ts b/apps/desktop/src/renderer/src/preview/source-edit-messages.test.ts new file mode 100644 index 00000000..86f78425 --- /dev/null +++ b/apps/desktop/src/renderer/src/preview/source-edit-messages.test.ts @@ -0,0 +1,72 @@ +import { readFile } from 'node:fs/promises'; +import { i18n, initI18n } from '@open-codesign/i18n'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + sourceEditOriginMessage, + sourceEditReasonKeys, + sourceEditReasonMessage, +} from './source-edit-messages'; + +const t = (key: string) => String(i18n.t(key)); +afterEach(async () => { + await initI18n('en'); +}); + +describe('localized source edit explanations', () => { + it('provides Chinese and English explanations for every supported reason code', async () => { + for (const locale of ['zh-CN', 'en']) { + await initI18n(locale); + for (const code of Object.keys(sourceEditReasonKeys)) { + const message = sourceEditReasonMessage(code, t); + expect(message).not.toContain('canvas.sourceEdit'); + expect(message).not.toContain('⟦'); + expect(message).not.toBe(code); + expect(message.length).toBeGreaterThan(10); + if (locale === 'zh-CN') expect(message).toMatch(/[\u4e00-\u9fff]/); + else expect(message).not.toMatch(/[\u4e00-\u9fff]/); + } + } + }); + it('uses a safe localized fallback for unknown or inherited codes', async () => { + await initI18n('zh-CN'); + const fallback = t('canvas.sourceEdit.reasons.unknown'); + for (const code of ['new-backend-error', 'constructor', 'toString', '__proto__', '']) { + expect(sourceEditReasonMessage(code, t)).toBe(fallback); + } + await initI18n('en'); + expect(sourceEditReasonMessage('future-code', t)).toBe(t('canvas.sourceEdit.reasons.unknown')); + }); + it('describes shared source definitions and operational failures in the current language', async () => { + await initI18n('zh-CN'); + expect(sourceEditOriginMessage(t)).toContain('所有引用'); + expect(sourceEditReasonMessage('stale-source', t)).toContain('源码已发生变化'); + expect(sourceEditReasonMessage('busy', t)).toContain('等待生成结束'); + expect(sourceEditReasonMessage('dynamic-attribute', t)).toContain('静态字符串'); + expect(sourceEditReasonMessage('no-inline-style', t)).toContain('不能在这里新增样式'); + for (const key of ['inspectFailed', 'saveFailed', 'refreshWarning', 'diagnostics']) { + expect(t(`canvas.sourceEdit.${key}`)).toMatch(/[\u4e00-\u9fff]/); + } + }); + it('covers all literal reasons currently emitted by source inspection and saving', async () => { + const sources = await Promise.all( + [ + '../../../main/source-edit-engine.ts', + '../../../main/source-edits-ipc.ts', + '../../../main/source-edit-atomic.ts', + ].map((path) => readFile(new URL(path, import.meta.url), 'utf8')), + ); + const codes = new Set<string>(); + for (const source of sources) { + for (const match of source.matchAll( + /(?:reject(?:ed)?|new SourceEditCommitError)\(\s*'([^']+)'/g, + )) + if (match[1]) codes.add(match[1]); + for (const match of source.matchAll( + /problem\(\s*'(?:target|text|attributes|style)'\s*,\s*'([^']+)'/g, + )) + if (match[1]) codes.add(match[1]); + } + expect(codes.size).toBeGreaterThan(35); + for (const code of codes) expect(Object.hasOwn(sourceEditReasonKeys, code), code).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/src/preview/source-edit-messages.ts b/apps/desktop/src/renderer/src/preview/source-edit-messages.ts new file mode 100644 index 00000000..82ad5231 --- /dev/null +++ b/apps/desktop/src/renderer/src/preview/source-edit-messages.ts @@ -0,0 +1,58 @@ +type Translate = (key: string, options?: Record<string, unknown>) => string; + +// The main process owns reason codes; presentation follows the current UI locale. +export const sourceEditReasonKeys = { + 'unsupported-host': 'unsupportedHost', + 'spread-attributes': 'spreadAttributes', + 'duplicate-attributes': 'duplicateAttributes', + 'reserved-provenance': 'reservedProvenance', + 'children-prop': 'childrenProp', + 'mutable-host': 'mutableHost', + 'unresolved-text-source': 'unresolvedTextSource', + 'non-static-text': 'nonStaticText', + 'dynamic-attribute': 'dynamicAttribute', + 'shared-or-dynamic-style': 'sharedOrDynamicStyle', + 'ambiguous-style': 'ambiguousStyle', + 'dynamic-style-value': 'dynamicStyleValue', + 'no-static-style': 'noStaticStyle', + 'no-static-attribute': 'noStaticAttribute', + 'no-inline-style': 'noInlineStyle', + 'literal-too-large': 'literalTooLarge', + 'source-mode-removed': 'sourceModeRemoved', + 'unsupported-path': 'unsupportedPath', + 'parse-error': 'parseError', + 'unsupported-module': 'unsupportedModule', + 'unsupported-entry': 'unsupportedEntry', + 'custom-component-ancestor': 'customComponentAncestor', + 'text-definition-only': 'textDefinitionOnly', + 'unsupported-targets': 'unsupportedTargets', + 'invalid-scope': 'invalidScope', + 'invalid-operation': 'invalidOperation', + 'stale-source': 'staleSource', + 'invalid-target': 'invalidTarget', + 'unsupported-field': 'unsupportedField', + 'invalid-style': 'invalidStyle', + 'runtime-reserved-value': 'runtimeReservedValue', + 'reparse-failed': 'reparseFailed', + 'workspace-error': 'workspaceError', + cancelled: 'cancelled', + unavailable: 'unavailable', + busy: 'busy', + 'unsupported-source': 'unsupportedSource', + 'workspace-required': 'workspaceRequired', + 'workspace-changed': 'workspaceChanged', + 'unsafe-path': 'unsafePath', + 'invalid-input': 'invalidInput', + 'stage-changed': 'stageChanged', +} as const; + +export function sourceEditReasonMessage(reason: string, t: Translate): string { + const key = Object.hasOwn(sourceEditReasonKeys, reason) + ? sourceEditReasonKeys[reason as keyof typeof sourceEditReasonKeys] + : 'unknown'; + return t(`canvas.sourceEdit.reasons.${key}`); +} + +export function sourceEditOriginMessage(t: Translate): string { + return t('canvas.sourceEdit.origin'); +} diff --git a/apps/desktop/src/renderer/src/preview/useWorkspaceSourceEdit.test.tsx b/apps/desktop/src/renderer/src/preview/useWorkspaceSourceEdit.test.tsx index a20c0e6f..7f7b8743 100644 --- a/apps/desktop/src/renderer/src/preview/useWorkspaceSourceEdit.test.tsx +++ b/apps/desktop/src/renderer/src/preview/useWorkspaceSourceEdit.test.tsx @@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useWorkspaceSourceEdit } from './useWorkspaceSourceEdit'; vi.mock('@open-codesign/i18n', () => ({ useT: () => translate })); -const translate = (key: string) => key; +let translate = (key: string) => key; const hash = 'a'.repeat(64); const target: SourceEditTarget = { id: '1:20', @@ -54,6 +54,7 @@ const inspect = vi.fn(); const apply = vi.fn(); const onPersist = vi.fn(); const onSaved = vi.fn(); +const validate = vi.fn(); function workspaceSource() { if (!props.source) throw new Error('Missing workspace fixture'); return props.source; @@ -79,6 +80,7 @@ async function enableAndSelect() { ); } beforeEach(() => { + translate = (key: string) => key; vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); inspect.mockReset().mockResolvedValue(ready); apply.mockReset().mockImplementation(async (request: SourceEditApplyRequestV1) => ({ @@ -87,6 +89,12 @@ beforeEach(() => { })); onPersist.mockReset(); onSaved.mockReset(); + validate.mockReset().mockImplementation(async (request) => ({ + targetId: request.targetId, + sourceHash: request.sourceHash, + previewRevision: request.previewRevision, + fieldStates: [{ key: request.fieldKey, status: 'ready' }], + })); Object.defineProperty(window, 'codesign', { configurable: true, value: { sourceEdits: { inspect, apply } }, @@ -99,6 +107,7 @@ beforeEach(() => { generating: false, onPersist, onSaved, + validate, }; container = document.createElement('div'); document.body.append(container); @@ -180,6 +189,58 @@ describe('revision-bound local source edits', () => { expect(onSaved).not.toHaveBeenCalled(); expect(api.message).toBeTruthy(); }); + it.each([ + 'inspect-rejected', + 'save-rejected', + 'inspect-throw', + 'save-throw', + 'refresh-warning', + ] as const)('localizes Chinese %s instead of displaying backend English', async (outcome) => { + const { i18n, initI18n } = + await vi.importActual<typeof import('@open-codesign/i18n')>('@open-codesign/i18n'); + await initI18n('zh-CN'); + translate = (key: string) => String(i18n.t(key, { lng: 'zh-CN' })); + const raw = 'Raw backend English failure'; + const rejected = { + schemaVersion: 1, + status: 'rejected', + reason: 'source-conflict', + message: raw, + }; + if (outcome === 'inspect-rejected') inspect.mockResolvedValue(rejected); + if (outcome === 'inspect-throw') inspect.mockRejectedValue(new Error(raw)); + if (outcome.startsWith('inspect')) { + await render(); + await act(async () => api.toggle()); + } else { + await enableAndSelect(); + if (outcome === 'save-rejected') apply.mockResolvedValue(rejected); + if (outcome === 'save-throw') apply.mockRejectedValue(new Error(raw)); + if (outcome === 'refresh-warning') + apply.mockImplementation(async (request: SourceEditApplyRequestV1) => ({ + ...applied, + previewRevision: request.previewRevision, + warnings: [raw, 'Another refresh failure'], + })); + await act(async () => api.apply({ kind: 'set-text', value: 'New' })); + } + if (outcome === 'refresh-warning') { + expect(onSaved).toHaveBeenCalledExactlyOnceWith([ + translate('canvas.sourceEdit.refreshWarning'), + ]); + expect(onPersist).toHaveBeenCalledOnce(); + } else { + expect(api.message).toMatch(/[\u4e00-\u9fff]/); + expect(api.message).not.toContain(raw); + expect(api.message).not.toContain('source-conflict'); + expect(api.message).not.toContain('canvas.sourceEdit.'); + if (outcome === 'inspect-throw') + expect(api.message).toBe(translate('canvas.sourceEdit.inspectFailed')); + if (outcome === 'save-throw') + expect(api.message).toBe(translate('canvas.sourceEdit.saveFailed')); + expect(onSaved).not.toHaveBeenCalled(); + } + }); it('reports a successful disk save with nonfatal notification warnings', async () => { await enableAndSelect(); apply.mockImplementation(async (request: SourceEditApplyRequestV1) => ({ @@ -189,7 +250,7 @@ describe('revision-bound local source edits', () => { })); await act(async () => api.apply({ kind: 'set-text', value: 'New' })); expect(onPersist).toHaveBeenCalledOnce(); - expect(onSaved).toHaveBeenCalledWith(['Refresh notification failed']); + expect(onSaved).toHaveBeenCalledWith(['canvas.sourceEdit.refreshWarning']); }); it.each([ 'design', @@ -268,6 +329,113 @@ describe('revision-bound local source edits', () => { expect.objectContaining({ expectedContent: '<p>Longer tweak value</p>' }), ); }); + it('has one preview-only inspection path with no source-picker bypass', async () => { + await enableAndSelect(); + expect(api).not.toHaveProperty('sourceMode'); + expect(api).not.toHaveProperty('enableSourceSelection'); + expect(api).not.toHaveProperty('sourceTargets'); + expect(inspect.mock.calls[0]?.[0]).not.toHaveProperty('selectionMode'); + await act(async () => api.apply({ kind: 'set-text', value: 'New' })); + expect(validate).toHaveBeenCalledOnce(); + expect(apply.mock.calls[0]?.[0]).not.toHaveProperty('selectionMode'); + }); + it('fails closed when a production target has no field states', async () => { + inspect.mockResolvedValue({ + ...ready, + targets: [{ ...target, textLayout: [{ kind: 'text', value: 'Old' }] }], + }); + await enableAndSelect(); + expect(api.selection?.id).toBe(target.id); + await act(async () => api.apply({ kind: 'set-text', value: 'New' })); + expect(apply).not.toHaveBeenCalled(); + expect(validate).not.toHaveBeenCalled(); + expect(api.message).toBe('canvas.sourceEdit.validationFailed'); + }); + it('keeps a known element selected while gating static and dynamic fields independently', async () => { + inspect.mockResolvedValue({ + ...ready, + targets: [ + { + ...target, + textLayout: [{ kind: 'text', value: 'Old' }, { kind: 'dynamic' }], + editableFields: [ + ...target.editableFields, + { kind: 'set-text', textId: '8:10', value: 'Other' }, + ], + }, + ], + }); + await enableAndSelect(); + const revision = api.inspection?.previewRevision; + if (!revision) throw new Error('no revision'); + await act(async () => + api.select({ + targetId: target.id, + sourceHash: hash, + previewRevision: revision, + fieldStates: [ + { key: 'text:only', status: 'ready' }, + { key: 'text:8:10', status: 'ambiguous' }, + ], + }), + ); + expect(api.selection?.editableFields).toHaveLength(2); + await act(async () => api.apply({ kind: 'set-text', textId: '8:10', value: 'No' })); + expect(apply).not.toHaveBeenCalled(); + await act(async () => api.apply({ kind: 'set-text', value: 'New' })); + expect(apply).toHaveBeenCalledOnce(); + }); + it.each([ + null, + { targetId: target.id, sourceHash: hash, previewRevision: 'old' }, + ])('rejects missing/stale commit validation before IPC apply: %j', async (reply) => { + await enableAndSelect(); + validate.mockResolvedValue(reply); + await act(async () => api.apply({ kind: 'set-text', value: 'New' })); + expect(apply).not.toHaveBeenCalled(); + expect(onSaved).not.toHaveBeenCalled(); + expect(api.selection?.id).toBe(target.id); + expect(api.message).toBe('canvas.sourceEdit.validationFailed'); + }); + it('rejects a field that changes after it was selected', async () => { + await enableAndSelect(); + validate.mockImplementation(async (request) => ({ + ...request, + fieldStates: [{ key: request.fieldKey, status: 'changed' }], + })); + await act(async () => api.apply({ kind: 'set-text', value: 'New' })); + expect(apply).not.toHaveBeenCalled(); + expect(api.fieldStates?.[0]?.status).toBe('changed'); + }); + it('aborts pending validation and ignores late replies after source/context changes', async () => { + await enableAndSelect(); + const pending = deferred<Awaited<ReturnType<typeof props.validate>>>(); + validate.mockReturnValue(pending.promise); + let saving: Promise<void> | undefined; + await act(async () => { + saving = api.apply({ kind: 'set-text', value: 'New' }); + }); + const request = validate.mock.calls[0]?.[0]; + const signal = validate.mock.calls[0]?.[1] as AbortSignal; + props = { ...props, source: { ...workspaceSource(), content: 'external' } }; + await render(); + expect(signal.aborted).toBe(true); + await act(async () => { + pending.resolve({ ...request, fieldStates: [{ key: 'text:only', status: 'ready' }] }); + await saving; + }); + expect(apply).not.toHaveBeenCalled(); + expect(onSaved).not.toHaveBeenCalled(); + }); + it('stores ancestor choices separately even without a source target and clears them on context changes', async () => { + await enableAndSelect(); + await act(async () => api.select(undefined, [{ selector: '/main[1]', tagName: 'main' }])); + expect(api.selection).toBeNull(); + expect(api.ancestors).toEqual([{ selector: '/main[1]', tagName: 'main' }]); + props = { ...props, loading: true }; + await render(); + expect(api.ancestors).toEqual([]); + }); it('cannot inspect or select while generation owns the workspace', async () => { props = { ...props, generating: true }; await render(); diff --git a/apps/desktop/src/renderer/src/preview/useWorkspaceSourceEdit.ts b/apps/desktop/src/renderer/src/preview/useWorkspaceSourceEdit.ts index a91b0d4a..9e948809 100644 --- a/apps/desktop/src/renderer/src/preview/useWorkspaceSourceEdit.ts +++ b/apps/desktop/src/renderer/src/preview/useWorkspaceSourceEdit.ts @@ -1,7 +1,17 @@ import { useT } from '@open-codesign/i18n'; import type { SourceEditSelection } from '@open-codesign/runtime'; -import type { SourceEditOperation, SourceEditTarget } from '@open-codesign/shared'; +import { + type SourceEditOperation, + type SourceEditTarget, + sourceEditFieldKey, +} from '@open-codesign/shared'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + type SourceEditAncestor, + type SourceEditValidationRequest, + sourceEditFieldState, +} from './helpers'; +import { sourceEditReasonMessage } from './source-edit-messages'; import { inspectWorkspaceSourceEdit, persistWorkspaceSourceEdit } from './source-edit-persistence'; export interface SourceEditWorkspaceSource { @@ -17,17 +27,21 @@ export function useWorkspaceSourceEdit(input: { available: boolean; loading?: boolean; generating: boolean; + validate: ( + request: SourceEditValidationRequest, + signal: AbortSignal, + ) => Promise<SourceEditSelection | null>; onPersist: (source: SourceEditWorkspaceSource) => void; onSaved: (warnings: string[]) => void; }) { const t = useT(); - const [sourceMode, setSourceMode] = useState(false); - const [canSelectSource, setCanSelectSource] = useState(false); const [enabled, setEnabled] = useState(false); const [refresh, setRefresh] = useState(0); const [busy, setBusy] = useState(false); const [message, setMessage] = useState<string | null>(null); const [selection, setSelection] = useState<SourceEditTarget | null>(null); + const [fieldStates, setFieldStates] = useState<SourceEditSelection['fieldStates']>(); + const [ancestors, setAncestors] = useState<SourceEditAncestor[]>([]); const allowed = input.available && !input.generating && @@ -46,7 +60,6 @@ export function useWorkspaceSourceEdit(input: { allowed, enabled, refresh, - sourceMode, }), [ input.designId, @@ -57,13 +70,14 @@ export function useWorkspaceSourceEdit(input: { allowed, enabled, refresh, - sourceMode, ], ); const current = useRef(context); current.current = context; const epoch = useRef(0); + const selectionEpoch = useRef(0); const applying = useRef(false); + const validation = useRef<AbortController | null>(null); const [inspected, setInspected] = useState<{ context: typeof context; source: string; @@ -76,11 +90,14 @@ export function useWorkspaceSourceEdit(input: { useEffect(() => { const ticket = ++epoch.current; + validation.current?.abort(); applying.current = false; + selectionEpoch.current++; setSelection(null); + setFieldStates(undefined); + setAncestors([]); setInspected(null); setMessage(null); - setCanSelectSource(false); setBusy(false); if (!context.allowed) setEnabled(false); if ( @@ -103,18 +120,13 @@ export function useWorkspaceSourceEdit(input: { designId: context.designId, path: context.path, expectedContent: context.content, - ...(context.sourceMode ? { selectionMode: 'source' as const } : {}), }, api.inspect, ) .then((result) => { if (current.current !== context || epoch.current !== ticket) return; if (result.status === 'rejected') { - setCanSelectSource( - !context.sourceMode && - ['unsafe-source', 'reused-entry', 'cross-file-source'].includes(result.reason), - ); - setMessage(`${result.message} (${result.reason})`); + setMessage(sourceEditReasonMessage(result.reason, t)); return; } setInspected({ @@ -125,34 +137,49 @@ export function useWorkspaceSourceEdit(input: { targets: result.targets, }); }) - .catch((error: unknown) => { - if (current.current === context && epoch.current === ticket) { - setMessage(error instanceof Error ? error.message : t('errors.unknown')); - } + .catch(() => { + if (current.current === context && epoch.current === ticket) + setMessage(t('canvas.sourceEdit.inspectFailed')); }) .finally(() => { if (current.current === context && epoch.current === ticket) setBusy(false); }); return () => { epoch.current++; + validation.current?.abort(); }; }, [context, t]); + const clearSelection = useCallback(() => { + selectionEpoch.current++; + validation.current?.abort(); + setSelection(null); + setFieldStates(undefined); + setAncestors([]); + }, []); + + const exit = useCallback(() => { + setEnabled(false); + clearSelection(); + }, [clearSelection]); + const select = useCallback( - (meta?: SourceEditSelection) => { - if (!active || sourceMode || applying.current) return; + (meta?: SourceEditSelection, trail: SourceEditAncestor[] = []) => { + if (!active || !inspection || applying.current) return; + selectionEpoch.current++; setMessage(null); - const target = - inspection && - meta && - meta.sourceHash === inspection.sourceHash && - meta.previewRevision === inspection.previewRevision - ? inspection.targets.find((item) => item.id === meta.targetId) - : undefined; + const matches = + meta?.sourceHash === inspection.sourceHash && + meta.previewRevision === inspection.previewRevision; + const target = matches + ? inspection.targets.find((item) => item.id === meta.targetId) + : undefined; setSelection(target ?? null); + setFieldStates(target ? meta?.fieldStates : undefined); + setAncestors(!meta || matches ? trail : []); if (!target) setMessage(t('canvas.sourceEdit.unsupportedSelection')); }, - [active, inspection, sourceMode, t], + [active, inspection, t], ); const apply = useCallback( @@ -166,6 +193,14 @@ export function useWorkspaceSourceEdit(input: { current.current !== context ) return; + const fieldKey = sourceEditFieldKey(operation); + if ( + !selection.editableFields.some((field) => sourceEditFieldKey(field) === fieldKey) || + sourceEditFieldState(selection, operation, fieldStates).status !== 'ready' + ) { + setMessage(t('canvas.sourceEdit.validationFailed')); + return; + } const api = window.codesign?.sourceEdits; if (!api) { setMessage(t('canvas.sourceEdit.unavailable')); @@ -173,9 +208,48 @@ export function useWorkspaceSourceEdit(input: { } applying.current = true; const ticket = epoch.current; + const selectedTicket = selectionEpoch.current; + const controller = new AbortController(); + validation.current = controller; setBusy(true); setMessage(null); try { + const checked = await input.validate( + { + targetId: selection.id, + sourceHash: inspection.sourceHash, + previewRevision: inspection.previewRevision, + fieldKey, + }, + controller.signal, + ); + if ( + current.current !== context || + epoch.current !== ticket || + selectionEpoch.current !== selectedTicket || + controller.signal.aborted + ) + return; + const valid = + checked?.targetId === selection.id && + checked.sourceHash === inspection.sourceHash && + checked.previewRevision === inspection.previewRevision; + if ( + !valid || + sourceEditFieldState(selection, operation, checked?.fieldStates).status !== 'ready' + ) { + setFieldStates( + valid + ? checked?.fieldStates + : selection.editableFields.map((field) => ({ + key: sourceEditFieldKey(field), + status: 'unmapped' as const, + })), + ); + setMessage(t('canvas.sourceEdit.validationFailed')); + return; + } + setFieldStates(checked.fieldStates); const result = await persistWorkspaceSourceEdit( { schemaVersion: 1, @@ -186,16 +260,15 @@ export function useWorkspaceSourceEdit(input: { targetId: selection.id, operation, scope: 'source-definition', - ...(context.sourceMode ? { selectionMode: 'source' as const } : {}), }, api.apply, ); if (current.current !== context || epoch.current !== ticket) return; if (result.status === 'rejected') { - setMessage(`${result.message} (${result.reason})`); + setMessage(sourceEditReasonMessage(result.reason, t)); return; } - setSelection(null); + clearSelection(); setInspected(null); setRefresh((value) => value + 1); input.onPersist({ @@ -204,19 +277,29 @@ export function useWorkspaceSourceEdit(input: { workspaceDesignId: context.designId, }); // Only the atomic-write ACK may announce a save, never an optimistic local patch. - input.onSaved(result.warnings ?? []); - } catch (error) { - if (current.current === context && epoch.current === ticket) { - setMessage(error instanceof Error ? error.message : t('errors.unknown')); - } + input.onSaved(result.warnings?.length ? [t('canvas.sourceEdit.refreshWarning')] : []); + } catch { + if (current.current === context && epoch.current === ticket && !controller.signal.aborted) + setMessage(t('canvas.sourceEdit.saveFailed')); } finally { + if (validation.current === controller) validation.current = null; if (current.current === context && epoch.current === ticket) { applying.current = false; setBusy(false); } } }, - [context, inspection, selection, input.onPersist, input.onSaved, t], + [ + context, + inspection, + selection, + fieldStates, + input.validate, + input.onPersist, + input.onSaved, + clearSelection, + t, + ], ); return { @@ -224,34 +307,16 @@ export function useWorkspaceSourceEdit(input: { eligible, busy, message, - inspection: sourceMode ? null : inspection, - sourceMode, - canSelectSource, - enableSourceSelection: () => { - setSourceMode(true); - setSelection(null); - }, - sourceTargets: - sourceMode && inspection - ? inspection.targets.filter((target) => target.editableFields.length > 0) - : [], - selectSource: (id: string) => { - if (!sourceMode || !inspection || applying.current) return; - setMessage(null); - setSelection( - inspection.targets.find((target) => target.id === id && target.editableFields.length > 0) ?? - null, - ); - }, + inspection, selection: inspection ? selection : null, + fieldStates: inspection ? fieldStates : undefined, + ancestors: inspection ? ancestors : [], toggle: () => { setEnabled((value) => !value); - setSourceMode(false); - setSelection(null); - }, - clearSelection: () => { - if (!sourceMode) setSelection(null); + clearSelection(); }, + clearSelection, + exit, select, apply, }; diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index c59c5b23..140342ed 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -66,16 +66,70 @@ "openInTab": "Open in tab", "previewHint": "Thumbnail preview · double-click the file or use Open in tab for full view", "sourceEdit": { - "chooseSource": "Choose a static source field", - "sourceField": "Source field", - "sourceHint": "Choose a field in the source to edit. Page behavior may affect the rendered result; preview selection is unavailable in this mode.", - "noSourceFields": "No editable direct static fields.", + "diagnostics": "Diagnostic details", + "origin": "Text source: a string definition in this file. Changes affect every use of this definition.", + "inspectFailed": "Could not inspect the current source for editing. Reload the file and try again.", + "saveFailed": "The save request did not complete normally. Check the workspace file and refresh the preview before retrying.", + "refreshWarning": "The source was saved, but the preview refresh notification failed. Reload the preview manually.", + "reasons": { + "unsupportedHost": "Custom elements and hosts containing scripts, styles or other executable content are not editable.", + "spreadAttributes": "Spread or namespaced attributes prevent determining the actual attribute source.", + "duplicateAttributes": "Duplicate attributes prevent safely identifying the definition to edit.", + "reservedProvenance": "This element uses reserved preview provenance attributes and cannot be edited.", + "childrenProp": "Content is supplied through children or HTML injection, obscuring its directly owned source fields.", + "mutableHost": "Editable content or customized behavior prevents establishing a stable source boundary.", + "unresolvedTextSource": "This text is computed, mutable, or supplied by a loop/component parameter. Its literal source cannot be determined.", + "nonStaticText": "This element has no directly owned text. Select its text-bearing child.", + "dynamicAttribute": "This attribute is not a directly declared static string and cannot be edited.", + "sharedOrDynamicStyle": "Style comes from a variable or expression. Only directly owned inline style objects are supported.", + "ambiguousStyle": "Style spreads, duplicate or computed keys prevent identifying the definition to edit.", + "dynamicStyleValue": "This style value is not a static string or number and cannot be edited.", + "noStaticStyle": "No supported static style field is available. Only allowlisted properties such as colors, font size and spacing are editable.", + "noStaticAttribute": "No editable static attribute is available. Only existing static title, placeholder and alt attributes are supported.", + "noInlineStyle": "This element has no directly declared inline style. Adding styles or editing stylesheets is not supported here.", + "literalTooLarge": "This field exceeds the size limit for a single edit.", + "sourceModeRemoved": "Source selection mode was removed. Select an element in the preview to edit it.", + "unsupportedPath": "This file cannot be edited here. Open a workspace JSX or TSX source file.", + "parseError": "The JSX/TSX source could not be parsed. Fix its syntax first.", + "unsupportedModule": "This file uses an unsupported module import or export structure.", + "unsupportedEntry": "This file does not have a supported App/_App entry structure.", + "customComponentAncestor": "This content is passed through a custom component, so its rendered ownership cannot be established.", + "textDefinitionOnly": "Only traced text definitions are editable here. Attribute and layout ownership cannot be established. Shared definitions affect every use.", + "unsupportedTargets": "The source exceeds the supported number of elements or text segments.", + "invalidScope": "Only source-definition edits are supported, not overrides for a single rendered instance.", + "invalidOperation": "This edit operation is unsupported. Use one of the fields offered in the panel.", + "staleSource": "The source changed; no edit was written. Reload the file and select the element again.", + "invalidTarget": "The selected source target is no longer valid. Select an element in the current preview again.", + "unsupportedField": "This is not an editable static field of the selected target. No edit was written.", + "invalidStyle": "The style format, unit or range is unsupported. Use a supported color or nonnegative size.", + "runtimeReservedValue": "The value contains reserved preview runtime markers and cannot be saved as a field.", + "reparseFailed": "The patched source failed its syntax check. No content was written.", + "workspaceError": "The workspace source could not be read or edited. Check the file, reload and try again.", + "cancelled": "The editing window was closed and the operation was cancelled.", + "unavailable": "Generation state is unavailable, so source editing is disabled. Try again later.", + "busy": "This design or workspace is generating. Wait for generation to finish before editing.", + "unsupportedSource": "This source file is unsupported. Editing requires valid JSX/TSX text no larger than 2 MiB.", + "workspaceRequired": "A real local workspace must be bound before source edits can be saved.", + "workspaceChanged": "The workspace changed. Open the current file and select the element again.", + "unsafePath": "The file path or link state does not meet safe-write requirements. No edit can be saved.", + "invalidInput": "The edit request is invalid. Reopen the editing panel and try again.", + "stageChanged": "The temporary save file changed. Writing stopped; check the file before retrying.", + "unknown": "This content cannot currently be confirmed safe to edit. Refresh and select again, or inspect the source if it remains unavailable." + }, + "ancestors": "Containing elements", + "selectAncestor": "Select containing element", + "validationFailed": "The selected preview field could not be verified. Nothing was saved. Select the element again and retry.", + "fieldState": { + "changed": "The displayed content has changed from its source. Select again after refreshing the preview.", + "ambiguous": "This displayed field has more than one possible source or instance. It cannot be edited safely.", + "unmapped": "This field cannot be mapped to the selected preview element. No source will be changed." + }, "toggle": "Edit source", "title": "Local source edit", "close": "Close source editor", "text": "Text", "apply": "Save source definition", - "scope": "Edits the source definition, not an arbitrary rendered instance.", + "scope": "Edits a source definition. Shared variables or component text affect every use, not just the clicked instance. Review the source below before saving.", "reloadWarning": "Saving reloads the preview and may reset its state.", "selectHint": "Select an element in the preview. Only supported static source fields can be edited.", "unsupportedSelection": "This element has no supported source target. Dynamic or ambiguous sources cannot be edited.", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index e0d4fdb6..3b7e893c 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -66,16 +66,70 @@ "openInTab": "在新标签打开", "previewHint": "缩略预览 · 双击文件名或点击「在新标签打开」可在独立标签查看", "sourceEdit": { - "chooseSource": "从源码选择静态字段", - "sourceField": "源码字段", - "sourceHint": "选择源码中的字段进行修改。页面行为可能影响显示结果;此模式不支持预览点选。", - "noSourceFields": "没有可编辑的直接静态字段。", + "diagnostics": "诊断详情", + "origin": "文字来源:当前文件中的字符串定义;修改会影响该定义的所有引用。", + "inspectFailed": "无法检查当前源码是否可编辑,请重新加载文件后再试。", + "saveFailed": "保存请求未正常完成,请检查工作区文件并刷新预览,确认结果后再试。", + "refreshWarning": "源码已保存,但预览刷新通知失败,请手动重新加载预览。", + "reasons": { + "unsupportedHost": "此类自定义元素或承载脚本、样式等内容的元素暂不支持直接编辑。", + "spreadAttributes": "此元素使用展开属性或命名空间属性,无法确定属性的实际来源。", + "duplicateAttributes": "此元素存在重复属性,无法安全确定要修改的位置。", + "reservedProvenance": "此元素使用了预览定位的保留属性,不能直接编辑。", + "childrenProp": "内容通过 children 或 HTML 注入传入,无法确定直接对应的源码字段。", + "mutableHost": "此元素允许直接修改内容或使用自定义行为,无法确认稳定的源码对应关系。", + "unresolvedTextSource": "此处文字由计算、可变数据或循环/组件参数生成,暂时无法确定要修改的源码位置。", + "nonStaticText": "此元素没有直接文字,请选择包含文字的子元素。", + "dynamicAttribute": "该属性的值不是直接声明的静态字符串,暂不支持修改。", + "sharedOrDynamicStyle": "样式来自变量或表达式;目前只支持元素内直接声明的样式对象。", + "ambiguousStyle": "样式含展开、重复或计算属性等结构,无法确定要修改的样式定义。", + "dynamicStyleValue": "该样式值不是静态字符串或数字,暂不支持修改。", + "noStaticStyle": "没有可修改的静态样式字段;目前仅支持颜色、字号、间距等指定属性。", + "noStaticAttribute": "没有可修改的静态属性;目前仅支持已有的 title(提示)、placeholder(占位文字)和 alt(替代文字)。", + "noInlineStyle": "此元素没有直接声明的内联样式;不能在这里新增样式或修改样式表。", + "literalTooLarge": "此字段内容超过单次编辑的大小限制,暂不支持修改。", + "sourceModeRemoved": "源码选择模式已移除,请在预览中选中元素进行修改。", + "unsupportedPath": "此文件不支持源码编辑,请打开工作区中的 JSX 或 TSX 文件。", + "parseError": "无法解析当前 JSX/TSX 源码,请先修复语法错误。", + "unsupportedModule": "当前文件使用了暂不支持的模块导入或导出结构。", + "unsupportedEntry": "当前文件不符合受支持的 App/_App 入口结构,暂不能直接编辑。", + "customComponentAncestor": "此内容位于自定义组件的传入子节点中,无法确定它在预览中的实际归属。", + "textDefinitionOnly": "此处仅支持修改已确定来源的文字定义;无法确认属性和布局的归属。共享定义会影响所有引用。", + "unsupportedTargets": "当前源码的元素或文字片段数量超过编辑分析限制。", + "invalidScope": "当前只支持修改源码定义,不支持仅修改某个显示实例。", + "invalidOperation": "此修改操作不受支持,请使用面板中提供的字段。", + "staleSource": "源码已发生变化,本次修改未写入。请重新加载文件并再次选中元素。", + "invalidTarget": "选中的源码目标已失效,请重新选中当前预览中的元素。", + "unsupportedField": "此字段不属于当前目标可修改的静态字段,未写入修改。", + "invalidStyle": "样式值的格式、单位或范围不受支持,请使用受支持的颜色或非负尺寸。", + "runtimeReservedValue": "输入内容包含预览运行时的保留标记,不能作为字段值保存。", + "reparseFailed": "修改后的源码未通过语法检查,未写入任何内容。", + "workspaceError": "无法读取或修改工作区源码,请检查文件并重新加载后再试。", + "cancelled": "编辑窗口已关闭,本次操作已取消。", + "unavailable": "暂时无法确认生成状态,源码编辑已停用,请稍后重试。", + "busy": "当前设计或工作区正在生成,请等待生成结束后再编辑。", + "unsupportedSource": "此源码文件不符合编辑要求;仅支持不超过 2 MiB 的有效 JSX/TSX 文本文件。", + "workspaceRequired": "需要绑定真实的本地工作区,才能保存源码修改。", + "workspaceChanged": "工作区已发生变化,请打开当前文件并重新选中元素。", + "unsafePath": "该文件路径或链接状态不符合安全写入要求,不能保存修改。", + "invalidInput": "编辑请求无效,请重新打开编辑面板后再试。", + "stageChanged": "保存时检测到临时文件发生变化,已停止写入,请检查文件后重试。", + "unknown": "暂时无法确认此内容可安全修改,请刷新预览后重新选择;若仍不可编辑,请检查源码。" + }, + "ancestors": "上层元素", + "selectAncestor": "选择上层元素", + "validationFailed": "无法确认当前预览字段的来源,未保存任何修改。请重新选中元素后再试。", + "fieldState": { + "changed": "显示内容已与源码不同,请刷新预览后重新选中。", + "ambiguous": "这段显示内容可能对应多个来源或实例,无法安全确定修改对象。", + "unmapped": "无法将此字段对应到选中的预览元素,不会修改源码。" + }, "toggle": "编辑源码", "title": "局部源码编辑", "close": "关闭源码编辑", "text": "文字", "apply": "保存源定义", - "scope": "修改源定义,而非任意渲染实例。", + "scope": "修改源码中的定义。共享变量或组件文案会影响所有引用;不会仅修改当前点击的实例。请核对下方来源后保存。", "reloadWarning": "保存会重新加载预览,可能重置页面状态。", "selectHint": "点击预览中的元素;仅可修改受支持的静态源码字段。", "unsupportedSelection": "此元素没有受支持的源码目标,动态或不明确的来源不可编辑。", diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 3cb84bd6..6213f304 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -40,7 +40,13 @@ export type { IframeErrorMessage } from './iframe-errors'; export { isIframeErrorMessage } from './iframe-errors'; export type { ElementRectsMessage, OverlayMessage } from './overlay'; export { isElementRectsMessage, isOverlayMessage, OVERLAY_SCRIPT } from './overlay'; -export type { SourceEditPreviewOptions, SourceEditSelection } from './source-edit-instrumentation'; +export type { SourceEditFieldState } from './source-edit-binding'; +export type { + SourceEditAncestor, + SourceEditPreviewOptions, + SourceEditSelection, +} from './source-edit-instrumentation'; +export { isSourceEditSelection } from './source-edit-instrumentation'; export { isTweakCompatibilityNotice } from './tweaks-bridge'; const JSX_TEMPLATE_BEGIN = '<!-- AGENT_BODY_BEGIN -->'; @@ -379,11 +385,12 @@ ${baseTag(opts.baseHref)}${runtimeFontLinks(jsx)}<style>*,*::before,*::after{box <script>${TWEAKS_BRIDGE_SETUP}</script> ${jsxRuntimeComponentScripts()} ${applyInitialTweaksScript(normalized)} +${opts.sourceEdit ? `<script>${buildOverlayScript(opts.sourceEdit)}</script>` : ''} ${JSX_TEMPLATE_BEGIN} ${compileAndRunScript(normalized, kind, { liveTweaks: true })} ${JSX_TEMPLATE_END} <script>${TWEAKS_BRIDGE_LISTENER}</script> -<script>${opts.sourceEdit ? buildOverlayScript(opts.sourceEdit) : OVERLAY_SCRIPT}</script> +${opts.sourceEdit ? '' : `<script>${OVERLAY_SCRIPT}</script>`} </body> </html>`; } diff --git a/packages/runtime/src/overlay.test.ts b/packages/runtime/src/overlay.test.ts index 88aeb160..9600143a 100644 --- a/packages/runtime/src/overlay.test.ts +++ b/packages/runtime/src/overlay.test.ts @@ -96,6 +96,10 @@ interface ListenerHarness { windowListeners: Map<string, (e: unknown) => void>; parent: object; postedToParent: unknown[]; + setHitTarget: (target: unknown) => void; + runTick: () => void; + queueMutations: (records: object[]) => void; + hitLayer: { isConnected: boolean; style: Record<string, string> }; } function runOverlayWithHarness(script = OVERLAY_SCRIPT): ListenerHarness { @@ -106,24 +110,56 @@ function runOverlayWithHarness(script = OVERLAY_SCRIPT): ListenerHarness { const windowListeners = new Map<string, (e: unknown) => void>(); const postedToParent: unknown[] = []; const parent = { postMessage: (msg: unknown) => postedToParent.push(msg) }; + let hitTarget: unknown = null; + let tick = () => {}; + const hitLayer = { + isConnected: false, + style: {} as Record<string, string>, + setAttribute: () => {}, + addEventListener: () => {}, + remove: () => { + hitLayer.isConnected = false; + }, + }; const fakeDocument = { body, querySelectorAll: (selector: string) => selectorMatches.get(selector) ?? [], + querySelector: (selector: string) => selectorMatches.get(selector)?.[0] ?? null, + createElement: () => hitLayer, + documentElement: { + appendChild: () => { + hitLayer.isConnected = true; + }, + }, + elementFromPoint: () => hitTarget, getElementById: (id: string) => elementIds.get(id) ?? null, addEventListener: (type: string, fn: (e: unknown) => void) => { documentListeners.set(type, fn); }, removeEventListener: () => {}, }; + let mutations: object[] = []; + class MutationObserver { + observe() {} + takeRecords() { + const records = mutations; + mutations = []; + return records; + } + } const fakeWindow = { + MutationObserver, CSS: { escape: (value: string) => value.replaceAll(':', '\\:') }, addEventListener: (type: string, fn: (e: unknown) => void) => { windowListeners.set(type, fn); }, parent, }; - const fakeSetInterval = () => 1; + const fakeSetInterval = (fn: () => void) => { + tick = fn; + return 1; + }; const sandbox = new Function( 'window', 'document', @@ -140,6 +176,14 @@ function runOverlayWithHarness(script = OVERLAY_SCRIPT): ListenerHarness { windowListeners, parent, postedToParent, + setHitTarget: (target) => { + hitTarget = target; + }, + runTick: () => tick(), + queueMutations: (records) => { + mutations.push(...records); + }, + hitLayer, }; } @@ -675,7 +719,13 @@ describe('source provenance selection hints', () => { previewRevision: 'preview-1', targets: { '10:50': 'button' }, }; - function click(h: ListenerHarness, marker: string | null, tagName = 'BUTTON', parent?: object) { + function click( + h: ListenerHarness, + marker: string | null, + tagName = 'BUTTON', + parent?: object, + childNodes: { nodeType: number; textContent: string }[] = [], + ) { h.windowListeners.get('message')?.({ source: h.parent, data: { __codesign: true, type: 'SET_MODE', mode: 'comment' }, @@ -689,6 +739,7 @@ describe('source provenance selection hints', () => { parentElement: parent ?? h.body, style: {}, outerHTML: '<button>Save</button>', + childNodes, getAttribute: (name: string) => (name === 'data-codesign-source-id' ? marker : null), getBoundingClientRect: () => ({ top: 0, left: 0, width: 10, height: 10 }), }, @@ -707,6 +758,31 @@ describe('source provenance selection hints', () => { }); expect(isOverlayMessage(selection)).toBe(true); }); + it('validates direct text without consuming icon or emphasized sibling content', () => { + const h = runOverlayWithHarness( + buildOverlayScript({ ...context, directTexts: { '10:50': ' Save now' } }), + ); + expect( + click(h, 'preview-1:10:50', 'BUTTON', undefined, [ + { nodeType: 1, textContent: 'icon' }, + { nodeType: 3, textContent: ' Save' }, + { nodeType: 8, textContent: 'React separator' }, + { nodeType: 3, textContent: ' now' }, + ]), + ).toHaveProperty('sourceEdit'); + }); + it.each([ + 'Changed by an effect', + 'Save now', + ' Save now!', + ])('refuses DOM text changed from the inspected definition: %s', (textContent) => { + const h = runOverlayWithHarness( + buildOverlayScript({ ...context, directTexts: { '10:50': ' Save now' } }), + ); + expect( + click(h, 'preview-1:10:50', 'BUTTON', undefined, [{ nodeType: 3, textContent }]), + ).not.toHaveProperty('sourceEdit'); + }); it('never guesses an ancestor origin or trusts unknown marker/tag pairs', () => { for (const [marker, tagName] of [ [null, 'SPAN'], @@ -768,3 +844,308 @@ describe('source provenance selection hints', () => { expect(isOverlayMessage(invalid)).toBe(false); }); }); + +describe('unified preview edit selection and validation', () => { + const sourceHash = 'b'.repeat(64); + const previewRevision = 'preview-edit'; + function fixture() { + const h = runOverlayWithHarness( + buildOverlayScript({ + sourceHash, + previewRevision, + targets: { '10:50': 'button' }, + fieldPlans: { + '10:50': { + textLayout: [ + { kind: 'text', textId: '11:20', value: 'Cart (' }, + { kind: 'dynamic' }, + { kind: 'text', textId: '30:40', value: ')' }, + ], + editableFields: [ + { kind: 'set-text', textId: '11:20', value: 'Cart (' }, + { kind: 'set-text', textId: '30:40', value: ')' }, + ], + }, + }, + }), + ); + const button = { + nodeType: 1, + tagName: 'BUTTON', + id: 'cart', + parentElement: h.body, + style: {}, + isConnected: true, + outerHTML: '<button disabled>Cart (2)</button>', + childNodes: [ + { nodeType: 3, textContent: 'Cart (' }, + { nodeType: 3, textContent: '2' }, + { nodeType: 3, textContent: ')' }, + ], + getAttribute: (name: string) => + name === 'data-codesign-source-id' ? 'preview-edit:10:50' : null, + getBoundingClientRect: () => ({ top: 0, left: 0, width: 100, height: 30 }), + }; + h.selectorMatches.set('#cart', [button]); + h.setHitTarget(button); + function control(type: string, extra: Record<string, unknown> = {}) { + h.windowListeners.get('message')?.({ + source: h.parent, + data: { __codesign: true, type, sourceHash, previewRevision, ...extra }, + }); + } + control('SET_MODE', { mode: 'source-edit' }); + function pointer() { + const preventDefault = vi.fn(); + const stopImmediatePropagation = vi.fn(); + h.documentListeners.get('pointerdown')?.({ + type: 'pointerdown', + clientX: 10, + clientY: 10, + preventDefault, + stopImmediatePropagation, + target: h.hitLayer, + }); + return { preventDefault, stopImmediatePropagation }; + } + return { h, button, control, pointer }; + } + it('selects disabled hosts via a removable hit layer without dispatching artifact clicks', () => { + const { h, control, pointer } = fixture(); + expect(h.hitLayer.isConnected).toBe(true); + const event = pointer(); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(event.stopImmediatePropagation).toHaveBeenCalledOnce(); + const selected = h.postedToParent.at(-1); + expect(isOverlayMessage(selected)).toBe(true); + expect(selected).toMatchObject({ + sourceEditRevision: { sourceHash, previewRevision }, + sourceEdit: { + targetId: '10:50', + fieldStates: [ + { key: 'text:11:20', status: 'ready' }, + { key: 'text:30:40', status: 'ready' }, + ], + }, + }); + control('SET_MODE', { mode: 'default' }); + expect(h.hitLayer.isConnected).toBe(false); + }); + it('revalidates the pinned instance immediately before saving and reports DOM changes', () => { + const { h, button, control, pointer } = fixture(); + pointer(); + const prefixNode = button.childNodes[0]; + if (!prefixNode) throw new Error('Missing text node'); + prefixNode.textContent = 'Changed ('; + control('SOURCE_EDIT_VALIDATE', { + requestId: 'save-1', + targetId: '10:50', + fieldKey: 'text:11:20', + }); + expect(h.postedToParent.at(-1)).toMatchObject({ + type: 'SOURCE_EDIT_VALIDATED', + requestId: 'save-1', + sourceEdit: { + fieldStates: [ + { key: 'text:11:20', status: 'changed' }, + { key: 'text:30:40', status: 'ready' }, + ], + }, + }); + h.runTick(); + expect(h.postedToParent.at(-1)).toMatchObject({ + type: 'ELEMENT_SELECTED', + sourceEdit: { + fieldStates: [ + { key: 'text:11:20', status: 'changed' }, + { key: 'text:30:40', status: 'ready' }, + ], + }, + }); + button.isConnected = false; + control('SOURCE_EDIT_VALIDATE', { + requestId: 'save-2', + targetId: '10:50', + fieldKey: 'text:11:20', + }); + expect(h.postedToParent.at(-1)).toMatchObject({ + type: 'SOURCE_EDIT_VALIDATED', + sourceEdit: null, + }); + }); + it('requires current trusted controls and explicit ancestor navigation', () => { + const { h, button, control, pointer } = fixture(); + const icon = { + ...button, + tagName: 'SVG', + id: 'icon', + parentElement: button, + outerHTML: '<svg/>', + getAttribute: () => null, + }; + h.setHitTarget(icon); + pointer(); + expect(h.postedToParent.at(-1)).toMatchObject({ + sourceEditAncestors: [{ selector: '#cart', tagName: 'button' }], + sourceEditRevision: { sourceHash, previewRevision }, + }); + expect(h.postedToParent.at(-1)).not.toHaveProperty('sourceEdit'); + const count = h.postedToParent.length; + h.windowListeners.get('message')?.({ + source: {}, + data: { + __codesign: true, + type: 'SOURCE_EDIT_SELECT', + selector: '#cart', + sourceHash, + previewRevision, + }, + }); + control('SOURCE_EDIT_SELECT', { selector: '#cart', previewRevision: 'old' }); + control('SOURCE_EDIT_SELECT', { selector: '#elsewhere' }); + expect(h.postedToParent).toHaveLength(count); + control('SOURCE_EDIT_SELECT', { selector: '#cart' }); + expect(h.postedToParent.at(-1)).toMatchObject({ sourceEdit: { targetId: '10:50' } }); + }); + it('flushes pre-selection removals rather than binding a same-valued dynamic survivor', () => { + const { h, button, pointer } = fixture(); + const removed = { nodeType: 3, textContent: 'Cart (' }; + // The first surviving node has the same value but came from dynamic content. + h.queueMutations([ + { type: 'childList', target: button, removedNodes: [removed], addedNodes: [] }, + ]); + pointer(); + expect(h.postedToParent.at(-1)).toMatchObject({ + sourceEdit: { + fieldStates: [ + { key: 'text:11:20', status: 'changed' }, + { key: 'text:30:40', status: 'ready' }, + ], + }, + }); + }); + it('retains original owners when changed text moves before the first observer delivery', () => { + const { h, button, pointer } = fixture(); + const other = { getAttribute: () => null }; + const changed = { nodeType: 3, textContent: 'Z', parentElement: other }; + h.queueMutations([ + { type: 'characterData', target: changed, oldValue: 'Cart (' }, + { type: 'childList', target: button, removedNodes: [changed], addedNodes: [] }, + { type: 'childList', target: other, removedNodes: [], addedNodes: [changed] }, + ]); + pointer(); + expect(h.postedToParent.at(-1)).toMatchObject({ + sourceEdit: { + fieldStates: [ + { key: 'text:11:20', status: 'changed' }, + { key: 'text:30:40', status: 'ready' }, + ], + }, + }); + }); + it('tracks exact static node identity without poisoning an established dynamic sibling', () => { + const { h, button, pointer, control } = fixture(); + pointer(); + const dynamicNode = button.childNodes[1]; + const original = button.childNodes[0]; + if (!dynamicNode || !original) throw new Error('Missing fixture text'); + dynamicNode.textContent = 'Cart ('; + Object.assign(dynamicNode, { parentElement: button }); + h.queueMutations([{ type: 'characterData', target: dynamicNode, oldValue: '2' }]); + control('SOURCE_EDIT_VALIDATE', { + requestId: 'dynamic', + targetId: '10:50', + fieldKey: 'text:11:20', + }); + expect(h.postedToParent.at(-1)).toMatchObject({ + sourceEdit: { + fieldStates: [ + { key: 'text:11:20', status: 'ready' }, + { key: 'text:30:40', status: 'ready' }, + ], + }, + }); + button.childNodes[0] = { nodeType: 3, textContent: 'Cart (' }; + h.queueMutations([ + { + type: 'childList', + target: button, + removedNodes: [original], + addedNodes: [button.childNodes[0]], + }, + ]); + control('SOURCE_EDIT_VALIDATE', { + requestId: 'replacement', + targetId: '10:50', + fieldKey: 'text:11:20', + }); + expect(h.postedToParent.at(-1)).toMatchObject({ + sourceEdit: { + fieldStates: [ + { key: 'text:11:20', status: 'changed' }, + { key: 'text:30:40', status: 'ready' }, + ], + }, + }); + }); + it('fails text readiness closed when mutation observation exceeds its budget', () => { + const { h, button, pointer } = fixture(); + h.queueMutations( + Array.from({ length: 4097 }, () => ({ + type: 'childList', + target: button, + removedNodes: [], + addedNodes: [], + })), + ); + pointer(); + expect(h.postedToParent.at(-1)).toMatchObject({ + sourceEdit: { fieldStates: [{ status: 'unmapped' }, { status: 'unmapped' }] }, + }); + }); + it('bounds new field states, ancestor paths and revisions', () => { + const { h, pointer } = fixture(); + pointer(); + const message = h.postedToParent.at(-1); + if (!isOverlayMessage(message)) throw new Error('Missing selection'); + for (const invalid of [ + { + ...message, + sourceEdit: { + ...message.sourceEdit, + fieldStates: [{ key: 'text:11:20', status: 'approved' }], + }, + }, + { + ...message, + sourceEdit: { + ...message.sourceEdit, + fieldStates: [{ key: 'text:11:20', status: 'ready', start: 0 }], + }, + }, + { + ...message, + sourceEdit: { + ...message.sourceEdit, + fieldStates: [ + { key: 'text:11:20', status: 'ready' }, + { key: 'text:11:20', status: 'ready' }, + ], + }, + }, + { + ...message, + sourceEditAncestors: Array.from({ length: 17 }, () => ({ + selector: '#cart', + tagName: 'button', + })), + }, + { + ...message, + sourceEditAncestors: [{ selector: '#cart', tagName: 'button', sourcePath: '../other.jsx' }], + }, + { ...message, sourceEditRevision: { sourceHash: 'bad', previewRevision } }, + ]) + expect(isOverlayMessage(invalid)).toBe(false); + }); +}); diff --git a/packages/runtime/src/overlay.ts b/packages/runtime/src/overlay.ts index dcfbf742..89045f5c 100644 --- a/packages/runtime/src/overlay.ts +++ b/packages/runtime/src/overlay.ts @@ -16,9 +16,12 @@ * the runtime's iframe HTML builder. */ +import { bindSourceEditFields } from './source-edit-binding'; import { + isSourceEditRevision, isSourceEditSelection, SOURCE_EDIT_ATTRIBUTE, + type SourceEditAncestor, type SourceEditOverlayContext, type SourceEditSelection, } from './source-edit-instrumentation'; @@ -37,7 +40,93 @@ export function buildOverlayScript(sourceEdit?: SourceEditOverlayContext): strin } var currentMode = 'default'; var sourceEditContext = ${JSON.stringify(sourceEdit ?? null).replaceAll('<', '\\u003c')}; + var bindFields = ${sourceEdit?.fieldPlans ? bindSourceEditFields.toString() : 'null'}; + var editHitLayer = null; + var pinnedSourceEditSignature = ''; + var fieldBindings = new WeakMap(); + var invalidTextFields = new WeakMap(); + var textObserver = null; + var mutationOverflow = false; + function markedFieldPlan(el) { + if (!sourceEditContext || !sourceEditContext.fieldPlans || !el || !el.getAttribute) return null; + var marker = el.getAttribute('${SOURCE_EDIT_ATTRIBUTE}'); + var prefix = sourceEditContext.previewRevision + ':'; + return marker && marker.indexOf(prefix) === 0 ? sourceEditContext.fieldPlans[marker.slice(prefix.length)] : null; + } + function rememberBindings(el, plan) { + if (fieldBindings.has(el) || !plan || !bindFields) return; + var bindings = new Map(); + bindFields(el, plan, sourceEditContext.previewRevision, function(key, node) { bindings.set(key, node); }); + fieldBindings.set(el, bindings); + } + function invalidateRemovedText(host, node, oldValue) { + var plan = markedFieldPlan(host); + if (!plan) return; + var bindings = fieldBindings.get(host); + var invalid = invalidTextFields.get(host) || new Set(); + for (var i = 0; i < plan.textLayout.length; i++) { + var part = plan.textLayout[i]; + if (part.kind !== 'text') continue; + var key = 'text:' + (part.textId || 'only'); + if (bindings && bindings.has(key) ? bindings.get(key) === node : part.value === oldValue) invalid.add(key); + } + invalidTextFields.set(host, invalid); + } + function processTextMutations(records) { + if (records.length > 4096) { mutationOverflow = true; return; } + var oldParents = new WeakMap(); + var scanRoots = []; + var touched = new Set(); + var work = 0; + for (var i = 0; i < records.length; i++) { + var record = records[i]; + if (record.type === 'childList') { + touched.add(record.target); + for (var j = 0; j < record.removedNodes.length; j++) { + if (++work > 10000) { mutationOverflow = true; return; } + var removed = record.removedNodes[j]; + if (removed.nodeType === 3) { + var owners = oldParents.get(removed) || new Set(); + owners.add(record.target); + oldParents.set(removed, owners); + invalidateRemovedText(record.target, removed, removed.textContent); + } + } + for (var j = 0; j < record.addedNodes.length; j++) { + if (++work > 10000) { mutationOverflow = true; return; } + if (record.addedNodes[j].nodeType === 1) scanRoots.push(record.addedNodes[j]); + } + } + } + for (var i = 0; i < records.length; i++) { + var record = records[i]; + if (record.type === 'characterData') { + var owners = oldParents.get(record.target) || new Set(); + if (record.target.parentElement) owners.add(record.target.parentElement); + var ownerList = Array.from(owners); + for (var j = 0; j < ownerList.length; j++) { + if (++work > 10000) { mutationOverflow = true; return; } + touched.add(ownerList[j]); + invalidateRemovedText(ownerList[j], record.target, record.oldValue); + } + } + } + touched.forEach(function(el) { rememberBindings(el, markedFieldPlan(el)); }); + for (var i = 0; i < scanRoots.length; i++) { + var stack = [scanRoots[i]]; + while (stack.length) { + if (++work > 10000) { mutationOverflow = true; return; } + var el = stack.pop(); + rememberBindings(el, markedFieldPlan(el)); + for (var child = el.firstElementChild; child; child = child.nextElementSibling) stack.push(child); + } + } + } + function flushTextMutations() { + if (textObserver) processTextMutations(textObserver.takeRecords()); + } function sourceEditSelection(el) { + flushTextMutations(); if (!sourceEditContext || !el || typeof el.getAttribute !== 'function') return null; var marker = el.getAttribute('${SOURCE_EDIT_ATTRIBUTE}'); var prefix = sourceEditContext.previewRevision + ':'; @@ -45,8 +134,35 @@ export function buildOverlayScript(sourceEdit?: SourceEditOverlayContext): strin var id = marker.slice(prefix.length); if (!Object.prototype.hasOwnProperty.call(sourceEditContext.targets, id)) return null; if (String(el.tagName).toLowerCase() !== sourceEditContext.targets[id].toLowerCase()) return null; + var fieldPlan = sourceEditContext.fieldPlans && sourceEditContext.fieldPlans[id]; + if (!fieldPlan && sourceEditContext.directTexts && Object.prototype.hasOwnProperty.call(sourceEditContext.directTexts, id)) { + var directText = ''; + for (var i = 0; i < el.childNodes.length; i++) { + if (el.childNodes[i].nodeType === 3) directText += el.childNodes[i].textContent; + } + if (directText !== sourceEditContext.directTexts[id]) return null; + } // Authored code can forge same-frame DOM and messages; main must revalidate the source. - return { targetId: id, sourceHash: sourceEditContext.sourceHash, previewRevision: sourceEditContext.previewRevision }; + var selection = { targetId: id, sourceHash: sourceEditContext.sourceHash, previewRevision: sourceEditContext.previewRevision }; + if (fieldPlan && bindFields) { + rememberBindings(el, fieldPlan); + var remembered = fieldBindings.get(el); + selection.fieldStates = bindFields(el, fieldPlan, sourceEditContext.previewRevision, function(key, node) { + if (remembered && remembered.has(key) && remembered.get(key) !== node) { + var invalid = invalidTextFields.get(el) || new Set(); + invalid.add(key); + invalidTextFields.set(el, invalid); + } + }); + var invalid = invalidTextFields.get(el); + selection.fieldStates.forEach(function(field) { + if (field.key.indexOf('text:') === 0 && (mutationOverflow || (invalid && invalid.has(field.key)))) { + field.status = mutationOverflow ? 'unmapped' : 'changed'; + field.message = 'The original preview text node changed or was removed; reload before editing this field.'; + } + }); + } + return selection; } window.addEventListener('keydown', function(e) { @@ -215,6 +331,86 @@ export function buildOverlayScript(sourceEdit?: SourceEditOverlayContext): strin return '/' + parts.join('/'); } + function sourceAncestors(el) { + var result = []; + var parent = el && el.parentElement; + while (parent && parent !== document.body && result.length < 16) { + var marker = parent.getAttribute && parent.getAttribute('${SOURCE_EDIT_ATTRIBUTE}'); + var prefix = sourceEditContext && sourceEditContext.previewRevision + ':'; + if (marker && prefix && marker.indexOf(prefix) === 0 && Object.prototype.hasOwnProperty.call(sourceEditContext.targets, marker.slice(prefix.length))) { + result.push({ selector: getXPath(parent), tagName: String(parent.tagName).toLowerCase() }); + } + parent = parent.parentElement; + } + return result; + } + function emitSourceSelection(el) { + if (!el || !el.getBoundingClientRect) return; + var rect = el.getBoundingClientRect(); + var selector = getXPath(el); + clearHover(); + pinElement(el, selector, false); + var provenance = sourceEditSelection(el); + pinnedSourceEditSignature = JSON.stringify(provenance); + var selection = { + __codesign: true, type: 'ELEMENT_SELECTED', selector: selector, + tag: String(el.tagName).toLowerCase(), outerHTML: String(el.outerHTML || '').slice(0, 800), + rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }, + sourceEditAncestors: sourceAncestors(el), + sourceEditRevision: { sourceHash: sourceEditContext.sourceHash, previewRevision: sourceEditContext.previewRevision } + }; + if (provenance) selection.sourceEdit = provenance; + window.parent.postMessage(selection, '*'); + } + function elementUnderEditLayer(x, y) { + if (!editHitLayer) return document.elementFromPoint(x, y); + editHitLayer.style.pointerEvents = 'none'; + try { return document.elementFromPoint(x, y); } + finally { editHitLayer.style.pointerEvents = 'auto'; } + } + function syncEditHitLayer() { + if (currentMode !== 'source-edit') { + if (editHitLayer) editHitLayer.remove(); + editHitLayer = null; + return; + } + if (!editHitLayer) { + editHitLayer = document.createElement('div'); + editHitLayer.setAttribute('data-codesign-edit-hit-layer', ''); + editHitLayer.setAttribute('aria-hidden', 'true'); + editHitLayer.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:transparent;cursor:crosshair;touch-action:none'; + editHitLayer.addEventListener('wheel', function(e) { + if (e.ctrlKey) return; + var el = elementUnderEditLayer(e.clientX, e.clientY); + while (el) { + if (el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth) { + var style = window.getComputedStyle(el); + if (/(auto|scroll)/.test(style.overflowY + style.overflowX) || el === document.scrollingElement) { + var unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? window.innerHeight : 1; + el.scrollBy(e.deltaX * unit, e.deltaY * unit); + e.preventDefault(); + return; + } + } + el = el.parentElement; + } + }, { passive: false }); + } + if (!editHitLayer.isConnected && document.documentElement) document.documentElement.appendChild(editHitLayer); + } + function onEditPointer(e) { + if (currentMode !== 'source-edit') return; + var el = elementUnderEditLayer(e.clientX, e.clientY); + if (e.type === 'pointerdown') { + e.preventDefault(); + e.stopImmediatePropagation(); + if (el) emitSourceSelection(el); + } else { + if (hovered !== el) clearHover(); + hovered = el; + if (hovered && hovered !== pinned) setOutline(hovered, HOVER_OUTLINE); + } + } function onMouseOver(e) { if (currentMode !== 'comment') return; // Don't override pinned outline on hover-in of a different element. @@ -231,6 +427,13 @@ export function buildOverlayScript(sourceEdit?: SourceEditOverlayContext): strin clearHover(); } function onClick(e) { + if (currentMode === 'source-edit') { + e.preventDefault(); + e.stopImmediatePropagation(); + var hit = e.target === editHitLayer ? elementUnderEditLayer(e.clientX, e.clientY) : e.target; + if (hit && hit !== pinned) emitSourceSelection(hit); + return; + } if (currentMode === 'comment') { e.preventDefault(); e.stopPropagation(); @@ -302,12 +505,26 @@ export function buildOverlayScript(sourceEdit?: SourceEditOverlayContext): strin var data = ev.data; if (!data || data.__codesign !== true) return; if (data.type === 'SET_MODE') { - var next = data.mode === 'comment' ? 'comment' : 'default'; + var next = data.mode === 'source-edit' && sourceEditContext ? 'source-edit' : data.mode === 'comment' ? 'comment' : 'default'; if (next === currentMode) return; currentMode = next; - if (currentMode === 'default') { - clearHover(); - clearPinned(); + clearHover(); + clearPinned(); + syncEditHitLayer(); + return; + } + if (data.type === 'SOURCE_EDIT_SELECT' || data.type === 'SOURCE_EDIT_VALIDATE') { + if (currentMode !== 'source-edit' || !sourceEditContext || data.sourceHash !== sourceEditContext.sourceHash || data.previewRevision !== sourceEditContext.previewRevision) return; + if (data.type === 'SOURCE_EDIT_SELECT') { + if (typeof data.selector !== 'string' || data.selector.length > 4000 || !pinned) return; + var ancestors = sourceAncestors(pinned); + if (!ancestors.some(function(item) { return item.selector === data.selector; })) return; + var ancestor = resolveSelector(data.selector); + if (ancestor) emitSourceSelection(ancestor); + } else if (typeof data.requestId === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(data.requestId) && typeof data.fieldKey === 'string' && data.fieldKey.length < 100) { + var validated = pinned && pinned.isConnected ? sourceEditSelection(pinned) : null; + if (!validated || validated.targetId !== data.targetId) validated = null; + window.parent.postMessage({ __codesign: true, type: 'SOURCE_EDIT_VALIDATED', requestId: data.requestId, sourceEdit: validated }, '*'); } return; } @@ -376,7 +593,15 @@ export function buildOverlayScript(sourceEdit?: SourceEditOverlayContext): strin { evt: 'click', fn: onClick }, { evt: 'submit', fn: function(e) { e.preventDefault(); } } ]; + if (sourceEditContext) installs.push({ evt: 'pointerdown', fn: onEditPointer }, { evt: 'pointermove', fn: onEditPointer }); function reattach() { + if (currentMode === 'source-edit') { + syncEditHitLayer(); + if (pinned && pinned.isConnected) { + var signature = JSON.stringify(sourceEditSelection(pinned)); + if (signature !== pinnedSourceEditSignature) emitSourceSelection(pinned); + } + } for (var i = 0; i < installs.length; i++) { var spec = installs[i]; try { document.removeEventListener(spec.evt, spec.fn, true); } catch (err) { warnOnce('removeEventListener failed for ' + spec.evt, err); } @@ -400,6 +625,11 @@ export function buildOverlayScript(sourceEdit?: SourceEditOverlayContext): strin } catch (err) { warnOnce('attach scroll/resize listener failed', err); } } } + if (sourceEditContext && bindFields && window.MutationObserver && document.documentElement) { + textObserver = new window.MutationObserver(processTextMutations); + textObserver.observe(document.documentElement, { childList: true, subtree: true, characterData: true, characterDataOldValue: true }); + processTextMutations([{ type: 'childList', target: document.documentElement, removedNodes: [], addedNodes: [document.documentElement] }]); + } reattach(); if (window.MutationObserver && document.body) { var observer = new window.MutationObserver(scheduleRectsBroadcast); @@ -445,6 +675,8 @@ export interface OverlayMessage { __codesign: true; type: 'ELEMENT_SELECTED'; sourceEdit?: SourceEditSelection; + sourceEditAncestors?: SourceEditAncestor[]; + sourceEditRevision?: Pick<SourceEditSelection, 'sourceHash' | 'previewRevision'>; selector: string; tag: string; outerHTML: string; @@ -461,6 +693,20 @@ export function isOverlayMessage(data: unknown): data is OverlayMessage { d.__codesign === true && d.type === 'ELEMENT_SELECTED' && (d.sourceEdit === undefined || isSourceEditSelection(d.sourceEdit)) && + (d.sourceEditRevision === undefined || isSourceEditRevision(d.sourceEditRevision)) && + (d.sourceEditAncestors === undefined || + (Array.isArray(d.sourceEditAncestors) && + d.sourceEditAncestors.length <= 16 && + d.sourceEditAncestors.every( + (ancestor) => + ancestor && + typeof ancestor.selector === 'string' && + ancestor.selector.length > 0 && + ancestor.selector.length <= 4000 && + typeof ancestor.tagName === 'string' && + /^[a-z][A-Za-z0-9-]{0,127}$/.test(ancestor.tagName) && + Object.keys(ancestor).every((key) => ['selector', 'tagName'].includes(key)), + ))) && typeof d.selector === 'string' && d.selector.length > 0 && d.selector.length <= 8192 && diff --git a/packages/runtime/src/source-edit-binding.test.ts b/packages/runtime/src/source-edit-binding.test.ts new file mode 100644 index 00000000..ce48b26e --- /dev/null +++ b/packages/runtime/src/source-edit-binding.test.ts @@ -0,0 +1,173 @@ +import type { SourceEditOperation, SourceEditTextLayout } from '@open-codesign/shared'; +import { describe, expect, it } from 'vitest'; +import { bindSourceEditFields } from './source-edit-binding'; + +type Child = string | { id?: string; comment?: string }; +function host( + children: Child[], + attributes: Record<string, string> = {}, + styleValues: Record<string, string> = {}, +): Element { + function style(values: Record<string, string>) { + return { + getPropertyValue: (name: string) => values[name] ?? '', + setProperty: (name: string, value: string) => { + values[name] = value; + }, + }; + } + return { + childNodes: children.map((child) => + typeof child === 'string' + ? { nodeType: 3, textContent: child } + : child.comment !== undefined + ? { nodeType: 8, textContent: child.comment } + : { nodeType: 1, getAttribute: () => (child.id ? `rev:${child.id}` : null) }, + ), + getAttribute: (name: string) => attributes[name] ?? null, + style: style(styleValues), + ownerDocument: { createElement: () => ({ style: style({}) }) }, + } as unknown as Element; +} +function text(value: string, textId = '1:2'): SourceEditTextLayout[number] { + return { kind: 'text', value, textId }; +} +function operation(value: string, textId = '1:2'): SourceEditOperation { + return { kind: 'set-text', value, textId }; +} +const dynamic = { kind: 'dynamic' } as const; +function states( + children: Child[], + textLayout: SourceEditTextLayout, + fields: SourceEditOperation[], +) { + return bindSourceEditFields(host(children), { textLayout, editableFields: fields }, 'rev'); +} + +describe('field-level source-definition DOM binding', () => { + it('binds static prefixes and suffixes around changing dynamic content', () => { + const layout = [text('Cart ('), dynamic, text(')', '3:4')]; + const fields = [operation('Cart ('), operation(')', '3:4')]; + for (const count of ['0', '32', '']) { + expect(states(['Cart (', count, ')'], layout, fields).map((s) => s.status)).toEqual([ + 'ready', + 'ready', + ]); + } + }); + it('supports an opaque icon element without flattening its descendants', () => { + expect( + states([{}, 'Download'], [dynamic, text('Download')], [operation('Download')])[0]?.status, + ).toBe('ready'); + expect(states([{}], [text('Download')], [operation('Download')])[0]?.status).toBe('changed'); + }); + it('preserves marked native sibling boundaries, comments and adjacent text nodes', () => { + const layout = [ + text('Hello'), + { kind: 'element' as const, targetId: '10:20' }, + text('world', '3:4'), + ]; + expect( + states(['Hello', { comment: 'React' }, { id: '10:20' }, 'world'], layout, [ + operation('Hello'), + operation('world', '3:4'), + ]).map((s) => s.status), + ).toEqual(['ready', 'ready']); + expect(states(['Helloworld'], layout, [operation('Hello')])[0]?.status).toBe('changed'); + }); + it('does not let a dynamic slot swallow an authored native sibling', () => { + expect( + states( + [{ id: '10:20' }, 'Name'], + [dynamic, text('Name'), { kind: 'element', targetId: '10:20' }], + [operation('Name')], + )[0]?.status, + ).toBe('changed'); + }); + it('keeps uniquely anchored fields while refusing only ambiguous fields', () => { + const result = states( + ['Prefix', 'X', '--', 'X'], + [text('Prefix'), dynamic, text('X', '3:4'), dynamic], + [operation('Prefix'), operation('X', '3:4')], + ); + expect(result.map((s) => s.status)).toEqual(['ready', 'ambiguous']); + }); + it('can independently bind a prefix after an unrelated sibling changed', () => { + expect( + states( + ['Prefix', 'unknown', 'replaced'], + [text('Prefix'), dynamic, text('Suffix', '3:4')], + [operation('Prefix'), operation('Suffix', '3:4')], + ).map((s) => s.status), + ).toEqual(['ready', 'changed']); + }); + it('refuses changed static content and never trims or normalizes user text', () => { + for (const actual of ['Changed', ' Hello', 'Hello ', 'hello']) { + expect(states([actual], [text('Hello')], [operation('Hello')])[0]?.status).toBe('changed'); + } + }); + it('refuses deleted duplicate definitions and surviving sibling substrings', () => { + for (const [left, right, actual] of [ + ['A', 'A', 'A'], + ['A', 'AB', 'AB'], + ]) { + if (left === undefined || right === undefined || actual === undefined) + throw new Error('Missing fixture'); + expect( + states( + [actual], + [text(left), text(right, '3:4')], + [operation(left), operation(right, '3:4')], + ).map((s) => s.status), + ).toEqual(['changed', 'changed']); + } + expect(states(['PrefixA'], [dynamic, text('A')], [operation('A')])[0]?.status).toBe('changed'); + expect(states(['He', 'llo'], [text('Hello')], [operation('Hello')])[0]?.status).toBe('changed'); + }); + it('does not confuse a literal NUL with an element boundary', () => { + expect(states([{}], [text('\0')], [operation('\0')])[0]?.status).toBe('changed'); + expect(states(['\0'], [text('\0')], [operation('\0')])[0]?.status).toBe('ready'); + }); + it('binds attributes and inline properties independently of changed text', () => { + const result = bindSourceEditFields( + host(['Changed'], { title: 'Tooltip' }, { gap: '12px', color: 'red' }), + { + textLayout: [text('Original')], + editableFields: [ + operation('Original'), + { kind: 'set-attribute', name: 'title', value: 'Tooltip' }, + { kind: 'set-style', property: 'gap', value: '12' }, + { kind: 'set-style', property: 'color', value: 'blue' }, + ], + }, + 'rev', + ); + expect(result.map((s) => s.status)).toEqual(['changed', 'ready', 'ready', 'changed']); + }); + it('does not bind a field missing from the inspected text layout', () => { + expect(states(['Hello'], [dynamic], [operation('Hello')])[0]?.status).toBe('changed'); + }); + it('supports a sole legacy text field without an explicit segment id', () => { + expect( + states(['Hello'], [{ kind: 'text', value: 'Hello' }], [{ kind: 'set-text', value: 'Hello' }]), + ).toEqual([{ key: 'text:only', status: 'ready' }]); + }); + it('fails closed on excessive text, layout and matching work', () => { + expect(states(['a'.repeat(50_001)], [dynamic, text('a')], [operation('a')])[0]?.status).toBe( + 'unmapped', + ); + expect( + states( + ['x'], + Array.from({ length: 257 }, () => dynamic), + [operation('x')], + )[0]?.status, + ).toBe('unmapped'); + const result = states( + Array.from({ length: 1024 }, () => 'x'), + [dynamic, text(''), dynamic, text('absent', '3:4')], + [operation(''), operation('absent', '3:4')], + ); + expect(result.every((s) => s.status === 'unmapped')).toBe(true); + }); +}); diff --git a/packages/runtime/src/source-edit-binding.ts b/packages/runtime/src/source-edit-binding.ts new file mode 100644 index 00000000..6ff31c51 --- /dev/null +++ b/packages/runtime/src/source-edit-binding.ts @@ -0,0 +1,187 @@ +import type { SourceEditOperation, SourceEditTextLayout } from '@open-codesign/shared'; + +export interface SourceEditFieldState { + key: string; + status: 'ready' | 'changed' | 'ambiguous' | 'unmapped'; + message?: string; +} +export interface SourceEditBindingPlan { + textLayout: SourceEditTextLayout; + editableFields: SourceEditOperation[]; +} +interface BindingStyle { + getPropertyValue(name: string): string; + setProperty(name: string, value: string): void; +} +interface BindingNode { + nodeType: number; + textContent: string | null; + getAttribute?: (name: string) => string | null; +} +// Structural types keep this module importable by the no-DOM main-process compiler. +export interface SourceEditBindingHost { + childNodes: ArrayLike<BindingNode>; + getAttribute(name: string): string | null; + style?: BindingStyle; + ownerDocument: { createElement(tag: string): { style: BindingStyle } }; +} + +// Self-contained: this exact function also runs inside the isolated preview. +// React's scalar children own Text nodes. Never discard their boundaries and +// mistake a surviving dynamic/sibling substring for a removed definition. +export function bindSourceEditFields( + host: SourceEditBindingHost, + plan: SourceEditBindingPlan, + previewRevision: string, + bindNode?: (key: string, node: BindingNode | null) => void, +): SourceEditFieldState[] { + function fieldKey(field: SourceEditOperation): string { + if (field.kind === 'set-text') return `text:${field.textId ?? 'only'}`; + return field.kind === 'set-attribute' ? `attribute:${field.name}` : `style:${field.property}`; + } + const fields = plan.editableFields.slice(0, 512); + const states: SourceEditFieldState[] = fields.map((field) => ({ + key: fieldKey(field), + status: 'unmapped', + })); + const textStates = states.filter((_, index) => fields[index]?.kind === 'set-text'); + const observed: (string | { id: string | null })[] = []; + const nodes: BindingNode[] = []; + const bindings = new Map<string, BindingNode | null>(); + let textLength = 0; + const prefix = `${previewRevision}:`; + let bounded = + plan.textLayout.length <= 256 && fields.length <= 128 && host.childNodes.length <= 1024; + if (bounded && textStates.length) { + for (const child of Array.from(host.childNodes)) { + if (child.nodeType === 3) { + const value = child.textContent ?? ''; + if (value) { + observed.push(value); + nodes.push(child); + } + textLength += value.length; + } else if (child.nodeType === 1) { + const marker = child.getAttribute?.('data-codesign-source-id'); + observed.push({ id: marker?.startsWith(prefix) ? marker.slice(prefix.length) : null }); + nodes.push(child); + } + if (textLength > 50_000) { + bounded = false; + break; + } + } + } + const anchorIds = new Set( + plan.textLayout.flatMap((part) => (part.kind === 'element' ? [part.targetId] : [])), + ); + const anchorPositions = observed.flatMap((part, index) => + typeof part !== 'string' && part.id !== null && anchorIds.has(part.id) ? [index] : [], + ); + type MatchToken = SourceEditTextLayout[number] | { kind: 'text-slot'; empty: boolean }; + let budget = 30_000; + function match(layout: MatchToken[], wanted: string[]): Map<string, Set<string>> { + const tokens: MatchToken[] = []; + for (const part of layout) { + if (part.kind !== 'dynamic' || tokens.at(-1)?.kind !== 'dynamic') tokens.push(part); + } + const ranges = new Map(wanted.map((key) => [key, new Set<string>()])); + const captures = new Map<string, string>(); + function visit(index: number, position: number): void { + if (--budget < 0 || position > observed.length) return; + if (wanted.every((key) => (ranges.get(key)?.size ?? 0) > 1)) return; + const token = tokens[index]; + if (!token) { + if (position === observed.length) + for (const key of wanted) { + const range = captures.get(key); + if (range !== undefined) ranges.get(key)?.add(range); + } + return; + } + const actual = observed[position]; + if (token.kind === 'text') { + if (token.value !== '' && actual !== token.value) return; + const end = position + (token.value === '' ? 0 : 1); + const key = `text:${token.textId ?? 'only'}`; + captures.set(key, `${position}:${end}`); + visit(index + 1, end); + captures.delete(key); + } else if (token.kind === 'text-slot') { + if (token.empty || typeof actual === 'string') + visit(index + 1, position + (token.empty ? 0 : 1)); + } else if (token.kind === 'element') { + if (typeof actual === 'object' && actual.id === token.targetId) + visit(index + 1, position + 1); + } else { + // Dynamic children cannot consume explicitly owned native siblings. + const last = anchorPositions.find((at) => at >= position) ?? observed.length; + if (!tokens[index + 1]) { + if (last === observed.length) visit(index + 1, observed.length); + } else { + for (let at = position; at <= last && budget >= 0; at++) visit(index + 1, at); + } + } + } + visit(0, 0); + return ranges; + } + if (bounded && textStates.length) { + const ranges = match( + plan.textLayout, + textStates.map((state) => state.key), + ); + for (const state of textStates) { + let found = ranges.get(state.key); + if (!found?.size && budget >= 0) { + // A changed sibling must still occupy its scalar Text slot, not an + // arbitrary wildcard that lets two absent definitions reuse one node. + const independent: MatchToken[] = plan.textLayout.map((part) => + part.kind === 'text' && `text:${part.textId ?? 'only'}` !== state.key + ? { kind: 'text-slot', empty: part.value === '' } + : part, + ); + found = match(independent, [state.key]).get(state.key); + } + state.status = + found?.size === 1 ? 'ready' : found && found.size > 1 ? 'ambiguous' : 'changed'; + if (state.status === 'ready' && found) { + const [start, end] = [...found][0]?.split(':').map(Number) ?? []; + bindings.set(state.key, start === end ? null : (nodes[start ?? -1] ?? null)); + } + } + } + if (!bounded || budget < 0) for (const state of textStates) state.status = 'unmapped'; + for (let index = 0; index < fields.length; index++) { + const field = fields[index]; + const state = states[index]; + if (!field || !state) continue; + if (field.kind === 'set-attribute') { + state.status = host.getAttribute(field.name) === field.value ? 'ready' : 'changed'; + } else if (field.kind === 'set-style') { + const cssName = field.property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + const scratch = host.ownerDocument.createElement('div'); + let value = field.value; + if ( + field.property !== 'color' && + field.property !== 'backgroundColor' && + /^(?:\d+(?:\.\d+)?|\.\d+)$/.test(value) + ) + value += 'px'; + scratch.style.setProperty(cssName, value); + const expected = scratch.style.getPropertyValue(cssName); + const actual = host.style?.getPropertyValue(cssName); + state.status = expected && actual === expected ? 'ready' : 'changed'; + } + if (state.status === 'ready' && field.kind === 'set-text' && bindings.has(state.key)) + bindNode?.(state.key, bindings.get(state.key) ?? null); + if (state.status === 'changed') + state.message = 'The displayed field no longer matches its source definition.'; + if (state.status === 'ambiguous') + state.message = + 'Several text positions match this definition; its rendered origin is ambiguous.'; + if (state.status === 'unmapped') + state.message = 'This field could not be mapped within the preview matching limits.'; + } + return states; +} diff --git a/packages/runtime/src/source-edit-instrumentation.test.ts b/packages/runtime/src/source-edit-instrumentation.test.ts index ff445aad..590a56ce 100644 --- a/packages/runtime/src/source-edit-instrumentation.test.ts +++ b/packages/runtime/src/source-edit-instrumentation.test.ts @@ -59,6 +59,45 @@ describe('source edit preview instrumentation', () => { ); }); + it('carries field plans for mixed text without adding DOM wrappers or rewriting expressions', () => { + const source = 'function App(){const count=2;return <button>Cart ({count})</button>}'; + const plan = planFor(source, ['button']); + const target = plan.targets[0]; + if (!target) throw new Error('Missing target'); + target.textLayout = [ + { kind: 'text', textId: '1:2', value: 'Cart (' }, + { kind: 'dynamic' }, + { kind: 'text', textId: '3:4', value: ')' }, + ]; + target.editableFields = [ + { kind: 'set-text', textId: '1:2', value: 'Cart (' }, + { kind: 'set-text', textId: '3:4', value: ')' }, + ]; + const instrumented = instrumentSourceForEditing(source, plan); + expect(instrumented.context.fieldPlans?.[target.id]).toEqual({ + textLayout: target.textLayout, + editableFields: target.editableFields, + }); + expect( + instrumented.source.replace(/ data-codesign-source-id="[A-Za-z0-9_-]+:\d+:\d+"/g, ''), + ).toBe(source); + expect(instrumented.source).not.toContain('<span'); + const document = buildInteractivePreviewDocument(source, { path: 'App.jsx', sourceEdit: plan }); + const compiled = artifactCompileInput(document); + expect(() => babel.transform(compiled.source, compiled.options)).not.toThrow(); + const scripts = [...document.matchAll(/<script>([\s\S]*?)<\/script>/g)].map( + (match) => match[1] ?? '', + ); + const observerIndex = scripts.findIndex((script) => + script.includes('textObserver.observe(document.documentElement'), + ); + const artifactIndex = scripts.findIndex( + (script) => script.includes('var source =') && script.includes('Cart ('), + ); + expect(observerIndex).toBeGreaterThanOrEqual(0); + expect(artifactIndex).toBeGreaterThan(observerIndex); + }); + it('honors inspected offsets through greater-than text and nested JSX attributes', () => { const source = 'function App(){return <div title="a > b" data-label={<span>Label</span>}>Body</div>}'; diff --git a/packages/runtime/src/source-edit-instrumentation.ts b/packages/runtime/src/source-edit-instrumentation.ts index 17430e9a..9bf72d0e 100644 --- a/packages/runtime/src/source-edit-instrumentation.ts +++ b/packages/runtime/src/source-edit-instrumentation.ts @@ -1,4 +1,5 @@ import type { SourceEditTarget } from '@open-codesign/shared'; +import type { SourceEditBindingPlan, SourceEditFieldState } from './source-edit-binding'; export const SOURCE_EDIT_ATTRIBUTE = 'data-codesign-source-id'; export const MAX_SOURCE_EDIT_TARGETS = 10_000; @@ -8,22 +9,31 @@ export interface SourceEditPreviewOptions { source: string; sourceHash: string; previewRevision: string; - targets: readonly Pick< + targets: readonly (Pick< SourceEditTarget, - 'id' | 'tagName' | 'start' | 'end' | 'insertionOffset' - >[]; + 'id' | 'tagName' | 'start' | 'end' | 'insertionOffset' | 'directText' + > & + Partial<Pick<SourceEditTarget, 'editableFields' | 'textLayout'>>)[]; } export interface SourceEditSelection { targetId: string; sourceHash: string; previewRevision: string; + fieldStates?: SourceEditFieldState[]; +} + +export interface SourceEditAncestor { + selector: string; + tagName: string; } export interface SourceEditOverlayContext { sourceHash: string; previewRevision: string; targets: Record<string, string>; + directTexts?: Record<string, string>; + fieldPlans?: Record<string, SourceEditBindingPlan>; } export function isSourceEditRevision( @@ -46,8 +56,27 @@ export function isSourceEditSelection(value: unknown): value is SourceEditSelect return ( typeof selection.targetId === 'string' && /^\d{1,10}:\d{1,10}$/.test(selection.targetId) && - Object.keys(value).length === 3 && - Object.keys(value).every((key) => ['targetId', 'sourceHash', 'previewRevision'].includes(key)) + Object.keys(value).every((key) => + ['targetId', 'sourceHash', 'previewRevision', 'fieldStates'].includes(key), + ) && + (selection.fieldStates === undefined || + (Array.isArray(selection.fieldStates) && + selection.fieldStates.length <= 512 && + new Set(selection.fieldStates.map((field) => field?.key)).size === + selection.fieldStates.length && + selection.fieldStates.every( + (field) => + field && + typeof field === 'object' && + typeof field.key === 'string' && + /^(?:text:(?:only|\d{1,10}:\d{1,10})|attribute:(?:title|placeholder|alt)|style:(?:color|backgroundColor|fontSize|gap|padding|borderRadius|maxWidth))$/.test( + field.key, + ) && + ['ready', 'changed', 'ambiguous', 'unmapped'].includes(field.status) && + (field.message === undefined || + (typeof field.message === 'string' && field.message.length <= 500)) && + Object.keys(field).every((key) => ['key', 'status', 'message'].includes(key)), + ))) ); } @@ -71,6 +100,8 @@ export function instrumentSourceForEditing( throw new Error('Invalid source edit target plan.'); } const targets: Record<string, string> = {}; + const directTexts: Record<string, string> = {}; + const fieldPlans: Record<string, SourceEditBindingPlan> = {}; const offsets = new Set<number>(); for (const target of plan.targets) { if ( @@ -98,6 +129,12 @@ export function instrumentSourceForEditing( throw new Error('Invalid source edit target plan.'); } targets[target.id] = target.tagName; + if (typeof target.directText === 'string') directTexts[target.id] = target.directText; + if (target.textLayout && target.editableFields) + fieldPlans[target.id] = { + textLayout: target.textLayout, + editableFields: target.editableFields, + }; offsets.add(target.insertionOffset); } const chunks: string[] = []; @@ -112,6 +149,12 @@ export function instrumentSourceForEditing( chunks.push(source.slice(0, end)); return { source: chunks.reverse().join(''), - context: { sourceHash: plan.sourceHash, previewRevision: plan.previewRevision, targets }, + context: { + sourceHash: plan.sourceHash, + previewRevision: plan.previewRevision, + targets, + ...(Object.keys(directTexts).length ? { directTexts } : {}), + ...(Object.keys(fieldPlans).length ? { fieldPlans } : {}), + }, }; } diff --git a/packages/shared/src/source-edits.test.ts b/packages/shared/src/source-edits.test.ts index 74569418..89b8cd03 100644 --- a/packages/shared/src/source-edits.test.ts +++ b/packages/shared/src/source-edits.test.ts @@ -6,6 +6,7 @@ import { SourceEditInspectResultV1, SourceEditOperation, SourceEditTarget, + sourceEditFieldKey, } from './source-edits'; const hash = 'a'.repeat(64); @@ -71,6 +72,57 @@ describe('source edit wire contracts', () => { }).status, ).toBe('ready'); }); + it('carries ordered field layout while rejecting executable or oversized descriptors', () => { + const textLayout = [ + { kind: 'text', textId: '38:43', value: 'Cart (' }, + { kind: 'dynamic' }, + { kind: 'element', targetId: '44:49' }, + ]; + expect(SourceEditTarget.parse({ ...target, textLayout }).textLayout).toEqual(textLayout); + for (const invalid of [ + [{ kind: 'text', value: 'x', start: 0, end: 100 }], + [{ kind: 'dynamic', expression: 'run()' }], + [{ kind: 'element', targetId: '../file.jsx' }], + [{ kind: 'text', value: 'x'.repeat(100_001) }], + Array.from({ length: 10_001 }, () => ({ kind: 'dynamic' })), + ]) + expect(SourceEditTarget.safeParse({ ...target, textLayout: invalid }).success).toBe(false); + expect(SourceEditApplyRequestV1.safeParse({ ...request, textLayout }).success).toBe(false); + }); + it('uses field identities independent from current displayed values', () => { + expect(sourceEditFieldKey({ kind: 'set-text', value: 'Hello' })).toBe('text:only'); + expect(sourceEditFieldKey({ kind: 'set-text', textId: '38:43', value: 'Changed' })).toBe( + 'text:38:43', + ); + expect(sourceEditFieldKey({ kind: 'set-attribute', name: 'title', value: 'Tip' })).toBe( + 'attribute:title', + ); + expect(sourceEditFieldKey({ kind: 'set-style', property: 'gap', value: '12' })).toBe( + 'style:gap', + ); + }); + it('adds bounded text segment identities without accepting caller write ranges', () => { + const operation = { kind: 'set-text', value: 'New', textId: '38:43' }; + expect(SourceEditOperation.parse(operation)).toEqual(operation); + for (const textId of ['../App.jsx', 'run()', '-1:2', '38:43:44', '']) { + expect(SourceEditOperation.safeParse({ ...operation, textId }).success).toBe(false); + } + expect(SourceEditOperation.safeParse({ ...operation, start: 0, end: 999 }).success).toBe(false); + expect( + SourceEditTarget.parse({ + ...target, + directText: 'Hello', + textSources: [ + { + textId: '38:43', + start: 0, + end: 7, + origin: 'const title → string literal', + }, + ], + }).textSources?.[0]?.start, + ).toBe(0); + }); it.each([ 'onClick', 'style', diff --git a/packages/shared/src/source-edits.ts b/packages/shared/src/source-edits.ts index 944e9f03..10291df5 100644 --- a/packages/shared/src/source-edits.ts +++ b/packages/shared/src/source-edits.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -// Source selection edits an explicitly chosen literal without trusting a rendered element. +// The retired source value remains parseable only to return an explicit migration refusal. export const SourceEditSelectionMode = z.enum(['preview', 'source']); export type SourceEditSelectionMode = z.infer<typeof SourceEditSelectionMode>; @@ -20,7 +20,17 @@ export const SourceEditStyleProperty = z.enum([ export type SourceEditStyleProperty = z.infer<typeof SourceEditStyleProperty>; export const SourceEditOperation = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('set-text'), value: z.string().max(100_000) }).strict(), + z + .object({ + kind: z.literal('set-text'), + value: z.string().max(100_000), + // Identifies one direct text segment, not a rendered instance or a client-supplied write range. + textId: z + .string() + .regex(/^\d+:\d+$/) + .optional(), + }) + .strict(), z .object({ kind: z.literal('set-attribute'), @@ -38,6 +48,32 @@ export const SourceEditOperation = z.discriminatedUnion('kind', [ ]); export type SourceEditOperation = z.infer<typeof SourceEditOperation>; +export function sourceEditFieldKey(operation: SourceEditOperation): string { + if (operation.kind === 'set-text') return `text:${operation.textId ?? 'only'}`; + if (operation.kind === 'set-attribute') return `attribute:${operation.name}`; + return `style:${operation.property}`; +} + +export const SourceEditTextLayout = z + .array( + z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('text'), + textId: z + .string() + .regex(/^\d+:\d+$/) + .optional(), + value: z.string().max(100_000), + }) + .strict(), + z.object({ kind: z.literal('element'), targetId: z.string().regex(/^\d+:\d+$/) }).strict(), + z.object({ kind: z.literal('dynamic') }).strict(), + ]), + ) + .max(10_000); +export type SourceEditTextLayout = z.infer<typeof SourceEditTextLayout>; + export const SourceEditUnsupported = z .object({ field: z.enum(['target', 'text', 'attributes', 'style']), @@ -57,6 +93,24 @@ export const SourceEditTarget = z insertionOffset: z.number().int().nonnegative(), scope: SourceEditScope, editableFields: z.array(SourceEditOperation), + // Legacy aggregate text is informational; current previews bind fields independently. + directText: z.string().optional(), + textLayout: SourceEditTextLayout.optional(), + textSources: z + .array( + z + .object({ + textId: z + .string() + .regex(/^\d+:\d+$/) + .optional(), + start: z.number().int().nonnegative(), + end: z.number().int().nonnegative(), + origin: z.string(), + }) + .strict(), + ) + .optional(), unsupported: z.array(SourceEditUnsupported), }) .strict(); From 552fdb169db4bbaa95fafedabb050e51a1b663c4 Mon Sep 17 00:00:00 2001 From: HUANG <15866338256@163.com> Date: Sun, 27 Sep 2026 01:33:02 +0800 Subject: [PATCH 2/4] test(runtime): avoid HTML regex in source edit ordering check --- .../src/source-edit-instrumentation.test.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/runtime/src/source-edit-instrumentation.test.ts b/packages/runtime/src/source-edit-instrumentation.test.ts index 590a56ce..e0aba0e2 100644 --- a/packages/runtime/src/source-edit-instrumentation.test.ts +++ b/packages/runtime/src/source-edit-instrumentation.test.ts @@ -30,11 +30,19 @@ function planFor(source: string, tags: string[]): SourceEditPreviewOptions { }; } -function artifactCompileInput(document: string): { source: string; options: object } { +function artifactCompileInput(document: string): { + source: string; + options: object; + offset: number; +} { const matches = [...document.matchAll(/var source = (.+);\n {2}var options = (.+);/g)]; const artifact = matches.at(-1); if (!artifact?.[1] || !artifact[2]) throw new Error('Missing artifact compile input'); - return { source: JSON.parse(artifact[1]) as string, options: JSON.parse(artifact[2]) as object }; + return { + source: JSON.parse(artifact[1]) as string, + options: JSON.parse(artifact[2]) as object, + offset: artifact.index, + }; } const babel = new Function('exports', 'module', `${BABEL_STANDALONE}\nreturn exports;`)({}, {}) as { @@ -85,17 +93,11 @@ describe('source edit preview instrumentation', () => { const document = buildInteractivePreviewDocument(source, { path: 'App.jsx', sourceEdit: plan }); const compiled = artifactCompileInput(document); expect(() => babel.transform(compiled.source, compiled.options)).not.toThrow(); - const scripts = [...document.matchAll(/<script>([\s\S]*?)<\/script>/g)].map( - (match) => match[1] ?? '', - ); - const observerIndex = scripts.findIndex((script) => - script.includes('textObserver.observe(document.documentElement'), - ); - const artifactIndex = scripts.findIndex( - (script) => script.includes('var source =') && script.includes('Cart ('), - ); + // Check generator-owned markers, not HTML tag syntax: the observer must precede user code. + const observerIndex = document.indexOf('textObserver.observe(document.documentElement'); expect(observerIndex).toBeGreaterThanOrEqual(0); - expect(artifactIndex).toBeGreaterThan(observerIndex); + expect(compiled.source).toContain('Cart ('); + expect(compiled.offset).toBeGreaterThan(observerIndex); }); it('honors inspected offsets through greater-than text and nested JSX attributes', () => { From 6c762edbd2f06303b532dbdda71dbef20f3fb944 Mon Sep 17 00:00:00 2001 From: Sun-sunshine06 <Sun-sunshine06@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:27:00 +0800 Subject: [PATCH 3/4] test(desktop): synchronize source edit mutation with submit Inject the runtime mutation in the form submit capture phase so live preview invalidation cannot disable Save before the validation test submits. Keep a held-pointer regression stimulus across the overlay refresh interval and retain the existing real validation and no-overwrite assertions. Signed-off-by: Sun-sunshine06 <Sun-sunshine06@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com> --- .../SourceEditPanel.browser.test.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts b/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts index b0dc9ffe..443859a4 100644 --- a/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts +++ b/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts @@ -379,7 +379,7 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = expect(await page.$('aside select')).toBeNull(); await assertPanelLayout(); } - async function save(label: string, value: string) { + async function save(label: string, value: string, clickDelay = 0) { const selector = `[aria-label="Save source definition: ${label}"]`; const button = await page.waitForSelector(selector); if (!button) throw new Error('Missing save control'); @@ -400,7 +400,7 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = await page.keyboard.press('A'); await page.keyboard.up('Control'); await page.keyboard.type(value); - await page.click(selector); + await page.click(selector, { delay: clickDelay }); } async function loadPreviewOnlyFixture() { await writeFile(join(workspace, 'App.jsx'), previewOnlyFixture); @@ -451,8 +451,8 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = if (!label) throw new Error(`Missing text field for ${value}`); return label; } - async function saveText(previous: string, value: string) { - await save(await textLabel(previous), value); + async function saveText(previous: string, value: string, clickDelay = 0) { + await save(await textLabel(previous), value, clickDelay); } async function assertPreviewText(selector: string, value: string) { await expect @@ -1045,11 +1045,15 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = const iframe = await frame.frameElement(); if (!iframe) throw new Error('Missing selected preview iframe'); await iframe.evaluate((node) => { - const save = document.querySelector('[aria-label="Save source definition: Text 1"]'); - // Same-window FIFO delivers this mutation before the real click/submit - // validation request, without intercepting or replacing either protocol message. - save?.addEventListener( - 'pointerdown', + const form = document + .querySelector('[aria-label="Save source definition: Text 1"]') + ?.closest('form'); + if (!form) throw new Error('Missing source edit form'); + // The submit capture and React handler share one event turn. A pointerdown + // hook lets the overlay disable Save before submit. Same-window FIFO still + // delivers the mutation before validation, without replacing either message. + form.addEventListener( + 'submit', () => { (node as HTMLIFrameElement).contentWindow?.postMessage( { type: 'TEST_MUTATE_BEFORE_SAVE' }, @@ -1059,7 +1063,8 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = { capture: true, once: true }, ); }); - await saveText('Stable text', 'Must not save'); + // Hold across the overlay's 200 ms refresh to expose pre-submit mutation races. + await saveText('Stable text', 'Must not save', 300); await page.waitForFunction(() => document.querySelector('aside[aria-busy]')?.textContent?.includes('could not be verified'), ); From bf2081826409934a6766fe36046fc47119d7ad68 Mon Sep 17 00:00:00 2001 From: Sun-sunshine06 <Sun-sunshine06@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:00:01 +0800 Subject: [PATCH 4/4] test(desktop): align search settings registration timeout Use the existing 15-second registration-test budget for the first search settings case, which cold-imports the onboarding IPC graph. CI run 36422390415 exceeded its previous default five-second budget. Keep all assertions, production code, browser tests, and global timeouts unchanged. Signed-off-by: Sun-sunshine06 <Sun-sunshine06@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com> --- apps/desktop/src/main/onboarding-ipc.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/main/onboarding-ipc.test.ts b/apps/desktop/src/main/onboarding-ipc.test.ts index 565bfbc7..2aaae947 100644 --- a/apps/desktop/src/main/onboarding-ipc.test.ts +++ b/apps/desktop/src/main/onboarding-ipc.test.ts @@ -173,7 +173,7 @@ describe('registerOnboardingIpc — channel versioning', () => { } finally { probe.mockRestore(); } - }); + }, 15_000); it('registers settings:v1:list-providers without the unversioned settings:list-providers shim', async () => { await registerIpcForTest();