// Conversation view (#1507, CHAT-02 brief §2.2): full branch history through
// the real board routes and the real WebUI. The hostile-render fixture (R1)
// must stay inert text: no element, handler or navigation from session content.
// WEBUI_EVIDENCE=
also saves full-page screenshots at 320 and 1440 in the
// light and dark modes.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { historyFixture, session, user, assistant, toolResult, at, waiter, turns } from './history-fixture.mjs';
const HOSTILE = '
[click](javascript:window.injected=3) link \u001b[31mRED\u001b[0m \u001b]8;;http://example.invalid\u0007osc\u001b]8;;\u0007 \u009b31mCSI \u200emark \u202eevil';
const LONG = 'This answer is longer than the board summary. '.repeat(40) + 'LONG_END';
test('conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers', { timeout: 120000 }, async () => {
const f = await historyFixture();
let b;
try {
const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl');
const log = session(file, f.projectRoot);
log.add(user('Show me the file'), at(-300));
log.add(assistant([{ type: 'thinking', thinking: 'SECRET_THOUGHT' }, { type: 'text', text: 'Reading it.' }, { type: 'toolCall', id: 'call_1|fc_2', name: 'read', arguments: { path: 'README.md' } }], 'toolUse'), at(-290));
log.add(toolResult('call_1|fc_2', HOSTILE), at(-280));
log.raw('{"type":"message","id":');
log.add(assistant('Here: ' + HOSTILE), at(-270));
log.add(user('And the summary?'), at(-260));
log.add(assistant(LONG), at(-250));
b = await browser(); await b.viewport(1440, 1000);
const wait = waiter(b);
await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")');
const href = await b.evaluate('location.href');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("7 messages")');
assert.equal(await b.evaluate('document.querySelector("#board-view").hidden'), true);
assert.equal(await b.evaluate('document.activeElement.id'), 'conv-title');
assert.match(await b.evaluate('document.querySelector("#conv-meta").textContent'), /^repo · Show me the file · started /);
assert.deepEqual((await turns(b)).map(t => t[0]), ['User', 'Assistant', 'Tool', 'Notice', 'Assistant', 'User', 'Assistant']);
assert.match(await b.evaluate('document.querySelectorAll("#conv-log > li")[3].textContent'), /Line 5 could not be read/);
// The long answer in full: the board's 240-character summary is not used.
assert.equal(await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].at(-1).textContent'), LONG);
// Thinking and tools collapsed; their contents are not rendered visible.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#conv-log details")].map(d => [d.className, d.querySelector("summary").textContent, d.open, d.querySelector("pre").checkVisibility()])'), [
['conv-thinking', 'Thinking', false, false],
['conv-tool-call', 'Tool call: read', false, false],
['conv-tool-result', 'Tool result', false, false],
]);
assert.equal(await b.evaluate('document.querySelector(".conv-tool-call pre").textContent'), '{"path":"README.md"}');
// Hostile content: shown as text, controls made visible, nothing active created.
await b.evaluate('document.querySelectorAll("#conv-log details").forEach(d => d.open = true)');
const shown = await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).textContent');
assert.ok(shown.startsWith('Here:
[click](javascript:window.injected=3) link ␛[31mRED'), shown);
assert.ok(shown.includes('␛]8;;http://example.invalid␇osc') && shown.endsWith('[U+009B]31mCSI [U+200E]mark [U+202E]evil'), shown);
assert.equal(await b.evaluate('document.querySelector(".conv-tool-result pre").textContent'), shown.slice(6));
assert.equal(await b.evaluate('document.querySelectorAll("#conversation script, #conversation img, #conversation a, #conversation iframe, #conversation object, #conversation embed, #conversation svg, #conversation style, #conversation link").length'), 0);
assert.equal(await b.evaluate('[...document.querySelectorAll("*")].some(e => [...e.attributes].some(a => a.name.startsWith("on")))'), false);
assert.equal(await b.evaluate('/[\\u001b\\u009b\\u200e\\u202e]/.test(document.body.textContent)'), false);
await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).click()');
assert.equal(await b.evaluate('typeof window.injected'), 'undefined');
assert.equal(await b.evaluate('location.href'), href);
// A same-inode rewrite of the history: the next check refuses, the view keeps what it showed.
writeFileSync(file, readFileSync(file, 'utf8').replace('Show me the file', 'Show me the FILE'));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("[data-marker=reconcile]")');
assert.match(await b.evaluate('document.querySelector("[data-marker=reconcile]").textContent'), /out of date \(source-replaced\).*loaded before\.Reload conversation$/);
assert.equal(await b.evaluate('document.querySelectorAll("#conv-log > li").length'), 7);
assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /Show me the file/);
assert.match(await b.evaluate('document.querySelector("#conv-status").textContent'), /not checking for new entries/);
// Evidence shows thinking as a reader first sees it (closed), from the top of the page.
await b.evaluate('document.querySelector(".conv-thinking").open = false; scrollTo(0, 0)');
for (const width of [320, 1440]) for (const mode of ['light', 'dark']) {
await b.viewport(width, 1000);
await b.evaluate(`(() => { const m = document.querySelector("#mode"); m.value = ${JSON.stringify(mode)}; m.dispatchEvent(new Event("change")); })()`);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true, `no horizontal overflow at ${width} ${mode}`);
if (process.env.WEBUI_EVIDENCE) {
const { data } = await b.call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
writeFileSync(join(process.env.WEBUI_EVIDENCE, `conversation-${width}-${mode}.png`), Buffer.from(data, 'base64'));
}
}
// Reload takes a fresh snapshot of the rewritten file; Back returns focus to the History button.
await b.evaluate('document.querySelector("[data-conv-action=reload]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("Show me the FILE")');
assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null);
await b.evaluate('document.querySelector("#conv-back").click()');
assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true);
assert.equal(await b.evaluate('document.activeElement.dataset.history'), 'repo/fixture');
// The inspector opens the same view.
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
await wait('document.querySelector("#inspection [data-history]")');
await b.evaluate('document.querySelector("#inspection [data-history]").click()');
await wait('!document.querySelector("#conversation").hidden && document.querySelector("#conv-status").textContent.startsWith("7 messages")');
assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /Show me the FILE/);
} finally { if (b) await b.close(); await f.close(); }
});
test('conversation view: a fork keeps the open branch, says so, and opens the new one on request', { timeout: 60000 }, async () => {
const f = await historyFixture();
let b;
try {
const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl');
const log = session(file, f.projectRoot);
const first = log.add(user('Question'), at(-120));
log.add(assistant('MAIN_ANSWER'), at(-110));
b = await browser(); await b.viewport(1440, 1000);
const wait = waiter(b);
await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")');
// Pi forks from the first entry; the fork is now its default leaf, on branch b.fork-1.
log.raw(JSON.stringify({ type: 'message', id: 'fork-1', parentId: first, timestamp: at(0), message: assistant('FORK_ANSWER') }));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("[data-marker=branch]")');
assert.match(await b.evaluate('document.querySelector("[data-marker=branch]").textContent'), /continued on another branch\. This view stays on the branch it opened\.Open the latest branch$/);
const text = await b.evaluate('document.querySelector("#conv-log").textContent');
assert.ok(text.includes('MAIN_ANSWER') && !text.includes('FORK_ANSWER'), 'no silent switch');
// The open branch grows; the next poll still follows it, not the default branch.
log.add(user('MAIN_MORE'), at(0));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("#conv-log").textContent.includes("MAIN_MORE") || !!document.querySelector("[data-marker=reconcile]") || document.querySelector("#conv-status").textContent.includes("unavailable")');
assert.deepEqual(await turns(b), [['User', 'Question'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]);
assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null);
// The fork becomes the default leaf again. A same-inode rewrite then refuses the next check.
log.raw(JSON.stringify({ type: 'message', id: 'fork-2', parentId: 'fork-1', timestamp: at(0), message: user('FORK_MORE') }));
writeFileSync(file, readFileSync(file, 'utf8').replace('"Question"', '"QUESTION"'));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("[data-marker=reconcile]")');
// Reload stays on the branch the view was on, and still says the conversation went elsewhere.
await b.evaluate('document.querySelector("[data-conv-action=reload]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("QUESTION") && !document.querySelector("[data-marker=reconcile]")');
await wait('document.querySelector("[data-marker=branch]")');
assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]);
// "Open the latest branch" takes the default.
await b.evaluate('document.querySelector("[data-conv-action=latest]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("FORK_MORE")');
assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'FORK_ANSWER'], ['User', 'FORK_MORE']]);
assert.equal(await b.evaluate('document.querySelector("[data-marker=branch]")'), null);
// The fork is rewritten away. Reload cannot keep a branch that is gone, so it opens the default and says so.
writeFileSync(file, readFileSync(file, 'utf8').split('\n').filter(l => !l.includes('"fork-')).join('\n'));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("[data-marker=reconcile]")');
await b.evaluate('document.querySelector("[data-conv-action=reload]").click()');
await wait('document.querySelector("[data-marker=gone]")');
assert.match(await b.evaluate('document.querySelector("[data-marker=gone]").textContent'), /^The branch this view was on is no longer in the session\. This view shows the latest branch\.$/);
assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]);
assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null);
} finally { if (b) await b.close(); await f.close(); }
});
test('conversation view: a newer session with no readable history keeps the marker', { timeout: 60000 }, async () => {
const f = await historyFixture();
let b;
try {
const log = session(join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl'), f.projectRoot);
log.add(user('Question'), at(-120));
log.add(assistant('OLD_ANSWER'), at(-110));
b = await browser(); await b.viewport(1440, 1000);
const wait = waiter(b);
await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")');
// The board takes the newest file by mtime; the catalogue orders by last entry, so a header-only file sorts last.
const log2 = session(join(f.sessionsDir, '2026-09-26T11-00-00_s2.jsonl'), f.projectRoot, { id: 'sess-2', timestamp: at(0) });
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("[data-marker=newer]")');
await b.evaluate('document.querySelector("[data-conv-action=newest]").click()');
await wait('/not readable yet/.test(document.querySelector("[data-marker=newer]")?.textContent)');
assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /OLD_ANSWER/);
// Once the new file has entries, the same button opens it.
log2.add(user('NEW_QUESTION'), at(1));
await b.evaluate('document.querySelector("[data-conv-action=newest]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("NEW_QUESTION")');
assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null);
} finally { if (b) await b.close(); await f.close(); }
});
test('conversation view: seats without history say so and offer no reply',{ timeout: 60000 }, async () => {
const f = await historyFixture();
let b;
try {
// No session file yet: the board still lists the registered seat.
b = await browser(); await b.viewport(390, 900);
const wait = waiter(b);
await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.includes("No readable history")');
assert.equal(await b.evaluate('document.querySelectorAll("#conv-log > li").length'), 0);
assert.equal(await b.evaluate('document.querySelector("#conv-pick").disabled'), true);
} finally { if (b) await b.close(); await f.close(); }
});