Files
Codeman/test/response-viewer-file-links.test.ts
Codeman maintainer 24ed43935c fix: file-link and session-sidebar review follow-ups from 1.19.0
Five post-merge review items from PRs #306 (clickable file paths) and
#307 (session sidebar):

- constants.js FILE_PREVIEW_EXTENSIONS gains the media extensions it was
  missing vs the single-source sets in attachment-registry.ts (m4v ogv
  ogg oga m4a aac flac opus), so an in-workspace .m4a opens the preview
  player instead of the log viewer; new test/media-extension-parity.test.ts
  pins all three copies (constants.js, panels-ui.js, attachment-registry.ts)
  against each other.
- FILE_PATH_LINK_PATTERN drops `etc` from its root alternation: /etc is
  unconditionally in DEFAULT_BLOCKED_TREES, so every /etc link 403'd.
  Negative cases added to the link-provider and response-viewer tests.
- updateSidebarCount() counts the rows actually on the sidebar list
  (session rows + web-tab rows, minus filtered-out ones) instead of
  this.sessions.size, and applySidebarFilter() refreshes it so the count
  follows the filter box per keystroke.
- The incremental-render connection-line gate now also fires in sidebar
  layout (this._lineageEdgeCount is permanently 0 there), matching the
  strip-scroll listener widened in #307, so a badge changing row heights
  redraws subagent/ultracode connectors.
- isSensitivePath() blocks ~/.claude.json, ~/.claude/settings.json and
  ~/.claude/settings.local.json (credential-bearing by schema), anchored
  to homedir() read at check time so case-level .claude/settings*.json
  files stay servable in the File Viewer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 20:34:34 +02:00

151 lines
6.8 KiB
TypeScript

/**
* @fileoverview Response-viewer file-path linkifier (`CodemanApp._linkifyFilePaths`).
*
* The viewer renders markdown, so a path an agent wrote — "wrote the chart to
* /tmp/.../chart.png" — arrived as inert text: the terminal's link provider
* never sees the chat, and the file it just produced was a copy-paste away
* instead of a click. The linkifier wraps those paths in an anchor the click
* delegate hands to the file-preview overlay.
*
* Two properties matter more than the linking itself and are pinned here:
*
* 1. **The text is untouched.** Anchors are built from TEXT NODES with DOM
* APIs, never by rebuilding already-sanitized markup as a string, so the
* message reads identically and "copy code" still yields exactly what the
* agent printed.
* 2. **Model output cannot become markup.** The source is model text; a
* path-shaped string carrying HTML must stay text.
*
* Loaded via `vm` with a jsdom document injected (same technique as
* connection-indicator.test.ts — no per-file jsdom environment, which would
* externalize node:fs under vite).
*/
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 dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
const { document, NodeFilter } = dom.window;
function loadCodemanAppClass() {
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
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() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: {},
});
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
return (context as { __CodemanApp: { prototype: { _linkifyFilePaths(root: unknown): void } } }).__CodemanApp;
}
const CodemanApp = loadCodemanAppClass();
const APP_SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
/** Render `html` into a detached .rv-text div and run the linkifier over it. */
function linkify(html: string): HTMLElement {
const app = Object.create(CodemanApp.prototype) as { _linkifyFilePaths(root: unknown): void };
const root = document.createElement('div');
root.className = 'rv-text';
root.innerHTML = html;
app._linkifyFilePaths(root);
return root as unknown as HTMLElement;
}
const paths = (root: HTMLElement) => Array.from(root.querySelectorAll('a.rv-path'));
describe('response viewer file-path linkifier', () => {
it('links an absolute path written as prose', () => {
const path = '/tmp/claude-1000/-home-arkon-default-claudeman/7b3fefd2/scratchpad/probe-run-native.png';
const root = linkify(`<p>Saved the capture to ${path} — have a look.</p>`);
const links = paths(root);
expect(links).toHaveLength(1);
expect(links[0].getAttribute('data-path')).toBe(path);
expect(links[0].textContent).toBe(path);
expect(root.textContent).toBe(`Saved the capture to ${path} — have a look.`);
});
it('links a path inside inline code, which is how agents usually write one', () => {
const root = linkify('<p>See <code>/home/a/out/report.pdf</code> for the numbers.</p>');
const links = paths(root);
expect(links).toHaveLength(1);
expect(links[0].getAttribute('data-path')).toBe('/home/a/out/report.pdf');
// Still inside the <code> span — the code styling is not lost.
expect(links[0].closest('code')).not.toBeNull();
});
it('links every path in one text node and preserves the text between them', () => {
const root = linkify('<p>Compare /tmp/before.png with /tmp/after.png please</p>');
expect(paths(root).map((a) => a.getAttribute('data-path'))).toEqual(['/tmp/before.png', '/tmp/after.png']);
expect(root.textContent).toBe('Compare /tmp/before.png with /tmp/after.png please');
});
it('never re-cuts text already inside an anchor', () => {
// marked autolinks URLs; a path-looking tail inside one must stay whole, and
// a nested <a> is invalid markup that would swallow the outer link's click.
// ⚠️ The URL's tail MUST be a string the pattern matches on its own
// (`/tmp/...` here): with an unmatchable tail this test passes with the
// inside-anchor guard deleted, i.e. it pins nothing.
const root = linkify('<p><a href="https://example.com/tmp/shot.png">https://example.com/tmp/shot.png</a></p>');
expect(paths(root)).toHaveLength(0);
expect(root.querySelectorAll('a')).toHaveLength(1);
expect(root.querySelector('a')!.getAttribute('href')).toBe('https://example.com/tmp/shot.png');
});
it('leaves text with no path untouched', () => {
const root = linkify('<p>Ratio 3/4 on 2026/08/16, see src/app.ts</p>');
expect(paths(root)).toHaveLength(0);
expect(root.textContent).toBe('Ratio 3/4 on 2026/08/16, see src/app.ts');
});
it('never linkifies /etc paths — the server blocks the whole tree, so the link could only 403', () => {
// /etc sits in DEFAULT_BLOCKED_TREES (config/attachment-guard.ts); it used
// to be a root in the shared pattern, which made every /etc link a
// guaranteed-dead click on both surfaces.
const root = linkify('<p>Check /etc/hosts and /etc/app/config.json for the mapping.</p>');
expect(paths(root)).toHaveLength(0);
expect(root.textContent).toBe('Check /etc/hosts and /etc/app/config.json for the mapping.');
});
it('cannot turn model text into markup', () => {
// The anchor is built with createElement + textContent, so even a
// path-shaped payload stays text. (`<` also ends a match, so the linkifier
// never spans into it in the first place.)
const root = linkify('<p>/tmp/x.png&lt;img src=x onerror=alert(1)&gt;.png</p>');
expect(root.querySelector('img')).toBeNull();
expect(root.textContent).toContain('<img src=x onerror=alert(1)>.png');
for (const link of paths(root)) {
expect(link.innerHTML).toBe(link.textContent);
}
});
it('is wired into message rendering and the click delegate', () => {
// The linkifier is only reachable through these two call sites; losing
// either leaves inert paths (no linkify) or dead links (no handler).
expect(APP_SOURCE).toContain('this._linkifyFilePaths(renderedText)');
expect(APP_SOURCE).toMatch(/closest\('a\.rv-path'\)/);
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, this\.activeSessionId\)/);
});
});