Skip to content

Commit 769db54

Browse files
committed
Merge main (#283) into the memory-citation and patch-comparison slice
Both entries kept in docs/status.md. Coven-Harness: Claude Code / claude-opus-5-5
2 parents eda8445 + a4039b2 commit 769db54

12 files changed

Lines changed: 1263 additions & 84 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { test, expect, type ElectronApplication, type Page } from "@playwright/test";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { currentRow, items, launch, seedHome, tid } from "./support";
5+
6+
/**
7+
* How a reply reads in the transcript (#277). This part covers its Markdown: GitHub-flavoured blocks
8+
* render as elements, raw HTML stays text, and nothing in a reply runs, fetches or navigates.
9+
*/
10+
let app: ElectronApplication;
11+
let page: Page;
12+
let home: string;
13+
let repo: string;
14+
const dialogs: string[] = [];
15+
16+
const REPLY = [
17+
"## Result",
18+
"",
19+
"| File | Lines |",
20+
"| :--- | ---: |",
21+
"| a.ts | 12 |",
22+
"",
23+
"> Run the suite before pushing.",
24+
"",
25+
"- [x] parser",
26+
"- [ ] renderer",
27+
" 1. nested step",
28+
"",
29+
"```ts",
30+
"export const answer = 42;",
31+
"```",
32+
"",
33+
'Inert: <img src="https://example.com/x.png" onerror="alert(1)"> and [a script](javascript:alert(2)) and ![chart](https://example.com/c.png).',
34+
].join("\n");
35+
36+
test.beforeAll(async () => {
37+
({ home, repo } = seedHome());
38+
({ app, page } = await launch(home));
39+
page.on("dialog", (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss(); });
40+
const script = path.join(home, "rendering-script.json");
41+
fs.writeFileSync(script, JSON.stringify([{ content: REPLY }]));
42+
await page.evaluate((mock_script) => window.modex!.invoke("settings:update", { mock_script }), script);
43+
await page.keyboard.press("Meta+n");
44+
await expect(tid(page, "composer-input")).toBeFocused();
45+
await tid(page, "composer-input").fill("summarize the change");
46+
await page.keyboard.press("Meta+Enter");
47+
await expect(tid(page, "thread-view")).toBeVisible({ timeout: 15_000 });
48+
await expect(items(page, "assistant")).toHaveCount(1);
49+
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
50+
});
51+
52+
test.afterAll(async () => {
53+
await app?.close();
54+
fs.rmSync(home, { recursive: true, force: true });
55+
fs.rmSync(repo, { recursive: true, force: true });
56+
});
57+
58+
test("a reply's tables, quotes, task lists and code render as elements", async () => {
59+
const answer = items(page, "assistant").first();
60+
await expect(answer.getByRole("heading", { name: "Result" })).toBeVisible();
61+
const table = answer.locator("table");
62+
await expect(table.getByRole("columnheader")).toHaveText(["File", "Lines"]);
63+
await expect(table.getByRole("cell")).toHaveText(["a.ts", "12"]);
64+
await expect(answer.locator("blockquote")).toHaveText("Run the suite before pushing.");
65+
const boxes = answer.getByRole("checkbox");
66+
await expect(boxes).toHaveCount(2);
67+
await expect(boxes.nth(0)).toBeChecked();
68+
await expect(boxes.nth(1)).not.toBeChecked();
69+
await expect(boxes.nth(0)).toBeDisabled();
70+
await expect(answer.locator("li ol li")).toHaveText("nested step");
71+
await expect(answer.locator('pre.code[data-lang="ts"]')).toHaveText("export const answer = 42;");
72+
});
73+
74+
test("raw HTML, script links and images in a reply stay inert", async () => {
75+
const answer = items(page, "assistant").first();
76+
await expect(answer).toContainText('<img src="https://example.com/x.png" onerror="alert(1)">');
77+
await expect(answer).toContainText("[a script](javascript:alert(2))");
78+
await expect(answer.locator("img")).toHaveCount(0);
79+
await expect(answer.locator('a[href^="javascript:"]')).toHaveCount(0);
80+
await expect(answer.getByRole("link", { name: "Image: chart" })).toHaveAttribute("href", "https://example.com/c.png");
81+
expect(dialogs).toEqual([]);
82+
});
83+
84+
test("Copy on a code block puts exactly its code on the clipboard and starts nothing", async () => {
85+
const answer = items(page, "assistant").first();
86+
const block = tid(answer, "code-block");
87+
await expect(block).toContainText("ts");
88+
// A follow-up suggestion can arrive after the turn ends and grow the composer, which moves the
89+
// transcript under the pointer mid-click: the likely cause of the one CI run (39112253838) where
90+
// Copied never showed after a 42 ms click. A missed click copies nothing, so clicking again until
91+
// Copied shows cannot hide a wrong copy.
92+
const copy = tid(block, "code-copy");
93+
await expect(async () => {
94+
await copy.click();
95+
await expect(copy).toHaveText("Copied", { timeout: 1000 });
96+
}).toPass({ timeout: 15_000 });
97+
expect(await app.evaluate(({ clipboard }) => clipboard.readText())).toBe("export const answer = 42;");
98+
await expect(items(page, "tool")).toHaveCount(0);
99+
await expect(items(page, "approval")).toHaveCount(0);
100+
await expect(items(page, "user")).toHaveCount(1);
101+
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
102+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { test, expect, type ElectronApplication, type Page } from "@playwright/test";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { currentRow, items, launch, resizeWindow, seedHome, tid } from "./support";
5+
6+
/**
7+
* Markdown at the 900x600 minimum (#277): with the Review panel open the transcript is about 265 px
8+
* wide. A wide table and a long code line scroll inside their own boxes, and no table column is
9+
* squeezed to a word, in every theme.
10+
*/
11+
let app: ElectronApplication;
12+
let page: Page;
13+
let home: string;
14+
let repo: string;
15+
16+
test.beforeAll(async () => {
17+
({ home, repo } = seedHome());
18+
({ app, page } = await launch(home));
19+
await page.keyboard.press("Meta+n");
20+
await expect(tid(page, "composer-input")).toBeFocused();
21+
});
22+
23+
test.afterAll(async () => {
24+
await app?.close();
25+
fs.rmSync(home, { recursive: true, force: true });
26+
fs.rmSync(repo, { recursive: true, force: true });
27+
});
28+
29+
const WIDE = [
30+
"| Path | Change | Detail |",
31+
"| --- | --- | --- |",
32+
"| apps/desktop/src/renderer/components/transcript/CodeBlock.tsx | added | " + "a long cell that keeps going ".repeat(8) + "|",
33+
"",
34+
"```ts",
35+
"export const aVeryLongLineOfCode = [" + Array.from({ length: 30 }, (_, i) => `"item-${i}"`).join(", ") + "];",
36+
"```",
37+
].join("\n");
38+
39+
test("a reply's tables and code stay readable in every theme at the 900×600 minimum", async ({}, testInfo) => {
40+
const script = path.join(home, "wide-script.json");
41+
fs.writeFileSync(script, JSON.stringify([{ content: WIDE }]));
42+
await page.evaluate((mock_script) => window.modex!.invoke("settings:update", { mock_script }), script);
43+
const replies = await items(page, "assistant").count();
44+
await tid(page, "composer-input").fill("show the wide table");
45+
await page.keyboard.press("Meta+Enter");
46+
// Wait for this reply itself, then for the turn to end: the thread was already idle before it.
47+
await expect(items(page, "assistant")).toHaveCount(replies + 1, { timeout: 20_000 });
48+
await expect(items(page, "assistant").last()).toContainText("aVeryLongLineOfCode");
49+
await expect(currentRow(page)).toHaveAttribute("data-status", "idle", { timeout: 20_000 });
50+
const answer = items(page, "assistant").last();
51+
await expect(answer.locator("table")).toBeVisible();
52+
await resizeWindow(app, page, 900, 600);
53+
for (const theme of ["graphite", "jev", "coven"]) {
54+
await page.evaluate((value) => window.modex!.invoke("settings:update", { theme: value }), theme);
55+
await answer.scrollIntoViewIfNeeded();
56+
// The page never scrolls sideways: a wide table and a long code line scroll inside their own boxes.
57+
expect(await tid(page, "transcript").evaluate((el) => el.scrollWidth - el.clientWidth), `${theme}: no sideways page scroll`).toBeLessThanOrEqual(0);
58+
expect(await answer.locator(".md-table").evaluate((el) => el.scrollWidth > el.clientWidth), `${theme}: the table scrolls in its box`).toBe(true);
59+
// No column is squeezed to a word: before cells had a minimum width, Detail measured 58 px and its row 1,009 px tall.
60+
const narrowest = await answer.locator("td").evaluateAll((cells) => Math.min(...cells.map((c) => c.getBoundingClientRect().width / parseFloat(getComputedStyle(c).fontSize))));
61+
expect(narrowest, `${theme}: every cell is at least about ten characters wide`).toBeGreaterThanOrEqual(9.5);
62+
expect(await answer.locator("pre.code").evaluate((el) => el.scrollWidth > el.clientWidth), `${theme}: the code scrolls in its box`).toBe(true);
63+
await expect(tid(answer, "code-copy")).toBeVisible();
64+
await answer.locator("table").evaluate((table) => table.scrollIntoView({ block: "start" }));
65+
const shot = await page.screenshot();
66+
await testInfo.attach(`markdown-${theme}-900x600`, { body: shot, contentType: "image/png" });
67+
// MODEX_SCREENSHOTS=<dir> keeps them for review evidence.
68+
if (process.env.MODEX_SCREENSHOTS) fs.writeFileSync(path.join(process.env.MODEX_SCREENSHOTS, `markdown-${theme}-900x600.png`), shot);
69+
}
70+
});

‎apps/desktop/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,16 @@
3838
},
3939
"devDependencies": {
4040
"@playwright/test": "^1.63.0",
41+
"@types/mdast": "4.0.4",
4142
"@types/qrcode": "^1.5.6",
4243
"@types/react": "^19.0.0",
4344
"@types/react-dom": "^19.0.0",
4445
"@vitejs/plugin-react": "^6.1.1",
4546
"@xterm/addon-fit": "0.11.0",
4647
"@xterm/xterm": "6.0.0",
48+
"mdast-util-from-markdown": "2.0.3",
49+
"mdast-util-gfm": "3.1.0",
50+
"micromark-extension-gfm": "3.0.0",
4751
"electron": "^44.4.5",
4852
"electron-builder": "26.15.3",
4953
"react": "^19.3.0",

0 commit comments

Comments
 (0)