v1.13.19-html-artifact-panes: pane-based artifact viewer with on-request HTML
Every assistant message gets an "Open in pane" affordance that opens the
message in the workspace splitter — Markdown pane (Copy + Download .md) by
default; HTML pane (Download .html only) when the model emits a self-contained
<!DOCTYPE html> or fenced ```html artifact. BOOCHAT.md rule keeps Markdown
default at every length; HTML opt-in on explicit user request.
Backend: services/artifacts.ts (slug derivation + write helpers with
symlink-escape guard via realpath-after-mkdir), routes/artifacts.ts (POST
download + GET stream with nosniff + CSP sandbox defense-in-depth), HTML
detection in finalizeCompletion writing a new message_parts.kind='html_artifact'
row (schema CHECK extended via v1.13.13 pattern), graceful 1MB cap via the
pure decideHtmlArtifactWrite helper. PartKind union extended.
Frontend: MarkdownRenderer.tsx extracted from MessageBubble's inline
MarkdownBody for reuse; MarkdownArtifactPane.tsx + HtmlArtifactPane.tsx with
loading/error states; pane state is reference-only ({chat_id, message_id,
title}) — content fetched on mount to keep workspace_panes jsonb small and
avoid 1MB blobs riding session_workspace_updated frames. iframe sandbox
locked to allow-scripts allow-clipboard-write allow-downloads with no
allow-same-origin, srcDoc not src. openInPane discriminates 404 (expected
fallback) from real errors (toast + bail). PanelRightOpen icon button with
mobile 44px tap-target.
31 new server unit tests including a real-symlink filesystem case; 332/332
server tests passing, tsc clean both sides, pnpm -C apps/web build green.
Smoke deferred to first deploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
261
apps/server/src/services/__tests__/artifacts.test.ts
Normal file
261
apps/server/src/services/__tests__/artifacts.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, symlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decideHtmlArtifactWrite,
|
||||
deriveHtmlSlug,
|
||||
deriveHtmlTitle,
|
||||
deriveMarkdownSlug,
|
||||
detectHtmlArtifact,
|
||||
HTML_ARTIFACT_MAX_BYTES,
|
||||
writeHtmlArtifact,
|
||||
writeMarkdownArtifact,
|
||||
} from '../artifacts.js';
|
||||
import { PathScopeError } from '../path_guard.js';
|
||||
|
||||
describe('deriveMarkdownSlug', () => {
|
||||
it('uses the first # heading when present', () => {
|
||||
expect(deriveMarkdownSlug('# Hello World\n\nbody')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('falls back to first 6 words', () => {
|
||||
const s = deriveMarkdownSlug('the quick brown fox jumps over the lazy dog');
|
||||
expect(s).toBe('the-quick-brown-fox-jumps-over');
|
||||
});
|
||||
|
||||
it('returns "artifact" for empty input', () => {
|
||||
expect(deriveMarkdownSlug('')).toBe('artifact');
|
||||
});
|
||||
|
||||
it('caps at 60 chars and lowercases', () => {
|
||||
const long = '# ' + 'A'.repeat(200);
|
||||
const s = deriveMarkdownSlug(long);
|
||||
expect(s.length).toBeLessThanOrEqual(60);
|
||||
expect(s).toMatch(/^[a-z0-9-]+$/);
|
||||
});
|
||||
|
||||
it('strips trailing punctuation', () => {
|
||||
expect(deriveMarkdownSlug('# Hello, World!!!')).toBe('hello-world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveHtmlSlug', () => {
|
||||
it('prefers payload.title when set', () => {
|
||||
expect(
|
||||
deriveHtmlSlug({ html_content: '<html></html>', title: 'My Title' }),
|
||||
).toBe('my-title');
|
||||
});
|
||||
|
||||
it('falls back to <title> tag', () => {
|
||||
expect(
|
||||
deriveHtmlSlug({
|
||||
html_content: '<html><head><title>Page Title</title></head></html>',
|
||||
title: null,
|
||||
}),
|
||||
).toBe('page-title');
|
||||
});
|
||||
|
||||
it('falls back to first <h1> when no <title>', () => {
|
||||
expect(
|
||||
deriveHtmlSlug({
|
||||
html_content: '<html><body><h1>Heading One</h1></body></html>',
|
||||
title: null,
|
||||
}),
|
||||
).toBe('heading-one');
|
||||
});
|
||||
|
||||
it('falls back to inner text words', () => {
|
||||
expect(
|
||||
deriveHtmlSlug({
|
||||
html_content: '<div>one two three four five six seven</div>',
|
||||
title: null,
|
||||
}),
|
||||
).toBe('one-two-three-four-five-six');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveHtmlTitle', () => {
|
||||
it('returns <title> content', () => {
|
||||
expect(deriveHtmlTitle('<html><head><title>T</title></head></html>')).toBe('T');
|
||||
});
|
||||
|
||||
it('falls back to <h1>', () => {
|
||||
expect(deriveHtmlTitle('<body><h1>H</h1></body>')).toBe('H');
|
||||
});
|
||||
|
||||
it('falls back to first 80 chars of inner text', () => {
|
||||
const html = '<div>' + 'x '.repeat(100) + '</div>';
|
||||
const t = deriveHtmlTitle(html);
|
||||
expect(t).not.toBeNull();
|
||||
expect(t!.length).toBeLessThanOrEqual(80);
|
||||
});
|
||||
|
||||
it('returns null for empty html', () => {
|
||||
expect(deriveHtmlTitle('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHtmlArtifact', () => {
|
||||
it('detects <!DOCTYPE html> prefix case-insensitively', () => {
|
||||
const html = '<!doctype HTML><html><body>x</body></html>';
|
||||
expect(detectHtmlArtifact(html)).toBe(html);
|
||||
});
|
||||
|
||||
it('strips leading/trailing whitespace before matching', () => {
|
||||
const html = '\n\n<!DOCTYPE html>\n<html></html>\n';
|
||||
expect(detectHtmlArtifact(html)).toBe(html.trim());
|
||||
});
|
||||
|
||||
it('detects fenced ```html block wrapping entire message', () => {
|
||||
const wrapped = '```html\n<!DOCTYPE html>\n<html></html>\n```';
|
||||
expect(detectHtmlArtifact(wrapped)).toContain('<!DOCTYPE html>');
|
||||
});
|
||||
|
||||
it('rejects plain markdown', () => {
|
||||
expect(detectHtmlArtifact('# heading\n\nsome text')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects message with prose before the doctype', () => {
|
||||
expect(
|
||||
detectHtmlArtifact('Here you go: <!DOCTYPE html><html></html>'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects empty input', () => {
|
||||
expect(detectHtmlArtifact('')).toBeNull();
|
||||
expect(detectHtmlArtifact(' \n ')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects fenced block without doctype/<html>', () => {
|
||||
expect(detectHtmlArtifact('```html\n<div>x</div>\n```')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts fenced block containing <html> tag (no doctype)', () => {
|
||||
const r = detectHtmlArtifact('```html\n<html><body>x</body></html>\n```');
|
||||
expect(r).toContain('<html>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeMarkdownArtifact / writeHtmlArtifact', () => {
|
||||
let projectRoot: string;
|
||||
beforeEach(async () => {
|
||||
projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-test-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(projectRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes a markdown artifact under .boocode/artifacts/', async () => {
|
||||
const result = await writeMarkdownArtifact(
|
||||
{ content: '# Hello\n\nbody' },
|
||||
{ projectId: 'pid', projectRoot },
|
||||
);
|
||||
expect(result.path).toMatch(/\.boocode\/artifacts\/hello-\d+\.md$/);
|
||||
expect(result.url).toMatch(/^\/api\/projects\/pid\/artifacts\/hello-\d+\.md$/);
|
||||
const written = await readFile(result.path, 'utf8');
|
||||
expect(written).toBe('# Hello\n\nbody');
|
||||
});
|
||||
|
||||
it('writes an html artifact', async () => {
|
||||
const result = await writeHtmlArtifact(
|
||||
{
|
||||
html_content: '<!DOCTYPE html><html><head><title>X</title></head></html>',
|
||||
char_count: 56,
|
||||
title: 'X',
|
||||
},
|
||||
{ projectId: 'pid', projectRoot },
|
||||
);
|
||||
expect(result.path).toMatch(/\.boocode\/artifacts\/x-\d+\.html$/);
|
||||
const written = await readFile(result.path, 'utf8');
|
||||
expect(written).toContain('<!DOCTYPE html>');
|
||||
});
|
||||
|
||||
it('creates the artifacts directory if absent', async () => {
|
||||
// Confirm the writer mkdir-recursive's the artifacts dir on first call.
|
||||
const result = await writeMarkdownArtifact(
|
||||
{ content: '# T' },
|
||||
{ projectId: 'pid', projectRoot },
|
||||
);
|
||||
expect(result.path).toContain('.boocode/artifacts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('1MB cap behavior', () => {
|
||||
it('reports the correct byte threshold', () => {
|
||||
expect(HTML_ARTIFACT_MAX_BYTES).toBe(1_048_576);
|
||||
});
|
||||
|
||||
it('exceeds threshold for oversize payload', () => {
|
||||
const oversize = '<!DOCTYPE html>' + 'A'.repeat(HTML_ARTIFACT_MAX_BYTES);
|
||||
expect(Buffer.byteLength(oversize, 'utf8')).toBeGreaterThan(
|
||||
HTML_ARTIFACT_MAX_BYTES,
|
||||
);
|
||||
});
|
||||
|
||||
it('detectHtmlArtifact still returns content above the cap (cap is checked by caller)', () => {
|
||||
// Detection is content-shape; the cap check lives in finalizeCompletion
|
||||
// (error-handler.ts). This test pins that contract: the helper does not
|
||||
// silently drop oversize payloads on the floor.
|
||||
const big = '<!DOCTYPE html>' + 'x'.repeat(2_000_000);
|
||||
expect(detectHtmlArtifact(big)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideHtmlArtifactWrite', () => {
|
||||
// Pure helper extracted from finalizeCompletion's cap-skip branch. Pins
|
||||
// the warn-and-skip decision without mocking the full InferenceContext.
|
||||
it('returns write=true for payloads under the cap', () => {
|
||||
const html = '<!DOCTYPE html><html></html>';
|
||||
const decision = decideHtmlArtifactWrite(html);
|
||||
expect(decision.write).toBe(true);
|
||||
expect(decision.byteLen).toBe(Buffer.byteLength(html, 'utf8'));
|
||||
});
|
||||
|
||||
it('returns write=false with cap_exceeded reason for oversize payloads', () => {
|
||||
const big = '<!DOCTYPE html>' + 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
|
||||
const decision = decideHtmlArtifactWrite(big);
|
||||
expect(decision.write).toBe(false);
|
||||
if (!decision.write) {
|
||||
expect(decision.reason).toBe('cap_exceeded');
|
||||
expect(decision.byteLen).toBeGreaterThan(HTML_ARTIFACT_MAX_BYTES);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts payload exactly at the cap (boundary)', () => {
|
||||
// byteLen === cap should write; only strictly greater skips.
|
||||
const exact = 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
|
||||
const decision = decideHtmlArtifactWrite(exact);
|
||||
expect(decision.write).toBe(true);
|
||||
expect(decision.byteLen).toBe(HTML_ARTIFACT_MAX_BYTES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('symlink escape protection', () => {
|
||||
// Closes the gap where `.boocode/artifacts` is a symlink pointing
|
||||
// outside the project root. The lexical prefix check on the resolved
|
||||
// candidate path passes (it's under projectRoot textually), but the
|
||||
// post-mkdir realpath verification must catch the escape.
|
||||
let projectRoot: string;
|
||||
let outside: string;
|
||||
beforeEach(async () => {
|
||||
projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-symlink-root-'));
|
||||
outside = await mkdtemp(join(tmpdir(), 'artifacts-symlink-outside-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(projectRoot, { recursive: true, force: true });
|
||||
await rm(outside, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('throws PathScopeError when .boocode/artifacts is a symlink to outside the project', async () => {
|
||||
// Create .boocode dir, then make `artifacts` a symlink pointing outside.
|
||||
await mkdir(join(projectRoot, '.boocode'), { recursive: true });
|
||||
await symlink(outside, join(projectRoot, '.boocode', 'artifacts'));
|
||||
await expect(
|
||||
writeMarkdownArtifact(
|
||||
{ content: '# Hello' },
|
||||
{ projectId: 'pid', projectRoot },
|
||||
),
|
||||
).rejects.toBeInstanceOf(PathScopeError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user