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((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((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(); }); });