/** * @fileoverview Response-viewer links open in a NEW tab (`CodemanApp._renderMarkdown`). * * `marked` emits a bare `` and the markdown sanitizer's allowlist carries no * `target`, so every link in the chat used to navigate the CURRENT tab. On a phone that * unloads the whole dashboard — SSE, terminal buffers, unsent composer text — and the OS * back gesture reloads it from scratch, with no middle-click or open-in-new-tab affordance * to work around it. That is the "links don't open on mobile" report. * * `_renderMarkdown` therefore decorates anchors AFTER sanitizing, which makes it the single * source of both attributes: whatever an agent wrote is already stripped by then, and `rel` * is set on the same element in the same pass, so no page Codeman opens can reach back * through `window.opener` (reverse tabnabbing). * * Drives the SHIPPING artifacts — vendored `marked`, vendored DOMPurify + `sanitize-html.js`, * and `app.js` itself — in a `vm` with a jsdom document injected (the technique from * markdown-sanitizer.test.ts / response-viewer-file-links.test.ts; a per-file jsdom * environment would externalize node:fs under vite). * * No port / server needed. */ import { readFileSync } from 'node:fs'; import { performance } from 'node:perf_hooks'; import { resolve } from 'node:path'; import vm from 'node:vm'; import { JSDOM } from 'jsdom'; import { describe, expect, it, vi } from 'vitest'; const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8'); const dom = new JSDOM(''); const jsdomWindow = dom.window as unknown as Window & typeof globalThis; const { document, NodeFilter } = dom.window; /** The shipping sanitizer: vendored DOMPurify bound to our jsdom window + the real config. */ function loadShippingSanitizer(): (html: string) => string { const dpModule: { exports: unknown } = { exports: {} }; // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func new Function('module', 'exports', publicFile('vendor/dompurify.min.js'))(dpModule, dpModule.exports); const DOMPurify = (dpModule.exports as (win: unknown) => unknown)(jsdomWindow); const sanModule: { exports: { createMarkdownSanitizer?: (dp: unknown) => (html: string) => string } } = { exports: {}, }; // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func new Function('module', 'exports', publicFile('sanitize-html.js'))(sanModule, sanModule.exports); const create = sanModule.exports.createMarkdownSanitizer; if (typeof create !== 'function') throw new Error('createMarkdownSanitizer not exported'); return create(DOMPurify); } /** The vendored `marked` build the page loads, evaluated as CommonJS. */ function loadShippingMarked(): { parse: (src: string, opts?: unknown) => string } { const module: { exports: unknown } = { exports: {} }; // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func new Function('module', 'exports', publicFile('vendor/marked.min.js'))(module, module.exports); return module.exports as { parse: (src: string, opts?: unknown) => string }; } type RenderApp = { _renderMarkdown(text: string): string }; function loadCodemanAppClass(): { prototype: RenderApp } { const context = vm.createContext({ console, performance, setInterval: vi.fn(), clearInterval: vi.fn(), setTimeout, clearTimeout, requestAnimationFrame: vi.fn(), HTMLCanvasElement: class HTMLCanvasElement {}, fetch: vi.fn(), document, NodeFilter, localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, // The page wires the sanitizer onto window; _sanitizeHtml fails closed without it, // and a closed-failing render would make every assertion below vacuous. window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), sanitizeMarkdownHtml: loadShippingSanitizer() }, marked: loadShippingMarked(), MobileDetection: {}, }); vm.runInContext( `${publicFile('constants.js')}\n${publicFile('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`, context ); return (context as { __CodemanApp: { prototype: RenderApp } }).__CodemanApp; } const CodemanApp = loadCodemanAppClass(); /** Render markdown the way the response viewer does and return the resulting element. */ function render(markdown: string): HTMLElement { const app = Object.create(CodemanApp.prototype) as RenderApp; const root = document.createElement('div'); root.className = 'rv-text'; root.innerHTML = app._renderMarkdown(markdown); return root as unknown as HTMLElement; } const anchor = (root: HTMLElement, index = 0) => Array.from(root.querySelectorAll('a'))[index]; describe('response viewer external links', () => { it('opens a markdown link in a new tab, with rel set in the same pass', () => { const root = render('See [the docs](https://example.com/docs?a=1&b=2) for details.'); const a = anchor(root); expect(a, 'the link survived sanitizing').toBeDefined(); expect(a.getAttribute('href')).toBe('https://example.com/docs?a=1&b=2'); expect(a.getAttribute('target')).toBe('_blank'); expect(a.getAttribute('rel')).toBe('noopener noreferrer'); }); it('opens an autolinked bare URL in a new tab too', () => { // gfm autolinks a bare URL, which is how an agent usually prints one. const root = render('Login at https://claude.ai/oauth/authorize?code=true&client_id=abc to continue.'); const a = anchor(root); expect(a.getAttribute('href')).toBe('https://claude.ai/oauth/authorize?code=true&client_id=abc'); expect(a.getAttribute('target')).toBe('_blank'); expect(a.getAttribute('rel')).toBe('noopener noreferrer'); }); it('sends a same-origin path to a new tab as well — it is still a navigation away', () => { const root = render('Check [status](/api/status).'); expect(anchor(root).getAttribute('target')).toBe('_blank'); }); it('leaves an in-page fragment link alone', () => { // A target here would open a second copy of the app to scroll it. const root = render('Jump to [the section](#results).'); const a = anchor(root); expect(a.getAttribute('href')).toBe('#results'); expect(a.hasAttribute('target')).toBe(false); expect(a.hasAttribute('rel')).toBe(false); }); it('leaves mailto: and tel: to the OS instead of stranding an empty tab', () => { const root = render('Mail [me](mailto:a@example.com) or call [now](tel:+15551234).'); for (const a of Array.from(root.querySelectorAll('a'))) { expect(a.hasAttribute('target'), a.getAttribute('href') || '').toBe(false); } }); it('is the ONLY source of target/rel: an agent cannot ask for an opener', () => { // The sanitizer's allowlist has neither attribute, so agent-authored ones are gone // before this pass runs — and the pass sets both together, so `rel` can never end up // weaker than the target it accompanies. const root = render('click'); const a = anchor(root); expect(a.getAttribute('target')).toBe('_blank'); expect(a.getAttribute('rel')).toBe('noopener noreferrer'); }); it('still drops a javascript: link rather than decorating it', () => { const root = render('[x](javascript:alert(1))'); const a = anchor(root); // DOMPurify strips the unsafe href; whatever is left must not carry a target either, // which would turn a hollow anchor into a window-opening one. expect(a?.getAttribute('href') ?? null).toBeNull(); expect(a?.hasAttribute('target') ?? false).toBe(false); }); it('keeps code blocks and their copy affordance intact', () => { // The anchor pass shares the one template walk with the code-block wrapper; a mistake // there would silently drop the toolbar rather than fail loudly. const root = render('```\nconst a = 1;\n```'); expect(root.querySelector('.rv-code-wrap')).not.toBeNull(); expect(root.querySelector('.rv-copy-btn')).not.toBeNull(); expect(root.querySelector('pre code')?.textContent).toContain('const a = 1;'); }); });