Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
507 lines
13 KiB
TypeScript
507 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
interface FakeDisposable {
|
|
disposed: boolean;
|
|
dispose(): void;
|
|
}
|
|
|
|
interface FakeMaterial extends FakeDisposable {
|
|
map?: FakeDisposable;
|
|
needsUpdate: boolean;
|
|
opacity: number;
|
|
visible: boolean;
|
|
}
|
|
|
|
interface FakeMesh {
|
|
children: FakeMesh[];
|
|
geometry: FakeDisposable & { kind: "badge" | "edge" | "icon" };
|
|
material: FakeMaterial;
|
|
position: { set: ReturnType<typeof vi.fn>; z: number };
|
|
rotation: { set: ReturnType<typeof vi.fn>; x: number; z: number };
|
|
userData: { agentIndex?: number };
|
|
}
|
|
|
|
const threeState = vi.hoisted(() => ({
|
|
failTextureUpload: false,
|
|
materials: [] as FakeMaterial[],
|
|
meshes: [] as FakeMesh[],
|
|
raycastObjects: [] as FakeMesh[],
|
|
renderCount: 0,
|
|
rendererDisposed: false,
|
|
textures: [] as FakeDisposable[]
|
|
}));
|
|
|
|
vi.mock("three", () => {
|
|
class FakeGeometry implements FakeDisposable {
|
|
disposed = false;
|
|
readonly kind: "badge" | "edge" | "icon";
|
|
|
|
constructor(kind: "badge" | "edge" | "icon") {
|
|
this.kind = kind;
|
|
}
|
|
|
|
dispose(): void {
|
|
this.disposed = true;
|
|
}
|
|
}
|
|
|
|
class PlaneGeometry extends FakeGeometry {
|
|
constructor() {
|
|
super("icon");
|
|
}
|
|
}
|
|
|
|
class CircleGeometry extends FakeGeometry {
|
|
constructor() {
|
|
super("badge");
|
|
}
|
|
}
|
|
|
|
class CylinderGeometry extends FakeGeometry {
|
|
constructor() {
|
|
super("edge");
|
|
}
|
|
}
|
|
|
|
class MeshBasicMaterial implements FakeMaterial {
|
|
disposed = false;
|
|
map?: FakeDisposable;
|
|
needsUpdate = false;
|
|
opacity = 1;
|
|
visible: boolean;
|
|
|
|
constructor(input: { visible?: boolean }) {
|
|
this.visible = input.visible ?? true;
|
|
threeState.materials.push(this);
|
|
}
|
|
|
|
dispose(): void {
|
|
this.disposed = true;
|
|
}
|
|
}
|
|
|
|
class MeshStandardMaterial extends MeshBasicMaterial {}
|
|
|
|
class Group {
|
|
readonly children: FakeMesh[] = [];
|
|
readonly position = { set: vi.fn(), z: 0 };
|
|
readonly rotation = { set: vi.fn(), x: 0, z: 0 };
|
|
readonly scale = { setScalar: vi.fn() };
|
|
readonly userData: { agentIndex?: number } = {};
|
|
visible = true;
|
|
|
|
add(...children: FakeMesh[]): void {
|
|
this.children.push(...children);
|
|
}
|
|
}
|
|
|
|
class Mesh extends Group implements FakeMesh {
|
|
constructor(
|
|
readonly geometry: FakeDisposable & {
|
|
kind: "badge" | "edge" | "icon";
|
|
},
|
|
readonly material: FakeMaterial
|
|
) {
|
|
super();
|
|
threeState.meshes.push(this);
|
|
}
|
|
}
|
|
|
|
class CanvasTexture implements FakeDisposable {
|
|
anisotropy = 0;
|
|
colorSpace = "";
|
|
disposed = false;
|
|
|
|
constructor() {
|
|
threeState.textures.push(this);
|
|
}
|
|
|
|
dispose(): void {
|
|
this.disposed = true;
|
|
}
|
|
}
|
|
|
|
class WebGLRenderer {
|
|
dispose(): void {
|
|
threeState.rendererDisposed = true;
|
|
}
|
|
|
|
initTexture(): void {
|
|
if (threeState.failTextureUpload) {
|
|
throw new Error("texture upload failed");
|
|
}
|
|
}
|
|
|
|
render(): void {
|
|
threeState.renderCount += 1;
|
|
}
|
|
setClearColor(): void {}
|
|
setPixelRatio(): void {}
|
|
setSize(): void {}
|
|
}
|
|
|
|
class Scene {
|
|
add(): void {}
|
|
}
|
|
|
|
class PerspectiveCamera {
|
|
aspect = 1;
|
|
readonly position = { set: vi.fn() };
|
|
updateProjectionMatrix(): void {}
|
|
}
|
|
|
|
class Raycaster {
|
|
intersectObjects(objects: FakeMesh[]): Array<{ object: FakeMesh }> {
|
|
threeState.raycastObjects = objects;
|
|
const badge = objects.find((mesh) => mesh.geometry.kind === "badge");
|
|
return badge ? [{ object: badge }] : [];
|
|
}
|
|
|
|
setFromCamera(): void {}
|
|
}
|
|
|
|
class Vector2 {}
|
|
|
|
class AmbientLight {}
|
|
|
|
class DirectionalLight {
|
|
readonly position = { set: vi.fn() };
|
|
}
|
|
|
|
return {
|
|
AmbientLight,
|
|
CanvasTexture,
|
|
CircleGeometry,
|
|
CylinderGeometry,
|
|
DirectionalLight,
|
|
Group,
|
|
MathUtils: {
|
|
clamp: (value: number, min: number, max: number) =>
|
|
Math.min(Math.max(value, min), max),
|
|
smoothstep: (value: number, min: number, max: number) =>
|
|
Math.min(Math.max((value - min) / (max - min), 0), 1)
|
|
},
|
|
Mesh,
|
|
MeshBasicMaterial,
|
|
MeshStandardMaterial,
|
|
PerspectiveCamera,
|
|
PlaneGeometry,
|
|
Raycaster,
|
|
Scene,
|
|
SRGBColorSpace: "srgb",
|
|
Vector2,
|
|
WebGLRenderer
|
|
};
|
|
});
|
|
|
|
import { AgentGuiHeroCarouselScene } from "./agentGuiHeroCarouselScene";
|
|
|
|
class FakeImage {
|
|
static instances: FakeImage[] = [];
|
|
static nextDecodeFailure = false;
|
|
static nextLoadFailure = false;
|
|
|
|
complete = false;
|
|
crossOrigin: string | null = null;
|
|
decode?: () => Promise<void>;
|
|
decoding = "auto";
|
|
height = 100;
|
|
loading = "auto";
|
|
naturalWidth = 100;
|
|
onerror: (() => void) | null = null;
|
|
onload: (() => void) | null = null;
|
|
width = 100;
|
|
private value = "";
|
|
|
|
constructor() {
|
|
if (FakeImage.nextDecodeFailure) {
|
|
FakeImage.nextDecodeFailure = false;
|
|
this.decode = () => Promise.reject(new Error("decode failed"));
|
|
}
|
|
FakeImage.instances.push(this);
|
|
}
|
|
|
|
get src(): string {
|
|
return this.value;
|
|
}
|
|
|
|
set src(value: string) {
|
|
this.value = value;
|
|
if (value) {
|
|
if (FakeImage.nextLoadFailure) {
|
|
FakeImage.nextLoadFailure = false;
|
|
this.onerror?.();
|
|
} else {
|
|
this.onload?.();
|
|
}
|
|
}
|
|
}
|
|
|
|
setAttribute(): void {}
|
|
}
|
|
|
|
describe("AgentGuiHeroCarouselScene", () => {
|
|
const originalImage = globalThis.Image;
|
|
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
|
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
|
let getContextSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
threeState.failTextureUpload = false;
|
|
threeState.materials.length = 0;
|
|
threeState.meshes.length = 0;
|
|
threeState.raycastObjects.length = 0;
|
|
threeState.renderCount = 0;
|
|
threeState.rendererDisposed = false;
|
|
threeState.textures.length = 0;
|
|
FakeImage.instances.length = 0;
|
|
FakeImage.nextDecodeFailure = false;
|
|
FakeImage.nextLoadFailure = false;
|
|
globalThis.Image = FakeImage as unknown as typeof Image;
|
|
globalThis.requestAnimationFrame = vi.fn(() => 1);
|
|
globalThis.cancelAnimationFrame = vi.fn();
|
|
getContextSpy = vi
|
|
.spyOn(HTMLCanvasElement.prototype, "getContext")
|
|
.mockReturnValue({
|
|
arc: vi.fn(),
|
|
beginPath: vi.fn(),
|
|
clearRect: vi.fn(),
|
|
clip: vi.fn(),
|
|
createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
|
createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
|
drawImage: vi.fn(),
|
|
fill: vi.fn(),
|
|
fillRect: vi.fn(),
|
|
restore: vi.fn(),
|
|
rotate: vi.fn(),
|
|
roundRect: vi.fn(),
|
|
save: vi.fn(),
|
|
stroke: vi.fn(),
|
|
translate: vi.fn()
|
|
} as unknown as CanvasRenderingContext2D);
|
|
});
|
|
|
|
afterEach(() => {
|
|
getContextSpy.mockRestore();
|
|
globalThis.Image = originalImage;
|
|
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
|
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
|
|
});
|
|
|
|
it("uploads decoded badge textures, picks badges, mirrors opacity, and disposes GPU resources", () => {
|
|
const loadedImage = {
|
|
complete: true,
|
|
height: 100,
|
|
naturalWidth: 100,
|
|
onload: null,
|
|
width: 100
|
|
} as unknown as HTMLImageElement;
|
|
const scene = AgentGuiHeroCarouselScene.create({
|
|
canvas: document.createElement("canvas"),
|
|
items: [
|
|
{
|
|
targetId: "agent-1",
|
|
agentTargetId: "agent-1",
|
|
provider: "codex",
|
|
label: "Agent 1",
|
|
iconUrl: "app://agent-1.png",
|
|
badge: { iconUrl: "app://owner-1.png", label: "Owner 1" }
|
|
},
|
|
{
|
|
targetId: "agent-2",
|
|
agentTargetId: "agent-2",
|
|
provider: "claude-code",
|
|
label: "Agent 2",
|
|
iconUrl: "app://agent-2.png"
|
|
}
|
|
],
|
|
loadedBadgeImages: [loadedImage, null],
|
|
loadedCoverImages: [null, null],
|
|
loadedImages: [loadedImage, loadedImage],
|
|
onSettle: vi.fn()
|
|
});
|
|
|
|
expect(scene).not.toBeNull();
|
|
expect(FakeImage.instances).toHaveLength(0);
|
|
const badgeMeshes = threeState.meshes.filter(
|
|
(mesh) => mesh.geometry.kind === "badge"
|
|
);
|
|
expect(badgeMeshes.some((mesh) => mesh.material.visible)).toBe(true);
|
|
expect(
|
|
badgeMeshes
|
|
.filter((mesh) => mesh.material.visible)
|
|
.every((mesh) => mesh.material.map)
|
|
).toBe(true);
|
|
|
|
const iconMeshes = threeState.meshes.filter(
|
|
(mesh) => mesh.geometry.kind === "icon"
|
|
);
|
|
const centeredIcon = iconMeshes.find(
|
|
(mesh) => mesh.userData.agentIndex === 0 && mesh.material.opacity === 1
|
|
);
|
|
const centeredBadge = badgeMeshes.find(
|
|
(mesh) => mesh.userData.agentIndex === 0 && mesh.material.visible
|
|
);
|
|
expect(centeredIcon).toBeDefined();
|
|
expect(centeredBadge?.material.opacity).toBe(
|
|
centeredIcon?.material.opacity
|
|
);
|
|
|
|
expect(scene?.pick(50, 50, 100, 100)).toBe(0);
|
|
expect(
|
|
threeState.raycastObjects.some((mesh) => mesh.geometry.kind === "badge")
|
|
).toBe(true);
|
|
|
|
scene?.dispose();
|
|
expect(threeState.rendererDisposed).toBe(true);
|
|
expect(threeState.materials.every((material) => material.disposed)).toBe(
|
|
true
|
|
);
|
|
expect(threeState.textures.every((texture) => texture.disposed)).toBe(true);
|
|
});
|
|
|
|
it("keeps a visible programmatic badge when the decoded badge is unavailable", () => {
|
|
const requestAnimationFrame = vi.mocked(globalThis.requestAnimationFrame);
|
|
const scene = createSceneWithBadge(null);
|
|
const badgeMaterials = badgeMaterialsForAgent(0);
|
|
|
|
expect(FakeImage.instances).toHaveLength(0);
|
|
expect(badgeMaterials.every((material) => material.visible)).toBe(true);
|
|
expect(badgeMaterials.every((material) => material.map == null)).toBe(true);
|
|
expect(requestAnimationFrame).toHaveBeenCalled();
|
|
|
|
scene?.dispose();
|
|
});
|
|
|
|
it("renders immediately after resizing to avoid a cleared frame", () => {
|
|
const scene = createSceneWithBadge(null);
|
|
|
|
expect(scene).not.toBeNull();
|
|
expect(threeState.renderCount).toBe(0);
|
|
|
|
scene?.setSize(320, 112);
|
|
|
|
expect(threeState.renderCount).toBe(1);
|
|
scene?.dispose();
|
|
});
|
|
|
|
it("settles to zero scheduled frames after its static render", () => {
|
|
let nextFrameID = 1;
|
|
const pendingFrames = new Map<number, FrameRequestCallback>();
|
|
globalThis.requestAnimationFrame = vi.fn((callback) => {
|
|
const frameID = nextFrameID;
|
|
nextFrameID += 1;
|
|
pendingFrames.set(frameID, callback);
|
|
return frameID;
|
|
});
|
|
globalThis.cancelAnimationFrame = vi.fn((frameID) => {
|
|
pendingFrames.delete(frameID);
|
|
});
|
|
const scene = createSceneWithBadge(null);
|
|
|
|
expect(scene).not.toBeNull();
|
|
expect(pendingFrames.size).toBeGreaterThan(0);
|
|
for (const [frameID, callback] of [...pendingFrames]) {
|
|
pendingFrames.delete(frameID);
|
|
callback(0);
|
|
}
|
|
expect(pendingFrames.size).toBe(0);
|
|
scene?.dispose();
|
|
});
|
|
|
|
it("stops scheduling frames after an interactive spring settles", () => {
|
|
let nextFrameID = 1;
|
|
const pendingFrames = new Map<number, FrameRequestCallback>();
|
|
globalThis.requestAnimationFrame = vi.fn((callback) => {
|
|
const frameID = nextFrameID;
|
|
nextFrameID += 1;
|
|
pendingFrames.set(frameID, callback);
|
|
return frameID;
|
|
});
|
|
globalThis.cancelAnimationFrame = vi.fn((frameID) => {
|
|
pendingFrames.delete(frameID);
|
|
});
|
|
const scene = createSceneWithBadge(null);
|
|
|
|
for (const [frameID, callback] of [...pendingFrames]) {
|
|
pendingFrames.delete(frameID);
|
|
callback(0);
|
|
}
|
|
scene?.stepBy(1);
|
|
|
|
let now = 16;
|
|
while (pendingFrames.size > 0 && now < 5_000) {
|
|
const next = pendingFrames.entries().next().value as [
|
|
number,
|
|
FrameRequestCallback
|
|
];
|
|
pendingFrames.delete(next[0]);
|
|
next[1](now);
|
|
now += 16;
|
|
}
|
|
|
|
expect(pendingFrames.size).toBe(0);
|
|
expect(now).toBeLessThan(5_000);
|
|
scene?.dispose();
|
|
});
|
|
|
|
it("disposes a rejected texture and keeps the fallback when WebGL upload fails", () => {
|
|
threeState.failTextureUpload = true;
|
|
const scene = createSceneWithBadge(createLoadedImage());
|
|
const badgeMaterials = badgeMaterialsForAgent(0);
|
|
|
|
expect(badgeMaterials.every((material) => material.visible)).toBe(true);
|
|
expect(badgeMaterials.every((material) => material.map == null)).toBe(true);
|
|
expect(threeState.textures).toHaveLength(2);
|
|
expect(threeState.textures.at(-1)?.disposed).toBe(true);
|
|
|
|
scene?.dispose();
|
|
});
|
|
});
|
|
|
|
function createLoadedImage(): HTMLImageElement {
|
|
return {
|
|
complete: true,
|
|
height: 100,
|
|
naturalWidth: 100,
|
|
onload: null,
|
|
width: 100
|
|
} as unknown as HTMLImageElement;
|
|
}
|
|
|
|
function createSceneWithBadge(
|
|
badgeImage: HTMLImageElement | null
|
|
): AgentGuiHeroCarouselScene | null {
|
|
const loadedImage = createLoadedImage();
|
|
return AgentGuiHeroCarouselScene.create({
|
|
canvas: document.createElement("canvas"),
|
|
items: [
|
|
{
|
|
targetId: "agent-1",
|
|
agentTargetId: "agent-1",
|
|
provider: "codex",
|
|
label: "Agent 1",
|
|
iconUrl: "app://agent-1.png",
|
|
badge: {
|
|
iconUrl: "https://cdn.example.com/owner-1.png",
|
|
label: "Owner 1"
|
|
}
|
|
}
|
|
],
|
|
loadedBadgeImages: [badgeImage],
|
|
loadedCoverImages: [null],
|
|
loadedImages: [loadedImage],
|
|
onSettle: vi.fn()
|
|
});
|
|
}
|
|
|
|
function badgeMaterialsForAgent(agentIndex: number): FakeMaterial[] {
|
|
return threeState.meshes
|
|
.filter(
|
|
(mesh) =>
|
|
mesh.geometry.kind === "badge" &&
|
|
mesh.userData.agentIndex === agentIndex
|
|
)
|
|
.map((mesh) => mesh.material);
|
|
}
|