fix(test): align FileViewer tests with merged component

- Drop FileViewer.inspect-empty-hint.test.tsx (deleted on main; should
  have been gone after the merge but the modify/delete conflict left it
  in the tree).
- Take main's FileViewer.test.tsx so the manual-edit interaction tests
  match the merged FileViewer.tsx behaviour, then patch every
  <FileViewer> render with projectKind="prototype" so it satisfies
  the prop requirement release added in #1509.
This commit is contained in:
lefarcen
2026-05-13 18:49:44 +08:00
parent 4096d65125
commit fa2bb59ab3
2 changed files with 84 additions and 232 deletions
@@ -1,164 +0,0 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { FileViewer } from '../../src/components/FileViewer';
import type { ProjectFile } from '../../src/types';
// Regression coverage for nexu-io/open-design#890. When the agent emits
// an HTML artifact with no `data-od-id` / `data-screen-label`
// annotations (a freeform PRD → HTML pass without going through a
// skill, for example), the existing inspect-empty-hint banner lied:
// it said "Click any element with `data-od-id` to tune its style"
// even though no element on the page carried that attribute. The user
// would click, the bridge's click handler would walk up to <html>,
// find nothing tagged, and silently bail — no UI feedback. This test
// pins the new dispatch:
//
// - liveCommentTargets.size === 0 → empty-state copy explaining
// what's missing and how to fix it.
// - liveCommentTargets.size > 0 → existing instruction copy.
//
// And mirrors the same affordance into Picker mode so both inspect
// surfaces give the user the same calibration signal.
function htmlFile(name = 'mock.html'): ProjectFile {
return {
name,
path: name,
type: 'file',
size: 1024,
mtime: 1710000000,
kind: 'html',
mime: 'text/html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Mock',
entry: name,
renderer: 'html',
exports: ['html'],
},
};
}
function postTargetsFromIframe(targets: Array<{ elementId: string }>): void {
// FileViewer's message handler keys off `ev.source ===
// iframeRef.current?.contentWindow` to defend against cross-frame
// chatter on the host. Find the host's iframe in the rendered DOM
// and dispatch a real MessageEvent with `source` set to its
// contentWindow so the handler accepts the payload.
const iframe = document.querySelector('iframe');
if (!iframe) throw new Error('iframe not in DOM yet');
const event = new MessageEvent('message', {
data: {
type: 'od:comment-targets',
targets: targets.map((t) => ({
elementId: t.elementId,
selector: `[data-od-id="${t.elementId}"]`,
label: 'div',
text: '',
position: { x: 0, y: 0, width: 100, height: 50 },
htmlHint: '',
})),
},
source: iframe.contentWindow,
});
window.dispatchEvent(event);
}
describe('FileViewer Inspect/Picker empty-annotation hint (#890)', () => {
afterEach(() => {
cleanup();
});
it('shows the empty-state copy in Inspect mode when the iframe reports zero annotated targets', async () => {
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlFile()}
liveHtml="<html><body><h1>Plain PRD with no data-od-id</h1></body></html>"
/>,
);
// Activate Inspect — the toolbar button has a stable test id from
// the surrounding viewer chrome.
fireEvent.click(screen.getByTestId('inspect-mode-toggle'));
// The bridge boot sequence ends with a `od:comment-targets` post
// carrying an empty array for unannotated artifacts (pinned in
// tests/runtime/srcdoc-bridge-empty-targets.test.ts). Simulate
// that broadcast and assert the host renders the empty-state
// copy instead of the instructive default.
await act(async () => {
postTargetsFromIframe([]);
});
expect(screen.queryByTestId('inspect-empty-hint-no-targets')).toBeTruthy();
expect(screen.queryByTestId('inspect-empty-hint')).toBeNull();
// The message must mention what's missing so the user knows what
// to ask the agent for. We assert on the attribute name verbatim
// — it's the exact token the agent has to add to the artifact.
expect(screen.getByTestId('inspect-empty-hint-no-targets').textContent ?? '')
.toMatch(/data-od-id/);
});
it('switches back to the instructive copy once the iframe reports at least one annotated target', async () => {
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlFile()}
liveHtml="<html><body><main data-od-id='hero'>Hero</main></body></html>"
/>,
);
fireEvent.click(screen.getByTestId('inspect-mode-toggle'));
await act(async () => {
postTargetsFromIframe([{ elementId: 'hero' }]);
});
// Existing copy survives — pinning that the new dispatch doesn't
// accidentally drop the long-standing affordance for users whose
// artifacts already ship annotations.
expect(screen.getByTestId('inspect-empty-hint')).toBeTruthy();
expect(screen.queryByTestId('inspect-empty-hint-no-targets')).toBeNull();
});
it('shows the empty-state copy in Picker mode when the iframe reports zero annotated targets', async () => {
// Picker mode (Comments → Picker tool) has the same failure
// surface as Inspect: clicking an unannotated element no-ops.
// This test mirrors the Inspect coverage above.
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlFile()}
liveHtml="<html><body><h1>No annotations</h1></body></html>"
/>,
);
// Tweaks mode boots with the Picker tool already selected
// (`boardTool` defaults to `'inspect'`), so the empty-state hint
// path fires the moment we enter Tweaks — no inner button click
// needed. The inner `comment-mode-toggle` only renders alongside
// its Pods sibling once Tweaks is on.
fireEvent.click(screen.getByTestId('board-mode-toggle'));
await act(async () => {
postTargetsFromIframe([]);
});
// The same testid surfaces the empty-state copy regardless of
// which inspect surface is active — keeps the i18n-free copy
// consolidated and makes future migration to translated strings
// a single edit instead of two.
expect(screen.queryByTestId('inspect-empty-hint-no-targets')).toBeTruthy();
// Copy is mode-aware so the action is concrete: in Picker mode
// the user is leaving comments, not tuning style.
expect(screen.getByTestId('inspect-empty-hint-no-targets').textContent ?? '')
.toMatch(/comment on/i);
});
});
+84 -68
View File
@@ -1,5 +1,7 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -375,10 +377,7 @@ describe('FileViewer SVG artifacts', () => {
});
const markup = renderToStaticMarkup(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
<FileViewer projectId="project-1" projectKind="prototype" file={file}
isDeck
liveHtml={'<html><body><section class="slide">one</section></body></html>'}
/>,
@@ -406,10 +405,7 @@ describe('FileViewer SVG artifacts', () => {
});
const markup = renderToStaticMarkup(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
<FileViewer projectId="project-1" projectKind="prototype" file={file}
liveHtml={'<html><body><section class="slide">one</section><section class="slide">two</section></body></html>'}
/>,
);
@@ -435,10 +431,7 @@ describe('FileViewer SVG artifacts', () => {
});
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
<FileViewer projectId="project-1" projectKind="prototype" file={file}
liveHtml="<html><body><h1>Hello</h1></body></html>"
/>,
);
@@ -518,10 +511,7 @@ describe('FileViewer SVG artifacts', () => {
vi.stubGlobal('fetch', fetchMock);
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
<FileViewer projectId="project-1" projectKind="prototype" file={file}
liveHtml="<html><body><h1>Hello</h1></body></html>"
/>,
);
@@ -583,10 +573,7 @@ describe('FileViewer SVG artifacts', () => {
vi.stubGlobal('fetch', fetchMock);
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
<FileViewer projectId="project-1" projectKind="prototype" file={file}
liveHtml="<html><body><h1>Hello</h1></body></html>"
/>,
);
@@ -712,10 +699,7 @@ describe('FileViewer SVG artifacts', () => {
vi.stubGlobal('fetch', fetchMock);
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
<FileViewer projectId="project-1" projectKind="prototype" file={file}
liveHtml="<html><body><h1>Hello</h1></body></html>"
/>,
);
@@ -806,10 +790,7 @@ describe('FileViewer SVG artifacts', () => {
});
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
<FileViewer projectId="project-1" projectKind="prototype" file={file}
liveHtml="<html><body><h1>Hello</h1></body></html>"
/>,
);
@@ -856,10 +837,7 @@ describe('FileViewer SVG artifacts', () => {
}), { status: 200 })));
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
<FileViewer projectId="project-1" projectKind="prototype" file={file}
liveHtml="<html><body><h1>Hello</h1></body></html>"
/>,
);
@@ -921,10 +899,7 @@ describe('FileViewer SVG artifacts', () => {
});
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={file}
<FileViewer projectId="project-1" projectKind="prototype" file={file}
liveHtml="<html><body><h1>Hello</h1></body></html>"
/>,
);
@@ -950,7 +925,7 @@ describe('FileViewer SVG artifacts', () => {
});
});
describe('FileViewer comment picker and tweaks mode', () => {
describe('FileViewer tweaks toolbar', () => {
function htmlPreviewFile(): ProjectFile {
return baseFile({
name: 'preview.html',
@@ -968,54 +943,85 @@ describe('FileViewer comment picker and tweaks mode', () => {
});
}
it('turns off tweaks when manual edit is enabled so picker actions disappear', () => {
it('renders the toolbar Draw entry and no legacy picker/pod toggle', () => {
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
const tweaksToggle = screen.getByTestId('board-mode-toggle');
const manualEditToggle = screen.getByTestId('manual-edit-mode-toggle');
fireEvent.click(tweaksToggle);
expect(tweaksToggle.getAttribute('aria-pressed')).toBe('true');
expect(screen.getByTestId('comment-mode-toggle')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Pods' })).toBeTruthy();
fireEvent.click(manualEditToggle);
expect(manualEditToggle.getAttribute('aria-pressed')).toBe('true');
expect(tweaksToggle.getAttribute('aria-pressed')).toBe('false');
expect(screen.getByTestId('palette-tweaks-toggle')).toBeTruthy();
expect(screen.getByTestId('draw-overlay-toggle')).toBeTruthy();
expect(screen.queryByPlaceholderText('Type anywhere to add a note')).toBeNull();
expect(screen.queryByTestId('board-mode-toggle')).toBeNull();
expect(screen.queryByTestId('comment-mode-toggle')).toBeNull();
expect(screen.queryByRole('button', { name: 'Pods' })).toBeNull();
expect(screen.queryByTestId('inspect-mode-toggle')).toBeNull();
expect(screen.queryByRole('button', { name: 'Inspect' })).toBeNull();
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
expect(screen.getByPlaceholderText('Type anywhere to add a note')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Click' })).toBeTruthy();
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
expect(screen.queryByPlaceholderText('Type anywhere to add a note')).toBeNull();
});
it('turns off manual edit when tweaks are enabled from edit mode', () => {
it('keeps the Draw bar open after queueing an annotation', () => {
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
const tweaksToggle = screen.getByTestId('board-mode-toggle');
const manualEditToggle = screen.getByTestId('manual-edit-mode-toggle');
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
const note = screen.getByPlaceholderText('Type anywhere to add a note');
fireEvent.change(note, { target: { value: 'mark this' } });
fireEvent.click(screen.getByRole('button', { name: 'Queue' }));
fireEvent.click(manualEditToggle);
expect(manualEditToggle.getAttribute('aria-pressed')).toBe('true');
expect(screen.getByPlaceholderText('Type anywhere to add a note')).toBeTruthy();
expect(screen.getAllByRole('button', { name: 'Draw' })[1]?.getAttribute('aria-pressed')).toBe('true');
expect(screen.getByRole('button', { name: 'Click' }).getAttribute('aria-pressed')).toBe('false');
fireEvent.click(tweaksToggle);
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
expect(screen.queryByPlaceholderText('Type anywhere to add a note')).toBeNull();
});
expect(tweaksToggle.getAttribute('aria-pressed')).toBe('true');
expect(manualEditToggle.getAttribute('aria-pressed')).toBe('false');
expect(screen.getByTestId('comment-mode-toggle')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Pods' })).toBeTruthy();
it('enables element picking while the Draw bar is in click mode', async () => {
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
await waitFor(() => expect(frame.srcdoc).not.toContain('data-od-selection-bridge'));
fireEvent.click(screen.getByRole('button', { name: 'Click' }));
await waitFor(() => expect(frame.srcdoc).toContain('data-od-selection-bridge'));
expect(frame.srcdoc).toContain('data-od-comment-mode');
});
it('disables Draw direct send while a task is running but keeps queue available', () => {
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
streaming
/>,
);
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
fireEvent.change(screen.getByPlaceholderText('Type anywhere to add a note'), {
target: { value: 'mark this' },
});
const queue = screen.getByRole('button', { name: 'Queue' }) as HTMLButtonElement;
expect(queue.disabled).toBe(false);
const send = screen.getByRole('button', { name: 'Send (当前正有任务在执行)' }) as HTMLButtonElement;
expect(send.disabled).toBe(true);
expect(send.getAttribute('title')).toBe('当前正有任务在执行');
});
});
@@ -1568,6 +1574,16 @@ function baseLiveArtifactWorkspaceEntry(
}
describe('LiveArtifactViewer', () => {
it('keeps the presentation exit button aligned with preview chrome spacing', () => {
const css = readFileSync(join(process.cwd(), 'src/index.css'), 'utf8');
const rule = css.match(/\.present-exit\s*\{[^}]+\}/)?.[0] ?? '';
expect(rule).toContain('top: calc(env(safe-area-inset-top, 0px) + 20px);');
expect(rule).toContain('right: calc(env(safe-area-inset-right, 0px) + 20px);');
expect(rule).toContain('display: inline-flex;');
expect(rule).toContain('align-items: center;');
});
it('enters and exits in-tab presentation from the present menu', async () => {
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);