mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
The agent composer is now a CodeMirror editor: the text stays plain and is what's sent, while @ file references are drawn as chips (icon, name, lines) that move and delete as one piece, open on click and preview on hover; Backspace after a chip opens it as text. Quotes show as blocks, and sent messages get the same chips and quote blocks. The @ completion lists open editors first, highlights matches and shows the folder; the focused editor's file is offered above the text, a pasted project path becomes a reference, and dropping files shows a hint. Lines copied from an editor paste as a reference, terminal output as a terminal block, and code blocks show as cards. The reference trimming regex avoids lookbehind for WebKit before 16.4.
549 lines
22 KiB
TypeScript
549 lines
22 KiB
TypeScript
/** Markdown in agent replies, in the real conversation: browser defaults don't suit a narrow chat. */
|
|
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
const TASK = {
|
|
id: "t1",
|
|
title: "Repro task",
|
|
status: "ready" as const,
|
|
isolation: "local" as const,
|
|
provider: "codex",
|
|
model: null,
|
|
cwd: "/dev/acme-api",
|
|
branch: null,
|
|
base: null,
|
|
baseCommit: null,
|
|
error: null,
|
|
createdMs: 0,
|
|
updatedMs: 0,
|
|
config: [],
|
|
commands: [],
|
|
usage: null,
|
|
queue: [],
|
|
capabilities: { fork: true, rollback: true, steer: true, images: true },
|
|
};
|
|
|
|
const PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
|
const REPLY = [
|
|
"# Heading one",
|
|
"",
|
|
"## Heading two",
|
|
"",
|
|
"##### Heading five",
|
|
"",
|
|
"> A quote",
|
|
"",
|
|
"- [x] done",
|
|
"- [ ] open",
|
|
"",
|
|
"---",
|
|
"",
|
|
``,
|
|
"",
|
|
"| Path | Owner | Status | Notes |",
|
|
"| --- | --- | --- | --- |",
|
|
"| src/features/agents/conversation/very/long/path/file.tsx | someone | modified | a fairly long note |",
|
|
"",
|
|
"```ts",
|
|
"const a = 1;",
|
|
"```",
|
|
].join("\n");
|
|
|
|
async function openReply(page: Page, text: string, imageData?: string, theme?: "dark" | "light") {
|
|
await page.goto("/");
|
|
await page.getByRole("tree", { name: "Project" }).waitFor();
|
|
await page.evaluate(
|
|
async ({ task, text, imageData, theme }) => {
|
|
if (theme) document.documentElement.dataset.theme = theme;
|
|
if (imageData) {
|
|
const routePath = "/src/platform/tauriCore.ts";
|
|
const { setInvokeRoute } = await import(routePath);
|
|
setInvokeRoute((cmd: string) => (cmd === "fs_read_image" ? Promise.resolve(imageData) : null));
|
|
}
|
|
// Paths go through variables: knip can't resolve these browser-absolute URLs.
|
|
const storePath = "/src/features/agents/store.ts";
|
|
const store = await import(storePath);
|
|
store.useAgents.setState({
|
|
loaded: true,
|
|
error: null,
|
|
providers: [],
|
|
tasks: new Map([[task.id, task]]),
|
|
entries: new Map([
|
|
[
|
|
task.id,
|
|
[
|
|
{
|
|
type: "user",
|
|
text: "Show Markdown",
|
|
attachments: [],
|
|
sentMs: 0,
|
|
doneMs: 1,
|
|
steered: false,
|
|
},
|
|
{ type: "agent", text },
|
|
],
|
|
],
|
|
]),
|
|
unread: new Set(),
|
|
docked: null,
|
|
});
|
|
store.dockTask(task.id);
|
|
},
|
|
{ task: TASK, text, imageData, theme },
|
|
);
|
|
const reply = page.locator("[class*='_markdown_']").first();
|
|
await reply.locator("h1, p").first().waitFor();
|
|
return reply;
|
|
}
|
|
|
|
const css = (reply: ReturnType<Page["locator"]>, selector: string, property: string) =>
|
|
reply
|
|
.locator(selector)
|
|
.first()
|
|
.evaluate((el, name) => getComputedStyle(el).getPropertyValue(name), property);
|
|
const px = (value: string) => Number.parseFloat(value);
|
|
|
|
test("styles tables so a reply reads as a table", async ({ page }) => {
|
|
const reply = await openReply(page, REPLY);
|
|
expect(await css(reply, "td", "border-top-width")).toBe("1px");
|
|
expect(await css(reply, "td", "padding-left")).toBe("8px");
|
|
expect(await css(reply, "th", "border-top-width")).toBe("1px");
|
|
// A narrow chat must not squeeze a column to a few letters.
|
|
const widths = await reply
|
|
.locator("td")
|
|
.evaluateAll((cells) => cells.map((cell) => cell.getBoundingClientRect().width));
|
|
for (const width of widths) expect(width).toBeGreaterThanOrEqual(70);
|
|
});
|
|
|
|
test("scrolls a wide table inside the reply", async ({ page }) => {
|
|
const reply = await openReply(page, REPLY);
|
|
const sizes = await reply.evaluate((el) => {
|
|
const table = el.querySelector("table")!;
|
|
return {
|
|
reply: el.clientWidth,
|
|
table: table.clientWidth,
|
|
content: table.scrollWidth,
|
|
};
|
|
});
|
|
expect(sizes.table).toBeLessThanOrEqual(sizes.reply);
|
|
expect(sizes.content).toBeGreaterThan(sizes.table);
|
|
});
|
|
|
|
test("sizes headings for a chat and keeps small ones readable", async ({ page }) => {
|
|
const reply = await openReply(page, REPLY);
|
|
const body = px(await css(reply, "td", "font-size"));
|
|
const h1 = px(await css(reply, "h1", "font-size"));
|
|
expect(h1).toBeGreaterThan(body);
|
|
expect(h1).toBeLessThanOrEqual(body * 1.2);
|
|
expect(px(await css(reply, "h2", "font-size"))).toBeLessThan(h1);
|
|
expect(px(await css(reply, "h5", "font-size"))).toBe(body);
|
|
});
|
|
|
|
test("starts and ends a reply without stray margins", async ({ page }) => {
|
|
const reply = await openReply(page, REPLY);
|
|
expect(await css(reply, "> :first-child", "margin-top")).toBe("0px");
|
|
expect(await css(reply, "> :last-child", "margin-bottom")).toBe("0px");
|
|
});
|
|
|
|
test("styles quotes, rules, images and task lists", async ({ page }) => {
|
|
const reply = await openReply(page, REPLY);
|
|
expect(await css(reply, "blockquote", "margin-left")).toBe("0px");
|
|
expect(await css(reply, "blockquote", "border-left-width")).toBe("3px");
|
|
expect(await css(reply, "hr", "border-left-width")).toBe("0px");
|
|
expect(await css(reply, "hr", "border-top-width")).toBe("1px");
|
|
expect(await css(reply, "img", "max-width")).toBe("100%");
|
|
// A checkbox stands in for the bullet.
|
|
expect(await css(reply, "li:has(> input)", "list-style-type")).toBe("none");
|
|
});
|
|
|
|
test("loads local reply images, and web ones when asked", async ({ page }) => {
|
|
let fetched = 0;
|
|
await page.route("https://images.example.com/code.gif", (route) => {
|
|
fetched++;
|
|
return route.fulfill({
|
|
contentType: "image/gif",
|
|
body: Buffer.from(PIXEL.split(",")[1]!, "base64"),
|
|
});
|
|
});
|
|
const reply = await openReply(
|
|
page,
|
|
"\n\n",
|
|
PIXEL,
|
|
);
|
|
const width = (name: string) =>
|
|
reply.getByRole("img", { name, includeHidden: true }).evaluate((img: HTMLImageElement) => img.naturalWidth);
|
|
await expect.poll(() => width("Local QR")).toBe(1);
|
|
const load = reply.getByRole("button", {
|
|
name: "Load image from images.example.com",
|
|
});
|
|
await expect(load).toBeVisible();
|
|
expect(fetched).toBe(0);
|
|
await load.click();
|
|
await expect.poll(() => width("Web QR")).toBe(1);
|
|
expect(fetched).toBe(1);
|
|
});
|
|
|
|
test("copies reply Markdown with the keyboard and keeps code copying separate", async ({ page, context }) => {
|
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
|
const reply = await openReply(page, REPLY);
|
|
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
|
const actions = copy.locator("..");
|
|
await page.mouse.move(0, 0);
|
|
await expect(actions).toHaveCSS("opacity", "1");
|
|
await copy.focus();
|
|
await expect(actions).toHaveCSS("opacity", "1");
|
|
await page.keyboard.press("Enter");
|
|
await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible();
|
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(REPLY);
|
|
await reply.getByRole("button", { name: "Copy Code", exact: true }).click();
|
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("const a = 1;");
|
|
});
|
|
|
|
test("keeps latest reply actions visible and quotes into the draft without shifting content", async ({ page }) => {
|
|
const reply = await openReply(page, REPLY);
|
|
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
|
const actions = copy.locator("..");
|
|
await page.mouse.move(0, 0);
|
|
await expect(actions).toHaveCSS("opacity", "1");
|
|
const before = await reply.boundingBox();
|
|
await reply.hover();
|
|
await expect(actions).toHaveCSS("opacity", "1");
|
|
expect(await reply.boundingBox()).toEqual(before);
|
|
await expect(actions.getByRole("button")).toHaveCount(4);
|
|
await page.mouse.move(0, 0);
|
|
await expect(actions).toHaveCSS("opacity", "1");
|
|
const userCopy = page.getByRole("button", { name: "Copy", exact: true });
|
|
await expect(userCopy.locator("..")).toHaveCSS("opacity", "0");
|
|
await page.getByText("Show Markdown", { exact: true }).hover();
|
|
await expect(userCopy.locator("..")).toHaveCSS("opacity", "1");
|
|
await reply.hover();
|
|
await page.getByRole("button", { name: "Quote Reply", exact: true }).click();
|
|
const draft = page.getByRole("textbox", { name: "Message", exact: true });
|
|
await expect(draft).toBeFocused();
|
|
// Quoted as `> ` lines, drawn as a block without their markers, an empty line to type in after it.
|
|
const quoted = REPLY.trimEnd().split("\n");
|
|
await expect(draft.locator(".cm-line")).toHaveText([...quoted, "", ""]);
|
|
await expect(draft.locator(".cm-line[class*='_quoteLine_']")).toHaveCount(quoted.length);
|
|
});
|
|
|
|
test("copies a mouse selection with Ctrl+C and pops up no Quote button", async ({ page, context }) => {
|
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
|
const reply = await openReply(page, "Selectable answer");
|
|
const box = (await reply.locator("p").first().boundingBox())!;
|
|
await page.mouse.move(box.x + 2, box.y + box.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(box.x + box.width - 2, box.y + box.height / 2);
|
|
await page.mouse.up();
|
|
await page.keyboard.press("ControlOrMeta+c");
|
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("Selectable answer");
|
|
await expect(page.getByRole("button", { name: "Quote", exact: true })).toHaveCount(0);
|
|
});
|
|
|
|
test("keeps chat history readable and selections free of timestamps and status labels", async ({ page, context }) => {
|
|
await page.setViewportSize({ width: 1800, height: 1000 });
|
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
|
await openReply(page, "Hi! What would you like to work on?");
|
|
await page.evaluate(async (task) => {
|
|
const path = "/src/features/agents/store.ts";
|
|
const store = await import(path);
|
|
const user = (text: string) => ({
|
|
type: "user",
|
|
text,
|
|
attachments: [],
|
|
sentMs: 1,
|
|
doneMs: 5001,
|
|
steered: false,
|
|
});
|
|
store.useAgents.setState({
|
|
entries: new Map([
|
|
[
|
|
task.id,
|
|
[
|
|
user("hi"),
|
|
{ type: "agent", text: "Hi! What would you like to work on?" },
|
|
user("im bob"),
|
|
{ type: "agent", text: "Hi Bob! What are we working on today?" },
|
|
user("yes"),
|
|
{ type: "thought", text: "**Requesting task context**" },
|
|
{ type: "agent", text: "What would you like me to proceed with?" },
|
|
],
|
|
],
|
|
]),
|
|
});
|
|
store.openTaskInEditorLocation(task.id);
|
|
}, TASK);
|
|
const stream = page.locator("[class*='_stream_']:visible");
|
|
const replies = stream.locator("[class*='_reply_']");
|
|
await expect(replies).toHaveCount(3);
|
|
const geometry = await stream.evaluate((root) => {
|
|
const reply = root.querySelector<HTMLElement>("[class*='_reply_']")!;
|
|
const bubble = root.querySelector<HTMLElement>("[class*='_user_']")!;
|
|
const composer = root
|
|
.closest("[class*='_conversation_']")!
|
|
.querySelector<HTMLElement>("[class*='_composerArea_']")!;
|
|
const rr = reply.getBoundingClientRect();
|
|
const br = bubble.getBoundingClientRect();
|
|
const cr = composer.getBoundingClientRect();
|
|
return {
|
|
reply: rr.width,
|
|
root: root.clientWidth,
|
|
bubbleLeft: br.left,
|
|
replyLeft: rr.left,
|
|
bubbleRight: br.right,
|
|
replyRight: rr.right,
|
|
columnCenter: (() => {
|
|
const column = reply.closest("[class*='_agentMessage_']")!.getBoundingClientRect();
|
|
return (column.left + column.right) / 2;
|
|
})(),
|
|
composerCenter: (cr.left + cr.right) / 2,
|
|
};
|
|
});
|
|
expect(geometry.reply).toBeGreaterThan(0);
|
|
expect(geometry.reply).toBeLessThan(geometry.root);
|
|
expect(geometry.bubbleLeft).toBeGreaterThan(geometry.replyLeft);
|
|
expect(geometry.bubbleRight).toBeGreaterThan(geometry.replyRight);
|
|
expect(Math.abs(geometry.columnCenter - geometry.composerCenter)).toBeLessThan(1);
|
|
await stream.getByText("hi", { exact: true }).hover();
|
|
await expect(stream.locator("[class*='_messageTime_']").first()).toBeVisible();
|
|
const selected = await stream.evaluate((root) => {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(root);
|
|
const selection = window.getSelection()!;
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
return selection.toString();
|
|
});
|
|
expect(selected).toContain("hi");
|
|
expect(selected).toContain("Hi Bob!");
|
|
expect(selected).toContain("What would you like me to proceed with?");
|
|
expect(selected).not.toMatch(/AM|PM|5s|Reasoning|Requesting task context/);
|
|
await page.keyboard.press("ControlOrMeta+c");
|
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(selected);
|
|
// The finished turn's reasoning is folded with its work.
|
|
await stream.locator("[class*='_turnFold_'] > summary").click();
|
|
await stream.locator("[class*='_thought_'] > summary").click();
|
|
await expect(stream.locator("[class*='_thoughtPreview_']")).toHaveText("Requesting task context");
|
|
await expect(stream.locator("[class*='_thoughtBody_'] strong")).toHaveText("Requesting task context");
|
|
await page.evaluate(async (id) => {
|
|
const path = "/src/features/agents/store.ts";
|
|
const store = await import(path);
|
|
store.dockTask(id);
|
|
}, TASK.id);
|
|
await expect(stream).toBeVisible();
|
|
const narrow = await stream.evaluate((root) => ({
|
|
width: root.clientWidth,
|
|
content: root.scrollWidth,
|
|
reply: root.querySelector<HTMLElement>("[class*='_reply_']")!.getBoundingClientRect().width,
|
|
padding:
|
|
Number.parseFloat(getComputedStyle(root).paddingLeft) + Number.parseFloat(getComputedStyle(root).paddingRight),
|
|
}));
|
|
expect(narrow.width).toBeGreaterThan(0);
|
|
expect(narrow.content).toBeLessThanOrEqual(narrow.width);
|
|
expect(narrow.reply).toBeLessThanOrEqual((narrow.width - narrow.padding) * 0.96);
|
|
});
|
|
|
|
test("keeps hover actions reachable when the pointer leaves the message body", async ({ page, context }) => {
|
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
|
const reply = await openReply(page, "A short answer.");
|
|
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
|
await reply.hover();
|
|
const box = await copy.boundingBox();
|
|
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, {
|
|
steps: 20,
|
|
});
|
|
await expect(copy.locator("..")).toHaveCSS("opacity", "1");
|
|
await copy.click();
|
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("A short answer.");
|
|
});
|
|
|
|
test("selects through code blocks and tables while keeping copy controls outside the code", async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
|
const tree = "rust/\n├── Cargo.toml\n└── src/\n └── main.rs";
|
|
const flow = "内置文本 SAMPLE\n ↓ parser::parse\nVec<Item>\n ↓ Inventory::add\n终端输出";
|
|
const text = `项目结构:\n\n\`\`\`text\n${tree}\n\`\`\`\n\n| 模块 | 职责 |\n| --- | --- |\n| main.rs | 程序入口 |\n\n数据流是:\n\n\`\`\`text\n${flow}\n\`\`\``;
|
|
const reply = await openReply(page, text);
|
|
await expect(reply.getByRole("button", { name: "Copy Code", exact: true })).toHaveCount(2);
|
|
const selected = await reply.evaluate((root) => {
|
|
const codes = [...root.querySelectorAll("pre > code")];
|
|
if (codes.some((code) => code.parentElement!.querySelector("button"))) throw new Error("Control in code selection");
|
|
const start = root.querySelector("p")!.firstChild!;
|
|
const end = codes.at(-1)!.lastChild!;
|
|
window.getSelection()!.setBaseAndExtent(start, 0, end, end.textContent!.length);
|
|
return window.getSelection()!.toString();
|
|
});
|
|
expect(selected).toContain(tree);
|
|
expect(selected).toContain("程序入口");
|
|
expect(selected).toContain(flow);
|
|
await page.keyboard.press("ControlOrMeta+c");
|
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(selected);
|
|
await page.evaluate(() => window.getSelection()!.removeAllRanges());
|
|
const copies = reply.getByRole("button", { name: "Copy Code", exact: true });
|
|
await copies.first().click();
|
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(tree);
|
|
await copies.last().click();
|
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(flow);
|
|
});
|
|
|
|
test("keeps mermaid `<style>` rules scoped to the rendered svg after id rewriting", async ({ page }) => {
|
|
const diagram = [
|
|
"```mermaid",
|
|
"%%{init: {'theme': 'base', 'flowchart': {'htmlLabels': false, 'curve': 'basis'}}}%%",
|
|
"flowchart TB",
|
|
' subgraph T1["Tier 1"]',
|
|
" A[(Local)] <--> B[(Peer)]",
|
|
" end",
|
|
' subgraph T2["Tier 2"]',
|
|
" C[(Host RAM)]",
|
|
" end",
|
|
' C -->|"PCIe"| A',
|
|
"```",
|
|
].join("\n");
|
|
const reply = await openReply(page, `Diagram:\n\n${diagram}`);
|
|
await expect(reply.locator("[data-mermaid-state='ok'] .mermaid-output svg")).toBeVisible();
|
|
expect(await css(reply, ".cluster rect", "fill")).not.toBe("rgb(0, 0, 0)");
|
|
expect(await css(reply, ".flowchart-link", "fill")).toBe("none");
|
|
});
|
|
|
|
test("renders KaTeX math in mermaid labels", async ({ page }) => {
|
|
const diagram = ["```mermaid", "flowchart LR", ' A["$$x^2 + \\frac{a}{b}$$"] --> B', "```"].join("\n");
|
|
const reply = await openReply(page, `Diagram:\n\n${diagram}`);
|
|
const svg = reply.locator("[data-mermaid-state='ok'] .mermaid-output svg");
|
|
await expect(svg).toBeVisible();
|
|
await expect(svg.locator("math mfrac")).toHaveCount(1);
|
|
});
|
|
|
|
test("keeps mermaid flowchart nodes and sequence control blocks off solid black", async ({ page }) => {
|
|
const flow = [
|
|
"```mermaid",
|
|
"flowchart LR",
|
|
' subgraph Client["Client"]',
|
|
' A["Local agent"]',
|
|
' B["Voice input"]',
|
|
" end",
|
|
' subgraph Cloud["Cloud"]',
|
|
' C["Cloud agent"]',
|
|
' D["Memory service"]',
|
|
" end",
|
|
" B --> A",
|
|
" A --> C",
|
|
" C --> D",
|
|
"```",
|
|
].join("\n");
|
|
const sequence = ["```mermaid", "sequenceDiagram", " loop every minute", " A->>B: ping", " end", "```"].join(
|
|
"\n",
|
|
);
|
|
const reply = await openReply(page, `Flow:\n\n${flow}\n\nSequence:\n\n${sequence}`);
|
|
await expect(reply.locator("[data-mermaid-state='ok'] .mermaid-output svg")).toHaveCount(2);
|
|
expect(await css(reply, ".node rect", "fill")).not.toBe("rgb(0, 0, 0)");
|
|
expect(await css(reply, ".cluster rect", "fill")).not.toBe("rgb(0, 0, 0)");
|
|
expect(await css(reply, ".labelBox", "fill")).not.toBe("rgb(0, 0, 0)");
|
|
});
|
|
|
|
const DIAGRAMS = [
|
|
"flowchart LR\n subgraph Client\n A[Local agent] --> B[Voice input]\n end",
|
|
"sequenceDiagram\n loop every minute\n A->>B: ping\n end",
|
|
"classDiagram\n class Agent {\n +String name\n +run()\n }",
|
|
"stateDiagram-v2\n [*] --> Ready\n Ready --> Running\n Running --> [*]",
|
|
'pie title Work\n "Ready" : 40\n "Running" : 35\n "Done" : 25',
|
|
"gantt\n dateFormat YYYY-MM-DD\n section Delivery\n Shipped :done, shipped, 2026-01-01, 5d\n Building :active, building, after shipped, 5d\n Next :next, after building, 5d\n Critical :crit, critical, after next, 5d",
|
|
]
|
|
.map((source) => `\`\`\`mermaid\n${source}\n\`\`\``)
|
|
.join("\n\n");
|
|
|
|
for (const theme of ["dark", "light"] as const) {
|
|
test(`keeps Mermaid shapes, labels and chart series readable in ${theme} theme`, async ({ page }) => {
|
|
const reply = await openReply(page, `Diagrams:\n\n${DIAGRAMS}`, undefined, theme);
|
|
const svgs = reply.locator("[data-mermaid-state='ok'] .mermaid-output svg");
|
|
await expect(svgs).toHaveCount(6);
|
|
for (const [index, selector] of [
|
|
[0, ".node rect"],
|
|
[1, ".labelBox"],
|
|
[2, ".outer-path path"],
|
|
[3, ".node rect"],
|
|
] as const) {
|
|
expect(await css(svgs.nth(index), selector, "fill")).not.toBe("rgb(0, 0, 0)");
|
|
expect(await css(svgs.nth(index), selector, "stroke")).not.toContain("url(");
|
|
}
|
|
expect(await css(svgs.nth(2), ".nodeLabel", "color")).not.toBe(await css(svgs.nth(2), ".outer-path path", "fill"));
|
|
const slices = await svgs
|
|
.nth(4)
|
|
.locator(".pieCircle")
|
|
.evaluateAll((els) => els.map((el) => getComputedStyle(el).fill));
|
|
expect(new Set(slices).size).toBe(3);
|
|
expect(slices).not.toContain("rgb(0, 0, 0)");
|
|
const contrasts = await svgs
|
|
.nth(5)
|
|
.locator("text.taskText")
|
|
.evaluateAll((els) => {
|
|
const luminance = (color: string) => {
|
|
const [r, g, b] = color
|
|
.match(/\d+/g)!
|
|
.map(Number)
|
|
.map((v) => {
|
|
const c = v / 255;
|
|
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
});
|
|
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
|
|
};
|
|
return els.map((el) => {
|
|
const bar = document.getElementById(el.id.replace(/-text$/, ""))!;
|
|
const fg = luminance(getComputedStyle(el).fill);
|
|
const bg = luminance(getComputedStyle(bar).fill);
|
|
return {
|
|
text: el.textContent,
|
|
ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05),
|
|
};
|
|
});
|
|
});
|
|
expect(contrasts).toHaveLength(4);
|
|
for (const { text, ratio } of contrasts) expect(ratio, `${theme}: ${text}`).toBeGreaterThanOrEqual(4.5);
|
|
});
|
|
}
|
|
|
|
test("draws a diagram without reaching the hosts its configuration names", async ({ page }) => {
|
|
const asked: string[] = [];
|
|
await page.route(/^https:\/\/([a-z\d-]+\.)*example\.com\//, (route) => {
|
|
asked.push(new URL(route.request().url()).pathname);
|
|
return route.fulfill({
|
|
contentType: "image/gif",
|
|
body: Buffer.from(PIXEL.split(",")[1]!, "base64"),
|
|
});
|
|
});
|
|
const at = (name: string) => `https://images.example.com/${name}`;
|
|
const fence = (...lines: string[]) => ["```mermaid", ...lines, "```"].join("\n");
|
|
const diagrams = [
|
|
fence(
|
|
`%%{init: {"themeCSS": ".nodeLabel{background-image:url(${at("theme-css")})} svg{background-image:url(${at("theme-css-root")})}"}}%%`,
|
|
"flowchart TD",
|
|
" A[One]-->B[Two]",
|
|
),
|
|
fence(
|
|
`%%{init: {"fontFamily": "x;background-image:url(${at("font-family")})"}}%%`,
|
|
"flowchart TD",
|
|
" A[One]-->B[Two]",
|
|
),
|
|
fence(
|
|
"---",
|
|
"config:",
|
|
` themeCSS: ".nodeLabel{background-image:url(${at("front-matter")})}"`,
|
|
"---",
|
|
"flowchart TD",
|
|
" A[One]-->B[Two]",
|
|
),
|
|
];
|
|
const reply = await openReply(page, `Diagrams:\n\n${diagrams.join("\n\n")}`);
|
|
await expect(reply.locator("[data-mermaid-state='ok'] .mermaid-output svg")).toHaveCount(diagrams.length);
|
|
await page.evaluate(
|
|
(src) =>
|
|
new Promise<void>((done) => {
|
|
const img = new Image();
|
|
img.onload = img.onerror = () => done();
|
|
img.src = src;
|
|
}),
|
|
"https://example.com/after",
|
|
);
|
|
expect(asked).toEqual(["/after"]);
|
|
});
|