源码
tests/desktop/composer.spec.ts
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ElectronApplication } from "@playwright/test";
import sharp from "sharp";
import { chunk, done, fakeServer, send } from "../fake-server.ts";
import { runCleanup } from "../helpers/cleanup.ts";
import { clipboardText, select } from "../helpers/composer.ts";
import { launchDesktop } from "../helpers/desktop.ts";
import { expect, test } from "../helpers/test.ts";
import { seedHistory } from "../history-fixture.ts";
test("add panel shares the composer width, lists files and preserves the insertion selection", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-composer-add-"));
const project = join(dir, "project");
await mkdir(join(project, "src"), { recursive: true });
await writeFile(join(project, "src", "main.ts"), "export const main = 1;");
await writeFile(join(project, "README.md"), "Project");
const server = await fakeServer((_, response) => done(response));
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, project, url: server.url });
const page = await app.firstWindow();
await page.getByLabel("添加项目", { exact: true }).click();
const editor = page.getByLabel("消息", { exact: true });
const add = page.getByRole("button", { name: "添加上下文", exact: true });
const panel = page.getByRole("listbox", { name: "添加上下文", exact: true });
await editor.fill("前尾");
await editor.evaluate((el) => {
const range = document.createRange();
range.setStart(el.firstChild as Node, 1);
range.collapse(true);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
});
await add.click();
await expect(panel).toBeFocused();
await expect(add).toHaveAttribute("aria-expanded", "true");
await expect(panel.locator(".composer-add-heading")).toHaveText(["添加", "文件"]);
await expect(panel.getByRole("option")).toHaveCount(4);
await expect(panel.getByRole("option", { name: "附件", exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.locator(".composer-add-hint code")).toHaveText(["@", "/", "$"]);
await expect(panel.locator(".composer-add-hint")).toContainText("输入内容以搜索文件");
const composerBounds = await page.locator(".composer").boundingBox();
const panelBounds = await panel.boundingBox();
if (!composerBounds || !panelBounds) throw new Error("Composer and add panel must be visible");
expect(panelBounds.x).toBe(composerBounds.x);
expect(panelBounds.width).toBe(composerBounds.width);
expect(composerBounds.y - panelBounds.y - panelBounds.height).toBe(4);
const clip = {
x: panelBounds.x,
y: panelBounds.y,
width: panelBounds.width,
height: composerBounds.y + composerBounds.height - panelBounds.y,
};
await page.screenshot({ path: "test-results/desktop-composer-add-light.png", clip });
await page.emulateMedia({ colorScheme: "dark" });
await page.screenshot({ path: "test-results/desktop-composer-add-dark.png", clip });
await page.emulateMedia({ colorScheme: "light" });
await panel.press("ArrowUp");
await expect(panel.getByRole("option", { name: "src", exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await panel.press("ArrowDown");
await expect(panel.getByRole("option", { name: "附件", exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await panel.getByRole("option", { name: "main.ts src/", exact: true }).click();
await expect(panel).toHaveCount(0);
await expect(editor).toBeFocused();
await expect(editor).toHaveText("前main.ts 尾");
await expect(editor.locator(".inline-mention.file")).toHaveAttribute(
"data-markdown",
`[main.ts](${await realpath(join(project, "src", "main.ts"))})`,
);
await page.keyboard.type("X");
await expect(editor).toHaveText("前main.ts X尾");
await add.click();
await panel.press("Escape");
await expect(panel).toHaveCount(0);
await expect(add).toBeFocused();
await add.press("Enter");
await expect(panel).toBeVisible();
await add.click();
await expect(panel).toHaveCount(0);
await expect(editor).toBeFocused();
await add.click();
await editor.click();
await expect(panel).toHaveCount(0);
await editor.fill("@main");
await expect(page.getByRole("listbox", { name: "引用上下文", exact: true })).toBeVisible();
await expect(page.locator(".file-option")).toHaveCount(1);
await page.getByRole("option", { name: "main.ts src/", exact: true }).click();
await expect(editor).toBeFocused();
await page.keyboard.type("Y");
await expect(editor).toHaveText("main.ts Y");
await editor.fill("@main");
await expect(page.getByRole("listbox", { name: "引用上下文", exact: true })).toBeVisible();
await add.click();
await expect(panel).toBeVisible();
await expect(page.getByRole("listbox", { name: "引用上下文", exact: true })).toHaveCount(0);
await page.locator(".topbar-title").click();
await expect(panel).toHaveCount(0);
for (let i = 0; i < 12; i++) await writeFile(join(project, `file-${i}.txt`), "file");
await app.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].setBounds({ width: 740, height: 560 });
});
await add.click();
await expect(panel.getByRole("option")).toHaveCount(11);
await panel.press("ArrowUp");
await expect(panel.getByRole("option").last()).toBeInViewport();
expect(await panel.locator(".command-options").evaluate((el) => el.scrollTop)).toBeGreaterThan(0);
expect((await panel.boundingBox())?.y).toBeGreaterThanOrEqual(0);
await expect(panel.locator(".composer-add-hint")).toBeInViewport();
await panel.press("ArrowDown");
await expect(panel.getByRole("option", { name: "附件", exact: true })).toBeInViewport();
await panel.press("Escape");
await add.click();
await expect(panel.getByRole("option")).toHaveCount(11);
await expect(panel.locator(".composer-add-heading").first()).toBeInViewport();
await page.screenshot({ path: "test-results/desktop-composer-add-small.png" });
expect(server.requests).toHaveLength(0);
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
for (const pane of ["main", "side"] as const) {
test(`${pane} composer puts screenshots above conversation references like ZCode`, async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-composer-context-rows-"));
const project = join(dir, "project");
await mkdir(project);
const images = [join(dir, "first.png"), join(dir, "second.png")];
for (const image of images)
await sharp({ create: { width: 2, height: 2, channels: 3, background: "white" } })
.png()
.toFile(image);
const server = await fakeServer((_, response) => {
send(response, chunk({ content: "可以引用的回答" }));
done(response);
});
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, project, images, url: server.url });
const page = await app.firstWindow();
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1600, 1000));
await page.getByRole("button", { name: "添加项目", exact: true }).first().click();
const main = page.locator("main");
await main.getByLabel("消息", { exact: true }).fill("主问题");
await main.getByLabel("发送", { exact: true }).click();
await expect(main.getByTestId("run")).toHaveAttribute("data-status", "completed");
if (pane === "side") {
await page.getByLabel("展开右侧栏", { exact: true }).click();
await page
.locator(".pane-empty-launcher")
.getByRole("button", { name: "辅助对话", exact: true })
.click();
}
const target = pane === "main" ? main : page.getByRole("region", { name: "辅助对话", exact: true });
const editor = target.getByLabel("消息", { exact: true });
if (pane === "side") {
await editor.fill("辅助问题");
await target.getByLabel("发送", { exact: true }).click();
await expect(target.getByTestId("run")).toHaveAttribute("data-status", "completed");
}
const composer = target.locator(".composer");
await editor.fill("保留草稿");
await composer.getByRole("button", { name: "添加上下文", exact: true }).click();
await target.getByRole("option", { name: "附件", exact: true }).click();
await expect(composer.locator(".image-chip")).toHaveCount(2);
const paragraph = target.locator(".answer p").first();
await paragraph.click();
await paragraph.evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
});
await page
.getByRole("toolbar", { name: "选文操作", exact: true })
.getByRole("button", {
name: pane === "main" ? "添加到当前任务" : "添加到当前辅助对话",
exact: true,
})
.click();
const reference = composer.locator(".selection-reference-chip");
await expect(reference).toContainText("1 条对话引用");
await expect
.poll(async () => {
const image = await composer.locator(".image-chip").last().boundingBox();
const quote = await reference.boundingBox();
return image && quote ? quote.y - image.y - image.height : -1;
})
.toBe(8);
await composer.screenshot({ path: test.info().outputPath("screenshots-above-references.png") });
await expect(editor).toHaveText("保留草稿");
await composer.getByLabel("移除对话引用", { exact: true }).click();
await expect(reference).toHaveCount(0);
await expect(composer.locator(".image-chip")).toHaveCount(2);
await expect
.poll(() => composer.locator(".input-context").evaluate((element) => element.clientHeight))
.toBe(48);
for (const name of ["first.png", "second.png"])
await composer.getByLabel(`移除 ${name}`, { exact: true }).click();
await expect(composer.locator(".input-context")).toBeHidden();
await expect(editor).toHaveText("保留草稿");
expect(server.requests).toHaveLength(pane === "main" ? 1 : 2);
} finally {
await runCleanup([
() => app?.close(),
() => server.close(),
() => rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
]);
}
});
}
test("add panel imports multiple attachments and retains the draft when the picker fails or is cancelled", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-composer-attachments-"));
const project = join(dir, "workspace");
await mkdir(project);
const images = [join(dir, "first.png"), join(dir, "second.png")];
for (const image of images)
await sharp({ create: { width: 2, height: 2, channels: 3, background: "white" } })
.png()
.toFile(image);
const server = await fakeServer((_, response) => done(response));
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, project, images, url: server.url });
const page = await app.firstWindow();
const editor = page.getByLabel("消息", { exact: true });
const add = page.getByRole("button", { name: "添加上下文", exact: true });
const panel = page.getByRole("listbox", { name: "添加上下文", exact: true });
await app.evaluate(({ ipcMain }) => {
const internal = ipcMain as unknown as {
_invokeHandlers: Map<string, (event: unknown, method: string, args: unknown[]) => Promise<unknown>>;
};
const original = internal._invokeHandlers.get("zpi:call");
if (!original) throw Error("handler");
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
(globalThis as unknown as { releasePicker: () => void }).releasePicker = release;
ipcMain.removeHandler("zpi:call");
ipcMain.handle("zpi:call", async (event, method, args) => {
const result = await original(event, method, args);
if (method === "pickAttachments") await gate;
return result;
});
});
await editor.fill("保留草稿");
await add.click();
await expect(panel).toContainText("暂无匹配文件");
await panel.press("Enter");
await expect(panel).toHaveCount(0);
await expect(page.locator(".composer [role=status]")).toHaveCount(1);
// A native picker takes focus away while it is open.
await page.getByLabel("模型选择", { exact: true }).focus();
await expect(editor).not.toBeFocused();
await app.evaluate(() => (globalThis as unknown as { releasePicker: () => void }).releasePicker());
await expect(page.locator(".composer .image-chip")).toHaveCount(2);
await expect(editor).toBeFocused();
await expect(editor).toHaveText("保留草稿");
await page.keyboard.type("X");
await expect(editor).toHaveText("保留草稿X");
await page.getByRole("button", { name: "移除 first.png", exact: true }).click();
await expect(page.locator(".composer .image-chip")).toHaveCount(1);
await app.evaluate(() => {
process.env.ZPI_TEST_IMAGE_FILES = "[]";
});
await add.click();
await panel.getByRole("option", { name: "附件", exact: true }).click();
await expect(page.locator(".composer .image-chip")).toHaveCount(1);
await expect(page.locator(".composer [role=status]")).toHaveCount(0);
await expect(editor).toBeFocused();
await expect(page.getByRole("alert")).toHaveCount(0);
await app.evaluate(
(_, path) => {
process.env.ZPI_TEST_IMAGE_FILES = JSON.stringify([path]);
},
join(dir, "missing.png"),
);
await add.click();
await panel.press(" ");
await expect(page.getByRole("alert")).toContainText("找不到文件");
await expect(page.locator(".composer .input-notice")).toContainText("找不到文件");
await expect(editor).toHaveText("保留草稿X");
await expect(editor).toBeFocused();
await expect(page.locator(".composer .image-chip")).toHaveCount(1);
expect(server.requests).toHaveLength(0);
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
test("submit failures stay with the draft instead of the window overlay", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-error-scope-"));
const server = await fakeServer((_, response) => done(response));
let app: ElectronApplication | undefined;
try {
const cwd = join(dir, "workspace");
await mkdir(cwd);
const id = seedHistory(dir, cwd, 1).id;
app = await launchDesktop({ dir, url: server.url });
const page = await app.firstWindow();
await select(page, id);
const editor = page.getByLabel("消息", { exact: true });
await editor.fill(`保留 [missing](${join(cwd, "missing.txt")})`);
await editor.press("Enter");
const notice = page.locator(".composer .input-notice");
await expect(notice).toContainText("找不到文件");
await expect(page.locator(".notice-window")).toHaveCount(0);
await expect(editor).toContainText("保留");
expect(server.requests).toHaveLength(0);
const composer = await page.locator(".composer").boundingBox();
await page.screenshot({ path: "test-results/desktop-composer-input-notice.png" });
await expect(page.getByRole("tooltip")).toHaveCount(0, { timeout: 5000 });
await expect(notice).toHaveCount(0, { timeout: 10000 });
expect(await page.locator(".composer").boundingBox()).toEqual(composer);
await expect(editor).toContainText("保留");
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
test("mixed file attachments work through picker, paste and drop, and file mentions share the text line", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-composer-files-"));
const project = join(dir, "project");
await mkdir(project);
await writeFile(join(project, "bubble.html"), "<p>Bubble</p>");
const image = join(project, "feishu-auth-qr.png");
await sharp({ create: { width: 3, height: 2, channels: 3, background: "white" } })
.png()
.toFile(image);
const files = [image, join(dir, "notes.txt"), join(dir, "document.pdf"), join(dir, "report.docx")];
await writeFile(files[1], "native attachment content");
await writeFile(files[2], "%PDF-1.7\n\0binary PDF payload");
await writeFile(files[3], "PK\0binary office payload");
const droppedPath = join(dir, "dropped.csv");
await writeFile(droppedPath, "dropped attachment content");
const server = await fakeServer((_, response) => done(response));
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, project, images: files, url: server.url });
const page = await app.firstWindow();
await page.getByLabel("添加项目", { exact: true }).click();
const editor = page.getByLabel("消息", { exact: true });
await page.getByRole("button", { name: "添加上下文", exact: true }).click();
await page.getByRole("option", { name: "附件", exact: true }).click();
await expect(page.locator(".composer .image-chip")).toHaveCount(1);
await expect(page.locator(".composer .file-attachment")).toHaveCount(3);
await expect(page.locator(".composer .file-attachment-info small")).toHaveText(["TXT", "PDF", "DOCX"]);
await expect(editor).toBeFocused();
expect((await page.locator(".composer .image-chip").boundingBox())?.height).toBe(48);
expect((await page.locator(".composer .file-attachment").first().boundingBox())?.height).toBe(48);
await page.getByRole("button", { name: "移除 document.pdf", exact: true }).click();
await expect(page.locator(".composer .file-attachment")).toHaveCount(2);
await editor.fill("@bubble");
await page.getByRole("option", { name: "bubble.html", exact: true }).click();
await page.keyboard.type("的颠三倒四");
await page.getByRole("button", { name: "添加上下文", exact: true }).click();
await page.getByRole("option", { name: "feishu-auth-qr.png", exact: true }).click();
await page.keyboard.type("都是");
await expect(editor).toHaveText("bubble.html 的颠三倒四feishu-auth-qr.png 都是");
const linePositions = await editor.evaluate((el) => {
const positions: { text: string; top: number; bottom: number }[] = [];
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
const node = walker.currentNode;
if (!node.textContent?.trim()) continue;
const range = document.createRange();
range.selectNodeContents(node);
const bounds = range.getBoundingClientRect();
positions.push({ text: node.textContent, top: bounds.top, bottom: bounds.bottom });
}
return positions;
});
expect(linePositions).toHaveLength(4);
expect(
Math.max(...linePositions.map((p) => p.top)) - Math.min(...linePositions.map((p) => p.top)),
).toBeLessThanOrEqual(1);
expect(
Math.max(...linePositions.map((p) => p.bottom)) - Math.min(...linePositions.map((p) => p.bottom)),
).toBeLessThanOrEqual(1);
await editor.evaluate((el) => {
const data = new DataTransfer();
data.items.add(new File(["pasted attachment content"], "pasted.md", { type: "text/markdown" }));
el.dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true }));
});
await expect(page.locator(".composer .file-attachment")).toHaveCount(3);
await page.evaluate(() => {
const input = document.createElement("input");
input.type = "file";
input.hidden = true;
input.dataset.testDroppedFile = "true";
document.body.append(input);
});
await page.locator("[data-test-dropped-file]").setInputFiles(droppedPath);
await page.locator(".composer").evaluate((el) => {
const input = document.querySelector<HTMLInputElement>("[data-test-dropped-file]");
const file = input?.files?.[0];
if (!file) throw Error("Expected native file");
const data = new DataTransfer();
data.items.add(file);
el.dispatchEvent(new DragEvent("dragenter", { dataTransfer: data, bubbles: true, cancelable: true }));
});
await expect(page.locator(".composer-drop-overlay")).toHaveText("松开以添加附件");
await expect(page.locator(".composer-drop-overlay")).toHaveCSS("backdrop-filter", "blur(8px)");
await page.locator(".composer").hover();
await expect(page.locator(".composer")).toHaveCSS("border-color", "rgb(0, 0, 0)");
// Entering a child must not clear the feedback when leaving the parent boundary event.
await editor.evaluate((el) => {
const data = new DataTransfer();
data.items.add(new File(["test"], "test.txt"));
el.dispatchEvent(new DragEvent("dragenter", { dataTransfer: data, bubbles: true }));
el.dispatchEvent(new DragEvent("dragleave", { dataTransfer: data, bubbles: true }));
});
await expect(page.locator(".composer-drop-overlay")).toBeVisible();
await page.locator(".composer").evaluate((el) => {
const input = document.querySelector<HTMLInputElement>("[data-test-dropped-file]");
const file = input?.files?.[0];
if (!file) throw Error("Expected native file");
const data = new DataTransfer();
data.items.add(file);
el.dispatchEvent(new DragEvent("drop", { dataTransfer: data, bubbles: true, cancelable: true }));
input?.remove();
});
await expect(page.locator(".composer .file-attachment")).toHaveCount(4);
await expect(page.locator(".composer-drop-overlay")).toBeHidden();
await expect(editor).toBeFocused();
await expect(page.getByRole("alert")).toHaveCount(0);
await page.locator(".composer").screenshot({ path: "test-results/desktop-composer-files-light.png" });
await page.emulateMedia({ colorScheme: "dark" });
await page.locator(".composer").screenshot({ path: "test-results/desktop-composer-files-dark.png" });
await page.emulateMedia({ colorScheme: "light" });
// The fixture uses a text-only model; ordinary files must still send successfully.
await page.getByRole("button", { name: "移除 feishu-auth-qr.png", exact: true }).click();
await page.getByRole("button", { name: "发送", exact: true }).click();
await expect(page.locator(".user-message-row .file-attachment")).toHaveCount(4);
await expect(page.locator(".composer .file-attachment")).toHaveCount(0);
await expect.poll(() => server.requests.length).toBe(1);
const body = JSON.stringify(server.requests[0]);
expect(body).toContain("native attachment content");
expect(body).toContain("pasted attachment content");
expect(body).toContain("dropped attachment content");
expect(body).toContain(await realpath(droppedPath));
expect(body).toContain(await realpath(files[3]));
expect(body).not.toContain("binary office payload");
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
test("Shift+Enter moves the caret to a visible empty line and preserves repeated line breaks", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-newline-"));
const server = await fakeServer((_, res) => {
send(res, chunk({ content: "reply" }));
done(res);
});
let app: ElectronApplication | undefined;
try {
const cwd = join(dir, "workspace");
await mkdir(cwd);
const id = seedHistory(dir, cwd, 1).id;
app = await launchDesktop({ dir, url: server.url });
const page = await app.firstWindow();
await select(page, id);
const editor = page.getByLabel("消息", { exact: true });
await editor.fill("第一行\n第二行\n第三行");
const height = () => editor.evaluate((el) => el.getBoundingClientRect().height);
const initialHeight = await height();
await editor.press("Shift+Enter");
expect(await height()).toBe(initialHeight + 20);
await editor.press("Shift+Enter");
expect(await height()).toBe(initialHeight + 40);
await editor.press("Meta+Z");
expect(await height()).toBe(initialHeight + 20);
await editor.press("Meta+Shift+Z");
expect(await height()).toBe(initialHeight + 40);
await editor.pressSequentially("末行");
await editor.press("Meta+A");
expect(await clipboardText(page)).toBe("第一行\n第二行\n第三行\n\n末行");
await editor.fill("首行");
await editor.press("Shift+Enter");
await editor.press("ArrowUp");
await editor.pressSequentially("X");
await editor.press("Meta+A");
expect(await clipboardText(page)).toBe("X首行\n");
await editor.fill("");
await editor.press("Shift+Enter");
const cdp = await page.context().newCDPSession(page);
await cdp.send("Input.imeSetComposition", { text: "中文", selectionStart: 2, selectionEnd: 2 });
await cdp.send("Input.insertText", { text: "中文" });
await expect
.poll(async () => {
const draft = await page.evaluate((id) => window.zpi.getDraft(id), id);
return draft.ok ? draft.value.text : "";
})
.toBe("\n中文");
expect(server.requests).toHaveLength(0);
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
test("Shift+Enter keeps the caret visible in a long pasted draft", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-newline-scroll-"));
const server = await fakeServer((_, res) => {
send(res, chunk({ content: "reply" }));
done(res);
});
let app: ElectronApplication | undefined;
try {
const cwd = join(dir, "workspace");
await mkdir(cwd);
const id = seedHistory(dir, cwd, 1).id;
app = await launchDesktop({ dir, url: server.url });
const page = await app.firstWindow();
await select(page, id);
const editor = page.getByLabel("消息", { exact: true });
const pasted = Array.from({ length: 40 }, (_, i) => `console output ${i}`).join("\n");
await editor.focus();
await editor.evaluate((el, pasted) => {
const data = new DataTransfer();
data.setData("text/plain", pasted);
el.dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true }));
}, pasted);
await expect(editor).toHaveText(pasted);
for (let i = 0; i < 3; i++) {
// Reproduce an editor scrolled away from its caret before inserting a newline.
await editor.evaluate((el) => {
el.scrollTop = 0;
});
await editor.press("Shift+Enter");
await expect(editor).toBeFocused();
await expect
.poll(() =>
editor.evaluate((el) => {
const selected = window.getSelection();
if (!selected?.rangeCount || !selected.isCollapsed || !el.contains(selected.focusNode))
return false;
const tail = el.querySelector("br[data-editor-tail]");
// A collapsed range after a final newline has no rect; the tail renders its empty line.
if (
!tail ||
selected.focusNode?.nextSibling !== tail ||
selected.focusOffset !== selected.focusNode.textContent?.length
)
return false;
const caret = tail.getBoundingClientRect();
const viewport = el.getBoundingClientRect();
return caret.height > 0 && caret.top >= viewport.top && caret.bottom <= viewport.bottom;
}),
)
.toBe(true);
}
await editor.pressSequentially("last line");
await editor.press("Meta+A");
expect(await clipboardText(page)).toBe(`${pasted}\n\n\nlast line`);
expect(server.requests).toHaveLength(0);
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
test("empty prefix suggestions allow Enter to send while matching suggestions still insert references", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-prefix-input-"));
const server = await fakeServer((_, res) => {
send(res, chunk({ content: "reply" }));
done(res);
});
let app: ElectronApplication | undefined;
try {
const cwd = join(dir, "workspace"),
skillDir = join(dir, ".zpi", "skills", "review");
await mkdir(cwd);
await mkdir(skillDir, { recursive: true });
await writeFile(join(cwd, "main.txt"), "file content");
await writeFile(
join(skillDir, "SKILL.md"),
"---\nname: review\ndescription: Review code\n---\nSKILL BODY ON DEMAND",
);
const id = seedHistory(dir, cwd, 1).id;
app = await launchDesktop({ dir, url: server.url });
const page = await app.firstWindow();
await select(page, id);
const editor = page.getByLabel("消息", { exact: true });
for (const text of [
"/中文读杠",
"/tmp/missing",
"$100",
"$missing",
"$中文",
"@missing-file",
"@someone@example.com",
]) {
await editor.fill(text);
if (text === "@someone@example.com") await expect(page.getByRole("listbox")).toHaveCount(0);
else await expect(page.getByRole("listbox")).toBeVisible();
await expect(page.getByRole("option")).toHaveCount(0);
const index = server.requests.length;
await editor.press("Enter");
await expect.poll(() => server.requests.length).toBe(index + 1);
expect(server.requests[index].messages).toContainEqual(
expect.objectContaining({ role: "user", content: text }),
);
await expect(page.locator(".answer").last()).toHaveText("reply");
await expect(editor).toHaveText("");
await expect(page.getByRole("listbox")).toHaveCount(0);
await expect(page.getByRole("alert")).toHaveCount(0);
}
const count = server.requests.length;
await editor.fill("/init");
await expect(page.getByRole("option", { name: /^\/init\b/ })).toBeVisible();
// Hold caret restoration until after a newer user selection to expose stale frame callbacks.
const resumeFrames = await page.evaluateHandle(() => {
const original = window.requestAnimationFrame;
const callbacks = new Map<number, FrameRequestCallback>();
window.requestAnimationFrame = (callback) => {
const id = original(() => {});
callbacks.set(id, callback);
return id;
};
return () => {
window.requestAnimationFrame = original;
for (const callback of callbacks.values()) callback(performance.now());
callbacks.clear();
};
});
await editor.press("Tab");
await expect(editor.locator(".inline-mention.command")).toHaveText("Init");
await expect(editor.locator(".inline-mention.command")).toHaveAttribute("data-markdown", "/init");
expect(server.requests).toHaveLength(count);
await editor.press("Meta+A");
await resumeFrames.evaluate((resume) => resume());
await resumeFrames.dispose();
await editor.pressSequentially("$review");
await expect
.poll(() => page.evaluate((id) => window.zpi.getDraft(id), id))
.toMatchObject({
ok: true,
value: { text: "$review" },
});
await expect(page.getByRole("option")).toHaveCount(1);
await editor.press("Enter");
await expect(editor.locator(".inline-mention.skill")).toHaveText("review");
expect(server.requests).toHaveLength(count);
await editor.press("Enter");
await expect.poll(() => server.requests.length).toBe(count + 1);
expect(server.requests[count].messages).toContainEqual(
expect.objectContaining({
role: "user",
content: `[$review](${await realpath(join(skillDir, "SKILL.md"))}) `,
}),
);
await expect(editor).toHaveText("");
await editor.fill("@main");
await expect(page.locator(".file-option")).toHaveCount(1);
await editor.press("Tab");
await expect(editor.locator(".inline-mention.file")).toHaveText("main.txt");
expect(server.requests).toHaveLength(count + 1);
await editor.press("Enter");
await expect.poll(() => server.requests.length).toBe(count + 2);
expect(JSON.stringify(server.requests[count + 1])).toContain("Referenced project files");
expect(JSON.stringify(server.requests[count + 1])).toContain(join(cwd, "main.txt"));
await expect(editor).toHaveText("");
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
test("canonical reference editing, IME, selection, undo/redo, cross-session caret and restart drafts", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-editor-"));
const server = await fakeServer((_, res) => {
send(res, chunk({ content: "done" }));
done(res);
});
let app: ElectronApplication | undefined;
try {
const cwd = join(dir, "workspace");
await mkdir(cwd);
const id = seedHistory(dir, cwd, 1).id,
b = seedHistory(dir, cwd, 1).id;
app = await launchDesktop({ dir, url: server.url });
let page = await app.firstWindow();
await select(page, id);
const file = join(cwd, "a.txt"),
skill = join(dir, ".zpi", "skills", "review", "SKILL.md");
await writeFile(file, "file content");
await mkdir(join(dir, ".zpi", "skills", "review"), { recursive: true });
await writeFile(skill, "---\nname: review\ndescription: review\n---\nReview");
const editor = page.getByLabel("消息", { exact: true }),
canonical = `前 [a.txt](${file}) 后 [$review](${skill}) 尾`;
await editor.fill(canonical);
await expect(editor.locator(".inline-mention")).toHaveCount(2);
await expect(editor).toHaveText("前 a.txt 后 review 尾");
await editor.press("Meta+A");
expect(await clipboardText(page)).toBe(canonical);
expect(await clipboardText(page, true)).toBe(canonical);
await expect(editor).toHaveText("");
await editor.press("Meta+Z");
await expect(editor).toHaveText("前 a.txt 后 review 尾");
await editor.press("Meta+Shift+Z");
await expect(editor).toHaveText("");
await editor.press("Meta+Z");
await editor.press("Meta+A");
await page.keyboard.insertText("选区替换");
await expect(editor).toHaveText("选区替换");
await editor.press("Meta+Z");
await expect(editor.locator(".inline-mention")).toHaveCount(2);
await editor.press("Meta+A");
await editor.evaluate((el, text) => {
const data = new DataTransfer();
data.setData("text/plain", text);
el.dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true }));
}, canonical);
await expect(editor).toHaveText("前 a.txt 后 review 尾");
await editor.evaluate((el) => {
const chip = el.querySelector(".inline-mention") as Node;
const range = document.createRange();
range.setStartBefore(chip);
range.collapse(true);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
});
await editor.pressSequentially("左");
await editor.evaluate((el) => {
const chip = el.querySelector(".inline-mention") as Node;
const range = document.createRange();
range.setStartAfter(chip);
range.collapse(true);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
});
await editor.pressSequentially("右");
await editor.press("Meta+A");
expect(await clipboardText(page)).toBe(`前 左[a.txt](${file})右 后 [$review](${skill}) 尾`);
await editor.press("Meta+Z");
await editor.press("Meta+Z");
await editor.evaluate((el) => {
const r = document.createRange();
r.setStart(el.firstChild as Node, 1);
r.setEnd(el, 2);
const s = window.getSelection();
s?.removeAllRanges();
s?.addRange(r);
});
await editor.press("Backspace");
await expect(editor.locator(".inline-mention.file")).toHaveCount(0);
await expect(editor.locator(".inline-mention.skill")).toHaveCount(1);
await editor.press("Meta+Z");
await expect(editor.locator(".inline-mention")).toHaveCount(2);
await editor.evaluate((el) => {
const r = document.createRange();
r.setStart(el.firstChild as Node, 1);
r.collapse(true);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(r);
});
await editor.pressSequentially("X");
await expect(editor).toContainText("前X");
await editor.press("Shift+Enter");
await editor.pressSequentially("line");
await expect(editor).toContainText("line");
await editor.fill("");
const cdp = await page.context().newCDPSession(page);
await cdp.send("Input.imeSetComposition", { text: "中文", selectionStart: 2, selectionEnd: 2 });
await expect(editor).toHaveText("中文");
await page.evaluate(() => document.dispatchEvent(new Event("selectionchange")));
await expect(page.getByRole("alert")).toHaveCount(0);
expect(server.requests).toHaveLength(0);
await cdp.send("Input.insertText", { text: "中文" });
await expect
.poll(async () => {
const draft = await page.evaluate((id) => window.zpi.getDraft(id), id);
return draft.ok ? draft.value.text : draft.error.message;
})
.toBe("中文");
await editor.press("Meta+Z");
await expect(editor).toHaveText("");
await editor.press("Meta+Shift+Z");
await expect(editor).toHaveText("中文");
// Commit IME in the middle of text, then continue typing through background renders.
await editor.fill("前尾");
await editor.evaluate((el) => {
const r = document.createRange();
r.setStart(el.firstChild as Node, 1);
r.collapse(true);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(r);
});
await cdp.send("Input.imeSetComposition", { text: "当前", selectionStart: 2, selectionEnd: 2 });
await cdp.send("Input.insertText", { text: "当前" });
await expect
.poll(async () => {
const draft = await page.evaluate((id) => window.zpi.getDraft(id), id);
return draft.ok ? draft.value.text : "";
})
.toBe("前当前尾");
await page.evaluate(async () => {
const settings = await window.zpi.getSettings();
if (!settings.ok) throw Error(settings.error.message);
await window.zpi.updatePreferences(settings.value.interface);
});
await page.getByLabel("添加上下文", { exact: true }).click();
// Native menus/window focus can clear the document selection while the editor is blurred.
await page.evaluate(() => window.getSelection()?.removeAllRanges());
await page.locator(".command-panel").press("Escape");
await editor.focus();
await editor.pressSequentially("X");
await expect(editor).toHaveText("前当前X尾");
await editor.press("Meta+Z");
await expect(editor).toHaveText("前当前尾");
await editor.fill(`甲乙 [missing](${cwd}/missing.txt)`);
await editor.evaluate((el) => {
const r = document.createRange();
r.setStart(el.firstChild as Node, 1);
r.collapse(true);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(r);
});
await select(page, b);
await editor.fill("会话 B 草稿");
await select(page, id);
await editor.focus();
await editor.pressSequentially("X");
await expect(editor).toHaveText("甲X乙 missing");
await editor.press("Enter");
await expect(page.getByRole("alert")).toBeVisible();
await expect(editor).toHaveText("甲X乙 missing");
await expect
.poll(async () => {
const r = await page.evaluate((id) => window.zpi.getDraft(id), id);
return r.ok ? r.value.text : "";
})
.toBe(`甲X乙 [missing](${cwd}/missing.txt)`);
await app.close();
app = await launchDesktop({ dir, url: server.url });
page = await app.firstWindow();
await expect(page.getByLabel("消息", { exact: true })).toHaveText("甲X乙 missing");
await expect(page.locator(".draft-warning")).toContainText("原文已保留");
await page.screenshot({ path: "test-results/desktop-draft-restored.png" });
await select(page, b);
await expect(page.getByLabel("消息", { exact: true })).toHaveText("会话 B 草稿");
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
test("accepted submit clears only submitted content, rejects duplicates and preserves other drafts", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-submit-"));
const server = await fakeServer((_, res) => {
send(res, chunk({ content: "reply" }));
done(res);
});
let app: ElectronApplication | undefined;
try {
const cwd = join(dir, "workspace");
await mkdir(cwd);
const a = seedHistory(dir, cwd, 1).id,
b = seedHistory(dir, cwd, 1).id;
app = await launchDesktop({ dir, url: server.url });
const page = await app.firstWindow();
await select(page, b);
await page.getByLabel("消息", { exact: true }).fill("B 草稿");
await select(page, a);
await app.evaluate(({ ipcMain }) => {
const internal = ipcMain as unknown as {
_invokeHandlers: Map<string, (event: unknown, method: string, args: unknown[]) => Promise<unknown>>;
};
const original = internal._invokeHandlers.get("zpi:call");
if (!original) throw Error("handler");
let release!: () => void;
const gate = new Promise<void>((r) => {
release = r;
});
(globalThis as unknown as { releaseSubmit: () => void }).releaseSubmit = release;
ipcMain.removeHandler("zpi:call");
ipcMain.handle("zpi:call", async (event, method, args) => {
const result = await original(event, method, args);
if (method === "submitInput") await gate;
return result;
});
});
const editor = page.getByLabel("消息", { exact: true });
await editor.fill("实际提交的完整内容");
await editor.press("Enter");
await editor.press("Enter");
await expect(page.locator(".answer").last()).toHaveText("reply");
await editor.fill("发送期间新输入");
// A later draft can happen to contain the same text as the submitted one.
await editor.fill("实际提交的完整内容");
await select(page, b);
await editor.pressSequentially(" 新内容");
await app.evaluate(() => (globalThis as unknown as { releaseSubmit: () => void }).releaseSubmit());
await expect(editor).toHaveText("B 草稿 新内容");
await select(page, a);
await expect(editor).toHaveText("实际提交的完整内容");
expect(server.requests).toHaveLength(1);
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
test("sidebar expansion, distinct composer entries and equal-width pane tabs", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-layout-"));
await mkdir(join(dir, ".agents", "skills", "review"), { recursive: true });
await writeFile(
join(dir, ".agents", "skills", "review", "SKILL.md"),
"---\nname: review\ndescription: Review code\n---\nReview",
);
const server = await fakeServer((_, response) => {
send(response, chunk({ content: "ok" }));
done(response);
});
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, url: server.url });
let page = await app.firstWindow();
await expect(page.getByLabel("消息", { exact: true })).toBeVisible();
const draftId = await page.evaluate(() => localStorage.getItem("zpi.selectedSession"));
const workspace = await page.evaluate(async (id) => {
const result = await window.zpi.getWorkspaceInfo(id as string);
if (!result.ok) throw new Error(result.error.message);
return result.value.cwd;
}, draftId);
await writeFile(join(workspace, "main.txt"), "file content");
await writeFile(join(workspace, "other.txt"), "other file");
await expect(page.locator(".recent-sessions .session-row")).toHaveCount(0);
await page.getByLabel("新建任务", { exact: true }).first().click();
expect(await page.evaluate(() => localStorage.getItem("zpi.selectedSession"))).toBe(draftId);
const text = `待发送 [main.txt](${join(workspace, "main.txt")})`;
await page.getByLabel("消息", { exact: true }).fill(text);
await expect
.poll(async () => {
const result = await page.evaluate((id) => window.zpi.getDraft(id as string), draftId);
return result.ok ? result.value.text : "";
})
.toBe(text);
await app.close();
app = await launchDesktop({ dir, url: server.url });
page = await app.firstWindow();
await expect(page.getByLabel("消息", { exact: true })).toHaveText("待发送 main.txt");
await expect(page.locator(".recent-sessions .session-row")).toHaveCount(0);
expect(await page.evaluate(() => localStorage.getItem("zpi.selectedSession"))).toBe(draftId);
const editor = page.getByLabel("消息", { exact: true });
await expect(editor).toBeVisible();
const toggle = page.getByTestId("left-sidebar-toggle");
const position = await toggle.boundingBox();
await toggle.click();
await expect(page.locator(".sidebar")).toBeHidden();
await toggle.click();
await expect(page.locator(".sidebar")).toBeVisible();
expect(await toggle.boundingBox()).toEqual(position);
await editor.fill("/");
const slash = page.getByRole("listbox", { name: "指令", exact: true });
await expect(slash.locator(".command-section-title")).toHaveText(["指令", "技能", "子智能体"]);
await expect(slash.locator('[role="option"] > strong').filter({ hasText: /^\// })).toHaveText([
"/init",
"/compact",
]);
await expect(slash.locator('[role="option"] > strong').filter({ hasText: /^\$/ })).toHaveText([
"$review",
]);
await expect(slash.getByRole("option", { name: /^Explore/ })).toBeVisible();
await expect(slash.getByRole("option", { name: /^general-purpose/ })).toBeVisible();
await editor.fill("$");
await page.getByRole("option", { name: /^review 用户/ }).click();
await expect(editor.locator(".inline-mention.skill")).toHaveText("review");
await expect(editor.locator(".inline-mention.skill")).toHaveAttribute(
"data-markdown",
/\[\$review\]\(.*SKILL\.md\)/,
);
await editor.fill("@main");
await expect(page.locator(".file-option")).toHaveCount(1);
await page.getByRole("option", { name: /main.txt/ }).click();
await expect(editor.locator(".inline-mention.file")).toHaveText("main.txt");
await editor.fill("");
await page.getByLabel("添加上下文", { exact: true }).click();
await expect(page.locator(".command-panel input")).toHaveCount(0);
await expect(page.getByRole("option")).toHaveCount(3);
await page.screenshot({ path: "test-results/desktop-composer-files-light.png" });
await page.emulateMedia({ colorScheme: "dark" });
await page.screenshot({ path: "test-results/desktop-composer-files-dark.png" });
await page.emulateMedia({ colorScheme: "light" });
await expect(page.getByRole("listbox")).toHaveAttribute("aria-label", "添加上下文");
await page.locator(".command-panel").press("ArrowDown");
await page.locator(".command-panel").press("Enter");
await expect(editor.locator(".inline-mention.file")).toHaveText("main.txt");
await expect(editor.locator(".inline-mention.file")).toHaveAttribute(
"data-markdown",
/\[main\.txt\]\(.*main\.txt\)/,
);
await editor.fill("/");
await page.screenshot({ path: "test-results/desktop-composer-commands.png" });
await editor.press("Escape");
const modelButton = page.getByLabel("模型选择", { exact: true });
await expect(modelButton).toHaveText("fake");
await expect(page.getByLabel("思考程度", { exact: true })).toHaveCount(0);
await modelButton.click();
await page.getByRole("menuitem", { name: "fake", exact: true }).hover();
await page.getByRole("menuitem", { name: "高", exact: true }).click();
await expect(modelButton).toHaveText("fake高");
await expect(modelButton.locator(".model-reasoning")).toHaveText("高");
await expect(modelButton).toBeEnabled();
await modelButton.screenshot({ path: "test-results/desktop-model-reasoning.png" });
await modelButton.click();
await page.getByRole("menuitem", { name: "fake", exact: true }).hover();
await page.getByRole("menuitem", { name: "关闭", exact: true }).click();
await expect(modelButton).toHaveText("fake");
await expect(modelButton.locator(".model-reasoning")).toHaveCount(0);
await page.getByLabel("展开右侧栏", { exact: true }).click();
await page.locator(".pane-empty-launcher").getByRole("button", { name: "浏览器", exact: true }).click();
await page.getByLabel("新增侧栏标签").click();
await page
.locator(".pane-empty-launcher:visible")
.getByRole("button", { name: "浏览器", exact: true })
.click();
await expect(page.getByRole("tab")).toHaveCount(2);
const widths = await page
.locator(".pane-tab")
.evaluateAll((tabs) => tabs.map((tab) => tab.getBoundingClientRect().width));
expect(widths[0]).toBe(widths[1]);
await page.getByLabel("关闭标签 浏览器", { exact: true }).last().click();
await expect(page.locator(".right-pane")).toBeVisible();
await page.getByLabel("关闭标签 浏览器", { exact: true }).click();
await expect(page.locator(".right-pane")).toBeHidden();
await page.getByLabel("展开右侧栏", { exact: true }).click();
await expect(page.locator(".pane-empty-launcher")).toBeVisible();
await page.screenshot({ path: "test-results/desktop-layout-light.png" });
await page.emulateMedia({ colorScheme: "dark" });
await page.screenshot({ path: "test-results/desktop-layout-dark.png" });
await editor.fill(`保留 [missing](${join(workspace, "missing.txt")})`);
await editor.press("Enter");
await expect(page.getByRole("alert")).toBeVisible();
await expect(editor).toHaveText("保留 missing");
await expect(page.locator(".recent-sessions .session-row")).toHaveCount(0);
await editor.fill("首条有效消息");
await editor.press("Enter");
await expect(page.locator(".answer")).toHaveText("ok");
await expect(page.locator(".recent-sessions .session-row")).toHaveCount(1);
await page.getByLabel("新建任务", { exact: true }).first().click();
await editor.fill("下一份待发送草稿");
await page.getByLabel("新建任务", { exact: true }).first().click();
await expect(editor).toHaveText("下一份待发送草稿");
await expect(page.locator(".recent-sessions .session-row")).toHaveCount(1);
await expect(page.getByRole("alert")).toHaveCount(0);
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});