open-design/apps/web/tests/components/FileViewer.manual-edit-history.test.tsx
chaoxiaoche fce444bcab
Consolidate chat comments preview on main (#2906)
* feat(web): queue chat sends

* feat(web): render code comment directives

* feat(web): add preview comments and manual edits

* fix(web): polish shared chrome controls

* fix(web): align queued send loading state

* feat(web): open primary project artifacts

* fix(web): keep queued sends and tests aligned

* fix(web): restore docked comment tools layout

* fix(web): align preview comment toolbar

* fix(web): place local cli beside handoff

* fix(web): move agent menu beside handoff

* fix(web): make project instructions a direct header action

* fix(web): compact handoff and toolbar labels

* fix(web): clarify handoff menu and annotation label

* fix(web): restore compact cursor handoff trigger

* fix(web): align agent menu trigger with handoff

* fix(web): add draw toolbar close action

* fix(web): move inspect editing into edit mode

* fix(web): avoid reserving comment sidebar in annotation mode

* fix(web): float preview comments panel

* fix(web): keep edit canvas full width

* fix(web): polish preview annotation tools

* fix(web): highlight active preview comments

* fix(web): open comments panel after annotation save

* fix(web): polish comment handoff controls

* fix(web): remove palette preview tool

* fix(web): simplify draw annotation toolbar

* fix(web): restore queued tasks into composer

* fix(web): restore queued send strip styling

* fix(web): hide internal comment target ids

* fix(web): align manual edit panel header

* test(web): cover visual interaction contracts

* fix(web): address PR feedback regressions

* fix(web): preserve artifact chrome state

* fix(daemon): restore project raw file routes

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
Co-authored-by: mrcfps <mrc@powerformer.com>
2026-05-26 10:31:19 +00:00

333 lines
13 KiB
TypeScript

// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ComponentProps } from 'react';
import { emptyManualEditStyles, type ManualEditTarget } from '../../src/edit-mode/types';
import type { ProjectFile } from '../../src/types';
const panelState = vi.hoisted(() => ({
props: null as ComponentProps<typeof import('../../src/components/ManualEditPanel').ManualEditPanel> | null,
}));
vi.mock('../../src/components/ManualEditPanel', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/components/ManualEditPanel')>();
return {
...actual,
ManualEditPanel: (props: ComponentProps<typeof actual.ManualEditPanel>) => {
panelState.props = props;
return <div data-testid="mock-manual-edit-panel" />;
},
};
});
import { FileViewer } from '../../src/components/FileViewer';
function openManualTools() {
// Manual tools now live directly in the primary toolbar.
}
function clickManualTool(testId: string) {
openManualTools();
fireEvent.click(screen.getByTestId(testId));
}
function clickAgentTool(testId: string) {
fireEvent.click(screen.getByTestId(testId));
}
afterEach(() => {
cleanup();
panelState.props = null;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('FileViewer manual edit history regressions', () => {
it('flushes pending style edits before activating draw mode from manual edit', async () => {
const initialSource = '<!doctype html><html><body><h1 data-od-id="hero" style="color: #111111">Hero</h1></body></html>';
let saveResolve!: (value: Response) => void;
const saveResponse = new Promise<Response>((resolve) => {
saveResolve = resolve;
});
const savedSources: string[] = [];
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.includes('/api/projects/project-1/deployments')) {
return new Response(JSON.stringify({ deployments: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
const payload = JSON.parse(String(init.body)) as { content: string };
savedSources.push(payload.content);
return saveResponse;
}
if (url.includes('/api/projects/project-1/raw/preview.html')) {
return new Response(initialSource, { status: 200 });
}
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
vi.stubGlobal('fetch', fetchMock);
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml={initialSource}
/>,
);
clickManualTool('manual-edit-mode-toggle');
await waitFor(() => expect(panelState.props).not.toBeNull());
act(() => {
panelState.props?.onStyleChange?.('hero', { color: '#ef4444' }, 'Style: Hero');
});
clickAgentTool('draw-overlay-toggle');
await waitFor(() => expect(savedSources).toHaveLength(1));
expect(savedSources[0]).toContain('rgb(239, 68, 68)');
openManualTools();
expect(screen.getByTestId('manual-edit-mode-toggle').getAttribute('aria-pressed')).toBe('true');
expect(screen.getByTestId('draw-overlay-toggle').getAttribute('aria-pressed')).toBe('false');
await act(async () => {
saveResolve(new Response(JSON.stringify({ file: htmlPreviewFile() }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
await saveResponse;
});
await waitFor(() => {
openManualTools();
expect(screen.getByTestId('manual-edit-mode-toggle').getAttribute('aria-pressed')).toBe('false');
});
expect(screen.getByTestId('draw-overlay-toggle').getAttribute('aria-pressed')).toBe('true');
});
it('uses the undone source snapshot for a follow-up edit after undo', async () => {
const initialSource = '<!doctype html><html><body><h1 data-od-id="hero" style="color: #111111">Hero</h1></body></html>';
let persistedSource = initialSource;
const savedSources: string[] = [];
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.includes('/api/projects/project-1/deployments')) {
return new Response(JSON.stringify({ deployments: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
const payload = JSON.parse(String(init.body)) as { content: string };
persistedSource = payload.content;
savedSources.push(payload.content);
return new Response(JSON.stringify({ file: htmlPreviewFile() }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/projects/project-1/raw/preview.html')) {
return new Response(persistedSource, { status: 200 });
}
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
vi.stubGlobal('fetch', fetchMock);
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml={initialSource}
/>,
);
clickManualTool('manual-edit-mode-toggle');
await waitFor(() => expect(panelState.props).not.toBeNull());
act(() => {
panelState.props?.onApplyPatch(
{ kind: 'set-style', id: 'hero', styles: { color: '#ef4444' } },
'Style: Hero',
);
});
await waitFor(() => expect(savedSources).toHaveLength(1));
expect(savedSources[0]).toContain('rgb(239, 68, 68)');
act(() => {
panelState.props?.onUndo();
});
await waitFor(() => expect(savedSources).toHaveLength(2));
expect(savedSources[1]).toBe(initialSource);
act(() => {
panelState.props?.onApplyPatch(
{ kind: 'set-style', id: 'hero', styles: { backgroundColor: '#f97316' } },
'Style: Hero',
);
});
await waitFor(() => expect(savedSources).toHaveLength(3));
expect(savedSources[2]).toContain('background-color: rgb(249, 115, 22)');
expect(savedSources[2]).not.toContain('rgb(239, 68, 68)');
});
it('refreshes the manual edit canvas after non-style source patches', async () => {
const initialSource = '<!doctype html><html><body><h1 data-od-id="hero">Hero</h1></body></html>';
const savedSources: string[] = [];
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.includes('/api/projects/project-1/deployments')) {
return new Response(JSON.stringify({ deployments: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
const payload = JSON.parse(String(init.body)) as { content: string };
savedSources.push(payload.content);
return new Response(JSON.stringify({ file: htmlPreviewFile() }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/projects/project-1/raw/preview.html')) {
return new Response(initialSource, { status: 200 });
}
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
vi.stubGlobal('fetch', fetchMock);
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml={initialSource}
/>,
);
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
await waitFor(() => expect(panelState.props).not.toBeNull());
const getActivePreviewFrame = () => screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
await waitFor(() => {
const frame = getActivePreviewFrame();
expect(frame.getAttribute('data-od-active')).toBe('true');
expect(frame.getAttribute('data-od-render-mode')).toBe('srcdoc');
expect(panelState.props?.draft.fullSource).toContain('Hero');
});
act(() => {
panelState.props?.onApplyPatch(
{ id: 'hero', kind: 'set-text', value: 'Updated hero' },
'Content: Hero',
);
});
await waitFor(() => expect(savedSources).toHaveLength(1));
await waitFor(() => expect(panelState.props?.draft.fullSource).toContain('Updated hero'));
await waitFor(() => {
expect(getActivePreviewFrame().srcdoc).toContain('Updated hero');
});
});
it('clears the selected target after deleting an element', async () => {
const initialSource = '<!doctype html><html><body><h1 data-od-id="hero">Hero</h1><p data-od-id="body">Body</p></body></html>';
let persistedSource = initialSource;
const savedSources: string[] = [];
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.includes('/api/projects/project-1/deployments')) {
return new Response(JSON.stringify({ deployments: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
const payload = JSON.parse(String(init.body)) as { content: string };
persistedSource = payload.content;
savedSources.push(payload.content);
return new Response(JSON.stringify({ file: htmlPreviewFile() }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/projects/project-1/raw/preview.html')) {
return new Response(persistedSource, { status: 200 });
}
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
vi.stubGlobal('fetch', fetchMock);
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml={initialSource}
/>,
);
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
await waitFor(() => expect(panelState.props).not.toBeNull());
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
const postMessageSpy = vi.spyOn(frame.contentWindow!, 'postMessage');
await act(async () => {
await panelState.props?.onSelectTarget(heroTarget());
});
await waitFor(() => expect(panelState.props?.selectedTarget?.id).toBe('hero'));
expect(panelState.props?.draft.text).toBe('Hero');
act(() => {
panelState.props?.onApplyPatch(
{ id: 'hero', kind: 'remove-element' },
'Delete element',
);
});
await waitFor(() => expect(savedSources).toHaveLength(1));
expect(savedSources[0]).not.toContain('data-od-id="hero"');
expect(savedSources[0]).toContain('data-od-id="body"');
await waitFor(() => expect(panelState.props?.selectedTarget).toBeNull());
expect(panelState.props?.draft.text).toBe('');
expect(panelState.props?.draft.fullSource).not.toContain('data-od-id="hero"');
expect(postMessageSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'od-edit-selected-target', id: null }),
'*',
);
await waitFor(() => {
expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).srcdoc)
.not.toContain('data-od-id="hero"');
});
});
});
function htmlPreviewFile(): ProjectFile {
return {
name: 'preview.html',
path: 'preview.html',
type: 'file',
size: 1024,
mtime: 1710000000,
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Preview',
entry: 'preview.html',
renderer: 'html',
exports: ['html'],
},
};
}
function heroTarget(): ManualEditTarget {
return {
id: 'hero',
kind: 'text',
label: 'Hero',
tagName: 'h1',
className: '',
text: 'Hero',
rect: { x: 0, y: 0, width: 120, height: 40 },
fields: { text: 'Hero' },
attributes: { 'data-od-id': 'hero' },
styles: emptyManualEditStyles(),
isLayoutContainer: false,
outerHtml: '<h1 data-od-id="hero">Hero</h1>',
};
}