mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* 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).
134 lines
4.9 KiB
TypeScript
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);
|
|
});
|
|
});
|