mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
Review round 2: with the stored conversation tried before the launch id, a respawn that resumed the launch conversation after a /clear was missed once claude exited again: the cleared-to conversation still had a file and won, so the edits made after the resume were never read. No fixed order of the ids is right both after a /clear and after such a resume. Without a live conversation the lookup now takes the most recently written transcript of all known ids (stored, claudeSessionId, id); the live one still goes first. Also from the review: - the remote no-transcript note and the docs name login profiles too (.profile, .bash_profile): sshd's `bash -c` reads neither, like .zshrc; - a wiring guard pins that server.ts looks a remote transcript up with remoteTranscriptIds and leaves the local lookup on the hook path; - the browser tests undo the injected remote session in a finally. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
529 lines
25 KiB
TypeScript
529 lines
25 KiB
TypeScript
/**
|
||
* @fileoverview Edit history button and panel, end to end in a real browser: real server, real
|
||
* Chromium. The edits API is mocked with `page.route` (the UI is built against its contract); the
|
||
* thumbnails (workspace `file-raw`, the attachment route outside it) and the transcript SSE
|
||
* events the open panel follows go through the real server.
|
||
*/
|
||
import { homedir } from 'node:os';
|
||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||
import { chromium, type Browser, type Page, type Route } from 'playwright';
|
||
import { WebServer } from '../src/web/server.js';
|
||
|
||
/** A 2×2 red PNG, so the thumbnail has real pixels to decode. */
|
||
const PNG = Buffer.from(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg==',
|
||
'base64'
|
||
);
|
||
|
||
type Entry = {
|
||
id: string;
|
||
ts: number;
|
||
path: string;
|
||
relPath: string;
|
||
kind: 'code' | 'image';
|
||
tool: string;
|
||
created: boolean;
|
||
added: number;
|
||
removed: number;
|
||
agentId?: string;
|
||
};
|
||
|
||
describe('Edit history panel in a real browser', () => {
|
||
let server: WebServer;
|
||
let browser: Browser;
|
||
let page: Page;
|
||
let root: string;
|
||
/** A directory outside the session's workspace, for the attachment-route thumbnail. */
|
||
let ext: string;
|
||
let sessionA: string;
|
||
let sessionB: string;
|
||
const settingsPutStatuses: number[] = [];
|
||
/** Bodies of POST /api/sessions/:id/attachments (the file preview registering an external image). */
|
||
const attachmentPosts: unknown[] = [];
|
||
/** Every request to the mocked API: `METHOD url`. */
|
||
const calls: string[] = [];
|
||
/** The mocked server state per session id. */
|
||
const state = new Map<string, Record<string, unknown>>();
|
||
let diffs: Record<string, { diff: string; truncated: boolean }> = {};
|
||
let olderEntries: Entry[] = [];
|
||
|
||
const entry = (over: Partial<Entry> & { id: string; relPath: string }): Entry => ({
|
||
ts: Date.now(),
|
||
path: join(root, over.relPath),
|
||
kind: 'code',
|
||
tool: 'Edit',
|
||
created: false,
|
||
added: 1,
|
||
removed: 1,
|
||
...over,
|
||
});
|
||
|
||
const json = (route: Route, data: unknown) =>
|
||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data }) });
|
||
|
||
const buttonVisible = () =>
|
||
page.evaluate(() => getComputedStyle(document.getElementById('editHistoryBtn')!).display !== 'none');
|
||
const bodyText = async () => (await page.textContent('#editHistoryBody')) ?? '';
|
||
const setSetting = async (on: boolean) => {
|
||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||
if ((await page.isChecked('#appSettingsShowEditHistory')) !== on)
|
||
await page.click('label.set-chip:has(#appSettingsShowEditHistory)');
|
||
await page.evaluate(() => (window as any).app.saveAppSettings());
|
||
await page.waitForTimeout(300);
|
||
await page.evaluate(() => (window as any).app.closeAppSettings());
|
||
};
|
||
const createSession = (dir: string) =>
|
||
page.evaluate(async (workingDir) => {
|
||
const res = await fetch('/api/sessions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ workingDir, mode: 'shell' }),
|
||
});
|
||
const id = (await res.json()).data.session.id;
|
||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||
return id as string;
|
||
}, dir);
|
||
const select = (id: string) =>
|
||
page.evaluate(async (sid) => {
|
||
const app = (window as any).app;
|
||
for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100));
|
||
await app.selectSession(sid);
|
||
}, id);
|
||
const reload = () => page.evaluate(() => (window as any).app.refreshEditHistory());
|
||
const syncReads = (sid: string) => calls.filter((c) => c === `GET /api/sessions/${sid}/edits?sync=1`).length;
|
||
const toolEnd = (sessionId: string, toolName: string) =>
|
||
(server as any).broadcast('transcript:tool_end', { sessionId, toolName, isError: false, timestamp: Date.now() });
|
||
const rows = () => page.locator('#editHistoryBody .edit-history-entry');
|
||
|
||
beforeAll(async () => {
|
||
root = mkdtempSync(join(homedir(), 'edit-history-ui-'));
|
||
writeFileSync(join(root, 'shot.png'), PNG);
|
||
ext = mkdtempSync(join(homedir(), 'edit-history-ext-'));
|
||
writeFileSync(join(ext, 'render.png'), PNG);
|
||
|
||
server = new WebServer(0, false, true);
|
||
await server.start();
|
||
browser = await chromium.launch({ headless: true });
|
||
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||
page.on('response', (r) => {
|
||
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
|
||
});
|
||
page.on('request', (r) => {
|
||
if (r.method() === 'POST' && /\/api\/sessions\/[^/]+\/attachments$/.test(r.url()))
|
||
attachmentPosts.push(r.postDataJSON());
|
||
});
|
||
await page.route('**/api/sessions/*/edits**', async (route) => {
|
||
const req = route.request();
|
||
const url = new URL(req.url());
|
||
calls.push(`${req.method()} ${url.pathname}${url.search}`);
|
||
const m = url.pathname.match(/\/api\/sessions\/([^/]+)\/edits(?:\/(.+))?$/)!;
|
||
const sid = decodeURIComponent(m[1]);
|
||
const rest = m[2] ? decodeURIComponent(m[2]) : '';
|
||
const s = state.get(sid) ?? { state: 'unsupported', backfillTruncated: false, agents: {}, entries: [] };
|
||
if (req.method() === 'POST' && rest === 'older') {
|
||
const entries = [...(s.entries as Entry[]), ...olderEntries];
|
||
state.set(sid, { ...s, entries, backfillTruncated: false });
|
||
return json(route, state.get(sid));
|
||
}
|
||
if (req.method() === 'GET' && rest) {
|
||
const d = diffs[rest];
|
||
if (!d) return route.fulfill({ status: 404, contentType: 'application/json', body: '{"success":false}' });
|
||
return json(route, d);
|
||
}
|
||
return json(route, s);
|
||
});
|
||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||
sessionA = await createSession(root);
|
||
sessionB = await createSession(root);
|
||
await select(sessionA);
|
||
|
||
const t0 = Date.now();
|
||
state.set(sessionA, {
|
||
state: 'ok',
|
||
backfillTruncated: true,
|
||
agents: {
|
||
agent1: { description: 'Refactor the parser', agentType: 'general-purpose', worktreeBranch: 'wt/parser' },
|
||
},
|
||
entries: [
|
||
entry({ id: 'e4', ts: t0, relPath: 'src/a.ts', added: 3, removed: 1 }),
|
||
entry({ id: 'e3', ts: t0 - 1000, relPath: 'shot.png', kind: 'image', tool: 'bash', created: true }),
|
||
entry({
|
||
id: 'e2',
|
||
ts: t0 - 2000,
|
||
relPath: 'src/b.ts',
|
||
created: true,
|
||
tool: 'Write',
|
||
added: 10,
|
||
removed: 0,
|
||
agentId: 'agent1',
|
||
}),
|
||
entry({ id: 'e1', ts: t0 - 3000, relPath: 'src/a.ts', added: 2, removed: 2 }),
|
||
],
|
||
});
|
||
diffs = {
|
||
e4: { diff: '@@ -1,3 +1,3 @@\n context\n-old line\n+new line\n+another\n', truncated: true },
|
||
e1: { diff: '@@ -5 +5 @@\n-x\n+y\n', truncated: false },
|
||
};
|
||
olderEntries = [entry({ id: 'e0', ts: t0 - 86_400_000, relPath: 'src/old.ts', added: 7, removed: 0 })];
|
||
}, 120000);
|
||
|
||
afterAll(async () => {
|
||
if (browser) await browser.close();
|
||
if (server) await server.stop();
|
||
rmSync(root, { recursive: true, force: true });
|
||
rmSync(ext, { recursive: true, force: true });
|
||
}, 60000);
|
||
|
||
it('is off by default: no button, and nothing asks the edits API', async () => {
|
||
await page.waitForTimeout(1000);
|
||
expect(await buttonVisible()).toBe(false);
|
||
expect(calls).toEqual([]);
|
||
});
|
||
|
||
it('turning it on through Settings shows the button and saves cleanly (the key never reaches the strict PUT)', async () => {
|
||
await setSetting(true);
|
||
expect(settingsPutStatuses.length).toBeGreaterThan(0);
|
||
expect(settingsPutStatuses.every((s) => s === 200)).toBe(true);
|
||
expect(await buttonVisible()).toBe(true);
|
||
// In the header next to Attachments, where the panel opens; no longer in the bottom bar.
|
||
expect(await page.evaluate(() => !!document.querySelector('#headerRight #editHistoryBtn'))).toBe(true);
|
||
expect(await page.evaluate(() => !!document.querySelector('.toolbar #editHistoryBtn'))).toBe(false);
|
||
expect(calls).toEqual([]); // no poll: nothing is read until the panel opens
|
||
});
|
||
|
||
it('opens a panel listing the entries newest first, with the subagent chip and an image thumbnail', async () => {
|
||
await page.click('#editHistoryBtn');
|
||
await page.waitForSelector('#editHistoryPanel.visible');
|
||
await page.waitForSelector('#editHistoryBody .edit-history-entry');
|
||
expect(calls[0]).toBe(`GET /api/sessions/${sessionA}/edits?sync=1`);
|
||
expect(await page.getAttribute('#editHistoryBtn', 'aria-expanded')).toBe('true');
|
||
expect(await rows().count()).toBe(4);
|
||
const paths = await page.locator('#editHistoryBody .edit-history-path').allTextContents();
|
||
expect(paths).toEqual(['src/a.ts', 'shot.png', 'src/b.ts', 'src/a.ts']);
|
||
expect(await rows().nth(0).locator('.edit-history-badge').textContent()).toBe('edit');
|
||
expect(await rows().nth(2).locator('.edit-history-badge').textContent()).toBe('new');
|
||
expect(await rows().nth(0).locator('.edit-history-stats').textContent()).toBe('+3−1');
|
||
// The image row: a symbol instead of a word, no +/− counts.
|
||
expect(await rows().nth(1).locator('.edit-history-badge--image svg').count()).toBe(1);
|
||
expect(await rows().nth(1).locator('.edit-history-stats').count()).toBe(0);
|
||
// The subagent chip: its description, and type + branch on hover.
|
||
const chip = rows().nth(2).locator('.edit-history-agent');
|
||
expect(await chip.textContent()).toBe('Refactor the parser');
|
||
expect(await chip.getAttribute('title')).toBe('general-purpose · wt/parser');
|
||
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(1);
|
||
// The thumbnail is served by the real file-raw route and decodes.
|
||
const thumb = page.locator('#editHistoryBody img.edit-history-thumb');
|
||
expect(await thumb.count()).toBe(1);
|
||
expect(await thumb.getAttribute('src')).toBe(
|
||
`/api/sessions/${sessionA}/file-raw?path=${encodeURIComponent(join(root, 'shot.png'))}`
|
||
);
|
||
await page.waitForFunction(
|
||
() => (document.querySelector('img.edit-history-thumb') as HTMLImageElement)?.naturalWidth === 2
|
||
);
|
||
expect(await page.textContent('#editHistoryCount')).toBe('4');
|
||
});
|
||
|
||
it('a code entry opens its diff inline with the Git panel line classes, and closes again', async () => {
|
||
await rows().nth(0).locator('.edit-history-spacer').click();
|
||
await page.waitForSelector('#editHistoryBody .edit-history-diff pre.git-diff');
|
||
expect(calls).toContain(`GET /api/sessions/${sessionA}/edits/e4`);
|
||
const lines = await page.$$eval('#editHistoryBody pre.git-diff .git-diff-line', (els) =>
|
||
els.map((e) => [e.className.replace('git-diff-line', '').trim(), e.textContent])
|
||
);
|
||
expect(lines).toEqual([
|
||
['git-diff-line--hunk', '@@ -1,3 +1,3 @@\n'],
|
||
['', ' context\n'],
|
||
['git-diff-line--del', '-old line\n'],
|
||
['git-diff-line--add', '+new line\n'],
|
||
['git-diff-line--add', '+another\n'],
|
||
]);
|
||
expect(await page.textContent('#editHistoryBody .edit-history-diff')).toContain('Diff cut short');
|
||
expect(await rows().nth(0).getAttribute('aria-expanded')).toBe('true');
|
||
// A re-read (what an SSE update does) keeps the open diff without fetching it again.
|
||
const before = calls.filter((c) => c.endsWith('/edits/e4')).length;
|
||
await reload();
|
||
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(1);
|
||
expect(calls.filter((c) => c.endsWith('/edits/e4')).length).toBe(before);
|
||
await rows().nth(0).locator('.edit-history-spacer').click();
|
||
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(0);
|
||
});
|
||
|
||
it('a path and an image open the shared file preview with the absolute path', async () => {
|
||
await page.evaluate(() => {
|
||
const w = window as any;
|
||
w.__previewed = [];
|
||
w.__openFilePreview = w.app.openFilePreview;
|
||
w.app.openFilePreview = (p: string, sid: string) => w.__previewed.push([p, sid]);
|
||
});
|
||
await rows().nth(2).locator('.edit-history-path').click();
|
||
await page.locator('#editHistoryBody img.edit-history-thumb').click();
|
||
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([
|
||
[join(root, 'src/b.ts'), sessionA],
|
||
[join(root, 'shot.png'), sessionA],
|
||
]);
|
||
// Clicking the path never toggles the row's diff.
|
||
expect(await page.locator('#editHistoryBody .edit-history-diff').count()).toBe(0);
|
||
await page.evaluate(() => {
|
||
const w = window as any;
|
||
w.app.openFilePreview = w.__openFilePreview;
|
||
});
|
||
});
|
||
|
||
it('"Main agent only" hides the subagent edits; "Group by file" puts edits under their file', async () => {
|
||
await page.check('#editHistoryMainOnlyToggle');
|
||
expect(await rows().count()).toBe(3);
|
||
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(0);
|
||
expect(await bodyText()).not.toContain('src/b.ts');
|
||
await page.uncheck('#editHistoryMainOnlyToggle');
|
||
expect(await rows().count()).toBe(4);
|
||
|
||
await page.check('#editHistoryGroupToggle');
|
||
const groups = page.locator('#editHistoryBody .edit-history-group');
|
||
expect(await groups.count()).toBe(3);
|
||
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-path').textContent()).toBe('src/a.ts');
|
||
expect(await groups.nth(0).locator('.edit-history-entry').count()).toBe(2);
|
||
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-stats').textContent()).toBe('+5−3');
|
||
await groups.nth(0).locator('.edit-history-entry').nth(1).locator('.edit-history-spacer').click();
|
||
await page.waitForSelector('#editHistoryBody .git-diff-line--add');
|
||
expect(await page.textContent('#editHistoryBody .git-diff-line--add')).toBe('+y\n');
|
||
await page.uncheck('#editHistoryGroupToggle');
|
||
});
|
||
|
||
it('"Load older" asks the server with a POST and shows what it returns', async () => {
|
||
const older = page.locator('#editHistoryBody button.edit-history-older');
|
||
expect(await older.count()).toBe(1);
|
||
await older.click();
|
||
await page.waitForFunction(() =>
|
||
/src\/old\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
|
||
);
|
||
expect(calls).toContain(`POST /api/sessions/${sessionA}/edits/older`);
|
||
expect(await rows().count()).toBe(5);
|
||
expect(await page.locator('#editHistoryBody button.edit-history-older').count()).toBe(0);
|
||
});
|
||
|
||
it('while open it follows the session: an edit-capable tool end or a turn end re-reads (debounced), other tools, sessions and keep-alive turns do not', async () => {
|
||
const before = syncReads(sessionA);
|
||
const s = state.get(sessionA)!;
|
||
state.set(sessionA, {
|
||
...s,
|
||
entries: [entry({ id: 'e5', relPath: 'src/fresh.ts', added: 4, removed: 0 }), ...(s.entries as Entry[])],
|
||
});
|
||
toolEnd(sessionA, 'Read');
|
||
toolEnd(sessionA, 'Grep');
|
||
toolEnd('someone-else', 'Edit');
|
||
await page.waitForTimeout(1000);
|
||
expect(syncReads(sessionA)).toBe(before);
|
||
toolEnd(sessionA, 'Edit');
|
||
toolEnd(sessionA, 'Bash');
|
||
await page.waitForFunction(() =>
|
||
/src\/fresh\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
|
||
);
|
||
await page.waitForTimeout(800);
|
||
expect(syncReads(sessionA)).toBe(before + 1);
|
||
// The end of a turn re-reads too (a parallel tool call reports one tool end only, a background
|
||
// subagent writes later): `session:idle`, since claude's transcript has no `result` line.
|
||
// Not another session's, and not the end of Codeman's own keep-alive turn (it edits nothing).
|
||
(server as any).broadcast('session:idle', { id: 'someone-else' });
|
||
(server as any).broadcast('session:idle', { id: sessionA, keepAlive: true });
|
||
await page.waitForTimeout(1200);
|
||
expect(syncReads(sessionA)).toBe(before + 1);
|
||
(server as any).broadcast('session:idle', { id: sessionA });
|
||
await page.waitForTimeout(1200);
|
||
expect(syncReads(sessionA)).toBe(before + 2);
|
||
});
|
||
|
||
it('while the panel is closed, transcript events read nothing; opening reads once', async () => {
|
||
await page.click('#editHistoryBtn');
|
||
await page.waitForSelector('#editHistoryPanel:not(.visible)', { state: 'attached' });
|
||
const before = syncReads(sessionA);
|
||
toolEnd(sessionA, 'Edit');
|
||
(server as any).broadcast('session:idle', { id: sessionA });
|
||
await page.waitForTimeout(1200);
|
||
expect(syncReads(sessionA)).toBe(before);
|
||
expect(await page.$('#editHistoryUnread')).toBeNull(); // no unread badge: closed means idle
|
||
await page.click('#editHistoryBtn');
|
||
await page.waitForSelector('#editHistoryPanel.visible');
|
||
await page.waitForFunction(() => document.querySelectorAll('#editHistoryBody .edit-history-entry').length > 0);
|
||
expect(syncReads(sessionA)).toBe(before + 1);
|
||
});
|
||
|
||
it('an image outside the workspace registers nothing until clicked, then previews through the attachment route quietly', async () => {
|
||
const s = state.get(sessionA)!;
|
||
const outside = join(ext, 'render.png');
|
||
state.set(sessionA, {
|
||
...s,
|
||
entries: [
|
||
entry({ id: 'ext', path: outside, relPath: outside, kind: 'image', tool: 'bash', added: 0, removed: 0 }),
|
||
...(s.entries as Entry[]),
|
||
],
|
||
});
|
||
await reload();
|
||
const row = page.locator('#editHistoryBody .edit-history-entry', { hasText: 'render.png' });
|
||
await row.waitFor();
|
||
// No thumbnail for it (that would mint an attachment id per render), and no registration.
|
||
expect(
|
||
await page.evaluate(() =>
|
||
[...document.querySelectorAll('#editHistoryBody img.edit-history-thumb')].map((i) => i.getAttribute('src'))
|
||
)
|
||
).not.toContainEqual(expect.stringContaining('render.png'));
|
||
await reload();
|
||
expect(attachmentPosts).toEqual([]);
|
||
// A click opens the shared preview, which registers the file once, without a notification.
|
||
await row.click();
|
||
await page.waitForSelector('#filePreviewOverlay.visible');
|
||
expect(attachmentPosts).toEqual([{ path: outside, notify: false }]);
|
||
await page.evaluate(() => (window as any).app.closeFilePreview());
|
||
state.set(sessionA, s);
|
||
});
|
||
|
||
it('a remote session lists its images without a thumbnail (no ssh read per row); a click still previews', async () => {
|
||
const thumbs = () => page.locator('#editHistoryBody img.edit-history-thumb').count();
|
||
expect(await thumbs()).toBe(1);
|
||
await page.evaluate((sid) => {
|
||
const w = window as any;
|
||
w.app.sessions.get(sid).remote = { hostId: 'h', label: 'h', host: '192.0.2.1', username: 'j', remotePath: '/' };
|
||
w.__previewed = [];
|
||
w.__openFilePreview = w.app.openFilePreview;
|
||
w.app.openFilePreview = (p: string, s: string) => w.__previewed.push([p, s]);
|
||
}, sessionA);
|
||
try {
|
||
await reload();
|
||
await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).waitFor();
|
||
expect(await thumbs()).toBe(0);
|
||
await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).click();
|
||
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([[join(root, 'shot.png'), sessionA]]);
|
||
} finally {
|
||
await page.evaluate((sid) => {
|
||
const w = window as any;
|
||
delete w.app.sessions.get(sid).remote;
|
||
w.app.openFilePreview = w.__openFilePreview;
|
||
}, sessionA);
|
||
await reload();
|
||
}
|
||
await page.locator('#editHistoryBody img.edit-history-thumb').waitFor();
|
||
});
|
||
|
||
it('a long path outside the workspace keeps its file name readable, the folder is cut at the start', async () => {
|
||
const s = state.get(sessionA)!;
|
||
const long =
|
||
'/tmp/claude-1000/-mnt-caches-ComfyUI/07e259af-d4d2-4ac3-8a7e-95972e3547df/scratchpad/icons/bremse_alt1_pedal.png';
|
||
state.set(sessionA, {
|
||
...s,
|
||
entries: [
|
||
entry({ id: 'long', relPath: long, kind: 'image', tool: 'sent', added: 0, removed: 0 }),
|
||
...(s.entries as Entry[]),
|
||
],
|
||
});
|
||
await reload();
|
||
const row = rows().nth(0);
|
||
await row.waitFor();
|
||
expect(await row.locator('.edit-history-path').textContent()).toBe(long);
|
||
const name = row.locator('.edit-history-path-name');
|
||
expect(await name.textContent()).toBe('bremse_alt1_pedal.png');
|
||
// The name is shown whole; only the folder part is shortened.
|
||
expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||
expect(await row.locator('.edit-history-path-dir').evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true);
|
||
state.set(sessionA, s);
|
||
});
|
||
|
||
it('renders a hostile path as text, never as markup', async () => {
|
||
const s = state.get(sessionA)!;
|
||
const evil = '<script>window.__pwned=1</script><img src=x onerror="window.__pwned=2">.ts';
|
||
state.set(sessionA, {
|
||
...s,
|
||
agents: { agent1: { description: '<b>bold</b>', agentType: 'x', worktreeBranch: '' } },
|
||
entries: [entry({ id: 'evil', relPath: evil, agentId: 'agent1' }), ...(s.entries as Entry[])],
|
||
});
|
||
await reload();
|
||
await page.waitForFunction(() => /__pwned/.test(document.getElementById('editHistoryBody')!.textContent ?? ''));
|
||
expect(await rows().nth(0).locator('.edit-history-path').textContent()).toBe(evil);
|
||
expect(await rows().nth(0).locator('.edit-history-agent').textContent()).toBe('<b>bold</b>');
|
||
expect(await page.$('#editHistoryBody script')).toBeNull();
|
||
expect(await page.$('#editHistoryBody b')).toBeNull();
|
||
expect(
|
||
await page.$$eval(
|
||
'#editHistoryBody img',
|
||
(els) => els.filter((e) => !e.classList.contains('edit-history-thumb')).length
|
||
)
|
||
).toBe(0);
|
||
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
|
||
});
|
||
|
||
it('unavailable: the saved entries stay, with the reason on top', async () => {
|
||
const s = state.get(sessionA)!;
|
||
state.set(sessionA, { ...s, state: 'unavailable', reason: 'EACCES: permission denied' });
|
||
await reload();
|
||
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
|
||
expect(await page.textContent('#editHistoryBody .edit-history-note--warn')).toBe(
|
||
'Showing saved entries only: EACCES: permission denied'
|
||
);
|
||
expect(await rows().count()).toBeGreaterThan(0);
|
||
state.set(sessionA, { ...s, state: 'ok' });
|
||
});
|
||
|
||
it('no transcript yet: says it comes with the next prompt, and names CLAUDE_CONFIG_DIR', async () => {
|
||
const s = state.get(sessionA)!;
|
||
state.set(sessionA, { ...s, state: 'unavailable', reason: 'no-transcript', entries: [] });
|
||
await reload();
|
||
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
|
||
const note = (await page.textContent('#editHistoryBody .edit-history-note--warn')) ?? '';
|
||
expect(note).toContain('No transcript found for this session yet');
|
||
expect(note).toContain('CLAUDE_CONFIG_DIR');
|
||
// A remote host sends no hooks: no promise that the next prompt finds it.
|
||
await page.evaluate((sid) => {
|
||
(window as any).app.sessions.get(sid).remote = { hostId: 'h', label: 'h', host: '192.0.2.1' };
|
||
}, sessionA);
|
||
try {
|
||
await reload();
|
||
const remoteNote = page.locator('#editHistoryBody .edit-history-note--warn', {
|
||
hasText: 'No transcript found on the remote host yet',
|
||
});
|
||
await remoteNote.waitFor();
|
||
const text = (await remoteNote.textContent()) ?? '';
|
||
expect(text).not.toContain('next prompt');
|
||
expect(text).toContain('.bash_profile');
|
||
} finally {
|
||
await page.evaluate((sid) => delete (window as any).app.sessions.get(sid).remote, sessionA);
|
||
state.set(sessionA, s);
|
||
await reload();
|
||
}
|
||
});
|
||
|
||
it('follows the active session, and says when a session is not a Claude session', async () => {
|
||
await select(sessionB);
|
||
await page.waitForFunction(() =>
|
||
/only available for Claude sessions outside Docker/.test(
|
||
document.getElementById('editHistoryBody')!.textContent ?? ''
|
||
)
|
||
);
|
||
expect(calls).toContain(`GET /api/sessions/${sessionB}/edits?sync=1`);
|
||
expect(await rows().count()).toBe(0);
|
||
await select(sessionA);
|
||
await page.waitForSelector('#editHistoryBody .edit-history-entry');
|
||
});
|
||
|
||
it('drags by the header', async () => {
|
||
const before = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left);
|
||
const box = (await page.locator('.edit-history-header').boundingBox())!;
|
||
await page.mouse.move(box.x + 40, box.y + 10);
|
||
await page.mouse.down();
|
||
await page.mouse.move(box.x - 140, box.y + 70, { steps: 5 });
|
||
await page.mouse.up();
|
||
const after = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left);
|
||
expect(after).toBeLessThan(before - 100);
|
||
});
|
||
|
||
it('turning the setting off hides the button and closes the panel; SSE then reads nothing', async () => {
|
||
await setSetting(false);
|
||
expect(await buttonVisible()).toBe(false);
|
||
expect(await page.isVisible('#editHistoryPanel')).toBe(false);
|
||
const before = calls.length;
|
||
toolEnd(sessionA, 'Edit');
|
||
await page.waitForTimeout(1200);
|
||
expect(calls.length).toBe(before);
|
||
});
|
||
});
|