Files
next-tjwater-frontend/tests/browser/agent-voice-hydration.e2e.ts
T
jiang c0e4231b83
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s
feat: expand intelligent investigation workspace
2026-10-30 17:30:19 +08:00

165 lines
6.2 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test.beforeEach(async ({ page }) => {
await mockWorkbenchContext(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 }, testInfo) => {
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([]);
const form = page.locator("form.agent-panel-input-form");
await form.screenshot({ path: testInfo.outputPath("composer-idle.png"), animations: "disabled" });
await form.getByRole("textbox").fill("检查城南压力");
await form.screenshot({ path: testInfo.outputPath("composer-ready.png"), animations: "disabled" });
const voice = form.getByRole("button", { name: "语音输入", exact: true });
await voice.hover();
await expect(voice).toHaveCSS("border-radius", "50%");
await form.screenshot({ path: testInfo.outputPath("composer-voice-hover.png"), animations: "disabled" });
await page.setViewportSize({ width: 375, height: 900 });
const mobilePanel = page.getByRole("complementary", { name: "Agent 命令面板" });
if (!await mobilePanel.isVisible()) await page.getByRole("button", { name: "打开 Agent 面板", exact: true }).click();
const mobileVoice = mobilePanel.getByRole("button", { name: "语音输入", exact: true });
await mobileVoice.hover();
await expect(mobileVoice).toHaveCSS("border-radius", "50%");
await mobilePanel.locator("form").screenshot({ path: testInfo.outputPath("composer-voice-hover-narrow.png"), animations: "disabled" });
});
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<Array<{ transcript: string }> & { 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);
});