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; 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: '' }); }); 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); });