open-design/apps/web/tests/components/FileViewer.manual-edit.test.tsx
Caprika 6736310a01
Implement manual edit inspector (#1448)
* feat(web): tweaks palette popover with HSL hue-shift recoloring

Adds a Tweaks color-palette popover to the HTML preview toolbar.
Selecting a palette re-skins the iframe in place via a srcDoc-side
bridge that walks the DOM and shifts every chromatic paint to the
target hue while preserving each color's saturation and lightness —
pale tints stay pale, bold CTAs stay bold, just in the new color
family. Mono-noir desaturates instead of shifting.

- runtime/srcdoc: new injectPaletteBridge + paletteBridge / initialPalette options
- file-viewer-render-mode: paletteActive flips URL-load back to srcDoc so the bridge can be injected
- FileViewer: state, popover, postMessage wiring, srcDoc + useUrlLoadPreview integration
- PaletteTweaks: popover UI with Original + Coral / Electric / Acid forest / Risograph / Mono noir
- PreviewDrawOverlay: stub pass-through until the draw branch lands

* feat(web): hide finalize-design toolbar from project header

* test(e2e): skip project actions toolbar flow after toolbar removal

* Polish manual edit inspector

* Implement manual edit inspector

* Fix manual edit review regressions

* Fix FileViewer CI regressions

* Fix remaining manual edit review issues

* Flush manual edit styles before draw exit

* Restore Critique Theater styles

* Accept pixel line-height manual edits

---------

Co-authored-by: qiongyu1999 <2694684348@qq.com>
2026-05-13 13:25:58 +08:00

167 lines
6 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', () => {
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"
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"
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" file={first} />);
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/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" 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();
}
});
});
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'],
},
};
}