Z ZPI 文档 v0.3.8
介绍页

源码

tests/desktop/background-tasks.spec.ts

typescript494 行查看原文
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ElectronApplication, Locator, 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 { expect, test } from "../helpers/test.ts";

for (const description of ["Dev server", ""]) {
  test(`background output and stop summaries retain ${description ? "the task description" : "the command fallback"} after reload`, async () => {
    const dir = await mkdtemp(join(tmpdir(), "zpi-background-summary-"));
    const command = "echo 'Server ready'; sleep 30";
    const taskName = description || command;
    const server = await fakeServer((body, response) => {
      const rows = body.messages as { role: string; content: string }[];
      const lastTool = rows.filter((row) => row.role === "tool").at(-1);
      const taskId = lastTool?.content.match(/ID: (exec_[\da-f-]+)/)?.[1];
      const outputTaskId = lastTool?.content.match(/<task_id>(exec_[^<]+)<\/task_id>/)?.[1];
      const name = !lastTool ? "bash" : taskId ? "TaskOutput" : outputTaskId ? "TaskStop" : undefined;
      if (name) {
        send(
          response,
          chunk({
            tool_calls: [
              {
                index: 0,
                id: name,
                type: "function",
                function: {
                  name,
                  arguments: JSON.stringify(
                    name === "bash"
                      ? { command, description, run_in_background: true }
                      : {
                          task_id: taskId ?? outputTaskId,
                          ...(name === "TaskOutput" ? { block: false, timeout: 0 } : {}),
                        },
                  ),
                },
              },
            ],
          }),
        );
        done(response, "tool_calls");
      } else {
        send(response, chunk({ content: "Background task stopped" }));
        done(response);
      }
    });
    let app: ElectronApplication | undefined;
    try {
      app = await launchDesktop({ dir, url: server.url });
      const page = await app.firstWindow();
      const editor = page.getByLabel("消息", { exact: true });
      await editor.fill("Start, read and stop the server");
      await editor.press("Enter");
      const main = page.locator("main");
      await expect(main.locator(".answer")).toContainText("Background task stopped");
      for (const theme of ["light", "dark"] as const) {
        await page.evaluate((theme) => window.zpi.updatePreferences({ theme }), theme);
        await page.reload();
        await main.getByTestId("progress").first().click();
        for (const name of ["TaskOutput", "TaskStop"]) {
          const card = main.locator(`[data-tool-name="${name}"]`);
          await expect(card.locator(".tool-summary-text")).toHaveText(taskName);
          const title = await card.getByTestId("tool-summary").getAttribute("title");
          expect(title?.startsWith(`${taskName}\nexec_`)).toBe(true);
          await expect(card.locator(".tool-summary-status")).toHaveText(
            name === "TaskOutput" ? "运行中" : "已停止",
          );
        }
        const stop = main.locator('[data-tool-name="TaskStop"]');
        await stop.getByTestId("tool-summary").click();
        await expect(stop.locator(".subagent-task-output")).toContainText(command);
        await page.screenshot({
          path: test.info().outputPath(`background-summary-${theme}.png`),
          animations: "disabled",
        });
      }
    } finally {
      await runCleanup([
        () => rm(dir, { recursive: true, force: true }),
        () => server.close(),
        () => app?.close(),
      ]);
    }
  });
}

async function checkTaskMenuLayer(page: Page, panel: Locator, theme: string) {
  await panel.getByRole("button", { name: "状态面板展开策略", exact: true }).click();
  const menu = page.getByRole("menuitemradio", { name: "自动展开", exact: true });
  await expect(menu).toBeVisible();
  await page.screenshot({ path: test.info().outputPath(`task-menu-${theme}.png`), animations: "disabled" });
  const layers = await page.locator(".task-activity").evaluate((element) => {
    const menu = document.querySelector(".task-activity-menu");
    if (!(element instanceof HTMLElement) || !menu) throw new Error("Missing task menu");
    const bounds = menu.getBoundingClientRect();
    const panelBounds = element.getBoundingClientRect();
    const previous = element.style.pointerEvents;
    // Modal menus disable background pointer events; restore them briefly so hit testing
    // detects a panel painted above the menu instead of clicking through that occlusion.
    element.style.pointerEvents = "auto";
    try {
      return {
        overlaps: bounds.top < panelBounds.bottom && bounds.bottom > panelBounds.top,
        unobscured: [0.25, 0.5, 0.75].every((x) =>
          menu.contains(document.elementFromPoint(bounds.left + bounds.width * x, bounds.top + 8)),
        ),
      };
    } finally {
      element.style.pointerEvents = previous;
    }
  });
  expect(layers.overlaps).toBe(true);
  expect(layers.unobscured).toBe(true);
  await page.keyboard.press("Escape");
  await expect(menu).toBeHidden();
}

test("one compact task panel manages agents and services and retains finished history without dismissal", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-task-panel-"));
  const server = await fakeServer((body, response) => {
    const tools = (body.tools as { function: { name: string } }[]).map((tool) => tool.function.name);
    if (!tools.includes("Agent")) {
      send(response, chunk({ content: "Inspecting the code" }));
      return;
    }
    const messages = body.messages as { role: string; content: string }[];
    const last = messages.at(-1);
    if (
      !messages.some((message) => message.role === "tool") ||
      (last?.role === "user" && last.content === "Again")
    ) {
      const repeated = last?.content === "Again";
      const calls = [
        ...(repeated
          ? []
          : [
              {
                name: "Agent",
                args: { description: "Inspect source", prompt: "Inspect", run_in_background: true },
              },
            ]),
        {
          name: "bash",
          args: {
            command: "echo 'Server ready'; sleep 30",
            description: repeated ? "Second server" : "Dev server",
            run_in_background: true,
          },
        },
      ];
      send(
        response,
        chunk({
          tool_calls: calls.map((call, index) => ({
            index,
            id: `${repeated ? "again" : "initial"}-${index}`,
            type: "function",
            function: { name: call.name, arguments: JSON.stringify(call.args) },
          })),
        }),
      );
      done(response, "tool_calls");
    } else {
      send(response, chunk({ content: "Tasks started" }));
      done(response);
    }
  });
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, url: server.url });
    const page = await app.firstWindow();
    const errors: string[] = [];
    page.on("pageerror", (error) => errors.push(error.message));
    await page.evaluate(async () => {
      const result = await window.zpi.setPluginEnabled("zpi.collaboration", true);
      if (!result.ok) throw new Error(result.error.message);
    });
    const editor = page.getByLabel("消息", { exact: true });
    await editor.fill("Start tasks");
    await editor.press("Enter");
    await expect(page.locator("main .answer")).toContainText("Tasks started");
    const panel = page.getByRole("complementary", { name: "任务状态", exact: true });
    await expect(panel).toHaveAttribute("data-display-mode", "auto");
    await expect(panel.getByRole("button", { name: "展开状态", exact: true })).toHaveText("2 运行");
    await expect(panel.locator(".task-summary-icon .lucide-activity")).toBeVisible();
    await panel.getByRole("button", { name: "展开状态", exact: true }).click();
    await expect(panel.getByRole("button", { name: "展开终端", exact: true })).toBeVisible();
    await expect(panel.getByRole("button", { name: "展开智能体", exact: true })).toBeVisible();
    await expect(panel.getByRole("button", { name: "查看后台任务 Dev server", exact: true })).toHaveCount(0);
    await panel.getByRole("button", { name: "展开终端", exact: true }).click();
    await expect(panel.getByRole("button", { name: "停止后台任务 Dev server", exact: true })).toHaveText(
      "停止",
    );
    await expect(panel.getByRole("button", { name: "查看子智能体 Inspect source", exact: true })).toHaveCount(
      0,
    );
    await panel.getByRole("button", { name: "展开智能体", exact: true }).click();
    await panel.getByRole("button", { name: "收起为胶囊", exact: true }).click();
    await expect(panel).toHaveAttribute("data-display-mode", "mini");
    await panel.getByRole("button", { name: "展开状态", exact: true }).click();
    // Each section retains its own expansion when the whole panel is collapsed.
    await expect(panel.getByRole("button", { name: "收起终端", exact: true })).toBeVisible();
    await expect(panel.getByRole("button", { name: "收起智能体", exact: true })).toBeVisible();
    await checkTaskMenuLayer(page, panel, "light");
    await page.mouse.move(0, 0);
    await panel.screenshot({ path: test.info().outputPath("task-panel-light.png"), animations: "disabled" });
    await expect(page.getByRole("button", { name: "关闭任务状态", exact: true })).toHaveCount(0);
    await page.getByRole("button", { name: "查看子智能体 Inspect source", exact: true }).click();
    await expect(page.locator(".subagent-pane")).toContainText("Inspecting the code");
    await page.getByRole("button", { name: "查看后台任务 Dev server", exact: true }).click();
    await expect(page.getByRole("region", { name: "后台任务输出", exact: true })).toContainText(
      "Server ready",
    );
    await page.screenshot({
      path: test.info().outputPath("task-workspace-light.png"),
      animations: "disabled",
    });
    await page.evaluate(() => window.zpi.updatePreferences({ theme: "dark" }));
    await page.reload();
    await panel.getByRole("button", { name: "展开状态", exact: true }).click();
    await panel.getByRole("button", { name: "展开终端", exact: true }).click();
    await panel.getByRole("button", { name: "展开智能体", exact: true }).click();
    await page.mouse.move(0, 0);
    await checkTaskMenuLayer(page, panel, "dark");
    await panel.screenshot({ path: test.info().outputPath("task-panel-dark.png"), animations: "disabled" });
    await panel.getByRole("button", { name: "停止后台任务 Dev server", exact: true }).click();
    await expect(panel.getByRole("button", { name: "停止后台任务 Dev server", exact: true })).toHaveCount(0);
    await expect(panel.locator('[data-status-section="agent"]')).toContainText("1 运行");
    await expect(page.getByRole("button", { name: "关闭任务状态", exact: true })).toHaveCount(0);
    await panel.getByRole("button", { name: "停止子智能体 Inspect source", exact: true }).click();
    await expect(panel).toHaveAttribute("data-running", "false");
    await expect(page.getByRole("button", { name: "关闭任务状态", exact: true })).toHaveCount(0);
    await expect(panel).toBeVisible();
    await page.reload();
    await expect(panel.getByRole("button", { name: "展开状态", exact: true })).toHaveText("2 已结束");
    await expect(page.getByRole("button", { name: "查看任务目录", exact: true })).toHaveCount(0);
    await panel.getByRole("button", { name: "展开状态", exact: true }).click();
    await panel.getByRole("button", { name: "展开终端", exact: true }).click();
    await panel.getByRole("button", { name: "已结束 1", exact: true }).click();
    await expect(page.getByRole("region", { name: "任务目录", exact: true })).toContainText("Dev server");
    await editor.fill("Again");
    await editor.press("Enter");
    await expect(panel).toBeVisible();
    // Finished history remains available, and a new launch updates the same capsule.
    await panel.getByRole("button", { name: "停止后台任务 Second server", exact: true }).click();
    await expect(panel).toHaveAttribute("data-running", "false");
    await expect(panel.locator('[data-status-section="terminal"] .task-ended')).toContainText("2");
    expect(errors).toEqual([]);
  } finally {
    await runCleanup([
      () => rm(dir, { recursive: true, force: true }),
      () => server.close(),
      () => app?.close(),
    ]);
  }
});

test("rename dialog covers the task capsule and expanded panel in both themes", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-task-panel-modal-"));
  const server = await fakeServer((body, response) => {
    const messages = body.messages as { role: string }[];
    if (messages.some((message) => message.role === "tool")) {
      send(response, chunk({ content: "Task finished" }));
      done(response);
      return;
    }
    send(
      response,
      chunk({
        tool_calls: [
          {
            index: 0,
            id: "background-task",
            type: "function",
            function: {
              name: "bash",
              arguments: JSON.stringify({ command: "echo done", run_in_background: true }),
            },
          },
        ],
      }),
    );
    done(response, "tool_calls");
  });
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, url: server.url });
    const page = await app.firstWindow();
    const editor = page.getByLabel("消息", { exact: true });
    await editor.fill("Run a background task");
    await editor.press("Enter");
    await expect(page.locator("main .answer")).toContainText("Task finished");
    const panel = page.locator(".task-activity");
    await expect(panel).toHaveAttribute("data-running", "false");
    await panel.getByRole("button", { name: "展开状态", exact: true }).click();
    await panel.getByRole("button", { name: "收起为胶囊", exact: true }).click();
    for (const theme of ["light", "dark"] as const) {
      await page.evaluate((theme) => window.zpi.updatePreferences({ theme }), theme);
      for (const mode of ["mini", "panel"]) {
        if (mode === "panel") await panel.getByRole("button", { name: "展开状态", exact: true }).click();
        await expect(panel).toHaveAttribute("data-display-mode", mode);
        await page.locator(".topbar .task-more").click();
        await page.getByRole("menuitem", { name: "重命名任务", exact: true }).click();
        const dialog = page.getByRole("dialog", { name: "重命名任务", exact: true });
        await expect(dialog.getByRole("textbox", { name: "名称", exact: true })).toBeFocused();
        const covered = await panel.evaluate((element) => {
          const overlay = document.querySelector(".modal-backdrop");
          const bounds = element.getBoundingClientRect();
          const previous = element.style.pointerEvents;
          // Radix disables background hit testing; enable it to test actual paint order.
          element.style.pointerEvents = "auto";
          try {
            return [0.25, 0.5, 0.75].every((x) =>
              overlay?.contains(document.elementFromPoint(bounds.left + bounds.width * x, bounds.top + 16)),
            );
          } finally {
            element.style.pointerEvents = previous;
          }
        });
        expect(covered).toBe(true);
        await page.screenshot({
          path: test.info().outputPath(`task-rename-${theme}-${mode}.png`),
          animations: "disabled",
        });
        await dialog.getByRole("button", { name: "取消", exact: true }).click();
        await expect(dialog).toBeHidden();
        await expect(panel).toHaveAttribute("data-display-mode", mode);
      }
      await panel.getByRole("button", { name: "收起为胶囊", exact: true }).click();
    }
  } finally {
    await runCleanup([
      () => rm(dir, { recursive: true, force: true }),
      () => server.close(),
      () => app?.close(),
    ]);
  }
});

test("terminal capsule matches ZCode and automatic layout follows the conversation width", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-terminal-status-"));
  const server = await fakeServer((body, response) => {
    const messages = body.messages as { role: string; content: string }[];
    if (messages.some((message) => message.role === "tool")) {
      send(response, chunk({ content: "Server started" }));
      done(response);
      return;
    }
    send(
      response,
      chunk({
        tool_calls: [
          {
            index: 0,
            id: "server",
            type: "function",
            function: {
              name: "bash",
              arguments: JSON.stringify({
                command: "echo 'Server ready'; sleep 120",
                description: "启动 Python HTTP 服务器",
                run_in_background: true,
              }),
            },
          },
        ],
      }),
    );
    done(response, "tool_calls");
  });
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, url: server.url });
    const page = await app.firstWindow();
    await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1200, 800));
    const editor = page.getByLabel("消息", { exact: true });
    await editor.fill("启动一个 python server");
    await editor.press("Enter");
    await expect(page.locator("main .answer")).toContainText("Server started");
    const panel = page.getByRole("complementary", { name: "任务状态", exact: true });
    const capsule = panel.getByRole("button", { name: "展开状态", exact: true });
    const capture = async (name: string) => {
      await page.mouse.move(0, 0);
      await panel.evaluate(async (element) => {
        await Promise.all(
          element.getAnimations({ subtree: true }).map((animation) => animation.finished.catch(() => {})),
        );
      });
      const box = await panel.boundingBox();
      if (!box) throw new Error("Missing task panel");
      await page.screenshot({
        path: test.info().outputPath(name),
        animations: "disabled",
        clip: { x: box.x - 12, y: box.y - 12, width: box.width + 24, height: box.height + 24 },
      });
    };
    await expect(capsule).toHaveText("1 后台");
    await expect(panel.locator(".task-summary-icon .lucide-square-terminal")).toBeVisible();
    const capsuleGeometry = await panel.evaluate((element) => {
      const button = element.querySelector(".task-activity-toggle") as HTMLElement;
      return {
        height: element.getBoundingClientRect().height,
        innerWidth: element.clientWidth,
        buttonWidth: button.getBoundingClientRect().width,
        radius: getComputedStyle(element).borderRadius,
      };
    });
    expect(capsuleGeometry.height).toBe(34);
    expect(capsuleGeometry.radius).toBe("16px");
    expect(Math.abs(capsuleGeometry.buttonWidth - capsuleGeometry.innerWidth)).toBeLessThan(0.5);
    await capture("terminal-capsule-light.png");
    await capsule.hover();
    await expect
      .poll(() =>
        panel
          .locator(".task-summary-icon .lucide-maximize-2")
          .evaluate((element) => getComputedStyle(element).opacity),
      )
      .toBe("1");
    await expect
      .poll(() =>
        panel
          .locator(".task-summary-icon .lucide-square-terminal")
          .evaluate((element) => getComputedStyle(element).opacity),
      )
      .toBe("0");
    await capsule.click();
    await expect(panel.getByRole("button", { name: "展开终端", exact: true })).toBeVisible();
    const expanded = await panel.boundingBox();
    expect(expanded?.width).toBe(320);
    expect(expanded?.height).toBe(52);
    await capture("terminal-panel-light.png");
    const elapsed = panel.locator(".task-section-summary > span").first();
    const before = await elapsed.textContent();
    await expect(elapsed).not.toHaveText(before ?? "");
    await panel.getByRole("button", { name: "展开终端", exact: true }).click();
    await expect(
      panel.getByRole("button", { name: "停止后台任务 启动 Python HTTP 服务器", exact: true }),
    ).toHaveText("停止");
    await capture("terminal-details-light.png");
    await panel.getByRole("button", { name: "收起终端", exact: true }).click();
    await expect(
      panel.getByRole("button", { name: "停止后台任务 启动 Python HTTP 服务器", exact: true }),
    ).toBeHidden();
    await panel.getByRole("button", { name: "状态面板展开策略", exact: true }).click();
    await page.getByRole("menuitemradio", { name: "自动展开", exact: true }).click();
    await expect(capsule).toBeVisible();
    await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1800, 900));
    await expect(panel.getByRole("button", { name: "收起为胶囊", exact: true })).toBeVisible();
    await expect(capsule).toBeHidden();
    await expect
      .poll(() =>
        page.locator(".conversation-inner").evaluate((element) => getComputedStyle(element).transform),
      )
      .toBe("matrix(1, 0, 0, 1, -168, 0)");
    await panel.getByRole("button", { name: "收起为胶囊", exact: true }).click();
    await expect(capsule).toBeVisible();
    await expect
      .poll(() =>
        page.locator(".conversation-inner").evaluate((element) => getComputedStyle(element).transform),
      )
      .toBe("none");
    await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1200, 800));
    await page.evaluate(() => window.zpi.updatePreferences({ theme: "dark" }));
    await page.reload();
    await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
    await expect(capsule).toBeVisible();
    await capture("terminal-capsule-dark.png");
    await capsule.click();
    await capture("terminal-panel-dark.png");
    await panel.getByRole("button", { name: "展开终端", exact: true }).click();
    await capture("terminal-details-dark.png");
    await panel.getByRole("button", { name: "停止后台任务 启动 Python HTTP 服务器", exact: true }).click();
    await expect(panel).toHaveAttribute("data-running", "false");
    // Stopping a task must not also trigger the transparent row's open-output button.
    await expect(page.getByRole("region", { name: "后台任务输出", exact: true })).toHaveCount(0);
    await expect(panel.getByRole("button", { name: "关闭任务状态", exact: true })).toHaveCount(0);
    await panel.getByRole("button", { name: "收起为胶囊", exact: true }).click();
    await expect(capsule).toHaveText("1 已结束");
    await page.reload();
    await expect(capsule).toHaveText("1 已结束");
  } finally {
    await runCleanup([
      () => rm(dir, { recursive: true, force: true }),
      () => server.close(),
      () => app?.close(),
    ]);
  }
});