import { expect, test, type Page } from "@playwright/test"; import { mockAgentApi } from "./support/mock-agent-api"; test.beforeEach(async ({ page }) => { await mockAgentApi(page); }); async function openAgentPanel(page: Page) { await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible(); } test("speech capability detection preserves the server hydration tree", async ({ page }) => { const hydrationErrors: string[] = []; page.on("console", (message) => { if (message.type() === "error" && /hydration|server rendered html/i.test(message.text())) { hydrationErrors.push(message.text()); } }); await page.addInitScript(() => { Object.defineProperty(window, "SpeechRecognition", { configurable: true, value: class SpeechRecognition {} }); }); await page.goto("/", { waitUntil: "domcontentloaded" }); await openAgentPanel(page); await expect(page.getByRole("button", { name: "语音输入" })).toBeVisible(); await expect(page.getByRole("button", { name: "权限批准模式" })).toBeVisible(); expect(hydrationErrors).toEqual([]); }); test("speech recognition failure clears the active animation and explains the error", async ({ page }) => { await page.addInitScript(() => { Object.defineProperty(window, "SpeechRecognition", { configurable: true, value: class SpeechRecognition { onerror: ((event: { error: string }) => void) | null = null; onend: (() => void) | null = null; start() { window.setTimeout(() => { this.onerror?.({ error: "not-allowed" }); this.onend?.(); }, 20); } stop() { this.onend?.(); } abort() {} } }); }); await page.goto("/", { waitUntil: "domcontentloaded" }); await openAgentPanel(page); await page.getByRole("button", { name: "语音输入" }).click(); await expect(page.getByRole("button", { name: "语音输入" })).toHaveAttribute("aria-pressed", "false"); await expect(page.locator('[data-slot="voice-input-pulse"]')).toHaveCount(0); await expect(page.getByText("无法使用麦克风,请在浏览器地址栏允许麦克风权限后重试")).toBeVisible(); }); test("speech recognition appends every final transcript in one result batch", async ({ page }) => { await page.addInitScript(() => { Object.defineProperty(window, "SpeechRecognition", { configurable: true, value: class SpeechRecognition { onresult: ((event: { resultIndex: number; results: Array & { isFinal: boolean }> }) => void) | null = null; onend: (() => void) | null = null; start() { window.setTimeout(() => { const firstResult = Object.assign([{ transcript: "检查城南泵站" }], { isFinal: true }); const secondResult = Object.assign([{ transcript: "水位" }], { isFinal: true }); this.onresult?.({ resultIndex: 0, results: [firstResult, secondResult] }); }, 20); } stop() { this.onend?.(); } abort() {} } }); }); await page.goto("/", { waitUntil: "domcontentloaded" }); await openAgentPanel(page); await page.getByRole("button", { name: "语音输入" }).click(); await expect(page.getByPlaceholder("询问供水问题,或描述需要处理的任务")).toHaveValue("检查城南泵站 水位"); }); test("speech recognition pulse stays transparent across its loop boundary", async ({ page }) => { await page.addInitScript(() => { Object.defineProperty(window, "SpeechRecognition", { configurable: true, value: class SpeechRecognition { start() {} stop() {} abort() {} } }); }); await page.goto("/", { waitUntil: "domcontentloaded" }); await openAgentPanel(page); await page.getByRole("button", { name: "语音输入" }).click(); const pulse = page.locator('[data-slot="voice-input-pulse"]'); await expect(pulse).toBeVisible(); const seamOpacity = await pulse.evaluate((element) => { const animation = element .getAnimations() .find((candidate) => (candidate.effect as KeyframeEffect | null) ?.getKeyframes() .some((keyframe) => keyframe.opacity !== undefined) ); if (!animation?.effect) { throw new Error("Voice input pulse opacity animation was not found"); } animation.pause(); const duration = Number(animation.effect.getTiming().duration); animation.currentTime = duration - 1; const before = Number(window.getComputedStyle(element).opacity); animation.currentTime = duration + 1; const after = Number(window.getComputedStyle(element).opacity); return { before, after }; }); expect(seamOpacity.before).toBeLessThan(0.02); expect(seamOpacity.after).toBeLessThan(0.02); });