源码
tests/desktop/workspace-panes.spec.ts
import { once } from "node:events";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import type { ElectronApplication } from "@playwright/test";
import { chunk, done, fakeServer, send } from "../fake-server.ts";
import { runCleanup } from "../helpers/cleanup.ts";
import { launchDesktop } from "../helpers/desktop.ts";
import { expect, test } from "../helpers/test.ts";
test("native terminal shell, browser link/navigation isolation, tabs and responsive pane bounds", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-native-panes-"));
await writeFile(join(dir, ".zshrc"), "PROMPT='ZPI> '\n");
let recovered = false;
const site = createServer((request, response) => {
if (request.url === "/recover" && !recovered) {
response.destroy();
return;
}
response.setHeader("Content-Type", "text/html; charset=utf-8");
response.end(
`<html><title>${request.url === "/next" ? "第二页" : "本地预览"}</title><body><h1>${request.url}</h1><a href="/next">下一页</a><p>native browser test</p></body></html>`,
);
});
site.listen(0, "127.0.0.1");
await once(site, "listening");
const url = `http://127.0.0.1:${(site.address() as AddressInfo).port}`;
const model = await fakeServer((_, response) => {
send(response, chunk({ content: `[本地预览](${url}/)` }));
done(response);
});
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, url: model.url });
const page = await app.firstWindow();
await page.getByLabel("新建任务", { exact: true }).first().click();
await page.getByLabel("展开右侧栏", { exact: true }).click();
await page.locator(".pane-empty-launcher").getByRole("button", { name: "终端", exact: true }).click();
const input = page.locator(".right-pane .xterm-helper-textarea");
await expect(input).toBeAttached();
const terminalTitle = await page.getByRole("tab", { selected: true }).getAttribute("aria-label");
if (!terminalTitle) throw Error("Missing terminal tab title");
await input.pressSequentially("printf 'ZPI_TERMINAL_%s\\n' 'OK'; pwd; stty size", { delay: 2 });
await input.press("Enter");
await expect(page.locator(".xterm-rows")).toContainText("ZPI_TERMINAL_OK");
await expect(page.locator(".xterm-rows")).toContainText("workspace");
const terminalColor = () => page.locator(".xterm-rows").evaluate((el) => getComputedStyle(el).color);
const terminalFont = await page.locator(".xterm-rows").evaluate((el) => getComputedStyle(el).fontSize);
await page.emulateMedia({ colorScheme: "light" });
const lightTerminal = await terminalColor();
await page.getByRole("button", { name: "设置", exact: true }).click();
const appearance = page.getByRole("region", { name: "设置", exact: true });
await appearance.getByRole("combobox", { name: "界面主题", exact: true }).click();
await page.getByRole("option", { name: "深色", exact: true }).click();
await appearance.getByRole("spinbutton", { name: "界面字号", exact: true }).fill("16");
await appearance.getByRole("spinbutton", { name: "界面字号", exact: true }).press("Enter");
await appearance.getByLabel("关闭设置").click();
await expect.poll(terminalColor).not.toBe(lightTerminal);
await expect
.poll(() => page.locator(".xterm-rows").evaluate((el) => getComputedStyle(el).fontSize))
.toBe(terminalFont);
await page.getByRole("button", { name: "设置", exact: true }).click();
await appearance.getByRole("combobox", { name: "界面主题", exact: true }).click();
await page.getByRole("option", { name: "浅色", exact: true }).click();
await appearance.getByRole("spinbutton", { name: "界面字号", exact: true }).fill("14");
await appearance.getByRole("spinbutton", { name: "界面字号", exact: true }).press("Enter");
await appearance.getByLabel("关闭设置").click();
await expect.poll(terminalColor).toBe(lightTerminal);
await page.screenshot({ path: "test-results/desktop-terminal.png" });
await page.getByLabel("消息", { exact: true }).fill("生成链接");
await page.getByLabel("消息", { exact: true }).press("Enter");
await page.locator(".answer").getByRole("button", { name: "本地预览", exact: true }).click();
await expect(page.getByLabel("浏览器地址")).toHaveValue(`${url}/`);
await expect(page.getByRole("tab", { name: "本地预览", exact: true })).toBeVisible();
const inspect = async () => {
const bounds = await page.locator(".browser-webview:visible").evaluateAll((views) =>
views.map((view) => {
const rect = view.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}),
);
return app?.evaluate(
async ({ webContents }, { url, bounds }) => {
const guest = webContents.getAllWebContents().find((contents) => contents.getURL().startsWith(url));
if (!guest) return null;
return {
url: guest.getURL(),
bounds,
isolated: await guest.executeJavaScript("[typeof window.zpi, typeof require, typeof process]"),
};
},
{ url, bounds },
);
};
await expect
.poll(async () => (await inspect())?.isolated)
.toEqual(["undefined", "undefined", "undefined"]);
await expect(page.getByLabel("刷新页面")).toBeVisible();
await app.evaluate(async ({ webContents }, url) => {
const guest = webContents.getAllWebContents().find((contents) => contents.getURL() === `${url}/`);
await guest?.executeJavaScript("document.querySelector('a').click()");
}, url);
await expect(page.getByLabel("浏览器地址")).toHaveValue(`${url}/next`);
await page.screenshot({ path: "test-results/desktop-browser-before-back.png" });
await page.getByLabel("浏览器后退").click();
await expect(page.getByLabel("浏览器地址")).toHaveValue(`${url}/`);
await page.getByLabel("浏览器前进").click();
await expect(page.getByLabel("浏览器地址")).toHaveValue(`${url}/next`);
await page.getByLabel("刷新页面").click();
await page.getByLabel("浏览器地址").fill(`${url}/recover`);
await page.getByLabel("浏览器地址").press("Enter");
await expect(page.locator(".browser-pane [role=alert]")).toBeVisible();
recovered = true;
await page.getByLabel("刷新页面").click();
await expect(page.locator(".browser-pane [role=alert]")).toHaveCount(0);
await expect(page.getByRole("tab", { name: "本地预览", exact: true })).toBeVisible();
const invalid = await page.evaluate(() => window.zpi.createBrowser("file://remote-host/share/page.html"));
expect(invalid.ok).toBe(false);
const invalidNavigation = await page.evaluate(async () => {
const blank = await window.zpi.createBrowser("");
if (!blank.ok) throw Error(blank.error.message);
const rejected = await window.zpi.browserAction(blank.value.id, "navigate", "javascript:alert(1)");
await window.zpi.closeBrowser(blank.value.id);
return rejected;
});
expect(invalidNavigation).toMatchObject({ ok: false, error: { code: "invalid_input" } });
await page.getByLabel("模型选择", { exact: true }).click();
await expect(page.getByRole("menu")).toHaveCount(1);
await page.evaluate(
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
);
await expect.poll(async () => (await inspect())?.bounds?.length).toBe(1);
await page.getByRole("menuitem", { name: "fake", exact: true }).hover();
await expect(page.getByRole("menu")).toHaveCount(2);
await expect
.poll(() =>
page.getByRole("menu").evaluateAll((menus) => {
const main = document.querySelector("main")?.getBoundingClientRect();
return main && menus.every((menu) => menu.getBoundingClientRect().right <= main.right);
}),
)
.toBe(true);
await page.evaluate(
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
);
await expect.poll(async () => (await inspect())?.bounds?.length).toBe(1);
await page.getByRole("menuitem", { name: "关闭", exact: true }).click();
await expect(page.getByRole("menu")).toHaveCount(0);
await expect.poll(async () => (await inspect())?.bounds?.length).toBe(1);
// DOM 菜单覆盖 webview 时保留网页画面,移动菜单也不改变 guest 的可见性。
await page.evaluate(() => {
const surface = document.querySelector('[data-testid="browser-surface"]')?.getBoundingClientRect();
if (!surface) throw Error("Missing browser surface");
const menu = document.createElement("div");
menu.id = "overlapping-menu";
menu.setAttribute("role", "menu");
Object.assign(menu.style, {
position: "fixed",
left: `${surface.left + 20}px`,
top: `${surface.top + 20}px`,
width: "100px",
height: "100px",
});
document.body.append(menu);
});
await expect.poll(async () => (await inspect())?.bounds?.length).toBe(1);
await page.evaluate(() => {
const menu = document.getElementById("overlapping-menu");
if (menu) menu.style.left = "0px";
});
await expect.poll(async () => (await inspect())?.bounds?.length).toBe(1);
await page.evaluate(() => document.getElementById("overlapping-menu")?.remove());
await page.getByRole("button", { name: "设置", exact: true }).click();
await expect.poll(async () => (await inspect())?.bounds?.length).toBe(1);
await page.getByRole("region", { name: "设置", exact: true }).getByLabel("关闭设置").click();
await expect.poll(async () => (await inspect())?.bounds?.length).toBe(1);
// webview 沿用 DOM 裁剪:展开动画不再需要手动上报原生视图 bounds。
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.getByLabel("收起右侧栏", { exact: true }).click();
await expect(page.locator(".right-pane")).toBeHidden();
const movingFrame = await page.evaluate(async () => {
document.querySelector<HTMLButtonElement>('[data-testid="right-sidebar-toggle"]')?.click();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const panel = document.querySelector(".right-pane");
for (const animation of panel?.getAnimations() ?? []) {
animation.pause();
animation.currentTime = 100;
}
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const clip = document.querySelector(".right-pane-frame")?.getBoundingClientRect();
if (!clip) throw Error("Missing browser clip frame");
return { left: clip.left, right: clip.right, width: clip.width };
});
const movingBrowser = (await inspect())?.bounds?.[0];
await page.evaluate(() => {
for (const animation of document.querySelector(".right-pane")?.getAnimations() ?? []) animation.play();
});
expect(movingBrowser).toBeDefined();
expect(movingBrowser?.x).toBeGreaterThanOrEqual(Math.floor(movingFrame.left));
await expect(page.locator(".right-pane-frame")).toHaveCSS("overflow", "hidden");
await expect(page.locator(".right-pane")).not.toHaveAttribute("data-animating", "true");
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.setSize(900, 640));
await expect
.poll(async () => {
const data = await inspect();
const bound = data?.bounds?.[0];
return Boolean(
bound &&
bound.x >= 0 &&
bound.y >= 0 &&
bound.x + bound.width <= 900 &&
bound.y + bound.height <= 640,
);
})
.toBe(true);
await page.screenshot({ path: "test-results/desktop-browser-narrow.png" });
// 同时保留独立 guest 截图,检查网页内容与宿主合成画面。
const browserPixels = await app.evaluate(async ({ webContents }, url) => {
const guest = webContents.getAllWebContents().find((contents) => contents.getURL().startsWith(url));
if (!guest) throw Error("Missing native browser page");
return (await guest.capturePage()).toPNG().toString("base64");
}, url);
await writeFile("test-results/desktop-browser-page.png", Buffer.from(browserPixels, "base64"));
await page.getByRole("tab", { name: terminalTitle, exact: true }).click();
await expect(page.locator(".xterm-rows")).toContainText("ZPI_TERMINAL_OK");
await page.getByLabel("收起右侧栏", { exact: true }).last().click();
await expect(page.locator(".right-pane")).toBeHidden();
await expect.poll(async () => (await inspect())?.bounds?.length).toBe(0);
await page.getByLabel("展开右侧栏", { exact: true }).click();
await page.getByLabel(`关闭标签 ${terminalTitle}`, { exact: true }).click();
await expect(page.getByRole("tab", { name: terminalTitle, exact: true })).toHaveCount(0);
} finally {
await runCleanup([
() => app?.close(),
() => rm(dir, { recursive: true, force: true }),
() => new Promise<void>((resolve) => site.close(() => resolve())),
() => model.close(),
]);
}
});
test("local HTML links and address-bar paths load sandboxed pages, assets and relative navigation", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-file-browser-"));
const project = join(dir, "中文 项目");
await mkdir(project);
const file = join(project, "本地 页面.html");
const url = `${pathToFileURL(file).href}?preview=1#start`;
await writeFile(
file,
'<html><title>本地文件预览</title><link rel="stylesheet" href="style.css"><body><h1 id="start">本地 HTML</h1><a href="next.html">下一页</a><script src="app.js"></script></body></html>',
);
await writeFile(join(project, "style.css"), "h1 { color: rgb(32, 96, 128); }");
await writeFile(join(project, "app.js"), "document.body.dataset.ready = 'local-script';");
await writeFile(join(project, "next.html"), "<html><title>本地第二页</title><body>第二页</body></html>");
const server = await fakeServer((_, response) => {
send(response, chunk({ content: `[打开本地页面](${url})` }));
done(response);
});
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, url: server.url, project });
const page = await app.firstWindow();
await page.getByLabel("新建任务", { exact: true }).first().click();
await page.getByLabel("消息", { exact: true }).fill("打开本地文件");
await page.getByLabel("消息", { exact: true }).press("Enter");
await page.locator(".answer").getByRole("button", { name: "打开本地页面" }).click();
await expect(page.getByLabel("浏览器地址")).toHaveValue(url);
await expect(page.getByRole("tab", { name: "本地文件预览", exact: true })).toBeVisible();
const inspect = () =>
app?.evaluate(async ({ webContents }, prefix) => {
const guest = webContents.getAllWebContents().find((c) => c.getURL().startsWith(prefix));
return guest
? guest.executeJavaScript(
"[document.body.dataset.ready, getComputedStyle(document.querySelector('h1')).color, typeof window.zpi, typeof require, typeof process]",
)
: null;
}, pathToFileURL(file).href);
await expect
.poll(inspect)
.toEqual(["local-script", "rgb(32, 96, 128)", "undefined", "undefined", "undefined"]);
await app.evaluate(async ({ webContents }, prefix) => {
const guest = webContents.getAllWebContents().find((c) => c.getURL().startsWith(prefix));
await guest?.executeJavaScript("document.querySelector('a').click()");
}, pathToFileURL(file).href);
await expect(page.getByLabel("浏览器地址")).toHaveValue(pathToFileURL(join(project, "next.html")).href);
await page.getByLabel("浏览器后退").click();
await expect(page.getByLabel("浏览器地址")).toHaveValue(url);
await page.getByLabel("浏览器前进").click();
await expect(page.getByLabel("浏览器地址")).toHaveValue(pathToFileURL(join(project, "next.html")).href);
await page.getByLabel("浏览器地址").fill(file);
await page.getByLabel("浏览器地址").press("Enter");
await expect(page.getByLabel("浏览器地址")).toHaveValue(pathToFileURL(file).href);
await expect
.poll(inspect)
.toEqual(["local-script", "rgb(32, 96, 128)", "undefined", "undefined", "undefined"]);
await page.getByLabel("浏览器地址").fill(pathToFileURL(file).href.replace("file:///", "file///"));
await page.getByLabel("浏览器地址").press("Enter");
await expect(page.getByLabel("浏览器地址")).toHaveValue(pathToFileURL(file).href);
const pixels = await app.evaluate(async ({ webContents }, prefix) => {
const guest = webContents.getAllWebContents().find((c) => c.getURL().startsWith(prefix));
if (!guest) throw Error("Missing local page");
return (await guest.capturePage()).toPNG().toString("base64");
}, pathToFileURL(file).href);
await writeFile("test-results/desktop-local-browser.png", Buffer.from(pixels, "base64"));
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
() => server.close(),
() => app?.close(),
]);
}
});
test("reasoning submenu keeps the native browser visible with long model names and a wide sidebar", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-reasoning-pane-"));
await mkdir(join(dir, "workspace"));
const file = join(dir, "workspace", "preview.html");
await writeFile(
file,
'<html><meta charset="utf-8"><title>菜单预览</title><body>Browser stays visible</body></html>',
);
const url = pathToFileURL(file).href;
let app: Awaited<ReturnType<typeof launchDesktop>>;
try {
app = await launchDesktop({ dir, url: "" });
const page = await app.firstWindow();
await page.evaluate(async () => {
const result = await window.zpi.saveProvider({
id: "menu-fixture",
name: "菜单测试",
baseUrl: "http://127.0.0.1:1",
apiKey: "fake",
models: [
{
id: "model",
name: "测试模型名称较长时思考级别菜单仍应保持浏览器可见",
reasoning: true,
input: ["text"],
},
],
});
if (!result.ok) throw Error(result.error.message);
});
await page.reload();
await page.getByLabel("展开右侧栏", { exact: true }).click();
await page.locator(".pane-empty-launcher").getByRole("button", { name: "浏览器", exact: true }).click();
await page.getByLabel("浏览器地址").fill(url);
await page.getByLabel("浏览器地址").press("Enter");
await expect(page.getByRole("tab", { name: "菜单预览", exact: true })).toBeVisible();
const attached = () => page.locator(".browser-webview:visible").count();
for (let i = 0; i < 9; i++) await page.getByLabel("右侧栏宽度", { exact: true }).press("ArrowLeft");
for (const width of [1200, 1100]) {
await app.evaluate(
({ BrowserWindow }, width) => BrowserWindow.getAllWindows()[0]?.setSize(width, 800),
width,
);
await expect.poll(attached).toBe(1);
await page.getByLabel("模型选择", { exact: true }).click();
await page
.getByRole("menuitem", { name: "测试模型名称较长时思考级别菜单仍应保持浏览器可见", exact: true })
.hover();
await expect(page.getByRole("menu")).toHaveCount(2);
await expect.poll(attached).toBe(1);
await expect
.poll(() =>
page.getByRole("menu").evaluateAll((menus) => {
const surface = document
.querySelector('[data-testid="browser-surface"]')
?.getBoundingClientRect();
const main = document.querySelector("main")?.getBoundingClientRect();
return (
surface &&
main &&
menus.every((menu) => {
const rect = menu.getBoundingClientRect();
return rect.left >= main.left && rect.right <= surface.left;
})
);
}),
)
.toBe(true);
await page.getByRole("menuitem", { name: "高", exact: true }).click();
await expect(page.getByRole("menu")).toHaveCount(0);
await expect.poll(attached).toBe(1);
}
} finally {
await runCleanup([() => rm(dir, { recursive: true, force: true }), () => app?.close()]);
}
});