import { expect, test } from "@playwright/test"; 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 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 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 writes final transcript into the prompt", 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 result = Object.assign([{ transcript: "检查城南泵站水位" }], { isFinal: true }); this.onresult?.({ resultIndex: 0, results: [result] }); }, 20); } stop() { this.onend?.(); } abort() {} } }); }); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByRole("button", { name: "语音输入" }).click(); await expect(page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应")).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 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); });