Welcome
+ returnWelcome
{[1, 2].map(n =>Repeated
)}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('{resume.name}
{resume.role}
+ +Profile
and craft.
{${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\n {/* comment */}\n
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(()=>{},[]);returnText
}`; + 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{'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}
; }{data.name}
`)).toBeUndefined(); + }); + + it('rejects mutation after the selected expression and inside closures', () => { + expect( + text( + `const data = { name: 'A' };{data.name}
; 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' };{data.name}
`)).toBeUndefined(); + }); + + it('resolves safe captured constants declared after the component function', () => { + expect( + text(`function App() { return{data.name}
; } const data = { name: 'A' };`)?.value, + ).toBe('A'); + }); + + it.each([ + `const title = 'Outer'; class App { constructor(public title: string) {{title}
; } }`, + `const title = 'Outer'; namespace App { import title = require('unknown');{title}
; }`, + `const title = 'Outer'; namespace App { enum title { Value }{title}
; }`, + `const title = 'Outer'; namespace App { namespace title {}{title}
; }`, + ])('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' };{data.name}
`)).toBeUndefined(); + expect(text(`export const title = 'A';{title}
`)?.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);{data.name}
`, + ), + ).toBeUndefined(); + expect( + text( + `const data = { name: 'A', experience: [{ company: 'B' }] }; data.experience.push(other);{data.name}
`, + ), + ).toBeUndefined(); + expect( + text( + `const data = { name: 'A' }; const title = data.name; data.name = 'B';{title}
`, + ), + ).toBeUndefined(); + }); + + it('keeps unrelated equal text and safe independent objects separate', () => { + const { results } = fixture( + `const good = { name: 'Same' }; const bad = { name: 'Same' }; consume(bad); <>{good.name}
{bad.name}
>`, + ); + 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 <>{resume.name}
{resume.jobs.map((job, index) =>{job.title}
{job.skills.map(skill =>{skill}
)}{resume.jobs[1].title}
>; }`, + ); + 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 =>{job.title}
`, + `(job, index) =>{job.title.toUpperCase()}
`, + `job => { const title = job.title; return{title}
; }`, + `job => format(job.title)`, + `job => job.skills.map(skill => {skill})`, + `function (job) { return{job.title}
; }`, + `() =>Static title
`, + ])('permits verified readonly inline map callbacks: %s', (callback) => { + expect( + text( + `const resume = { name: 'Alex', jobs: [{ title: 'Engineer', skills: ['JS'] }] }; resume.jobs.map(${callback});{resume.name}
`, + )?.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 =>{resume.name}
`, + ), + ).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);{resume.name}
`, + ), + ).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);{resume.name}
`, + ), + ).toBeUndefined(); + expect( + text( + `const resume = { name: 'Alex', jobs: [{ title: 'Engineer' }] }; resume.jobs.map(job => job.title, context);{resume.name}
`, + ), + ).toBeUndefined(); + }); + + it('requires expression node identity from its own tree and handles invalid roots', () => { + const { resolve, expressions } = fixture(`{'A'}
`); + 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 RecordOld
' }); + 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' + ? `` + : 'Old
', + })); + await mount('pages/preview.html'); + expect(read).toHaveBeenCalledWith('a', sourcePath); + expect(container.querySelector('iframe')?.srcdoc).toContain('Old
'); + await toggle(); + expect(inspect).toHaveBeenLastCalledWith({ + schemaVersion: 1, + designId: 'a', + path: sourcePath, + expectedContent: 'Old
', + }); + 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 = deferredThird
' : 'Old
' }; + }); + 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: 'Delayed
' })); + 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 = deferredCurrent
' : 'Old
' }; + }); + 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: 'Current
' }), + ); + await act(async () => pending.resolve({ path: 'App.jsx', content: 'Delayed
' })); + 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 = deferredNew workspace
' })); + expect(container.querySelector('iframe')?.srcdoc).toContain('New workspace'); + await toggle(); + expect(inspect).toHaveBeenLastCalledWith( + expect.objectContaining({ expectedContent: 'New workspace
' }), + ); + }); + + 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 = deferredOld
' }; + }); + 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.querySelectorExternal
' })); + expect(inspect).toHaveBeenLastCalledWith( + expect.objectContaining({ path: 'src/App.jsx', expectedContent: 'External
' }), + ); + 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.querySelectorOriginal heading
Repeated {n}
)}Team design{'ready'}
} +function PropLabel({label}){return{label}
} +function App(){ + React.useEffect(()=>{document.documentElement.dataset.fixture='mounted'},[]); + return{resume.name}
+{resume.city}
{role}
{labels[0]}
+Alex
+Team ready
+Shared map
)}Shared map
+{person.name}
)}{String(7)}
+Preview only
+ + +Left anchorRight
+{String('same')}{'same'}{String('same')}
+Stable text
+{'same'}{'same'}
+{String('prefix same')}{'same'}
+') && 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: '