Files
next-tjwater-frontend/tests/browser/agent-persona-resource.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

266 lines
12 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { build } from "esbuild";
import { expect, test } from "@playwright/test";
import { buildMaterialPersona, buildOfflinePersona, encodeSource } from "../../scripts/build-agent-persona.mjs";
test.use({ launchOptions: { args: ["--enable-unsafe-swiftshader"] } });
const harness = `import { RuntimeLoader } from '@rive-app/react-webgl2';
RuntimeLoader.setWasmUrl('/rive-probe/runtime.wasm');
window.ready = new Promise(resolve => RuntimeLoader.getInstance(resolve)).then(runtime => {
window.renderResource = async (version, steps) => {
const bytes = new Uint8Array(await (await fetch('/rive-probe/' + version + '.riv')).arrayBuffer());
const file = await runtime.load(bytes, undefined, false);
const artboard = file.defaultArtboard();
const machine = new runtime.StateMachineInstance(artboard.stateMachineByName('default'), artboard);
const inputs = {};
for (let i = 0; i < machine.inputCount(); i++) {
const input = machine.input(i);
inputs[input.name] = input.asBool();
}
const canvas = document.querySelector('canvas');
const renderer = runtime.makeRenderer(canvas, true);
const copy = document.createElement('canvas');
copy.width = copy.height = 192;
const context = copy.getContext('2d');
try {
const snapshots = [];
for (const step of steps) {
for (const name in inputs) inputs[name].value = step.state === name || (name === 'hover' && !!step.hovered);
if (step.pointer) machine.pointerMove(step.pointer[0], step.pointer[1], 0);
for (let frame = 0; frame < step.frames; frame++) {
machine.advance(1 / 60);
artboard.advance(1 / 60);
}
renderer.clear();
renderer.save();
renderer.align(runtime.Fit.contain, runtime.Alignment.center,
{ minX: 0, minY: 0, maxX: 192, maxY: 192 }, artboard.bounds);
artboard.draw(renderer);
renderer.restore();
renderer.flush();
runtime.resolveAnimationFrame();
context.clearRect(0, 0, 192, 192);
context.drawImage(canvas, 0, 0);
const pixels = context.getImageData(0, 0, 192, 192).data;
let visiblePixels = 0, bluePixels = 0, outerPixels = 0, compositedBluePixels = 0;
let minimumBodyAlpha = 255;
for (let i = 0; i < pixels.length; i += 4) {
const x = (i / 4) % 192 + 0.5 - 96;
const y = Math.floor(i / 4 / 192) + 0.5 - 96;
// Sample inside the feathered rim: map lines should not remain visible
// through the body's center in either online or offline states.
if (Math.hypot(x, y) < 50) minimumBodyAlpha = Math.min(minimumBodyAlpha, pixels[i + 3]);
if (pixels[i + 3] <= 32) continue;
visiblePixels++;
if (pixels[i + 2] > pixels[i] + 20 && pixels[i + 1] > pixels[i] + 10) bluePixels++;
// Check that the pale material stays blue when composited on white.
// A 20-level channel difference allows the selected white reflections.
if ((pixels[i + 2] - pixels[i]) * pixels[i + 3] / 255 > 20) compositedBluePixels++;
if (Math.hypot(x, y) > 79) outerPixels++;
}
snapshots.push({ image: canvas.toDataURL(), visiblePixels, bluePixels, outerPixels, compositedBluePixels, minimumBodyAlpha });
}
return snapshots;
} finally {
machine.delete(); artboard.delete(); file.delete(); renderer.delete();
}
};
});`;
declare global {
interface Window {
ready: Promise<void>;
renderResource: (version: string, steps: { state: string; frames: number; hovered?: boolean; pointer?: [number, number] }[]) => Promise<{
image: string;
visiblePixels: number;
bluePixels: number;
outerPixels: number;
compositedBluePixels: number;
minimumBodyAlpha: number;
}[]>;
}
}
let script: string;
let original: Buffer;
let recolored: Buffer;
let modified: Buffer;
let motionOnly: Buffer;
let wasm: Buffer;
test.beforeAll(async () => {
const source = JSON.parse(await readFile("design/agent/obsidian-runtime.json", "utf8"));
original = encodeSource(source);
recolored = buildMaterialPersona(source);
modified = await readFile("public/agent/obsidian-water-v1.riv");
expect(buildOfflinePersona(source)).toEqual(modified);
const offline = JSON.parse(await readFile("design/agent/obsidian-offline.json", "utf8"));
for (const layer of offline.layers) {
for (const light of layer.opacity) light.rest = light.crest = 1;
}
motionOnly = buildOfflinePersona(source, offline);
const require = createRequire(import.meta.url);
const riveRequire = createRequire(require.resolve("@rive-app/react-webgl2"));
wasm = await readFile(riveRequire.resolve("@rive-app/webgl2/rive.wasm"));
const result = await build({
stdin: { contents: harness, resolveDir: process.cwd() },
bundle: true, write: false, format: "iife"
});
script = result.outputFiles[0].text;
});
test.beforeEach(async ({ page }) => {
await page.route("**/rive-probe/**", async (route) => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith(".wasm")) return route.fulfill({ body: wasm, contentType: "application/wasm" });
if (path.endsWith(".riv")) return route.fulfill({
body: path.includes("original") ? original : path.includes("recolored") ? recolored : path.includes("motion-only") ? motionOnly : modified
});
if (path.endsWith(".js")) return route.fulfill({ body: script, contentType: "application/javascript" });
return route.fulfill({
contentType: "text/html",
body: '<canvas width="192" height="192"></canvas><script src="/rive-probe/harness.js"></script>'
});
});
await page.goto("/rive-probe/");
await page.evaluate(() => window.ready);
});
test("uses the blue palette while preserving the recolored original's online states pixel for pixel", async ({ page }) => {
for (const state of ["idle", "thinking", "listening", "speaking", "hover"]) {
const result = await page.evaluate(async (state) => {
const steps = [{ state: "idle", frames: 90 }, { state, frames: 180 }, { state, frames: 180 }];
return {
original: await window.renderResource("original", steps),
recolored: await window.renderResource("recolored", steps),
modified: await window.renderResource("modified", steps)
};
}, state);
expect(result.original[1].visiblePixels).toBeGreaterThan(12000);
expect(result.original[1].image).not.toBe(result.original[2].image);
expect(result.modified[1].image).not.toBe(result.original[1].image);
expect(result.modified[1].bluePixels).toBeGreaterThan(10000);
expect(result.modified[1].compositedBluePixels).toBeGreaterThan(6000);
expect(result.modified[1].minimumBodyAlpha).toBeGreaterThanOrEqual(230);
expect(result.modified, state).toEqual(result.recolored);
}
});
test("original-size hover reflections follow the pointer instead of whitening the fixed center", async ({ page }) => {
const result = await page.evaluate(async () => {
const pixelsOf = async (source: string) => {
const image = new Image();
image.src = source;
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 192;
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0);
return context.getImageData(0, 0, 192, 192).data;
};
const steps = [
{ state: "idle", frames: 90 },
{ state: "hover", frames: 180, pointer: [24, 64] as [number, number] },
{ state: "hover", frames: 1, pointer: [104, 64] as [number, number] },
{ state: "idle", frames: 180 }
];
const original = await window.renderResource("recolored", steps);
const edited = await window.renderResource("modified", steps);
const idle = await window.renderResource("modified", steps.map(({ frames }) => ({ state: "idle", frames })));
const centers = [];
for (const index of [1, 2]) {
const base = await pixelsOf(idle[index].image);
const active = await pixelsOf(edited[index].image);
let weight = 0;
let weightedX = 0;
for (let i = 0; i < base.length; i += 4) {
const brightness = Math.max(0, active[i] - base[i]);
if (brightness <= 8) continue;
weight += brightness;
weightedX += ((i / 4) % 192 + 0.5) * brightness;
}
centers.push({ x: weightedX / weight, weight });
}
return { centers, original, edited, recovered: edited[3].image === idle[3].image };
});
expect(result.edited).toEqual(result.original);
expect(result.centers[0].weight).toBeGreaterThan(1000);
expect(result.centers[1].weight).toBeGreaterThan(1000);
expect(result.centers[0].x).toBeLessThan(84);
expect(result.centers[1].x).toBeGreaterThan(108);
expect(result.centers[1].x - result.centers[0].x).toBeGreaterThan(30);
expect(result.recovered).toBe(true);
});
test("the original disappears offline while the edited resource keeps moving and recovers", async ({ page }) => {
const result = await page.evaluate(async () => {
const steps = [
{ state: "idle", frames: 90 }, { state: "asleep", frames: 180 },
{ state: "asleep", frames: 180 }, { state: "idle", frames: 120 },
{ state: "asleep", frames: 180 }, { state: "idle", frames: 120 }
];
return {
original: await window.renderResource("original", steps),
modified: await window.renderResource("modified", steps),
initiallyOffline: await window.renderResource("modified", [{ state: "asleep", frames: 180 }])
};
});
expect(result.original[1].visiblePixels).toBe(0);
expect(result.original[2].visiblePixels).toBe(0);
for (const frame of [...result.modified, ...result.initiallyOffline]) {
expect(frame.visiblePixels).toBeGreaterThan(12000);
expect(frame.minimumBodyAlpha).toBeGreaterThanOrEqual(230);
}
expect(result.modified[1].image).not.toBe(result.modified[2].image);
});
test("offline light settles without hiding the sphere and fully restores after reconnecting", async ({ page }) => {
const result = await page.evaluate(async () => {
const steps = [
{ state: "idle", frames: 90 },
{ state: "asleep", frames: 180 },
{ state: "asleep", frames: 180 },
{ state: "idle", frames: 120 },
{ state: "asleep", frames: 8 },
{ state: "idle", frames: 8 },
{ state: "asleep", frames: 8 },
{ state: "idle", frames: 180 },
{ state: "thinking", frames: 180 },
{ state: "listening", frames: 180 },
{ state: "speaking", frames: 180 },
{ state: "hover", frames: 180 }
];
return {
control: await window.renderResource("motion-only", steps),
edited: await window.renderResource("modified", steps)
};
});
for (const snapshot of result.edited) expect(snapshot.visiblePixels).toBeGreaterThan(12000);
for (const index of [1, 2]) {
expect(result.edited[index].image).not.toBe(result.control[index].image);
expect(result.edited[index].outerPixels).toBe(0);
}
for (const index of [0, 3, 7, 8, 9, 10, 11]) {
expect(result.edited[index], `recovered frame ${index}`).toEqual(result.control[index]);
}
});
test("offline has no outer halo, including hover and cold start, and reconnecting restores it", async ({ page }) => {
const snapshots = await page.evaluate(() => window.renderResource("modified", [
{ state: "asleep", frames: 180 },
{ state: "asleep", frames: 180, hovered: true },
{ state: "asleep", frames: 180 },
{ state: "idle", frames: 120 }
]));
for (const frame of snapshots.slice(0, 3)) {
expect(frame.visiblePixels).toBeGreaterThan(12000);
expect(frame.bluePixels).toBeGreaterThan(10000);
expect(frame.compositedBluePixels).toBeGreaterThan(6000);
expect(frame.outerPixels).toBe(0);
}
expect(snapshots[0].image).not.toBe(snapshots[2].image);
expect(snapshots[3].outerPixels).toBeGreaterThan(100);
});