Z ZPI 文档 v0.3.8
介绍页

源码

tests/desktop/task-notifications.spec.ts

typescript230 行查看原文
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ElectronApplication } from "@playwright/test";
import type { Notification } from "electron";
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";

// Exercise real Electron -> preload -> renderer delivery, intercepting only OS display.
declare global {
  var notificationTest: { focused: boolean; shown: Notification[]; presentation: string[] };
}

test("ZCode notification settings, disabled sound state and independent preferences survive restarts", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-notification-preferences-"));
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, url: "" });
    let page = await app.firstWindow();
    await page.getByRole("button", { name: "设置", exact: true }).click();
    await page.getByRole("button", { name: "常规", exact: true }).click();
    const master = page.getByRole("switch", { name: "任务通知", exact: true });
    const sound = page.getByRole("switch", { name: "通知声音", exact: true });
    await expect(master).toBeChecked();
    await expect(sound).toBeChecked();
    await expect(sound).toBeEnabled();
    const track = page.locator(".task-notification-switch > span").first();
    await expect(track).toHaveCSS("width", "32px");
    await expect(track).toHaveCSS("height", "18px");
    await page.screenshot({ path: "test-results/task-notifications-settings-light.png" });
    await master.uncheck();
    await expect(sound).toBeDisabled();
    await expect(sound).toBeChecked();
    await master.check();
    await sound.uncheck();
    await master.uncheck();
    await expect(sound).not.toBeChecked();
    await app.close();
    app = await launchDesktop({ dir, url: "" });
    page = await app.firstWindow();
    await page.getByRole("button", { name: "设置", exact: true }).click();
    await page.getByRole("button", { name: "常规", exact: true }).click();
    await expect(page.getByRole("switch", { name: "任务通知" })).not.toBeChecked();
    await expect(page.getByRole("switch", { name: "通知声音" })).toBeDisabled();
    await expect(page.getByRole("switch", { name: "通知声音" })).not.toBeChecked();
    await page.getByRole("switch", { name: "任务通知" }).check();
    await expect(page.getByRole("switch", { name: "通知声音" })).toBeEnabled();
    await expect(page.getByRole("switch", { name: "通知声音" })).not.toBeChecked();
    await page.getByRole("button", { name: "界面设置", exact: true }).click();
    await page.getByRole("combobox", { name: "界面主题", exact: true }).click();
    await page.getByRole("option", { name: "深色", exact: true }).click();
    await page.getByRole("button", { name: "常规", exact: true }).click();
    await page.screenshot({ path: "test-results/task-notifications-settings-dark.png" });
  } finally {
    await runCleanup([() => rm(dir, { recursive: true, force: true }), () => app?.close()]);
  }
});

test("live task completion, failure and interruption notify once, and clicking restores the matching task", async () => {
  const dir = await mkdtemp(join(tmpdir(), "zpi-task-notifications-"));
  const responses = new Map<number, import("node:http").ServerResponse>();
  const server = await fakeServer((_, response, index) => {
    responses.set(index, response);
    send(response, chunk({ content: `reply ${index}` }));
  });
  const responseAt = (index: number) => {
    const response = responses.get(index);
    if (!response) throw new Error(`Missing response ${index}`);
    return response;
  };
  let app: ElectronApplication | undefined;
  try {
    app = await launchDesktop({ dir, url: server.url });
    const page = await app.firstWindow();
    await expect(page.getByLabel("消息", { exact: true })).toBeVisible();
    await app.evaluate(({ BrowserWindow, Notification }) => {
      globalThis.notificationTest = { focused: true, shown: [], presentation: [] };
      Notification.isSupported = () => true;
      Notification.prototype.show = function () {
        globalThis.notificationTest.shown.push(this);
      };
      for (const window of BrowserWindow.getAllWindows())
        window.isFocused = () => globalThis.notificationTest.focused;
    });
    // Observe the bridge sound signal independently of whether OS autoplay is permitted.
    await page.evaluate(() => {
      (window as unknown as { notificationSounds: number }).notificationSounds = 0;
      window.zpi.onTaskNotificationSound(() => {
        (window as unknown as { notificationSounds: number }).notificationSounds++;
      });
    });
    const start = async (text: string) =>
      page.evaluate(async (text) => {
        const session = await window.zpi.createSession(null);
        if (!session.ok) throw new Error(session.error.message);
        const run = await window.zpi.startRun({ sessionId: session.value.id, text });
        if (!run.ok) throw new Error(run.error.message);
        return { id: session.value.id, runId: run.value.runId };
      }, text);
    const count = () => app?.evaluate(() => globalThis.notificationTest.shown.length);

    const foreground = await start("前台完成");
    await expect.poll(() => responses.has(0)).toBe(true);
    done(responseAt(0));
    await expect
      .poll(() =>
        page.evaluate(async (id) => {
          const snapshot = await window.zpi.getSessionSnapshot(id);
          return snapshot.ok ? snapshot.value.session.status : "error";
        }, foreground.id),
      )
      .toBe("completed");
    expect(await count()).toBe(0);

    await app.evaluate(() => {
      globalThis.notificationTest.focused = false;
    });
    const completed = await start("后台完成");
    await expect.poll(() => responses.has(1)).toBe(true);
    done(responseAt(1));
    await expect.poll(count).toBe(1);
    expect(
      await app.evaluate(() => {
        const notification = globalThis.notificationTest.shown[0];
        return { title: notification.title, body: notification.body, silent: notification.silent };
      }),
    ).toEqual({ title: "任务已完成", body: "任务:后台完成", silent: true });
    await expect
      .poll(() =>
        page.evaluate(() => (window as unknown as { notificationSounds: number }).notificationSounds),
      )
      .toBe(1);

    // Keep native presentation intercepted; unit tests cover the actual restoration calls.
    // The click still goes through main and preload while the settings page is open.
    await page.getByRole("button", { name: "设置", exact: true }).click();
    await app.evaluate(({ app, BrowserWindow }) => {
      const window = BrowserWindow.getAllWindows()[0];
      let minimized = true;
      let visible = false;
      window.isMinimized = () => minimized;
      window.isVisible = () => visible;
      window.restore = () => {
        globalThis.notificationTest.presentation.push("restore");
        minimized = false;
      };
      window.show = () => {
        globalThis.notificationTest.presentation.push("window.show");
        visible = true;
      };
      window.focus = () => {
        globalThis.notificationTest.presentation.push("window.focus");
      };
      if (process.platform === "darwin") {
        if (app.dock)
          app.dock.show = async () => {
            globalThis.notificationTest.presentation.push("dock.show");
          };
        app.show = () => {
          globalThis.notificationTest.presentation.push("app.show");
        };
        app.focus = () => {
          globalThis.notificationTest.presentation.push("app.focus");
        };
      }
      globalThis.notificationTest.shown[0].emit("click");
    });
    await expect(page.getByRole("region", { name: "设置", exact: true })).toHaveCount(0);
    await expect(page.locator(".topbar-title")).toHaveText("后台完成");
    await expect(page.locator(".answer")).toHaveText("reply 1");
    expect(await page.evaluate(() => localStorage.getItem("zpi.selectedSession"))).toBe(completed.id);
    expect(
      await app.evaluate(({ BrowserWindow }) => {
        const window = BrowserWindow.getAllWindows()[0];
        return { minimized: window.isMinimized(), visible: window.isVisible() };
      }),
    ).toEqual({ minimized: false, visible: true });
    expect(await app.evaluate(() => globalThis.notificationTest.presentation)).toEqual([
      "restore",
      "window.show",
      ...(process.platform === "darwin" ? ["dock.show", "app.show", "app.focus"] : []),
      "window.focus",
    ]);

    const interrupted = await start("手动停止");
    await expect.poll(() => responses.has(2)).toBe(true);
    await page.evaluate(async ({ id, runId }) => {
      const result = await window.zpi.abortRun({ sessionId: id, runId });
      if (!result.ok) throw new Error(result.error.message);
    }, interrupted);
    await expect.poll(count).toBe(2);
    expect(await app.evaluate(() => globalThis.notificationTest.shown[1].title)).toBe("任务已完成");

    await start("任务失败");
    await expect.poll(() => responses.has(3)).toBe(true);
    send(responseAt(3), { error: { message: "provider rejected", type: "invalid_request_error" } });
    responseAt(3).end("data: [DONE]\n\n");
    await expect.poll(count).toBe(3);
    expect(await app.evaluate(() => globalThis.notificationTest.shown[2].title)).toBe("任务出错");

    await page.evaluate(async () => {
      const result = await window.zpi.updatePreferences({ notificationEnabled: false });
      if (!result.ok) throw new Error(result.error.message);
    });
    const disabled = await start("已关闭通知");
    await expect.poll(() => responses.has(4)).toBe(true);
    done(responseAt(4));
    await expect
      .poll(() =>
        page.evaluate(async (id) => {
          const snapshot = await window.zpi.getSessionSnapshot(id);
          return snapshot.ok ? snapshot.value.session.status : "error";
        }, disabled.id),
      )
      .toBe("completed");
    expect(await count()).toBe(3);
    await page.reload();
    await expect(page.getByLabel("消息", { exact: true })).toBeVisible();
    expect(await count()).toBe(3);
  } finally {
    await runCleanup([
      () => rm(dir, { recursive: true, force: true }),
      () => server.close(),
      () => app?.close(),
    ]);
  }
});