open-design/apps/web/tests/runtime/markdown.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

88 lines
3.6 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderMarkdown } from '../../src/runtime/markdown';
describe('renderMarkdown — onLinkClick option', () => {
afterEach(() => cleanup());
it('omits onClick when the option is absent (backwards-compat for existing callers)', () => {
// Existing surfaces — file viewer, system reminders, anywhere that
// just renders markdown for display — must keep their previous
// target="_blank" behavior with no extra event wiring.
const { container } = render(
<div>{renderMarkdown('Click [here](https://example.com).')}</div>,
);
const anchor = container.querySelector('a');
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute('href')).toBe('https://example.com');
expect(anchor?.getAttribute('target')).toBe('_blank');
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor!.dispatchEvent(clickEvent);
expect(clickEvent.defaultPrevented).toBe(false);
});
it('fires onLinkClick on explicit [text](url) link click', () => {
const onLinkClick = vi.fn();
const { container } = render(
<div>
{renderMarkdown('Open [the file](template.html) to inspect.', { onLinkClick })}
</div>,
);
const anchor = container.querySelector('a');
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute('href')).toBe('template.html');
fireEvent.click(anchor!);
expect(onLinkClick).toHaveBeenCalledTimes(1);
expect(onLinkClick.mock.calls[0]?.[0]).toBe('template.html');
});
it('fires onLinkClick on autolinked bare https URLs found inline', () => {
// The bare-URL branch in `renderInline` (`m[6]`) — separate code
// path from the explicit `[text](url)` branch, must wire onClick
// the same way.
const onLinkClick = vi.fn();
const { container } = render(
<div>{renderMarkdown('See https://example.com/page for context.', { onLinkClick })}</div>,
);
const anchor = container.querySelector('a');
expect(anchor).not.toBeNull();
fireEvent.click(anchor!);
expect(onLinkClick).toHaveBeenCalledTimes(1);
expect(onLinkClick.mock.calls[0]?.[0]).toBe('https://example.com/page');
});
it('fires onLinkClick on URLs that fall to the pushText autolink path', () => {
// Text emitted between other inline tokens flows through `pushText`,
// which runs its own URL autolink scan. That third `<a>` creation
// site needs the same onClick wiring as the other two.
const onLinkClick = vi.fn();
const { container } = render(
<div>
{renderMarkdown('**bold** https://example.com/page then more text.', { onLinkClick })}
</div>,
);
const anchor = container.querySelector('a');
expect(anchor).not.toBeNull();
fireEvent.click(anchor!);
expect(onLinkClick).toHaveBeenCalledTimes(1);
expect(onLinkClick.mock.calls[0]?.[0]).toBe('https://example.com/page');
});
it('passes the React MouseEvent so the caller can preventDefault()', () => {
const onLinkClick = vi.fn<(href: string, event: { preventDefault(): void }) => void>(
(_href, event) => {
event.preventDefault();
},
);
const { container } = render(
<div>{renderMarkdown('Open [file](template.html).', { onLinkClick })}</div>,
);
const anchor = container.querySelector('a')!;
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor.dispatchEvent(clickEvent);
expect(onLinkClick).toHaveBeenCalledTimes(1);
expect(clickEvent.defaultPrevented).toBe(true);
});
});