Preserve the original reflection material while adding pointer inertia and a short exit hold. Retain the Rive pointer constraint through the fade and blend rapid re-entry to avoid abrupt changes. Cover timing, re-entry, cancellation, renderer disposal and native Rive frames. The fade regression fails with the previous resource and passes with the updated resource.
337 lines
15 KiB
TypeScript
337 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 }, testInfo) => {
|
|
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 命令面板" });
|
|
const orbs = [launcher.locator("[data-agent-orb]"), panel.locator("header [data-agent-orb]")];
|
|
for (const [index, orb] of orbs.entries()) {
|
|
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 testInfo.attach(`original-hover-${index === 0 ? "launcher" : "header"}`, {
|
|
body: await page.screenshot({
|
|
path: `/tmp/nexttjwater-persona-hover-${index}.png`,
|
|
clip: { x: Math.max(0, bounds.x - 12), y: Math.max(0, bounds.y - 12), width: 240, height: 104 }
|
|
}),
|
|
contentType: "image/png"
|
|
});
|
|
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();
|
|
});
|
|
});
|