mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* 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>
222 lines
8.4 KiB
TypeScript
222 lines
8.4 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 {
|
|
FileViewer,
|
|
cancelManualEditPendingStyleSnapshot,
|
|
} from '../../src/components/FileViewer';
|
|
import type { ProjectFile } from '../../src/types';
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('FileViewer manual edit regressions', () => {
|
|
function clickManualTool(testId: string) {
|
|
fireEvent.click(screen.getByTestId(testId));
|
|
}
|
|
|
|
it('removes invalid fields from pending manual edit style saves without dropping unrelated fields', () => {
|
|
expect(cancelManualEditPendingStyleSnapshot({
|
|
id: 'hero',
|
|
label: 'Style: Hero',
|
|
version: 1,
|
|
styles: { fontSize: '4px', color: '#111111' },
|
|
}, 'hero', ['fontSize'])).toEqual({
|
|
id: 'hero',
|
|
label: 'Style: Hero',
|
|
version: 1,
|
|
styles: { color: '#111111' },
|
|
});
|
|
|
|
expect(cancelManualEditPendingStyleSnapshot({
|
|
id: 'hero',
|
|
label: 'Style: Hero',
|
|
version: 1,
|
|
styles: { fontSize: '4px' },
|
|
}, 'hero', ['fontSize'])).toBeNull();
|
|
|
|
const otherTargetPending = {
|
|
id: 'hero',
|
|
label: 'Style: Hero',
|
|
version: 1,
|
|
styles: { fontSize: '4px' },
|
|
};
|
|
expect(cancelManualEditPendingStyleSnapshot(otherTargetPending, 'cta', ['fontSize'])).toBe(otherTargetPending);
|
|
});
|
|
|
|
it('does not let a pending manual edit style save survive a file switch', () => {
|
|
vi.useFakeTimers();
|
|
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/files') && init?.method === 'POST') {
|
|
return new Response(JSON.stringify({ file: htmlPreviewFile() }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response('<!doctype html><html><body></body></html>', { status: 200 });
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
try {
|
|
const first = htmlPreviewFile();
|
|
const second = { ...htmlPreviewFile(), name: 'second.html', path: 'second.html' };
|
|
const { rerender } = render(
|
|
<FileViewer projectId="project-1" projectKind="prototype" file={first}
|
|
liveHtml='<!doctype html><html><body><main data-od-id="hero">Hero</main></body></html>'
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
|
const baseSizeInput = Array.from(document.querySelectorAll('.cc-row'))
|
|
.find((row) => row.textContent?.includes('Base size'))
|
|
?.querySelector('input') as HTMLInputElement | null;
|
|
if (!baseSizeInput) throw new Error('Base size input not found');
|
|
fireEvent.change(baseSizeInput, { target: { value: '18' } });
|
|
|
|
rerender(
|
|
<FileViewer projectId="project-1" projectKind="prototype" file={second}
|
|
liveHtml='<!doctype html><html><body><main data-od-id="second">Second</main></body></html>'
|
|
/>,
|
|
);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(1100);
|
|
});
|
|
|
|
expect(fetchMock).not.toHaveBeenCalledWith(
|
|
'/api/projects/project-1/files',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('clears loaded source immediately on file switch without liveHtml before manual edit can save', async () => {
|
|
let secondResolve!: (value: Response) => void;
|
|
const secondFetch = new Promise<Response>((resolve) => {
|
|
secondResolve = resolve;
|
|
});
|
|
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/files') && init?.method === 'POST') {
|
|
return new Response(JSON.stringify({ file: htmlPreviewFile() }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
if (url.includes('/api/projects/project-1/raw/second.html')) return secondFetch;
|
|
return new Response('<!doctype html><html><body><main data-od-id="hero">First</main></body></html>', { status: 200 });
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
try {
|
|
const first = htmlPreviewFile();
|
|
const second = { ...htmlPreviewFile(), name: 'second.html', path: 'second.html' };
|
|
const { rerender } = render(<FileViewer projectId="project-1" projectKind="prototype" file={first} />);
|
|
|
|
// The raw fetch is cache-busted on every mtime / reload / files-refresh
|
|
// bump so srcDoc-mode previews see fresh HTML after agent edits.
|
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
|
expect.stringMatching(/^\/api\/projects\/project-1\/raw\/preview\.html(\?|$)/),
|
|
{},
|
|
));
|
|
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
|
const baseSizeInput = await waitFor(() => {
|
|
const input = Array.from(document.querySelectorAll('.cc-row'))
|
|
.find((row) => row.textContent?.includes('Base size'))
|
|
?.querySelector('input') as HTMLInputElement | null;
|
|
if (!input) throw new Error('Base size input not found');
|
|
return input;
|
|
});
|
|
fireEvent.change(baseSizeInput, { target: { value: '18' } });
|
|
|
|
rerender(<FileViewer projectId="project-1" projectKind="prototype" file={second} />);
|
|
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
});
|
|
|
|
expect(fetchMock).not.toHaveBeenCalledWith(
|
|
'/api/projects/project-1/files',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
);
|
|
secondResolve(new Response('<!doctype html><html><body><main data-od-id="second">Second</main></body></html>', { status: 200 }));
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('clears a prior manual edit save error after a later successful save', async () => {
|
|
const source = '<!doctype html><html><body><main data-od-id="hero">Hero</main></body></html>';
|
|
let saveAttempts = 0;
|
|
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/files') && init?.method === 'POST') {
|
|
saveAttempts += 1;
|
|
if (saveAttempts === 1) {
|
|
return new Response(JSON.stringify({
|
|
error: { code: 'FORBIDDEN', message: 'Request failed (403).' },
|
|
}), { status: 403, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
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(source, { 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={source}
|
|
/>,
|
|
);
|
|
|
|
clickManualTool('manual-edit-mode-toggle');
|
|
const baseSizeInput = await waitFor(() => {
|
|
const input = Array.from(document.querySelectorAll('.cc-row'))
|
|
.find((row) => row.textContent?.includes('Base size'))
|
|
?.querySelector('input') as HTMLInputElement | null;
|
|
if (!input) throw new Error('Base size input not found');
|
|
return input;
|
|
});
|
|
|
|
fireEvent.change(baseSizeInput, { target: { value: '18' } });
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Could not save the edited file/)).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.change(baseSizeInput, { target: { value: '19' } });
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/Could not save the edited file/)).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
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'],
|
|
},
|
|
};
|
|
}
|