Files
qwen-code/packages/web-shell/client/components/messages/Markdown.test.ts
T
易良andQwen-Coder 32f51a330b chore: restore regression coverage after WebUI retirement (#11101)
* chore(web-shell): follow up #9812 review findings and deferred suggestions

Address the remaining review suggestions from the @qwen-code/webui
retirement (#9812) and the deferred items tracked in #11076:

- Fix the 01-architecture.md adapter diagram path (missing session/).
- Correct the followup-suggestions doc: generation fires on end_turn, and
  headless/SDK daemon clients should opt out to avoid the per-turn LLM cost.
- Pin playwright to 1.61.1 to restore version parity with @playwright/test.
- Restore file: scheme rejection coverage in Markdown.test.ts.
- Pin the historical sessionStorage key prefix with a literal assertion.
- Cover the legacy exported JSONL rejection and ChatRecord happy path.
- Restore the shell-substring classifier trap with a live path.
- Point useDaemonFollowupSuggestion docs at ChatEditor, not the deleted InputForm.
- Replace the theme-toggle timing-race assertion and exercise the
  unhandledrejection fail-closed path in the document browser gate.

Closes #11076

* fix(test): poll the theme class instead of a matcher vitest lacks

The browser gate imports expect from vitest and drives playwright's
Locator directly, so toHaveClass does not exist on the assertion and
typecheck:integration failed with TS2339 on this line. Poll the class
attribute through the same expect.poll idiom the neighbouring
assertions use, which keeps the retry the assertion was switched to
and needs no @playwright/test dependency.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmtooas8sln

* fix(test): assert the exported theme class exactly

`document-main.tsx` toggles `dark` and `light` on <html> mutually
exclusively and nothing else writes `documentElement` classes, so the
attribute is always exactly one of them. Matching `/light/` as an
unanchored substring would also accept `dark light`, which is the
theme-toggle breakage this poll exists to catch.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtow5pgslz

* fix(test): realpath the exporter main check and pin its JSONL guard

Node realpath-resolves `import.meta.url` for the ESM main module but
leaves `process.argv[1]` as invoked, so comparing the raw spellings made
a symlinked invocation skip `main()` and exit 0 having written nothing —
a silent no-op the harness reads as success. Compare realpaths instead
and export the predicate so both directions are pinned.

Also extract the input gate into `assertRenderableJsonl` so the legacy
exported-JSONL rejection can be asserted directly: the previous tests
only pinned the two predicates feeding it, so deleting the guard and
losing the remediation hint kept every test green.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtow5pgslz

* docs(followup): name the real ACP stop reasons, drop "unconditional"

The parenthetical this PR added named `interrupted` as a daemon stop
reason. The ACP `StopReason` union is
`end_turn | max_tokens | max_turn_requests | refusal | cancelled`, so
there is no `interrupted`; an interrupted turn reports `cancelled`. Name
the four suppressing values the union actually defines so an integrator
writing `stopReason === '...'` gets a branch that can fire.

"unconditional" was wider than the code and contradicted the condition
list on the same page: `Session.ts#maybeEmitFollowupSuggestion` returns
early on the stop reason, the todo stop guard,
`ui.enableFollowupSuggestions`, PLAN approval mode, a missing chat, and a
non-model last history entry. Point at those conditions instead; the cost
advice still holds because the `enableFollowupSuggestions === false`
return sits before `generatePromptSuggestion` is reached.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtp6vibomd

* test(web-shell): drop the duplicate historical-key assertion

`persists under the literal historical key prefix`, added on this branch,
became a byte-equivalent twin of `writes under the historical WebUI key`,
which reached main with #11107 and arrived here through the merge at this
PR's head: same `persistStableClientId('client-a', 'session-a')`, same
literal `qwen-code-webui-client-id:session:session-a`, same expectation.
One fact was reported under two names, so a prefix rename reddened two
tests and an auditor could not tell which copy was load-bearing.

The surviving test's comment already carries the rename rationale, so
dropping the copy loses no coverage.

Verified with `cd packages/web-shell && npx vitest run
client/daemon/session/clientLifecycle.test.ts`: 19 passed before, 18
passed after. Mutating SESSION_CLIENT_ID_STORAGE_PREFIX to
`qwen-code-webshell-client-id:session:` after the dedupe still reddens
3 tests (15 passed), including the surviving `writes under the historical
WebUI key`; reverting the mutation returns to 18 passed.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtp6vibomd

* test(integration): drop the unreachable unhandledrejection arm

The block this PR added after the CDN-failure loop could not reach the
`window.addEventListener('unhandledrejection', showLoadError)` handler its
comment claimed to exercise. `page.route('**/*', route =>
route.abort('blockedbyclient'))` fails the `<script id="transcript-renderer">`
load, the template's capture-phase `error` listener (document-index.html:45-56,
matching `event.target.id === 'transcript-renderer'`) calls `showLoadError()`
first, and `setContent(..., { waitUntil: 'load' })` only resolves after that
subresource has failed - so `data-render-complete='error'` and the alert text
were already in place before `page.evaluate` dispatched anything, and
showLoadError's one-shot guard (`if (document.body.dataset.renderComplete)
return;`, document-index.html:30) made the synthetic rejection a no-op. The
block was a strict duplicate of the loop's own `rendererBody === null`
iteration: identical all-abort route, identical two assertions.

Exercising the handler for real needs a renderer body that passes the
document's pinned SRI integrity and then rejects during boot; no body this gate
can serve passes SRI, so the arm cannot be made honest here. Deleting it
returns the file to origin/main's state for this test - the handler and its
missing coverage are both pre-existing on main (packages/web-templates is not
in this PR's diff), so no coverage this PR was responsible for is lost.

Verified: `npm run typecheck:integration` reports 0 errors in this file (6
remain, all in packages/** against generated/dist paths absent from an unbuilt
worktree: channels/feishu, cli/src/generated/git-commit.js,
web-templates/src/generated/*); `npx prettier --check` and `npx eslint` clean
on the file. The browser gate itself was not executed locally - it needs the
full build chain plus Chromium.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtpfg5ewmp

* ci(review-context): route concurrent-runner changes to the helper-tests lane

This PR added
`integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs`,
the first `node:test` file under `integration-tests/`, and wired it into CI
through the `HELPER_TESTS` env list in `.github/workflows/ci.yml`. The tracked
review-context manifest still mapped the whole `integration-tests/**` path to
`recommendedTests: ['integration-tests']`, whose vitest lane collects
`**/*.test.ts` only (`integration-tests/vitest.config.ts:21`), so a future PR
touching only the exporter and its `.mjs` test is told to run a lane that
cannot collect the file - the same silent drop `HELPER_TESTS` exists to
prevent.

Add a narrower rule for `integration-tests/concurrent-runner/**` recommending
`helper-tests`. Matching rules merge rather than first-match, so the lane list
for that directory becomes the union and every other `integration-tests/` path
is untouched. The pinned `expectedManifest` in
`manifest-repository-context.committed.test.ts` moves with it, which is what
that pin is for.

Verified through the real provider (tsx importing
`packages/cli/src/commands/review/lib/manifest-repository-context.ts`) because
the packages/cli vitest globalSetup gate refuses to run on this unbuilt
worktree:

  concurrent-runner .mjs           -> ["helper-tests","integration-tests"]
  concurrent-runner .js            -> ["helper-tests","integration-tests"]
  chat-transcript-document.test.ts -> ["integration-tests"] (unchanged)
  all 10 rules co-matching         -> non-null, 80 relatedPaths, and every
                                      `paths` probe matched some rule

Also checked the committed manifest deep-equals the test's pinned literal (10
rules on both sides), and that dropping the new rule breaks that equality, so
the pin is not tautological. `npx prettier --check` and `npx eslint` clean on
both files.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtpfg5ewmp

* fix(deps): sync pnpm lockfile for Playwright pin

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-09-08 08:15:03 +00:00

1943 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @vitest-environment jsdom
*/
import { act, createElement, type ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
WebShellCustomizationProvider,
type WebShellCodeBlockRenderInfo,
} from '../../customization';
import { I18nProvider } from '../../i18n';
import { TOAST_REQUEST_EVENT, type ToastRequestDetail } from '../ToastHost';
import { ThemeProvider } from '../../themeContext';
import { TranscriptRenderModeProvider } from '../../transcriptRenderMode';
import * as EnhancedTableModule from './EnhancedMarkdownTable';
import {
MAX_HIGHLIGHT_LINE_CHARS,
__resetForTesting,
getCodeHighlighter,
} from './codeHighlighter';
import {
isSafeHref,
isSafeImageSrc,
Markdown,
markdownUrlTransform,
resolveFenceLanguage,
} from './Markdown';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
afterEach(() => {
vi.restoreAllMocks();
});
function renderMd(content: string): HTMLDivElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
createElement(
I18nProvider,
{ language: 'en' },
createElement(Markdown, { content }),
),
);
});
(container as HTMLDivElement & { __unmount: () => void }).__unmount = () =>
act(() => root.unmount());
return container as HTMLDivElement;
}
function cleanup(container: HTMLDivElement) {
(container as HTMLDivElement & { __unmount: () => void }).__unmount();
container.remove();
}
describe('isSafeHref', () => {
it('allows https URLs', () => {
expect(isSafeHref('https://example.com')).toBe(true);
});
it('allows http URLs', () => {
expect(isSafeHref('http://example.com')).toBe(true);
});
it('allows mailto URLs', () => {
expect(isSafeHref('mailto:[email protected]')).toBe(true);
});
it('allows anchor links', () => {
expect(isSafeHref('#section')).toBe(true);
});
it('allows relative paths', () => {
expect(isSafeHref('/path/to/page')).toBe(true);
});
it('blocks protocol-relative URLs', () => {
expect(isSafeHref('//evil.com')).toBe(false);
});
it('blocks javascript: scheme', () => {
expect(isSafeHref('javascript:alert(1)')).toBe(false);
});
it('blocks file: scheme', () => {
expect(isSafeHref('file://attacker.example/share/file.md')).toBe(false);
expect(isSafeHref('file:///etc/passwd')).toBe(false);
});
it('blocks data: URIs', () => {
expect(isSafeHref('data:text/html,<script>alert(1)</script>')).toBe(false);
});
it('blocks vbscript: scheme', () => {
expect(isSafeHref('vbscript:MsgBox("XSS")')).toBe(false);
});
it('returns false for empty/undefined', () => {
expect(isSafeHref(undefined)).toBe(false);
expect(isSafeHref('')).toBe(false);
expect(isSafeHref(' ')).toBe(false);
});
it('handles whitespace-padded schemes', () => {
expect(isSafeHref(' https://example.com')).toBe(true);
expect(isSafeHref(' javascript:alert(1)')).toBe(false);
});
});
describe('isSafeImageSrc', () => {
it('allows https URLs', () => {
expect(isSafeImageSrc('https://example.com/img.png')).toBe(true);
});
it('allows data:image/png base64', () => {
expect(isSafeImageSrc('data:image/png;base64,iVBOR')).toBe(true);
});
it('allows data:image/jpeg base64', () => {
expect(isSafeImageSrc('data:image/jpeg;base64,/9j')).toBe(true);
});
it('allows data:image/gif base64', () => {
expect(isSafeImageSrc('data:image/gif;base64,R0lG')).toBe(true);
});
it('allows data:image/webp base64', () => {
expect(isSafeImageSrc('data:image/webp;base64,UklG')).toBe(true);
});
it('allows data:image/bmp base64', () => {
expect(isSafeImageSrc('data:image/bmp;base64,Qk0=')).toBe(true);
});
it('blocks data:image/svg+xml (can load external resources)', () => {
expect(isSafeImageSrc('data:image/svg+xml;base64,PHN2Zz4=')).toBe(false);
});
it('blocks data:text/html', () => {
expect(isSafeImageSrc('data:text/html,<script>')).toBe(false);
});
it('blocks protocol-relative URLs', () => {
expect(isSafeImageSrc('//evil.com/img.png')).toBe(false);
});
it('blocks javascript: scheme', () => {
expect(isSafeImageSrc('javascript:alert(1)')).toBe(false);
});
it('blocks file: scheme', () => {
expect(isSafeImageSrc('file:///tmp/private.png')).toBe(false);
expect(isSafeImageSrc('file://attacker.example/share/img.png')).toBe(false);
});
it('allows relative paths', () => {
expect(isSafeImageSrc('/images/logo.png')).toBe(true);
});
it('allows only approved data images in document mode', () => {
expect(isSafeImageSrc('data:image/png;base64,iVBOR', true)).toBe(true);
expect(isSafeImageSrc('https://example.com/img.png', true)).toBe(false);
expect(isSafeImageSrc('/images/logo.png', true)).toBe(false);
expect(isSafeImageSrc('data:image/bmp;base64,Qk0=', true)).toBe(false);
expect(
isSafeImageSrc('data:image/png;base64,iVBOR" onerror=alert(1)', true),
).toBe(false);
});
});
describe('markdownUrlTransform', () => {
it('lets the qwen-session scheme through untouched', () => {
expect(markdownUrlTransform('qwen-session://abc-123')).toBe(
'qwen-session://abc-123',
);
expect(markdownUrlTransform(' qwen-session://abc-123 ')).toBe(
' qwen-session://abc-123 ',
);
});
it('defers every other url to react-markdown’s sanitizer', () => {
expect(markdownUrlTransform('https://example.com')).toBe(
'https://example.com',
);
expect(markdownUrlTransform('mailto:[email protected]')).toBe('mailto:[email protected]');
// defaultUrlTransform rewrites unsafe schemes to ''.
expect(markdownUrlTransform('javascript:alert(1)')).toBe('');
expect(markdownUrlTransform('data:text/html;base64,PHN2Zz4=')).toBe('');
});
it('allows only approved data images in document mode', () => {
expect(
markdownUrlTransform('data:image/png;base64,iVBORw0KGgo=', true),
).toBe('data:image/png;base64,iVBORw0KGgo=');
expect(
markdownUrlTransform('data:image/svg+xml;base64,PHN2Zz4=', true),
).toBe('');
});
});
describe('qwen-session:// links', () => {
it('survives react-markdown url sanitization and becomes a button', () => {
// Without `urlTransform`, react-markdown rewrites every non-http(s)/mailto
// href to '' before `components.a` runs, so the interception branch never
// fires and the link renders as an inert anchor.
const c = renderMd('[🧵 abc12345](qwen-session://abc12345-full-id)');
const a = c.querySelector('a')!;
expect(a).toBeTruthy();
expect(a.getAttribute('role')).toBe('button');
(c as HTMLDivElement & { __unmount: () => void }).__unmount();
c.remove();
});
it('dispatches qwen:open-session with the session id on click', () => {
const seen: unknown[] = [];
const handler = (e: Event) => seen.push((e as CustomEvent).detail);
window.addEventListener('qwen:open-session', handler);
const c = renderMd('[🧵 abc12345](qwen-session://abc12345-full-id)');
act(() => {
c.querySelector('a')!.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true }),
);
});
window.removeEventListener('qwen:open-session', handler);
expect(seen).toEqual(['abc12345-full-id']);
// The scheme is never written to the DOM: the anchor is a plain '#'.
expect(c.querySelector('a')!.getAttribute('href')).toBe('#');
(c as HTMLDivElement & { __unmount: () => void }).__unmount();
c.remove();
});
it('renders qwen session references as inert text in readonly mode', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const handler = vi.fn();
window.addEventListener('qwen:open-session', handler);
act(() => {
root.render(
createElement(
TranscriptRenderModeProvider,
{ value: 'readonly' },
createElement(Markdown, {
content: '[child](qwen-session://child-session)',
}),
),
);
});
expect(container.querySelector('a')).toBeNull();
expect(container.querySelector('span')?.textContent).toBe('child');
expect(handler).not.toHaveBeenCalled();
window.removeEventListener('qwen:open-session', handler);
act(() => root.unmount());
container.remove();
});
it('still sanitizes dangerous schemes', () => {
const c = renderMd('[x](javascript:alert(1))');
const a = c.querySelector('a')!;
expect(a.getAttribute('role')).not.toBe('button');
expect(a.getAttribute('href')).toBeNull();
(c as HTMLDivElement & { __unmount: () => void }).__unmount();
c.remove();
});
});
describe('document image policy', () => {
it('does not put remote Markdown image URLs into the DOM', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
createElement(
TranscriptRenderModeProvider,
{ value: 'document' },
createElement(Markdown, {
content: '![remote](https://example.com/secret.png)',
}),
),
);
});
expect(container.querySelector('img')?.getAttribute('src')).toBeNull();
expect(container.innerHTML).not.toContain('https://example.com');
act(() => root.unmount());
container.remove();
});
it('renders chart fences as static code in document mode', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
createElement(
TranscriptRenderModeProvider,
{ value: 'document' },
createElement(Markdown, {
content: '```echarts\n{"series":[]}\n```',
source: 'assistant',
}),
),
);
});
expect(container.querySelector('pre code')?.textContent).toContain(
'{"series":[]}',
);
expect(container.textContent).not.toContain('Show chart');
act(() => root.unmount());
container.remove();
});
});
describe('Markdown enhanced tables', () => {
it('uses enhanced table rendering when configured', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
createElement(
I18nProvider,
{ language: 'en' },
createElement(Markdown, {
content: '| A |\n| --- |\n| 1 |',
tableMode: 'advanced',
}),
),
);
});
expect(container.textContent).toContain('Copy table');
expect(
container.querySelector('button[aria-label="View details for row 1"]'),
).not.toBeNull();
act(() => root.unmount());
container.remove();
});
it('keeps enhanced table when source customizes table rendering', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
createElement(
I18nProvider,
{ language: 'en' },
createElement(
WebShellCustomizationProvider,
{
value: {
markdown: {
components: {
table({ children }: { children?: ReactNode }) {
return createElement(
'table',
{ 'data-custom-table': 'true' },
children,
);
},
},
},
},
},
createElement(Markdown, {
content: '| A |\n| --- |\n| 1 |',
source: 'assistant',
tableMode: 'advanced',
}),
),
),
);
});
expect(container.textContent).toContain('Copy table');
expect(container.querySelector('[data-custom-table="true"]')).toBeNull();
act(() => root.unmount());
container.remove();
});
it('uses plain table rendering when enhancement is disabled', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
createElement(
I18nProvider,
{ language: 'en' },
createElement(Markdown, {
content: '| A |\n| --- |\n| 1 |',
tableMode: 'basic',
}),
),
);
});
expect(container.querySelector('table')).not.toBeNull();
expect(container.textContent).not.toContain('Copy table');
act(() => root.unmount());
container.remove();
});
it('renders the plain table fallback when enhancement throws', () => {
vi.spyOn(EnhancedTableModule, 'EnhancedMarkdownTable').mockImplementation(
() => {
throw new Error('Enhanced table failed');
},
);
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
createElement(
I18nProvider,
{ language: 'en' },
createElement(Markdown, {
content: '| A |\n| --- |\n| 1 |',
tableMode: 'advanced',
}),
),
);
});
const table = container.querySelector('table');
expect(table).not.toBeNull();
expect(table?.textContent).toContain('A');
expect(table?.textContent).toContain('1');
expect(container.textContent).not.toContain('Copy table');
expect(consoleError).toHaveBeenCalledWith(
'[web-shell] enhanced markdown table failed:',
expect.any(Error),
expect.any(String),
);
act(() => root.unmount());
container.remove();
});
});
describe('resolveFenceLanguage', () => {
it('resolves common aliases to Shiki language ids', () => {
expect(resolveFenceLanguage('ts').resolvedLang).toBe('typescript');
expect(resolveFenceLanguage('js').resolvedLang).toBe('javascript');
expect(resolveFenceLanguage('py').resolvedLang).toBe('python');
expect(resolveFenceLanguage('c++').resolvedLang).toBe('cpp');
expect(resolveFenceLanguage('c#').resolvedLang).toBe('csharp');
expect(resolveFenceLanguage('f#').resolvedLang).toBe('fsharp');
expect(resolveFenceLanguage('sh').resolvedLang).toBe('bash');
expect(resolveFenceLanguage('yml').resolvedLang).toBe('yaml');
expect(resolveFenceLanguage('golang').resolvedLang).toBe('go');
});
it('passes through already-canonical languages', () => {
expect(resolveFenceLanguage('typescript').resolvedLang).toBe('typescript');
expect(resolveFenceLanguage('sql').resolvedLang).toBe('sql');
});
it('is case-insensitive', () => {
expect(resolveFenceLanguage('SQL').resolvedLang).toBe('sql');
expect(resolveFenceLanguage('TS').resolvedLang).toBe('typescript');
});
it('falls back to "text" for unknown languages', () => {
expect(resolveFenceLanguage('made-up').resolvedLang).toBe('text');
expect(resolveFenceLanguage('').resolvedLang).toBe('text');
expect(resolveFenceLanguage(undefined).resolvedLang).toBe('text');
});
it('keeps the user-typed label (original case) for the header', () => {
expect(resolveFenceLanguage('ts').label).toBe('ts');
// Original case is preserved for display even though resolution lowercases.
expect(resolveFenceLanguage('TypeScript').label).toBe('TypeScript');
expect(resolveFenceLanguage('TypeScript').resolvedLang).toBe('typescript');
expect(resolveFenceLanguage(undefined).label).toBe('text');
});
it('detects mermaid as its own language', () => {
expect(resolveFenceLanguage('mermaid').lang).toBe('mermaid');
});
it('does not leak inherited Object.prototype keys as a non-string lang', () => {
for (const evil of [
'__proto__',
'constructor',
'toString',
'hasOwnProperty',
]) {
const { lang, resolvedLang } = resolveFenceLanguage(evil);
expect(typeof lang).toBe('string');
expect(resolvedLang).toBe('text');
}
});
});
describe('Markdown mermaid rendering', () => {
it('keeps mermaid code blocks unrendered while streaming', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(
createElement(Markdown, {
content: '```mermaid\ngraph TD\nA --> B\n```',
isStreaming: true,
}),
);
});
expect(container.textContent).toContain('mermaid');
expect(container.textContent).toContain('graph TD');
expect(container.textContent).not.toContain('mermaid.rendering');
await act(async () => {
root.unmount();
});
container.remove();
});
});
describe('Markdown custom code block rendering', () => {
it('lets host renderers replace assistant fenced code blocks', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn((info: WebShellCodeBlockRenderInfo) => {
if (info.language !== 'echarts-fulldata') return undefined;
return createElement(
'div',
{ 'data-chart-theme': info.theme },
`${info.source}:${info.isStreaming}:${info.code}`,
);
});
await act(async () => {
root.render(
createElement(
ThemeProvider,
{ value: 'dark' },
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nconst option = {};\n```',
source: 'assistant',
isStreaming: true,
}),
),
),
);
});
expect(renderCodeBlock).toHaveBeenCalledWith({
language: 'echarts-fulldata',
resolvedLanguage: 'text',
className: 'language-echarts-fulldata',
code: 'const option = {};',
isStreaming: true,
isIncomplete: false,
source: 'assistant',
theme: 'dark',
});
expect(container.querySelector('[data-chart-theme="dark"]')).not.toBeNull();
expect(container.textContent).toContain(
'assistant:true:const option = {};',
);
expect(container.querySelector('pre code')).toBeNull();
await act(async () => {
root.unmount();
});
container.remove();
});
it('marks only the unterminated tail fence as incomplete', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() =>
createElement('div', { 'data-custom-code': 'true' }),
);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content:
'```first-chart\n{"value":1}\n```\n\ntext\n\n```second-chart\n{"value":',
source: 'assistant',
isStreaming: true,
}),
),
);
});
const infos = renderCodeBlock.mock.calls.map(
([info]) => info as WebShellCodeBlockRenderInfo,
);
expect(infos.map((info) => info.isStreaming)).toEqual([true, true]);
expect(infos.map((info) => info.isIncomplete)).toEqual([false, true]);
await act(async () => {
root.unmount();
});
container.remove();
});
it('falls back to the default code block when the host renderer declines', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => undefined);
await act(async () => {
root.render(
createElement(
ThemeProvider,
{ value: 'light' },
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```custom-chart\nconst option = {};\n```',
source: 'assistant',
isStreaming: false,
}),
),
),
);
});
expect(renderCodeBlock).toHaveBeenCalledWith(
expect.objectContaining({
language: 'custom-chart',
resolvedLanguage: 'text',
className: 'language-custom-chart',
code: 'const option = {};',
theme: 'light',
}),
);
expect(container.textContent).toContain('custom-chart');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('passes resolved language aliases to host renderers', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => undefined);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```ts\nconst x = 1;\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).toHaveBeenCalledWith(
expect.objectContaining({
language: 'ts',
resolvedLanguage: 'typescript',
}),
);
expect(container.querySelector('pre code')?.textContent).toContain(
'const x = 1;',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('passes punctuation language aliases to host renderers', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => undefined);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```c++\nstd::cout << "hello";\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).toHaveBeenCalledWith(
expect.objectContaining({
language: 'c++',
resolvedLanguage: 'cpp',
}),
);
expect(container.querySelector('pre code')?.textContent).toContain(
'std::cout << "hello";',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('extracts language prefixes from glued fence metadata', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => undefined);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content:
'```js{1,3}\nconst x = 1;\n```\n\n```c:main.c\nint main() {}\n```\n\n```vue{2}\n<template />\n```',
source: 'assistant',
}),
),
);
});
const infos = renderCodeBlock.mock.calls.map(
([info]) => info as WebShellCodeBlockRenderInfo,
);
expect(infos.map((info) => info.language)).toEqual(['js', 'c', 'vue']);
expect(infos.map((info) => info.resolvedLanguage)).toEqual([
'javascript',
'c',
'vue',
]);
await act(async () => {
root.unmount();
});
container.remove();
});
it('does not pass unsafe fence-language characters to host renderers', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement('div', null, 'custom'));
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```bad<script>\nconst option = {};\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).not.toHaveBeenCalled();
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('falls back to the default code block when the host renderer returns null', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => null);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```custom-chart\nconst option = {};\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).toHaveBeenCalledOnce();
expect(container.textContent).toContain('custom-chart');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('falls back to the default code block when the host renderer returns false', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => false);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```custom-chart\nconst option = {};\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).toHaveBeenCalledOnce();
expect(container.textContent).toContain('custom-chart');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('does not call host renderers when markdown source is omitted', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement('div', null, 'custom'));
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nconst option = {};\n```',
}),
),
);
});
expect(renderCodeBlock).not.toHaveBeenCalled();
expect(container.textContent).toContain('echarts-fulldata');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('does not call host renderers for inline code', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement('div', null, 'custom'));
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: 'Inline `const x = 1` example.',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).not.toHaveBeenCalled();
expect(container.querySelector('code')?.textContent).toBe('const x = 1');
await act(async () => {
root.unmount();
});
container.remove();
});
it('does not call host renderers for bare fenced code blocks', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement('div', null, 'custom'));
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```\nhello world\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).not.toHaveBeenCalled();
expect(container.querySelector('pre code')?.textContent).toContain(
'hello world',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('does not call host renderers for unfenced multiline code blocks', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement('div', null, 'custom'));
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: ' const x = 1;\n const y = 2;',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).not.toHaveBeenCalled();
expect(container.querySelector('pre code')?.textContent).toContain(
'const x = 1;',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('falls back to the default code block when the host renderer throws', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => {
throw new Error('boom');
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nconst option = {};\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(
'[web-shell] custom code block renderer call failed (lang=%s):',
'echarts-fulldata',
expect.any(Error),
);
expect(container.textContent).toContain('echarts-fulldata');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
} finally {
errorSpy.mockRestore();
await act(async () => {
root.unmount();
});
container.remove();
}
});
it('falls back to the default code block when custom rendered content throws', async () => {
function ThrowingChart(): never {
throw new Error('render boom');
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement(ThrowingChart));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nconst option = {};\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(
'[web-shell] custom code block component render (lang=echarts-fulldata) failed:',
expect.any(Error),
expect.any(String),
);
expect(container.textContent).toContain('echarts-fulldata');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
} finally {
errorSpy.mockRestore();
await act(async () => {
root.unmount();
});
container.remove();
}
});
it('retries custom rendered content after the error boundary reset key changes', async () => {
function ThrowingChart(): never {
throw new Error('render boom');
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn((info: WebShellCodeBlockRenderInfo) => {
if (info.code === 'bad') return createElement(ThrowingChart);
return createElement('div', { 'data-custom-code': info.code }, info.code);
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nbad\n```',
source: 'assistant',
}),
),
);
});
expect(container.querySelector('pre code')?.textContent).toContain('bad');
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\ngood\n```',
source: 'assistant',
}),
),
);
});
expect(errorSpy).toHaveBeenCalledWith(
'[web-shell] custom code block component render (lang=echarts-fulldata) failed:',
expect.any(Error),
expect.any(String),
);
expect(
container.querySelector('[data-custom-code="good"]'),
).not.toBeNull();
expect(container.querySelector('pre code')).toBeNull();
} finally {
errorSpy.mockRestore();
await act(async () => {
root.unmount();
});
container.remove();
}
});
it('retries custom rendered content when streaming settles', async () => {
function ThrowingChart(): never {
throw new Error('render boom');
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn((info: WebShellCodeBlockRenderInfo) => {
if (info.isStreaming) return createElement(ThrowingChart);
return createElement('div', { 'data-custom-code': 'settled' }, info.code);
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nfinal\n```',
source: 'assistant',
isStreaming: true,
}),
),
);
});
expect(container.querySelector('pre code')?.textContent).toContain(
'final',
);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nfinal\n```',
source: 'assistant',
isStreaming: false,
}),
),
);
});
expect(errorSpy).toHaveBeenCalledWith(
'[web-shell] custom code block component render (lang=echarts-fulldata) failed:',
expect.any(Error),
expect.any(String),
);
expect(
container.querySelector('[data-custom-code="settled"]'),
).not.toBeNull();
expect(container.querySelector('pre code')).toBeNull();
} finally {
errorSpy.mockRestore();
await act(async () => {
root.unmount();
});
container.remove();
}
});
it('retries custom rendered content as streaming code changes', async () => {
function ThrowingChart(): never {
throw new Error('render boom');
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn((info: WebShellCodeBlockRenderInfo) => {
if (info.code === 'bad') return createElement(ThrowingChart);
return createElement('div', { 'data-custom-code': info.code }, info.code);
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nbad\n```',
source: 'assistant',
isStreaming: true,
}),
),
);
});
expect(container.querySelector('pre code')?.textContent).toContain('bad');
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\ngood\n```',
source: 'assistant',
isStreaming: true,
}),
),
);
});
expect(errorSpy).toHaveBeenCalledWith(
'[web-shell] custom code block component render (lang=echarts-fulldata) failed:',
expect.any(Error),
expect.any(String),
);
expect(
container.querySelector('[data-custom-code="good"]'),
).not.toBeNull();
expect(container.querySelector('pre code')).toBeNull();
} finally {
errorSpy.mockRestore();
await act(async () => {
root.unmount();
});
container.remove();
}
});
it('retries custom rendered content when source or theme changes', async () => {
function ThrowingChart(): never {
throw new Error('render boom');
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn((info: WebShellCodeBlockRenderInfo) => {
if (info.source === 'assistant' && info.theme === 'dark') {
return createElement(ThrowingChart);
}
return createElement(
'div',
{ 'data-custom-code': `${info.source}:${info.theme}` },
info.code,
);
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const tree = (source: 'assistant' | 'thinking', theme: 'dark' | 'light') =>
createElement(
ThemeProvider,
{ value: theme },
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nsame\n```',
source,
}),
),
);
try {
await act(async () => {
root.render(tree('assistant', 'dark'));
});
expect(container.querySelector('pre code')?.textContent).toContain(
'same',
);
await act(async () => {
root.render(tree('thinking', 'light'));
});
expect(errorSpy).toHaveBeenCalledWith(
'[web-shell] custom code block component render (lang=echarts-fulldata) failed:',
expect.any(Error),
expect.any(String),
);
expect(
container.querySelector('[data-custom-code="thinking:light"]'),
).not.toBeNull();
expect(container.querySelector('pre code')).toBeNull();
} finally {
errorSpy.mockRestore();
await act(async () => {
root.unmount();
});
container.remove();
}
});
it('lets custom code components take precedence over renderCodeBlock', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement('div', null, 'custom'));
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{
value: {
markdown: {
renderCodeBlock,
components: {
code({ children }: { children?: ReactNode }) {
return createElement(
'code',
{ 'data-custom-code-component': 'true' },
children,
);
},
},
},
},
},
createElement(Markdown, {
content: '```echarts-fulldata\nconst option = {};\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).not.toHaveBeenCalled();
expect(
container.querySelector('[data-custom-code-component="true"]'),
).not.toBeNull();
await act(async () => {
root.unmount();
});
container.remove();
});
it('applies transformMarkdown customization before rendering', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const transformMarkdown = vi.fn((content: string) =>
content.replace('raw chart', 'transformed chart'),
);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { transformMarkdown } } },
createElement(Markdown, {
content: '**raw chart**',
source: 'assistant',
}),
),
);
});
expect(transformMarkdown).toHaveBeenCalledWith('**raw chart**', {
source: 'assistant',
});
expect(container.textContent).toContain('transformed chart');
expect(container.textContent).not.toContain('raw chart');
await act(async () => {
root.unmount();
});
container.remove();
});
it('applies transformMarkdown to a large streaming response', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const rawContent = `raw prefix ${'streaming text '.repeat(3_000)}`;
const transformMarkdown = vi.fn((content: string) =>
content.replace('raw prefix', 'transformed prefix'),
);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { transformMarkdown } } },
createElement(Markdown, {
content: rawContent,
source: 'assistant',
isStreaming: true,
}),
),
);
});
expect(
container.querySelector('[data-markdown-streaming-plain-text="true"]'),
).not.toBeNull();
expect(transformMarkdown).toHaveBeenCalledWith(rawContent, {
source: 'assistant',
});
expect(container.textContent).toContain('transformed prefix');
expect(container.textContent).not.toContain('raw prefix');
await act(async () => {
root.unmount();
});
container.remove();
});
});
describe('Markdown code highlighting while streaming', () => {
it('keeps code plain in document mode without loading the highlighter', async () => {
__resetForTesting();
await getCodeHighlighter('json');
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(
createElement(
TranscriptRenderModeProvider,
{ value: 'document' },
createElement(Markdown, {
content: '```json\n{ "safe": true }\n```',
isStreaming: false,
}),
),
);
});
expect(container.querySelector('.shiki')).toBeNull();
expect(container.querySelector('pre code')?.textContent).toContain(
'"safe": true',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('drops a warmed highlight when switching to document mode', async () => {
__resetForTesting();
await getCodeHighlighter('json');
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const content = '```json\n{ "safe": true }\n```';
await act(async () => {
root.render(
createElement(
TranscriptRenderModeProvider,
{ value: 'interactive' },
createElement(Markdown, { content, isStreaming: false }),
),
);
});
expect(container.querySelector('.shiki')).not.toBeNull();
await act(async () => {
root.render(
createElement(
TranscriptRenderModeProvider,
{ value: 'document' },
createElement(Markdown, { content, isStreaming: false }),
),
);
});
expect(container.querySelector('.shiki')).toBeNull();
expect(container.querySelector('pre code')?.textContent).toContain(
'"safe": true',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('keeps streamed code content visible while streaming', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(
createElement(Markdown, {
content: '```ts\nconst x: number = 1;\n```',
isStreaming: true,
}),
);
});
// The streamed code stays visible inside the rendered code element — not
// merely somewhere in the DOM (the lang label / copy button). Anchoring to
// `pre code` guards the "no streamed text is ever hidden" invariant: if the
// highlight HTML were set but empty, this element would be missing/blank
// even though container.textContent still matched the header.
const codeEl = container.querySelector('pre code');
expect(codeEl).not.toBeNull();
expect(codeEl?.textContent).toContain('const x: number = 1;');
await act(async () => {
root.unmount();
});
container.remove();
});
it('keeps a growing block plain and highlights it once settled', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(
createElement(Markdown, {
content: '```ts\nconst a = 1;\n```',
isStreaming: true,
}),
);
});
expect(container.querySelector('.shiki')).toBeNull();
expect(container.textContent).toContain('const a = 1;');
await act(async () => {
root.render(
createElement(Markdown, {
content: '```ts\nconst a = 1;\nconst b = 2;\n```',
isStreaming: true,
}),
);
// Wait for the 80ms streaming throttle to flush the new content
await new Promise((resolve) => setTimeout(resolve, 100));
});
expect(container.querySelector('.shiki')).toBeNull();
expect(container.textContent).toContain('const b = 2;');
await act(async () => {
root.render(
createElement(Markdown, {
content: '```ts\nconst a = 1;\nconst b = 2;\n```',
isStreaming: false,
}),
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});
expect(container.querySelector('.shiki')).not.toBeNull();
expect(container.textContent).toContain('const b = 2;');
await act(async () => {
root.unmount();
});
container.remove();
});
it('applies Shiki highlighting once a code block has settled', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(
createElement(Markdown, {
content: '```ts\nconst x: number = 1;\n```',
isStreaming: false,
}),
);
});
// Wait for the async highlight (language load + tokenization) to resolve.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});
expect(container.querySelector('.shiki')).not.toBeNull();
expect(container.textContent).toContain('const x');
await act(async () => {
root.unmount();
});
container.remove();
});
it('drops stale highlighted HTML when the code is replaced (regeneration)', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
// Settle an initial highlighted block.
await act(async () => {
root.render(
createElement(Markdown, {
content: '```ts\nconst aaa = 1;\n```',
isStreaming: false,
}),
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});
expect(container.textContent).toContain('const aaa');
// Replaced streaming content is shown as current plain text, never as the
// stale highlight from the previous settled response.
await act(async () => {
root.render(
createElement(Markdown, {
content: '```ts\nconst zzz = 2;\n```',
isStreaming: true,
}),
);
});
expect(container.querySelector('.shiki')).toBeNull();
expect(container.textContent).toContain('const zzz');
expect(container.textContent).not.toContain('const aaa');
// Once regenerated content settles, it is highlighted.
await act(async () => {
root.render(
createElement(Markdown, {
content: '```ts\nconst zzz = 2;\n```',
isStreaming: false,
}),
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});
expect(container.querySelector('.shiki')).not.toBeNull();
expect(container.textContent).toContain('const zzz');
await act(async () => {
root.unmount();
});
container.remove();
});
it('renders an oversized single-line fence as plain text even when the grammar is warm', async () => {
// Warm `json` up front so this test exercises the SIZE guard, not the cold
// path: if isTooLargeToHighlight were removed from CodeBlock, the warm
// synchronous highlight would run and produce `.shiki` — so the test would
// fail, which is what we want. (With an unwarmed language it would pass
// vacuously, because the cold path renders plain regardless of size.)
__resetForTesting();
await getCodeHighlighter('json');
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const longLine = 'a'.repeat(MAX_HIGHLIGHT_LINE_CHARS + 1);
// Sanity: a normal json block DOES highlight, proving the grammar is warm.
await act(async () => {
root.render(
createElement(Markdown, {
content: '```json\n{ "a": 1 }\n```',
isStreaming: false,
}),
);
});
expect(container.querySelector('.shiki')).not.toBeNull();
// The oversized single line is rendered plain despite the warm grammar — the
// size guard, not language coldness, is what suppresses highlighting.
await act(async () => {
root.render(
createElement(Markdown, {
content: '```json\n' + longLine + '\n```',
isStreaming: false,
}),
);
});
expect(container.textContent).toContain(longLine);
expect(container.querySelector('.shiki')).toBeNull();
await act(async () => {
root.unmount();
});
container.remove();
});
});
describe('Markdown streaming throttle', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('flushes the latest content when multiple tokens arrive in one throttle window', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
createElement(Markdown, {
content: 'Token 1',
isStreaming: true,
}),
);
});
act(() => {
root.render(
createElement(Markdown, {
content: 'Token 1 Token 2',
isStreaming: true,
}),
);
});
act(() => {
root.render(
createElement(Markdown, {
content: 'Token 1 Token 2 Token 3',
isStreaming: true,
}),
);
});
expect(container.textContent).toContain('Token 1');
expect(container.textContent).not.toContain('Token 1 Token 2 Token 3');
act(() => {
vi.advanceTimersByTime(80);
});
expect(container.textContent).toContain('Token 1 Token 2 Token 3');
act(() => {
root.unmount();
});
container.remove();
});
});
describe('external links in the desktop shell', () => {
type TauriWindow = { __TAURI__?: { core?: { invoke?: unknown } } };
function clickLink(container: HTMLElement): MouseEvent {
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
button: 0,
});
act(() => {
container.querySelector('a')!.dispatchEvent(event);
});
return event;
}
afterEach(() => {
delete (window as TauriWindow).__TAURI__;
});
it('routes external link clicks through the desktop opener', () => {
const invoke = vi.fn().mockResolvedValue(undefined);
(window as TauriWindow).__TAURI__ = { core: { invoke } };
const c = renderMd(
'[issue](https://github.com/QwenLM/qwen-code/issues/9060)',
);
const event = clickLink(c);
expect(event.defaultPrevented).toBe(true);
expect(invoke).toHaveBeenCalledWith('plugin:opener|open_url', {
url: 'https://github.com/QwenLM/qwen-code/issues/9060',
});
(c as HTMLDivElement & { __unmount: () => void }).__unmount();
c.remove();
});
it('routes modified desktop clicks through the opener', () => {
const invoke = vi.fn().mockResolvedValue(undefined);
(window as TauriWindow).__TAURI__ = { core: { invoke } };
const c = renderMd(
'[issue](https://github.com/QwenLM/qwen-code/issues/9060)',
);
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
button: 1,
ctrlKey: true,
});
act(() => {
c.querySelector('a')!.dispatchEvent(event);
});
expect(event.defaultPrevented).toBe(true);
expect(invoke).toHaveBeenCalledWith('plugin:opener|open_url', {
url: 'https://github.com/QwenLM/qwen-code/issues/9060',
});
(c as HTMLDivElement & { __unmount: () => void }).__unmount();
c.remove();
});
it('requests an error toast when the desktop opener fails', async () => {
const invoke = vi.fn().mockRejectedValue(new Error('no browser'));
(window as TauriWindow).__TAURI__ = { core: { invoke } };
const toasts: ToastRequestDetail[] = [];
const handler = (e: Event) =>
toasts.push((e as CustomEvent<ToastRequestDetail>).detail);
window.addEventListener(TOAST_REQUEST_EVENT, handler);
const c = renderMd(
'[issue](https://github.com/QwenLM/qwen-code/issues/9060)',
);
const event = clickLink(c);
expect(event.defaultPrevented).toBe(true);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
window.removeEventListener(TOAST_REQUEST_EVENT, handler);
expect(toasts).toHaveLength(1);
expect(toasts[0].tone).toBe('error');
expect(toasts[0].message).toContain('no browser');
(c as HTMLDivElement & { __unmount: () => void }).__unmount();
c.remove();
});
it('keeps native anchor behavior outside the desktop shell', () => {
const openSpy = vi.spyOn(window, 'open');
const c = renderMd(
'[issue](https://github.com/QwenLM/qwen-code/issues/9060)',
);
const a = c.querySelector('a')!;
expect(a.getAttribute('target')).toBe('_blank');
const event = clickLink(c);
expect(event.defaultPrevented).toBe(false);
expect(openSpy).not.toHaveBeenCalled();
openSpy.mockRestore();
(c as HTMLDivElement & { __unmount: () => void }).__unmount();
c.remove();
});
});
describe('Markdown CJK emphasis', () => {
// CommonMark flanking rules reject emphasis delimiters adjacent to CJK
// punctuation (commonmark/commonmark-spec#650); the registered CJK-friendly
// remark plugin relaxes that so assistant answers can bold quoted terms.
it('bolds emphasis starting with ASCII quotes after a CJK character', () => {
const c = renderMd('这是**"示例"文本**。');
const strong = c.querySelector('strong');
expect(strong?.textContent).toBe('"示例"文本');
expect(c.textContent).not.toContain('**');
cleanup(c);
});
it('bolds emphasis starting with fullwidth quotes after a CJK character', () => {
const c = renderMd('这是**“示例”文本**。');
const strong = c.querySelector('strong');
expect(strong?.textContent).toBe('“示例”文本');
expect(c.textContent).not.toContain('**');
cleanup(c);
});
it('bolds emphasis closed by a CJK full stop', () => {
const c = renderMd('**示例句子。**后续文本。');
const strong = c.querySelector('strong');
expect(strong?.textContent).toBe('示例句子。');
expect(c.textContent).not.toContain('**');
cleanup(c);
});
it('keeps plain English emphasis unchanged', () => {
const c = renderMd('This is **important**.');
const strong = c.querySelector('strong');
expect(strong?.textContent).toBe('important');
expect(c.textContent).not.toContain('**');
cleanup(c);
});
it('keeps CJK bold when a host supplies custom remark plugins', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
createElement(
I18nProvider,
{ language: 'en' },
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { remarkPlugins: [] } } },
createElement(Markdown, {
content: '这是**"示例"文本**。',
source: 'assistant',
}),
),
),
);
});
(container as HTMLDivElement & { __unmount: () => void }).__unmount = () =>
act(() => root.unmount());
const strong = container.querySelector('strong');
expect(strong?.textContent).toBe('"示例"文本');
expect(container.textContent).not.toContain('**');
cleanup(container);
});
});