源码
tests/desktop/sidebar-actions.spec.ts
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ElectronApplication, Page } 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 { expectZCodeSystemFont } from "../helpers/rendered-fonts.ts";
import { expect, test } from "../helpers/test.ts";
import { seedHistory } from "../history-fixture.ts";
async function settleMenuClose(page: Page) {
await expect(page.getByRole("menu")).toHaveCount(0);
// Radix restores the opener after the dismissed content unmounts; wait for it before the next input.
await page.evaluate(
() => new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
);
}
test("sidebar headings match ZCode hover states and dimensions; project menu only removes its entry", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-sidebar-actions-"));
const project = join(dir, "project");
await mkdir(project);
await writeFile(join(project, "keep.txt"), "project source");
const server = await fakeServer((_, response) => {
send(response, chunk({ content: "ok" }));
done(response);
});
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, project, url: server.url });
const page = await app.firstWindow();
const editor = page.getByLabel("消息", { exact: true });
const projectHeader = page.locator(".sidebar-heading").filter({ hasText: /^项目$/ });
const taskHeader = page.locator(".sidebar-heading").filter({ hasText: /^任务$/ });
await expect(page.locator(".topbar-title")).toHaveText("新任务");
// ZCode Root uses 1.5 for all icons; navigation stays 16px.
for (const selector of [".sidebar-global svg", ".sidebar-footer svg"]) {
await expect(page.locator(selector)).toHaveCSS("width", "16px");
await expect(page.locator(selector)).toHaveCSS("stroke-width", "1.5px");
}
await expectZCodeSystemFont(page, [
".sidebar-global button",
".sidebar-footer button",
".section-toggle span",
]);
if (process.platform === "darwin") {
// Measured in ZCode at 14px/500, zoom 1; PingFang SC would be 28px.
expect((await projectHeader.locator(".section-toggle span").boundingBox())?.width).toBeCloseTo(
26.84375,
2,
);
}
const colors = await page.evaluate(() => {
const probe = document.createElement("span");
document.body.append(probe);
const color = (token: string) => {
probe.style.color = `var(${token})`;
return getComputedStyle(probe).color;
};
const result = {
foreground: color("--color-foreground"),
subtlest: color("--color-text-subtlest"),
subtle: color("--color-text-subtle"),
};
probe.remove();
return result;
});
for (const header of [projectHeader, taskHeader]) {
await editor.focus();
await editor.hover();
const chevron = header.locator(".section-toggle svg");
const actions = header.locator(".sidebar-heading-actions");
const action = actions.locator(".muted-icon");
const toggle = header.locator(".section-toggle");
const handle = toggle;
await expect(chevron).toHaveCSS("opacity", "0");
await expect(actions).toHaveCSS("opacity", "0");
await expect(toggle).toHaveCSS("color", colors.subtlest);
await expect(action).toHaveCSS("color", colors.subtle);
await expect(toggle).toHaveCSS("font-size", "14px");
await expect(toggle).toHaveCSS("font-weight", "500");
await expect(toggle).toHaveCSS("height", "28px");
await expect(toggle).toHaveCSS("column-gap", "4px");
await expect(handle.locator("svg")).toHaveCSS("width", "14px");
for (const icon of await header.locator("svg").all()) {
await expect(icon).toHaveCSS("stroke-width", "1.5px");
}
await header.hover();
await expect(chevron).toHaveCSS("opacity", "1");
await expect(actions).toHaveCSS("opacity", "1");
await toggle.hover();
await expect(toggle).toHaveCSS("color", colors.foreground);
await expect(chevron).toHaveCSS("color", colors.foreground);
await handle.hover({ position: { x: 4, y: 4 } });
await expect(toggle).toHaveCSS("color", colors.foreground);
await action.hover();
await expect(action).toHaveCSS("color", colors.foreground);
await expect(handle).toHaveCSS("color", colors.subtlest);
await editor.hover();
await toggle.focus();
await expect(toggle).toHaveCSS("color", colors.foreground);
await expect(actions).toHaveCSS("opacity", "1");
const labelBox = await header.locator(".section-toggle span").boundingBox();
const iconBox = await chevron.boundingBox();
expect(iconBox?.x).toBeGreaterThan((labelBox?.x ?? 0) + (labelBox?.width ?? 0));
}
await expect(taskHeader.locator("svg.lucide-message-circle-plus")).toHaveCount(1);
await expect(
projectHeader.getByLabel("添加项目", { exact: true }).locator("svg.lucide-plus"),
).toHaveCount(1);
for (const header of [projectHeader, taskHeader]) {
await header.hover();
const background = await header.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(background).toBe("rgba(0, 0, 0, 0)");
await header.locator(".section-toggle").hover();
await expect(header.locator(".section-toggle")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(header).toHaveCSS("background-color", background);
await header.locator(".muted-icon").hover();
await expect(header.locator(".muted-icon")).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(header).toHaveCSS("background-color", background);
}
await projectHeader.hover();
await projectHeader.screenshot({ path: "test-results/sidebar-project-heading.png" });
await projectHeader.hover();
await projectHeader.getByLabel("添加项目", { exact: true }).click();
const projectRow = page.locator(".project-title");
const folderToggle = projectRow.locator(".project-toggle-title");
const folderIcon = folderToggle.locator("svg");
await expect(folderIcon).toHaveClass(/lucide-folder-open/);
await expect(folderIcon).toHaveCSS("width", "16px");
await expect(folderIcon).toHaveCSS("height", "16px");
await expect(folderIcon).toHaveCSS("stroke-width", "1.5px");
const slot = await folderToggle.boundingBox();
const icon = await folderIcon.boundingBox();
expect(icon?.x).toBe((slot?.x ?? 0) + 10);
await folderToggle.click();
await expect(folderIcon).toHaveClass(/lucide-folder(?!-open)/);
await expect(folderIcon).toHaveCSS("width", "16px");
await expect(folderIcon).toHaveCSS("stroke-width", "1.5px");
await folderToggle.click();
const more = projectRow.getByLabel("项目操作 project", { exact: true });
const create = projectRow.getByLabel("新建任务 project", { exact: true });
await expect(page.getByLabel("选择项目", { exact: true })).toHaveText("project");
await expect(page.locator(".topbar-title")).toHaveText("新任务");
await expect(projectRow.getByRole("button", { name: /重命名项目|移除项目/ })).toHaveCount(0);
await editor.focus();
await editor.hover();
await expect(more).toHaveCSS("opacity", "0");
await expect(create).toHaveCSS("opacity", "0");
await projectRow.hover();
await expect(more).toHaveCSS("opacity", "1");
await expect(create).toHaveCSS("opacity", "1");
await expect(create.locator("svg.lucide-message-circle-plus")).toHaveCount(1);
await expect(more.locator("svg.lucide-ellipsis")).toHaveCount(1);
await more.click();
const directoryMenu = page.getByRole("menu", { name: "项目操作 project", exact: true });
await expect(directoryMenu.getByRole("menuitem")).toHaveText(["从侧栏移除", "Finder", "复制路径"]);
await expect(directoryMenu.getByRole("separator")).toHaveCSS("height", "1px");
const finder = directoryMenu.getByRole("menuitem", { name: "Finder", exact: true });
await expect(finder.locator("img")).toHaveAttribute("src", /\/file-actions\/finder\.png$/);
await expect(finder.locator("img")).toHaveCSS("width", "16px");
await expect(
directoryMenu.getByRole("menuitem", { name: "复制路径", exact: true }).locator("svg"),
).toHaveClass(/lucide-copy/);
await directoryMenu.getByRole("menuitem", { name: "复制路径", exact: true }).click();
await expect
.poll(() => app?.evaluate(({ clipboard }) => clipboard.readText()))
.toBe(await realpath(project));
await app.evaluate(({ shell }) => {
shell.openPath = async (path) => {
(globalThis as typeof globalThis & { openedDirectory: string }).openedDirectory = path;
return "";
};
});
await expect(directoryMenu).toHaveCount(0);
await page.evaluate(
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
);
await projectRow.hover();
await more.click();
await expect(directoryMenu).toBeVisible();
await directoryMenu.hover();
await finder.click();
await expect
.poll(() =>
app?.evaluate(() => (globalThis as typeof globalThis & { openedDirectory?: string }).openedDirectory),
)
.toBe(await realpath(project));
await settleMenuClose(page);
await expect(page.getByTestId("run")).toHaveCount(0);
await projectRow.hover();
await page.locator(".sidebar").screenshot({ path: "test-results/sidebar-project-actions.png" });
await editor.fill("项目任务");
await editor.press("Enter");
await expect(page.getByTestId("run")).toHaveAttribute("data-status", "completed");
await expect(page.locator(".topbar-title")).toHaveText("项目任务");
await expect(page.locator(".projects .session-name")).toHaveText("项目任务");
await expect(page.locator(".recent-sessions .session-row")).toHaveCount(0);
await projectRow.hover();
await create.click();
await expect(page.locator(".topbar-title")).toHaveText("新任务");
await expect(page.getByLabel("选择项目", { exact: true })).toHaveText("project");
await taskHeader.hover();
await taskHeader.getByLabel("新建任务", { exact: true }).click();
await expect(page.getByLabel("选择项目", { exact: true })).toHaveText("选择项目");
await projectHeader.getByRole("button", { name: "收起项目列表", exact: true }).click();
await expect(page.locator(".projects")).toBeHidden();
await editor.focus();
await editor.hover();
await expect(projectHeader.locator(".section-toggle svg")).toHaveCSS("opacity", "0");
await page.reload();
await expect(page.locator(".projects")).toBeHidden();
await projectHeader.hover();
await projectHeader.getByRole("button", { name: "展开项目列表", exact: true }).click();
await taskHeader.hover();
await taskHeader.getByRole("button", { name: "收起任务列表", exact: true }).click();
await expect(page.locator(".recent-sessions")).toBeHidden();
await taskHeader.getByRole("button", { name: "展开任务列表", exact: true }).click();
await expect(taskHeader.getByRole("button", { name: "收起任务列表", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
);
await expect(page.locator('[data-sidebar-section="tasks"] .task-list-empty')).toBeVisible();
await projectRow.hover();
await more.click();
const menu = page.getByRole("menu", { name: "项目操作 project", exact: true });
await expect(menu.getByRole("menuitem")).toHaveText(["从侧栏移除", "Finder", "复制路径"]);
await menu.hover();
await expect(more).toHaveCSS("opacity", "1");
await menu.press("Escape");
await expect(menu).toHaveCount(0);
await expect(more).toBeFocused();
await editor.focus();
await editor.hover();
await expect(more).toHaveCSS("opacity", "0");
await more.focus();
await more.press("Enter");
await expect(menu).toBeVisible();
await menu.screenshot({ path: "test-results/sidebar-project-menu.png" });
await menu.getByRole("menuitem", { name: "从侧栏移除", exact: true }).click();
await expect(projectRow).toHaveCount(0);
await expect(page.locator(".recent-sessions .session-row")).toHaveCount(0);
await page.getByLabel("筛选视图", { exact: true }).click();
await page.getByRole("menuitemradio", { name: "最近更新", exact: true }).click();
await settleMenuClose(page);
await expect(
page.locator(".timeline-tasks").getByRole("button", { name: "项目任务", exact: true }),
).toBeVisible();
await page.getByLabel("筛选视图", { exact: true }).click();
await page.getByRole("menuitemradio", { name: "按项目", exact: true }).click();
await settleMenuClose(page);
expect(await readFile(join(project, "keep.txt"), "utf8")).toBe("project source");
await page.reload();
await expect(projectRow).toHaveCount(0);
await projectHeader.hover();
await projectHeader.getByLabel("添加项目", { exact: true }).click();
await expect(projectRow).toHaveCount(1);
await expect(
page.locator(".projects").getByRole("button", { name: "项目任务", exact: true }),
).toBeVisible();
await expect(page.locator(".recent-sessions .session-row")).toHaveCount(0);
await page.evaluate(() => window.zpi.updatePreferences({ theme: "dark", fontSize: 18 }));
await page.reload();
await editor.focus();
await editor.hover();
const foreground = await page.locator("body").evaluate((el) => getComputedStyle(el).color);
for (const header of [projectHeader, taskHeader]) {
const toggle = header.locator(".section-toggle");
const handle = toggle;
await expect(toggle).toHaveCSS("font-size", "18px");
await expect(toggle).toHaveCSS("height", "28px");
await toggle.hover();
await expect(toggle).toHaveCSS("color", foreground);
await expect(toggle.locator("svg")).toHaveCSS("width", "14px");
await handle.hover({ position: { x: 4, y: 4 } });
await expect(handle).toHaveCSS("color", foreground);
await expect(handle).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
}
await projectHeader.screenshot({ path: "test-results/sidebar-project-heading-dark.png" });
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
test("project directory actions preserve literal paths and report Finder failures", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-project-directory-"));
const project = join(dir, "project # % 项目 ");
await mkdir(project);
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, project, url: "" });
await app.evaluate(({ shell }) => {
shell.openPath = async (path) => {
(globalThis as typeof globalThis & { openedDirectory: string }).openedDirectory = path;
return "";
};
});
const page = await app.firstWindow();
await page.getByLabel("添加项目", { exact: true }).click();
const row = page.locator(".project-title");
const more = row.getByRole("button", { name: /^项目操作 / });
const menu = page.getByRole("menu");
await row.hover();
await more.click();
await menu.getByRole("menuitem", { name: "复制路径", exact: true }).click();
const path = await realpath(project);
await expect.poll(() => app?.evaluate(({ clipboard }) => clipboard.readText())).toBe(path);
await row.hover();
await more.click();
await menu.getByRole("menuitem", { name: "Finder", exact: true }).click();
await expect
.poll(() =>
app?.evaluate(() => (globalThis as typeof globalThis & { openedDirectory?: string }).openedDirectory),
)
.toBe(path);
await app.evaluate(({ shell }) => {
shell.openPath = async () => "无法打开项目目录";
});
await row.hover();
await more.click();
await menu.getByRole("menuitem", { name: "Finder", exact: true }).click();
await expect(page.getByRole("alert")).toContainText("无法打开项目目录");
await expect(row).toHaveCount(1);
} finally {
await runCleanup([() => rm(dir, { recursive: true, force: true }), () => app?.close()]);
}
});
test("tasks appear in one section and return to their original group after unpinning and restart", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-sidebar-groups-"));
const project = join(dir, "project");
await mkdir(project);
const server = await fakeServer((_, response) => {
send(response, chunk({ content: "ok" }));
done(response);
});
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, project, url: server.url });
let page = await app.firstWindow();
const expectGroup = async (title: string, group: string) => {
await expect(page.locator(".sidebar").getByRole("button", { name: title, exact: true })).toHaveCount(1);
for (const section of [".projects", ".recent-sessions", ".pinned-tasks"]) {
await expect(page.locator(section).getByRole("button", { name: title, exact: true })).toHaveCount(
section === group ? 1 : 0,
);
}
};
await page.getByLabel("添加项目", { exact: true }).click();
await page.getByLabel("消息", { exact: true }).fill("项目任务");
await page.getByLabel("消息", { exact: true }).press("Enter");
await expect(page.getByTestId("run")).toHaveAttribute("data-status", "completed");
const taskHeader = page.locator(".sidebar-heading").filter({ hasText: /^任务$/ });
await taskHeader.getByLabel("新建任务", { exact: true }).click();
await page.getByLabel("消息", { exact: true }).fill("独立任务");
await page.getByLabel("消息", { exact: true }).press("Enter");
await expect(page.getByTestId("run")).toHaveAttribute("data-status", "completed");
await expectGroup("项目任务", ".projects");
await expectGroup("独立任务", ".recent-sessions");
await expectZCodeSystemFont(page, ['[data-task-title-copy="original"]']);
// ZCode: text-ui-base headings/names, medium headings, normal names,
// 28px purpose headers and 24px task text slots inside 32px rows.
for (const size of [14, 18, 14]) {
await page.evaluate((fontSize) => window.zpi.updatePreferences({ fontSize }), size);
await page.reload();
const headings = page.locator(".section-toggle");
const names = page.locator(".sidebar .project-name, .sidebar .session-name");
for (const text of [...(await headings.all()), ...(await names.all())]) {
await expect(text).toHaveCSS("font-size", `${size}px`);
await expect(text).toHaveCSS("line-height", `${size * 1.5}px`);
await expect(text).toHaveCSS("font-family", /^ui-sans-serif, system-ui, sans-serif,/);
await expect(text).toHaveCSS("letter-spacing", "normal");
}
for (const heading of await headings.all()) {
await expect(heading).toHaveCSS("font-weight", "500");
await expect(heading).toHaveCSS("height", "28px");
}
for (const name of await names.all()) await expect(name).toHaveCSS("font-weight", "400");
for (const row of await page.locator(".sidebar .session-row").all()) {
await expect(row).toHaveCSS("height", "32px");
await expect(row).toHaveCSS("padding-top", "4px");
await expect(row.locator(".session-name")).toHaveCSS("height", "24px");
}
await expect(page.locator(".project-title")).toHaveCSS("height", "32px");
}
await page.locator(".sidebar").screenshot({ path: "test-results/sidebar-typography.png" });
await page.locator(".projects .session-row").hover();
await page.getByLabel("置顶任务 项目任务", { exact: true }).click();
await expectGroup("项目任务", ".pinned-tasks");
await page.getByLabel("任务菜单", { exact: true }).click();
await page.getByRole("menuitem", { name: "置顶", exact: true }).click();
await expectGroup("独立任务", ".pinned-tasks");
const pinnedHeading = page.locator(".pinned-tasks .sidebar-heading");
await expect(pinnedHeading).toHaveText("已置顶");
await page.getByLabel("消息", { exact: true }).hover();
const pinnedColor = await pinnedHeading.evaluate((el) => getComputedStyle(el).color);
await pinnedHeading.hover();
await expect(pinnedHeading).toHaveCSS("color", pinnedColor);
await expect(pinnedHeading).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await page.locator(".sidebar").screenshot({ path: "test-results/sidebar-exclusive-pinned.png" });
await app.close();
app = await launchDesktop({ dir, project, url: server.url });
page = await app.firstWindow();
await expectGroup("项目任务", ".pinned-tasks");
await expectGroup("独立任务", ".pinned-tasks");
await page.locator(".pinned-tasks .session-row").filter({ hasText: "项目任务" }).hover();
await page.getByLabel("取消置顶任务 项目任务", { exact: true }).click();
await expectGroup("项目任务", ".projects");
await page.getByLabel("任务菜单", { exact: true }).click();
await page.getByRole("menuitem", { name: "取消置顶", exact: true }).click();
await expectGroup("独立任务", ".recent-sessions");
await expect(page.locator(".pinned-tasks")).toHaveCount(0);
await page.reload();
await expectGroup("项目任务", ".projects");
await expectGroup("独立任务", ".recent-sessions");
await page.locator(".sidebar").screenshot({ path: "test-results/sidebar-exclusive-groups.png" });
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
test("sidebar section handles reorder with keyboard and pointer and preserve order after restart", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-sidebar-order-"));
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, url: "" });
let page = await app.firstWindow();
const sections = () => page.locator("[data-sidebar-section]");
const order = async () =>
sections().evaluateAll((els) => els.map((el) => el.getAttribute("data-sidebar-section")));
await expect(sections()).toHaveCount(2);
expect(await order()).toEqual(["projects", "tasks"]);
await page.getByLabel("收起项目列表", { exact: true }).click();
await page.getByLabel("收起任务列表", { exact: true }).click();
await expect(page.locator(".projects")).toBeHidden();
await expect(page.locator(".recent-sessions")).toBeHidden();
const handle = page.locator('[data-purpose-section-drag-trigger="projects"]');
await handle.focus();
await handle.press("Space");
await expect(handle).toHaveAttribute("aria-pressed", "true");
await expect(
page.locator('[role=status][aria-live="assertive"]').filter({ hasText: "was moved" }),
).toContainText("projects was moved over droppable area projects");
// dnd-kit announces activation before its deferred key listener and layout
// measurements are ready. Let the activation render finish before moving.
await page.evaluate(
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
);
await handle.press("ArrowDown");
await expect(
page.locator('[role=status][aria-live="assertive"]').filter({ hasText: "was moved" }),
).toContainText("droppable area tasks");
await expect(page.getByTestId("purpose-section-drop-indicator")).toHaveAttribute(
"data-drop-position",
"after",
);
await handle.press("Space");
await expect.poll(order).toEqual(["tasks", "projects"]);
await app.close();
app = await launchDesktop({ dir, url: "" });
page = await app.firstWindow();
await expect.poll(order).toEqual(["tasks", "projects"]);
await expect(page.getByLabel("展开项目列表", { exact: true })).toHaveAttribute("aria-expanded", "false");
await expect(page.getByLabel("展开任务列表", { exact: true })).toHaveAttribute("aria-expanded", "false");
const source = await page.locator('[data-purpose-section-drag-trigger="projects"]').boundingBox();
const target = await page.locator('[data-purpose-section-drag-trigger="tasks"]').boundingBox();
if (!source || !target) throw new Error("Missing section handles");
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
await page.mouse.down();
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2 - 10, { steps: 3 });
await expect(page.locator('[data-purpose-section-drag-trigger="projects"]')).toHaveAttribute(
"aria-pressed",
"true",
);
await page.evaluate(
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
);
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 });
await page.mouse.up();
await expect.poll(order).toEqual(["projects", "tasks"]);
await page.reload();
await expect.poll(order).toEqual(["projects", "tasks"]);
} finally {
await runCleanup([() => rm(dir, { recursive: true, force: true }), () => app?.close()]);
}
});
test("task controls follow native hover even when React misses mouseleave, and retain keyboard access", async () => {
const dir = await realpath(await mkdtemp(join(tmpdir(), "zpi-task-hover-")));
const workspace = join(dir, "workspace");
await mkdir(workspace);
seedHistory(dir, workspace, 1);
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, url: "" });
const page = await app.firstWindow();
const row = page.getByTestId("session-row");
const archive = row.locator(".task-archive");
const time = row.locator(".task-row-time");
const editor = page.getByLabel("消息", { exact: true });
await editor.hover();
await expect(time).toBeVisible();
await expect(archive).toBeHidden();
await row.hover();
await expect(archive).toBeVisible();
await expect(time).toBeHidden();
// Reproduce a lost React leave notification without changing native pointer hit testing.
await row.evaluate((element) => {
element.addEventListener("mouseout", (event) => event.stopPropagation(), { once: true });
});
await editor.hover();
await expect.poll(() => row.evaluate((element) => element.matches(":hover"))).toBe(false);
await expect(archive).toBeHidden();
await expect(time).toBeVisible();
await row.locator(".session-name").click();
await expect(page.getByTestId("run")).toHaveCount(1);
await expect(page.getByRole("alert")).toHaveCount(0);
await editor.hover();
await expect(archive).toBeHidden();
await expect(time).toBeVisible();
await editor.focus();
await page.keyboard.press("Shift+Tab");
// Enter keyboard modality before focusing the composite row.
await row.focus();
await expect(archive).toBeVisible();
await page.keyboard.press("Tab");
await expect(row.locator(".task-pin")).toBeFocused();
await page.keyboard.press("Tab");
await expect(archive).toBeFocused();
await editor.focus();
await expect(archive).toBeHidden();
await expect(time).toBeVisible();
} finally {
await runCleanup([() => rm(dir, { recursive: true, force: true }), () => app?.close()]);
}
});
test("project hover backgrounds center folder icons, titles and actions alongside their task rows", async () => {
const dir = await realpath(await mkdtemp(join(tmpdir(), "zpi-project-hover-alignment-")));
const project = join(dir, "project");
await mkdir(project);
const server = await fakeServer((_, response) => {
send(response, chunk({ content: "ok" }));
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();
await page.getByLabel("消息", { exact: true }).fill("项目任务");
await page.getByLabel("消息", { exact: true }).press("Enter");
await expect(page.getByTestId("run")).toHaveAttribute("data-status", "completed");
for (const size of [14, 20]) {
await page.evaluate((fontSize) => window.zpi.updatePreferences({ fontSize }), size);
await page.reload();
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
const header = page.locator(".project-title");
await header.hover();
await expect(header.locator(".project-row-action").first()).toHaveCSS("opacity", "1");
await page
.locator(".projects")
.screenshot({ path: test.info().outputPath(`project-hover-${colorScheme}-${size}.png`) });
const offsets = await header.evaluate((row) => {
const bounds = row.getBoundingClientRect();
const center = bounds.top + bounds.height / 2;
return Object.fromEntries(
[
".project-toggle-title",
".project-toggle-title > svg",
".project-toggle-title > span:first-of-type",
".project-row-action",
".project-row-action svg",
].map((selector) => [
selector,
Array.from(row.querySelectorAll(selector), (element) => {
const box = element.getBoundingClientRect();
return Math.round((box.top + box.height / 2 - center) * 100) / 100;
}),
]),
);
});
expect(offsets).toEqual({
".project-toggle-title": [0],
".project-toggle-title > svg": [0],
".project-toggle-title > span:first-of-type": [0],
".project-row-action": [0, 0],
".project-row-action svg": [0, 0],
});
const taskRow = page.locator(".projects .session-row");
await taskRow.hover();
await expect(taskRow.locator(".task-archive")).toBeVisible();
const taskOffsets = await taskRow.evaluate((row) => {
const bounds = row.getBoundingClientRect();
const center = bounds.top + bounds.height / 2;
return Array.from(
row.querySelectorAll(
".task-leading-slot, .session-name, [data-task-title-copy=original], .task-archive svg",
),
(element) => {
const box = element.getBoundingClientRect();
return Math.round((box.top + box.height / 2 - center) * 100) / 100;
},
);
});
expect(taskOffsets).toEqual([0, 0, 0, 0]);
await page
.locator(".projects")
.screenshot({ path: test.info().outputPath(`project-task-hover-${colorScheme}-${size}.png`) });
}
}
await expect(page.getByRole("alert")).toHaveCount(0);
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});