open-design/apps/web/tests/components/AssistantMessage.linkClick.test.tsx
YOMXXX 97ed479c6b
fix(web): route chat file links to workspace preview instead of new window (#1239) (#2576)
* fix(web): route chat file links to workspace preview instead of new window (#1239)

Chat-emitted markdown links like `[template.html](template.html)` rendered
as `<a target="_blank">` with no click handler. In Electron that hits
`setWindowOpenHandler` and creates a new `od://` BrowserWindow; relative
hrefs have no base so the new window can't resolve them and the user
lands on the home screen — the file they wanted to preview is never
shown.

Detect in-project file paths in chat markdown via a new
`asInProjectFilePath` helper and route them through the existing
`requestOpenFile` workspace tab opener. External URLs, `mailto:`,
`#anchors`, absolute paths and `..` traversal keep their default
browser-link behavior. The `renderMarkdown(options)` extension is
backwards-compatible: existing callers (file viewer, system reminders)
keep their default `target="_blank"` behavior when the option is
omitted.

Closes #1239.

* fix(web): decode percent-encoded chat file links before workspace open (#1239)

Chat markdown frequently emits links as URL-encoded text — `Mock%20Page.html`
for a file named `Mock Page.html`, multi-byte sequences for non-ASCII
filenames. The workspace tab opener (`requestOpenFile` →
`FileWorkspace`) matches by literal on-disk file name, so handing it
the raw `%20`-encoded form silently misses the existing tab and the
user sees nothing happen on click — the exact regression #1239
reopened against.

Decode after the literal `..` check and re-check `..` on the
decoded form so a `%2E%2E` smuggling attempt cannot bypass the
traversal guard. Malformed encodings fall through to `null` (default
browser link behavior) instead of letting URIError crash the
renderer.

The same gap was flagged on the earlier draft PR #1255 by mrcfps and
lefarcen (P2) but never landed there; this PR now covers it with
five new regression tests (ASCII spaces, nested subdirs, UTF-8 byte
sequences, malformed `%`, percent-encoded traversal).
2026-05-23 11:48:07 +08:00

134 lines
4.9 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('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);
});
});