Files
next-tjwater-frontend/tests/browser/agent-orb.e2e.ts
T
jiang 2d8506fea0
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s
feat: add project-aware agent workspace
2026-10-22 18:52:18 +08:00

329 lines
15 KiB
TypeScript

import { expect, test, type Locator } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responsesFixture } from "./support/responses-fixture";
test.use({ launchOptions: { args: ["--enable-unsafe-swiftshader"] } });
async function ready(orb: Locator) {
await expect(orb.locator('.agent-orb-live[data-ready="true"]')).toBeVisible({ timeout: 30000 });
await expect(orb.locator("canvas")).toBeVisible();
}
async function expectNoPressFeedback(orb: Locator) {
await expect(orb).toHaveCSS("opacity", "1");
await expect(orb.locator(".agent-orb-response")).toHaveCSS("transform", "none");
}
async function expectMoving(orb: Locator) {
const first = await orb.locator("canvas").screenshot();
await expect
.poll(async () => !(await orb.locator("canvas").screenshot()).equals(first))
.toBe(true);
}
test.beforeEach(async ({ page }) => {
await mockWorkbenchContext(page);
await mockAgentApi(page);
});
test("first load blends the original still into the animation without a visual cut", async ({ page }) => {
let release!: () => void;
const downloadReady = new Promise<void>((resolve) => {
release = resolve;
});
await page.route("**/obsidian-water-v1.riv*", async (route) => {
await downloadReady;
await route.continue();
});
try {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/", { waitUntil: "domcontentloaded" });
const orb = page.getByLabel("Agent 折叠栏").locator("[data-agent-orb]");
await expect(orb.locator(".agent-orb-canvas")).toHaveCount(1);
await expect(orb.locator("img")).toBeVisible();
await expect(orb.locator("img")).toHaveAttribute("src", /obsidian-water-idle\.png\?v=/);
const transition = orb.evaluate((element) => new Promise<{
opacity: number;
width: number;
height: number;
}[]>((resolve, reject) => {
const frames: { opacity: number; width: number; height: number }[] = [];
const start = performance.now();
const sample = () => {
const canvas = element.querySelector(".agent-orb-canvas")!;
const opacity = Number(getComputedStyle(canvas).opacity);
const { width, height } = element.getBoundingClientRect();
frames.push({ opacity, width, height });
if (opacity === 1) return resolve(frames);
if (performance.now() - start > 30000) return reject(new Error("Animation did not appear"));
requestAnimationFrame(sample);
};
sample();
}));
release();
const frames = await transition;
expect(frames[0].opacity).toBe(0);
expect(frames.some(({ opacity }) => opacity > 0 && opacity < 1)).toBe(true);
expect(frames.every(({ width, height }) => width === 64 && height === 64)).toBe(true);
await expect(orb.locator("img")).toBeHidden();
await expectMoving(orb);
} finally {
release();
}
});
test("renders original Obsidian, caches across reloads and releases offscreen renderers", async ({
page
}) => {
const resources: string[] = [];
page.on("request", (request) => {
if (/\.riv(?:\?|$)/.test(request.url())) resources.push(request.url());
});
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/", { waitUntil: "domcontentloaded" });
const launcher = page.getByLabel("Agent 折叠栏");
const orb = launcher.locator("[data-agent-orb]");
await expect(orb).toHaveAttribute("data-agent-orb", "idle", { timeout: 30000 });
await ready(orb);
await expect(orb.locator(".agent-orb-canvas")).toHaveCSS("filter", "none");
await expectMoving(orb);
await orb.hover();
await expect(orb).toHaveAttribute("data-hovered", "true");
await page.mouse.move(600, 600);
await expect(orb).toHaveAttribute("data-hovered", "false");
await orb.evaluate((element) => {
(element.parentElement as HTMLElement).style.transform = "translateX(-2000px)";
});
await expect(orb.locator("canvas")).toHaveCount(0);
await orb.evaluate((element) => {
(element.parentElement as HTMLElement).style.transform = "";
});
await ready(orb);
await launcher.getByRole("button").click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await ready(panel.locator("header [data-agent-orb]"));
await expect(panel.locator("header [data-agent-orb]")).toHaveCSS("width", "64px");
await expect(page.locator(".agent-orb canvas")).toHaveCount(1);
await panel.getByRole("button", { name: "折叠 Agent 面板" }).click();
await ready(orb);
expect(resources.map((url) => new URL(url).pathname)).toEqual(["/agent/obsidian-water-v1.riv"]);
// A full reload loses module memory. Block the asset network path to prove the
// next live renderer reads persistent Cache Storage rather than downloading.
await page.route("**/obsidian-water-v1.riv*", (route) => route.abort());
await page.reload({ waitUntil: "domcontentloaded" });
await ready(orb);
await expectMoving(orb);
expect(resources).toHaveLength(1);
await page.screenshot({
path: "/tmp/nexttjwater-obsidian-restored/workbench.png",
clip: { x: 0, y: 56, width: 340, height: 190 }
});
});
test("pointer coordinates reach the native hover listener in the launcher and header", async ({ page }) => {
await page.addInitScript(() => {
const addListener = HTMLCanvasElement.prototype.addEventListener;
HTMLCanvasElement.prototype.addEventListener = function (type, listener, options) {
if (type === "mousemove") {
// Observe delivery to Rive's native canvas event path. A hover boolean
// alone leaves the resource's pointer target at the sphere center.
addListener.call(this, type, (event) => {
const { clientX, clientY } = event as MouseEvent;
const bounds = this.getBoundingClientRect();
this.dataset.pointerPosition = JSON.stringify({
x: (clientX - bounds.left) / bounds.width,
y: (clientY - bounds.top) / bounds.height
});
});
}
addListener.call(this, type, listener, options);
};
});
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/", { waitUntil: "domcontentloaded" });
const launcher = page.getByLabel("Agent 折叠栏");
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
for (const orb of [launcher.locator("[data-agent-orb]"), panel.locator("header [data-agent-orb]")]) {
await ready(orb);
const bounds = (await orb.boundingBox())!;
for (const x of [0.25, 0.75, 0.5]) {
await page.mouse.move(bounds.x + bounds.width * x, bounds.y + bounds.height * 0.5);
await expect.poll(async () => {
const point = await orb.locator("canvas").getAttribute("data-pointer-position");
return point ? JSON.parse(point) : null;
}).toEqual({ x, y: 0.5 });
}
await page.mouse.move(600, 600);
await expect(orb).toHaveAttribute("data-hovered", "false");
if (!(await panel.isVisible())) await launcher.getByRole("button").click();
}
});
test("mouse presses preserve size and brightness while clicking still opens the Agent", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/", { waitUntil: "domcontentloaded" });
const orb = page.getByLabel("Agent 折叠栏").locator("[data-agent-orb]");
await ready(orb);
await orb.hover();
await page.mouse.down();
await expectNoPressFeedback(orb);
await expect(orb).toHaveAttribute("data-hovered", "true");
await page.mouse.move(600, 600);
await page.mouse.up();
await expectNoPressFeedback(orb);
await expect(orb).toHaveAttribute("data-hovered", "false");
await orb.click();
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
});
test("follows processing and completion while the Agent is collapsed", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
let release!: () => void;
const responseReady = new Promise<void>((resolve) => {
release = resolve;
});
await page.route("**/api/v1/responses", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
await responseReady;
await route.fulfill({
headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
body: responsesFixture([
{ type: "start", messageId: "orb-state-preview" },
{ type: "text-start", id: "answer" },
{ type: "text-delta", id: "answer", delta: "动态球状态演练回复。" },
{ type: "text-end", id: "answer" },
{ type: "finish", finishReason: "stop" }
])
});
});
try {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.locator("textarea").fill("检查当前供水状态");
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await panel.getByRole("button", { name: "折叠 Agent 面板" }).click();
const orb = page.getByLabel("Agent 折叠栏").locator("[data-agent-orb]");
await expect(orb).toHaveAttribute("data-agent-orb", "thinking");
await ready(orb);
await expectMoving(orb);
release();
await expect(orb).toHaveAttribute("data-agent-orb", "idle");
await expectMoving(orb);
} finally {
release();
}
});
test("offline plays the resource water loops without press feedback and opens the Agent", async ({
page
}) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.route("**/api/v1/models", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
await route.fulfill({
status: 503,
headers: { "Access-Control-Allow-Origin": "*" },
json: { error: "演练连接失败" }
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const launcher = page.getByLabel("Agent 折叠栏");
const orb = launcher.locator("[data-agent-orb]");
await expect(orb).toHaveAttribute("data-agent-orb", "asleep", { timeout: 30000 });
await ready(orb);
await expect(orb.locator("img")).toBeHidden();
await expectMoving(orb);
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(orb.locator("canvas")).toHaveCount(0);
await expect(orb.locator("img")).toBeVisible();
await expect(orb.locator("img")).toHaveAttribute("src", /obsidian-water-offline\.png\?v=/);
await page.emulateMedia({ reducedMotion: "no-preference" });
await ready(orb);
await orb.hover();
await page.mouse.down();
await expectNoPressFeedback(orb);
await page.mouse.move(600, 600);
await page.mouse.up();
await expectNoPressFeedback(orb);
await page.screenshot({
path: "/tmp/nexttjwater-obsidian-restored/offline.png",
clip: { x: 0, y: 56, width: 340, height: 190 }
});
await launcher.getByRole("button").click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await ready(panel.locator("header [data-agent-orb]"));
await expect(panel.locator("header [data-agent-orb]")).toHaveAttribute("data-agent-orb", "asleep");
await expect(page.locator(".agent-orb canvas")).toHaveCount(1);
});
test("reduced motion uses a still and keeps keyboard access in forced colors", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/", { waitUntil: "domcontentloaded" });
const launcher = page.getByLabel("Agent 折叠栏");
const orb = launcher.locator("[data-agent-orb]");
await expect(orb).toHaveAttribute("data-agent-orb", "idle", { timeout: 30000 });
await expect(orb.locator("canvas")).toHaveCount(0);
await orb.hover();
await page.mouse.down();
await expectNoPressFeedback(orb);
await page.mouse.move(600, 600);
await page.mouse.up();
await expect(orb).toHaveCSS("opacity", "1");
await page.emulateMedia({ reducedMotion: "reduce", forcedColors: "active" });
await expect(orb.locator(".agent-orb-contrast")).toBeVisible();
await expect(orb.locator(".agent-orb-contrast")).toHaveCSS("border-top-color", "rgb(0, 0, 0)");
await launcher.getByRole("button").focus();
await page.keyboard.press("Space");
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
});
test("animation download failure retains the original still and the working launcher", async ({
page
}) => {
await page.route("**/obsidian-water-v1.riv*", (route) => route.abort());
await page.goto("/", { waitUntil: "domcontentloaded" });
const launcher = page.getByLabel("Agent 折叠栏");
await expect(launcher.locator('.agent-orb-live[data-failed="true"]')).toBeVisible({
timeout: 30000
});
await expect(launcher.locator("img")).toBeVisible();
await expect(launcher.locator("canvas")).toHaveCount(0);
await launcher.getByRole("button").click();
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
});
test.describe("touch interaction", () => {
test.use({ hasTouch: true, viewport: { width: 375, height: 812 } });
test("mobile presses add no animation and tapping opens the Agent", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/", { waitUntil: "domcontentloaded" });
const button = page.getByRole("button", { name: "打开 Agent 面板", exact: true });
const orb = button.locator("[data-agent-orb]");
await ready(orb);
await expect(orb).toHaveCSS("width", "40px");
const bounds = (await orb.boundingBox())!;
const touch = await page.context().newCDPSession(page);
await touch.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }]
});
await expectNoPressFeedback(orb);
await expect(orb).toHaveAttribute("data-hovered", "false");
await touch.send("Input.dispatchTouchEvent", { type: "touchCancel", touchPoints: [] });
await expectNoPressFeedback(orb);
await expect(orb).toHaveAttribute("data-hovered", "false");
await button.tap();
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
await ready(page.locator('[aria-label="Agent 命令面板"] header [data-agent-orb]'));
await expect(page.locator(".agent-orb canvas")).toHaveCount(1);
await page.screenshot({ path: "/tmp/nexttjwater-orb-loading/mobile-375.png" });
await page.setViewportSize({ width: 320, height: 812 });
await expect(page.locator('[aria-label="Agent 命令面板"] header [data-agent-orb]')).toHaveCSS("width", "64px");
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: "/tmp/nexttjwater-orb-loading/mobile-320.png" });
await touch.detach();
});
});