mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +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).
This commit is contained in:
parent
bf699736d4
commit
97ed479c6b
6 changed files with 499 additions and 18 deletions
|
|
@ -1,7 +1,11 @@
|
|||
import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
import { FileOpsSummary } from "./FileOpsSummary";
|
||||
import { renderMarkdown } from "../runtime/markdown";
|
||||
import {
|
||||
renderMarkdown,
|
||||
type MarkdownLinkClickHandler,
|
||||
} from "../runtime/markdown";
|
||||
import { asInProjectFilePath } from "../runtime/in-project-link";
|
||||
import { projectFileUrl } from "../providers/registry";
|
||||
import { submitChatRunToolResult } from "../providers/daemon";
|
||||
import { useAnalytics } from "../analytics/provider";
|
||||
|
|
@ -239,6 +243,7 @@ export function AssistantMessage({
|
|||
});
|
||||
onSubmitForm?.(text);
|
||||
}}
|
||||
onRequestOpenFile={onRequestOpenFile}
|
||||
/>
|
||||
);
|
||||
if (b.kind === "thinking")
|
||||
|
|
@ -1215,6 +1220,7 @@ function ProseBlock({
|
|||
locallySubmitted,
|
||||
suppressDirectionForms,
|
||||
onSubmitForm,
|
||||
onRequestOpenFile,
|
||||
}: {
|
||||
text: string;
|
||||
isLastAssistant: boolean;
|
||||
|
|
@ -1223,9 +1229,23 @@ function ProseBlock({
|
|||
locallySubmitted: Set<string>;
|
||||
suppressDirectionForms: boolean;
|
||||
onSubmitForm: (formId: string, text: string) => void;
|
||||
onRequestOpenFile?: (name: string) => void;
|
||||
}) {
|
||||
const cleaned = useMemo(() => stripArtifact(text), [text]);
|
||||
const segments = useMemo(() => splitOnQuestionForms(cleaned), [cleaned]);
|
||||
// Route relative file-link clicks (`template.html`, `subdir/hero.html`)
|
||||
// through the workspace tab opener. Without this, Electron's window-open
|
||||
// handler creates a new app window whose relative href can't resolve, and
|
||||
// the user lands on the home screen — the file is never previewed.
|
||||
const onLinkClick = useMemo<MarkdownLinkClickHandler | undefined>(() => {
|
||||
if (!onRequestOpenFile) return undefined;
|
||||
return (href, event) => {
|
||||
const path = asInProjectFilePath(href);
|
||||
if (!path) return;
|
||||
event.preventDefault();
|
||||
onRequestOpenFile(path);
|
||||
};
|
||||
}, [onRequestOpenFile]);
|
||||
// Each text segment is further split on `<system-reminder>` blocks so
|
||||
// those render as their own collapsible chip instead of raw markup.
|
||||
const renderable = segments.flatMap(
|
||||
|
|
@ -1261,7 +1281,11 @@ function ProseBlock({
|
|||
return <SystemReminderBlock key={seg.key} text={seg.text} />;
|
||||
}
|
||||
if (seg.kind === "text") {
|
||||
return <Fragment key={seg.key}>{renderMarkdown(seg.text)}</Fragment>;
|
||||
return (
|
||||
<Fragment key={seg.key}>
|
||||
{renderMarkdown(seg.text, { onLinkClick })}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
if (seg.kind === "suppressed-direction") {
|
||||
return (
|
||||
|
|
|
|||
55
apps/web/src/runtime/in-project-link.ts
Normal file
55
apps/web/src/runtime/in-project-link.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Decide whether a markdown link href in chat output should resolve to
|
||||
* an in-project file (opened in the right-pane workspace) or fall
|
||||
* through to the default browser link behavior (Electron
|
||||
* `setWindowOpenHandler` → new window).
|
||||
*
|
||||
* Chat output frequently contains references like
|
||||
* `[template.html](template.html)` or `[hero](subdir/hero.html)`. Those
|
||||
* are relative paths into the current project's file workspace; with
|
||||
* default `target="_blank"` they open a new Electron window with no
|
||||
* project context and land on the home screen. Routing them through
|
||||
* the existing `requestOpenFile` callback keeps the user in the same
|
||||
* project view and previews the file in the right pane.
|
||||
*
|
||||
* Returns the normalized file path when the href looks like an
|
||||
* in-project link, or `null` to let the default link behavior win.
|
||||
*/
|
||||
export function asInProjectFilePath(href: string | null | undefined): string | null {
|
||||
if (typeof href !== 'string') return null;
|
||||
const trimmed = href.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith('#')) return null;
|
||||
// RFC 3986 scheme: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) followed by `:`.
|
||||
// Catches http:, https:, mailto:, file:, od:, blob:, javascript:, etc.
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return null;
|
||||
if (trimmed.startsWith('/')) return null;
|
||||
const stripped = trimmed.startsWith('./') ? trimmed.slice(2) : trimmed;
|
||||
// Refuse any `..` segment so a relative path can't climb out of the
|
||||
// project root. Cheaper and safer than full path normalization, and
|
||||
// assistant chat output never emits `..` for legitimate file refs.
|
||||
if (stripped.split('/').some((segment) => segment === '..')) return null;
|
||||
// Strip query and fragment — the workspace tab opener takes a file
|
||||
// path, not a URL.
|
||||
const withoutHash = stripped.split('#')[0] ?? stripped;
|
||||
const withoutQuery = withoutHash.split('?')[0] ?? withoutHash;
|
||||
if (!withoutQuery) return null;
|
||||
// Chat markdown emits links as URL-encoded text (`Mock%20Page.html`
|
||||
// for a file named `Mock Page.html`, multi-byte sequences for
|
||||
// non-ASCII names). The workspace tab opener
|
||||
// (`requestOpenFile` → `FileWorkspace`) matches by literal on-disk
|
||||
// file name, so passing the encoded form silently misses the tab.
|
||||
// Decode after the literal `..` check so a `%2E%2E` smuggling
|
||||
// attempt cannot bypass the traversal guard, and re-check `..` on
|
||||
// the decoded form. Treat malformed encodings as "not a real
|
||||
// in-project link" rather than letting the URIError crash the
|
||||
// renderer.
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(withoutQuery);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (decoded.split('/').some((segment) => segment === '..')) return null;
|
||||
return decoded;
|
||||
}
|
||||
|
|
@ -11,13 +11,30 @@
|
|||
* Output is a React fragment of typed elements — no dangerouslySetInnerHTML,
|
||||
* so untrusted text can't smuggle markup through.
|
||||
*/
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { Fragment, type MouseEvent, type ReactNode } from 'react';
|
||||
|
||||
export function renderMarkdown(input: string): ReactNode {
|
||||
export type MarkdownLinkClickHandler = (
|
||||
href: string,
|
||||
event: MouseEvent<HTMLAnchorElement>,
|
||||
) => void;
|
||||
|
||||
export interface RenderMarkdownOptions {
|
||||
/**
|
||||
* Fired on every rendered `<a>` click before the default link
|
||||
* behavior. Callers that want to intercept (e.g. route in-project
|
||||
* file links to a workspace tab opener instead of letting Electron
|
||||
* open a new window) must call `event.preventDefault()` themselves.
|
||||
* Omitting the option keeps the previous default `target="_blank"`
|
||||
* behavior for every link.
|
||||
*/
|
||||
onLinkClick?: MarkdownLinkClickHandler;
|
||||
}
|
||||
|
||||
export function renderMarkdown(input: string, options?: RenderMarkdownOptions): ReactNode {
|
||||
const blocks = parseBlocks(input);
|
||||
return (
|
||||
<>
|
||||
{blocks.map((b, i) => renderBlock(b, i))}
|
||||
{blocks.map((b, i) => renderBlock(b, i, options))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -194,19 +211,19 @@ function parseBlocks(input: string): Block[] {
|
|||
return out;
|
||||
}
|
||||
|
||||
function renderBlock(block: Block, key: number): ReactNode {
|
||||
function renderBlock(block: Block, key: number, options?: RenderMarkdownOptions): ReactNode {
|
||||
if (block.kind === 'p') {
|
||||
return <p key={key} className="md-p">{renderInline(block.text)}</p>;
|
||||
return <p key={key} className="md-p">{renderInline(block.text, options)}</p>;
|
||||
}
|
||||
if (block.kind === 'h') {
|
||||
const Tag = (`h${block.level}` as 'h1' | 'h2' | 'h3' | 'h4');
|
||||
return <Tag key={key} className={`md-h md-h${block.level}`}>{renderInline(block.text)}</Tag>;
|
||||
return <Tag key={key} className={`md-h md-h${block.level}`}>{renderInline(block.text, options)}</Tag>;
|
||||
}
|
||||
if (block.kind === 'ul') {
|
||||
return (
|
||||
<ul key={key} className="md-ul">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i}>{renderInline(item)}</li>
|
||||
<li key={i}>{renderInline(item, options)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
|
@ -215,7 +232,7 @@ function renderBlock(block: Block, key: number): ReactNode {
|
|||
return (
|
||||
<ol key={key} className="md-ol">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i}>{renderInline(item)}</li>
|
||||
<li key={i}>{renderInline(item, options)}</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
|
|
@ -239,7 +256,7 @@ function renderBlock(block: Block, key: number): ReactNode {
|
|||
<thead>
|
||||
<tr>
|
||||
{headers.map((cell, idx) => (
|
||||
<th key={idx} style={cellStyle(idx)}>{renderInline(cell)}</th>
|
||||
<th key={idx} style={cellStyle(idx)}>{renderInline(cell, options)}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -247,7 +264,7 @@ function renderBlock(block: Block, key: number): ReactNode {
|
|||
{rows.map((row, rIdx) => (
|
||||
<tr key={rIdx}>
|
||||
{headers.map((_, cIdx) => (
|
||||
<td key={cIdx} style={cellStyle(cIdx)}>{renderInline(row[cIdx] ?? '')}</td>
|
||||
<td key={cIdx} style={cellStyle(cIdx)}>{renderInline(row[cIdx] ?? '', options)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -284,8 +301,12 @@ function isSafeMarkdownImageSrc(src: string): boolean {
|
|||
// and plain text. We walk the string with a regex that matches whichever
|
||||
// delimiter shows up next; everything between delimiters becomes a text
|
||||
// span (which itself still gets autolink scanning).
|
||||
function renderInline(text: string): ReactNode {
|
||||
function renderInline(text: string, options?: RenderMarkdownOptions): ReactNode {
|
||||
const out: ReactNode[] = [];
|
||||
const onLinkClick = options?.onLinkClick;
|
||||
const linkClickHandler = onLinkClick
|
||||
? (href: string) => (event: MouseEvent<HTMLAnchorElement>) => onLinkClick(href, event)
|
||||
: undefined;
|
||||
// Order matters:
|
||||
// 1. inline code first so its contents are not re-tokenized as bold/italic.
|
||||
// 2. image syntax `` BEFORE the link branch. Both share
|
||||
|
|
@ -306,7 +327,7 @@ function renderInline(text: string): ReactNode {
|
|||
let key = 0;
|
||||
while ((m = re.exec(text))) {
|
||||
if (m.index > lastIndex) {
|
||||
pushText(out, text.slice(lastIndex, m.index), key++);
|
||||
pushText(out, text.slice(lastIndex, m.index), key++, options);
|
||||
}
|
||||
if (m[1]) {
|
||||
out.push(
|
||||
|
|
@ -333,16 +354,18 @@ function renderInline(text: string): ReactNode {
|
|||
} else {
|
||||
// Unsafe scheme — drop the image tag but keep the alt text so
|
||||
// the user sees what the model meant to show.
|
||||
pushText(out, alt, key++);
|
||||
pushText(out, alt, key++, options);
|
||||
}
|
||||
} else if (m[4] && m[5]) {
|
||||
const href = m[5];
|
||||
out.push(
|
||||
<a
|
||||
key={key++}
|
||||
className="md-link"
|
||||
href={m[5]}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={linkClickHandler?.(href)}
|
||||
>
|
||||
{m[4]}
|
||||
</a>,
|
||||
|
|
@ -358,6 +381,7 @@ function renderInline(text: string): ReactNode {
|
|||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={linkClickHandler?.(href)}
|
||||
>
|
||||
{href}
|
||||
</a>,
|
||||
|
|
@ -375,7 +399,7 @@ function renderInline(text: string): ReactNode {
|
|||
lastIndex = re.lastIndex;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
pushText(out, text.slice(lastIndex), key++);
|
||||
pushText(out, text.slice(lastIndex), key++, options);
|
||||
}
|
||||
return <Fragment>{out}</Fragment>;
|
||||
}
|
||||
|
|
@ -384,8 +408,9 @@ function renderInline(text: string): ReactNode {
|
|||
// text nodes. Newlines inside a paragraph become explicit <br />s — the
|
||||
// upstream parser has already left them in place because chat output
|
||||
// often relies on hard line breaks rather than blank-line separation.
|
||||
function pushText(out: ReactNode[], text: string, baseKey: number): void {
|
||||
function pushText(out: ReactNode[], text: string, baseKey: number, options?: RenderMarkdownOptions): void {
|
||||
if (!text) return;
|
||||
const onLinkClick = options?.onLinkClick;
|
||||
const urlRe = /(https?:\/\/[^\s)]+)/g;
|
||||
const segments: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
|
@ -403,6 +428,7 @@ function pushText(out: ReactNode[], text: string, baseKey: number): void {
|
|||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={onLinkClick ? (event) => onLinkClick(href, event) : undefined}
|
||||
>
|
||||
{href}
|
||||
</a>,
|
||||
|
|
|
|||
134
apps/web/tests/components/AssistantMessage.linkClick.test.tsx
Normal file
134
apps/web/tests/components/AssistantMessage.linkClick.test.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// @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);
|
||||
});
|
||||
});
|
||||
154
apps/web/tests/runtime/in-project-link.test.ts
Normal file
154
apps/web/tests/runtime/in-project-link.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { asInProjectFilePath } from '../../src/runtime/in-project-link';
|
||||
|
||||
describe('asInProjectFilePath', () => {
|
||||
describe('intercepts (returns normalized path)', () => {
|
||||
it('bare filename → unchanged', () => {
|
||||
expect(asInProjectFilePath('template.html')).toBe('template.html');
|
||||
});
|
||||
|
||||
it('strips a leading ./ prefix', () => {
|
||||
expect(asInProjectFilePath('./template.html')).toBe('template.html');
|
||||
});
|
||||
|
||||
it('keeps subdirectory paths intact', () => {
|
||||
expect(asInProjectFilePath('subdir/hero.html')).toBe('subdir/hero.html');
|
||||
});
|
||||
|
||||
it('strips ./ in front of a subdirectory path', () => {
|
||||
expect(asInProjectFilePath('./subdir/hero.html')).toBe('subdir/hero.html');
|
||||
});
|
||||
|
||||
it('drops a trailing query string', () => {
|
||||
expect(asInProjectFilePath('template.html?v=2')).toBe('template.html');
|
||||
});
|
||||
|
||||
it('drops a trailing fragment', () => {
|
||||
expect(asInProjectFilePath('template.html#section')).toBe('template.html');
|
||||
});
|
||||
|
||||
it('drops both query and fragment together', () => {
|
||||
expect(asInProjectFilePath('template.html?v=2#section')).toBe('template.html');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from the href', () => {
|
||||
expect(asInProjectFilePath(' template.html ')).toBe('template.html');
|
||||
});
|
||||
|
||||
it('handles the exact long filename shape from the issue screenshot', () => {
|
||||
expect(asInProjectFilePath('orbit-daily-digest-general-2026-05-11.html'))
|
||||
.toBe('orbit-daily-digest-general-2026-05-11.html');
|
||||
});
|
||||
|
||||
it('decodes percent-encoded filenames so the workspace tab opener matches the on-disk file', () => {
|
||||
// Chat markdown frequently emits links like
|
||||
// `[Mock page](Mock%20Page.html)` because the autolink path
|
||||
// percent-encodes spaces. The workspace tab opener
|
||||
// (`requestOpenFile` → `FileWorkspace`) matches by literal
|
||||
// on-disk file name, so handing it the raw `Mock%20Page.html`
|
||||
// would silently miss the existing tab. Decode the result
|
||||
// before returning. Earlier draft PR #1255 hit this exact
|
||||
// miss in review (mrcfps / lefarcen P2).
|
||||
expect(asInProjectFilePath('Mock%20Page.html')).toBe('Mock Page.html');
|
||||
});
|
||||
|
||||
it('decodes percent-encoded subdirectory paths', () => {
|
||||
expect(asInProjectFilePath('Visual%20Direction/hero%20alt.html')).toBe(
|
||||
'Visual Direction/hero alt.html',
|
||||
);
|
||||
});
|
||||
|
||||
it('decodes non-ASCII (UTF-8 percent-encoded) filenames', () => {
|
||||
// Chinese / Cyrillic / accented filenames percent-encode into
|
||||
// multi-byte sequences. `decodeURIComponent` handles them
|
||||
// correctly; the catch arm below keeps malformed encodings
|
||||
// from throwing.
|
||||
expect(asInProjectFilePath('%E9%A6%96%E9%A1%B5.html')).toBe('首页.html');
|
||||
});
|
||||
|
||||
it('returns null for malformed percent-encoding rather than throwing', () => {
|
||||
// A stray `%` (e.g. `Read%this.html` where the user meant a
|
||||
// literal percent) makes decodeURIComponent throw a URIError.
|
||||
// We never want a chat link to crash the renderer — fall
|
||||
// through to the default browser behavior instead.
|
||||
expect(asInProjectFilePath('Read%this.html')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('passes through (returns null) — external schemes', () => {
|
||||
it('http://', () => {
|
||||
expect(asInProjectFilePath('http://example.com/x')).toBeNull();
|
||||
});
|
||||
|
||||
it('https://', () => {
|
||||
expect(asInProjectFilePath('https://example.com/x')).toBeNull();
|
||||
});
|
||||
|
||||
it('mailto:', () => {
|
||||
expect(asInProjectFilePath('mailto:foo@bar.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('Electron od: protocol', () => {
|
||||
expect(asInProjectFilePath('od://app/projects/123')).toBeNull();
|
||||
});
|
||||
|
||||
it('blob: URLs', () => {
|
||||
expect(asInProjectFilePath('blob:https://example.com/abc')).toBeNull();
|
||||
});
|
||||
|
||||
it('file:// URLs (NOT in-project relative paths)', () => {
|
||||
expect(asInProjectFilePath('file:///etc/passwd')).toBeNull();
|
||||
});
|
||||
|
||||
it('javascript: scheme is refused even though it matches the RFC grammar', () => {
|
||||
expect(asInProjectFilePath('javascript:alert(1)')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('passes through (returns null) — non-link or unsafe shapes', () => {
|
||||
it('null', () => {
|
||||
expect(asInProjectFilePath(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('undefined', () => {
|
||||
expect(asInProjectFilePath(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('empty string', () => {
|
||||
expect(asInProjectFilePath('')).toBeNull();
|
||||
});
|
||||
|
||||
it('whitespace-only string', () => {
|
||||
expect(asInProjectFilePath(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('#fragment-only — anchor within the same document', () => {
|
||||
expect(asInProjectFilePath('#section')).toBeNull();
|
||||
});
|
||||
|
||||
it('absolute path starting with / — could mean filesystem root in Electron', () => {
|
||||
expect(asInProjectFilePath('/abs/path.html')).toBeNull();
|
||||
});
|
||||
|
||||
it('parent-traversal `..` — refuses to climb out of the project root', () => {
|
||||
expect(asInProjectFilePath('..')).toBeNull();
|
||||
});
|
||||
|
||||
it('relative path that walks up via .. — refused', () => {
|
||||
expect(asInProjectFilePath('../sibling.html')).toBeNull();
|
||||
});
|
||||
|
||||
it('mid-path .. segment is still refused', () => {
|
||||
expect(asInProjectFilePath('a/../b.html')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a `..` segment smuggled in via percent-encoding (`%2E%2E`)', () => {
|
||||
// Decoding happens after the literal-`..` check; without an
|
||||
// additional post-decode check a hostile chat link could
|
||||
// bypass the traversal guard by writing `%2E%2E/secret.html`
|
||||
// and the workspace opener would receive `../secret.html`.
|
||||
expect(asInProjectFilePath('%2E%2E/secret.html')).toBeNull();
|
||||
expect(asInProjectFilePath('a/%2E%2E/b.html')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
88
apps/web/tests/runtime/markdown.linkClick.test.tsx
Normal file
88
apps/web/tests/runtime/markdown.linkClick.test.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// @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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue