feat: add project-aware agent workspace
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user