Files
nexu-io--open-design/apps/web/tests/components/AssistantMessage.linkClick.test.tsx
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

228 lines
8.4 KiB
TypeScript

// @vitest-environment jsdom
/**
* End-to-end coverage for chat file-link routing (issue #1239).
*
* Before this fix, every `<a>` rendered from chat markdown carried
* `target="_blank"` with no `onClick`. In Electron that hits the desktop
* `setWindowOpenHandler` and creates a new `od://` BrowserWindow; relative
* hrefs like `template.html` have no base so the new window can't resolve
* them and the user lands on the home screen. The fix detects in-project
* file paths in chat markdown and routes them through the existing
* `requestOpenFile` workspace tab opener.
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AssistantMessage } from '../../src/components/AssistantMessage';
import type { ChatMessage } from '../../src/types';
afterEach(() => cleanup());
function messageWithText(text: string): ChatMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: text,
events: [{ kind: 'text', text }],
startedAt: 1_000,
endedAt: 3_000,
runStatus: 'succeeded',
};
}
describe('AssistantMessage — chat file-link routing (#1239)', () => {
it('routes a relative file-link click through onRequestOpenFile and suppresses the default new-window behavior', () => {
const onRequestOpenFile = vi.fn();
const { container } = render(
<AssistantMessage
message={messageWithText('Open [template.html](template.html) to preview.')}
streaming={false}
projectId="project-1"
onRequestOpenFile={onRequestOpenFile}
/>,
);
const anchor = container.querySelector('a.md-link');
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute('href')).toBe('template.html');
// Dispatch a real DOM MouseEvent so defaultPrevented reflects what
// Electron's setWindowOpenHandler actually reads.
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor!.dispatchEvent(clickEvent);
expect(onRequestOpenFile).toHaveBeenCalledTimes(1);
expect(onRequestOpenFile).toHaveBeenCalledWith('template.html');
expect(clickEvent.defaultPrevented).toBe(true);
});
it('normalizes ./ and nested subdirectory paths before opening', () => {
const onRequestOpenFile = vi.fn();
const { container } = render(
<AssistantMessage
message={messageWithText('Inspect [hero](./subdir/hero.html) section.')}
streaming={false}
projectId="project-1"
onRequestOpenFile={onRequestOpenFile}
/>,
);
const anchor = container.querySelector('a.md-link');
expect(anchor).not.toBeNull();
fireEvent.click(anchor!);
expect(onRequestOpenFile).toHaveBeenCalledTimes(1);
expect(onRequestOpenFile).toHaveBeenCalledWith('subdir/hero.html');
});
it('routes project raw file URLs through onRequestOpenFile instead of opening a new window', () => {
const onRequestOpenFile = vi.fn();
const { container } = render(
<AssistantMessage
message={messageWithText('Open [mutuals-v2.html](/api/projects/project-1/raw/mutuals-v2.html).')}
streaming={false}
projectId="project-1"
onRequestOpenFile={onRequestOpenFile}
/>,
);
const anchor = container.querySelector('a.md-link');
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute('href')).toBe('/api/projects/project-1/raw/mutuals-v2.html');
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor!.dispatchEvent(clickEvent);
expect(onRequestOpenFile).toHaveBeenCalledTimes(1);
expect(onRequestOpenFile).toHaveBeenCalledWith('mutuals-v2.html');
expect(clickEvent.defaultPrevented).toBe(true);
});
it('does not route app file URLs for a different project through the current workspace opener', () => {
const onRequestOpenFile = vi.fn();
const { container } = render(
<AssistantMessage
message={messageWithText('Open [index.html](/projects/other-project/files/index.html).')}
streaming={false}
projectId="project-1"
projectFileNames={new Set(['index.html'])}
onRequestOpenFile={onRequestOpenFile}
/>,
);
const anchor = container.querySelector('a.md-link');
expect(anchor).not.toBeNull();
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor!.dispatchEvent(clickEvent);
expect(onRequestOpenFile).not.toHaveBeenCalled();
expect(clickEvent.defaultPrevented).toBe(false);
});
it('routes same-origin absolute project raw URLs through onRequestOpenFile', () => {
const onRequestOpenFile = vi.fn();
const href = `${window.location.origin}/api/projects/project-1/raw/Web%20Prototype%20mutuals-v2.html`;
const { container } = render(
<AssistantMessage
message={messageWithText(`Open [Web Prototype mutuals-v2.html](${href}).`)}
streaming={false}
projectId="project-1"
onRequestOpenFile={onRequestOpenFile}
/>,
);
const anchor = container.querySelector('a.md-link');
expect(anchor).not.toBeNull();
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor!.dispatchEvent(clickEvent);
expect(onRequestOpenFile).toHaveBeenCalledTimes(1);
expect(onRequestOpenFile).toHaveBeenCalledWith('Web Prototype mutuals-v2.html');
expect(clickEvent.defaultPrevented).toBe(true);
});
it('routes local absolute paths that match project files through onRequestOpenFile', () => {
const onRequestOpenFile = vi.fn();
const { container } = render(
<AssistantMessage
message={messageWithText(
'已完成单文件原型:[index.html](/Users/mac/open-design/open-design-preview-0.10.0/projects/Web%20Prototype/index.html)。',
)}
streaming={false}
projectId="project-1"
projectFileNames={new Set(['index.html'])}
onRequestOpenFile={onRequestOpenFile}
/>,
);
const anchor = container.querySelector('a.md-link');
expect(anchor).not.toBeNull();
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor!.dispatchEvent(clickEvent);
expect(onRequestOpenFile).toHaveBeenCalledTimes(1);
expect(onRequestOpenFile).toHaveBeenCalledWith('index.html');
expect(clickEvent.defaultPrevented).toBe(true);
});
it('does not intercept external https:// URLs — preserves default target="_blank" behavior', () => {
const onRequestOpenFile = vi.fn();
const { container } = render(
<AssistantMessage
message={messageWithText('See [docs](https://example.com/docs) for context.')}
streaming={false}
projectId="project-1"
onRequestOpenFile={onRequestOpenFile}
/>,
);
const anchor = container.querySelector('a.md-link');
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute('href')).toBe('https://example.com/docs');
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor!.dispatchEvent(clickEvent);
expect(onRequestOpenFile).not.toHaveBeenCalled();
expect(clickEvent.defaultPrevented).toBe(false);
});
it('does not intercept #anchor fragments', () => {
const onRequestOpenFile = vi.fn();
const { container } = render(
<AssistantMessage
message={messageWithText('Jump to [intro](#intro) of this page.')}
streaming={false}
projectId="project-1"
onRequestOpenFile={onRequestOpenFile}
/>,
);
const anchor = container.querySelector('a.md-link');
expect(anchor).not.toBeNull();
fireEvent.click(anchor!);
expect(onRequestOpenFile).not.toHaveBeenCalled();
});
it('keeps default link behavior when the host did not pass onRequestOpenFile', () => {
// Some surfaces (e.g. read-only history view) intentionally do not
// pass `onRequestOpenFile`. The fix must not throw and the link must
// still render with its default target="_blank" behavior.
const { container } = render(
<AssistantMessage
message={messageWithText('Open [template.html](template.html) to preview.')}
streaming={false}
projectId="project-1"
/>,
);
const anchor = container.querySelector('a.md-link');
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute('target')).toBe('_blank');
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor!.dispatchEvent(clickEvent);
expect(clickEvent.defaultPrevented).toBe(false);
});
});