1import type { DesktopPreviewRecordingInput } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3
4import { createRecordingCompositor, RecordingDecorations } from "./recordingCompositor";
5
6const primaryColor = "oklch(0.65 0.2 310)";
7vi.mock("./annotationTheme", () => ({
8 readPreviewAnnotationTheme: () => ({ primary: "oklch(0.65 0.2 310)" }),
9}));
10
11const options = { showKeyPresses: true, showMousePresses: true, frameRate: 30 };
12const pointer = (
13 phase: "move" | "down" | "up" | "click",
14 x = 100,
15): DesktopPreviewRecordingInput => ({
16 type: "pointer",
17 phase,
18 x,
19 y: 80,
20 width: 800,
21 height: 600,
22});
23const context = () => ({
24 save: vi.fn(),
25 restore: vi.fn(),
26 beginPath: vi.fn(),
27 fill: vi.fn(),
28 stroke: vi.fn(),
29 ellipse: vi.fn(),
30 roundRect: vi.fn(),
31 fillText: vi.fn(),
32 drawImage: vi.fn(),
33 measureText: () => ({ width: 40 }),
34 globalAlpha: 1,
35 strokeStyle: "",
36 fillStyle: "",
37});
38
39describe("recording decorations", () => {
40 it("keeps rings aligned through dragging and stops following the cursor after release", () => {
41 const decorations = new RecordingDecorations(options, primaryColor);
42 const ctx = context();
43 decorations.apply(pointer("down"), 0);
44 decorations.apply(pointer("move", 120), 10);
45 decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 10);
46 expect(ctx.ellipse.mock.calls[0]?.slice(0, 4)).toEqual([240, 160, 40, 40]);
47 expect(ctx.strokeStyle).toBe(primaryColor);
48 expect(ctx.fillStyle).toBe(primaryColor);
49 expect(decorations.nextRedraw(10)).toBeNull();
50 decorations.apply(pointer("up", 130), 20);
51 decorations.apply(pointer("move", 300), 30);
52 decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 320);
53 expect(ctx.ellipse.mock.calls[1]?.slice(0, 4)).toEqual([260, 160, 50, 50]);
54 ctx.ellipse.mockClear();
55 decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 620);
56 expect(ctx.ellipse).not.toHaveBeenCalled();
57 expect(decorations.nextRedraw(620)).toBeNull();
58 });
59
60 it("pulses agent clicks and clears decorations on blur or navigation", () => {
61 const decorations = new RecordingDecorations(options, primaryColor);
62 const ctx = context();
63 decorations.apply(pointer("click"), 0);
64 decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 300);
65 expect(ctx.ellipse).toHaveBeenCalledOnce();
66 decorations.apply({ type: "clear" }, 301);
67 ctx.ellipse.mockClear();
68 decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 302);
69 expect(ctx.ellipse).not.toHaveBeenCalled();
70 expect(decorations.nextRedraw(302)).toBeNull();
71 });
72
73 it("holds shortcut badges until release, then expires them even on a static page", () => {
74 const decorations = new RecordingDecorations(options, primaryColor);
75 const ctx = context();
76 const key = { type: "key" as const, label: "⌘C", held: true, width: 800 };
77 decorations.apply(key, 0);
78 decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 5000);
79 expect(ctx.fillText.mock.calls[0]?.slice(0, 3)).toEqual(["⌘C", 800, 1098]);
80 decorations.apply({ ...key, held: false }, 5000);
81 expect(decorations.nextRedraw(5500)).toBe(400);
82 ctx.fillText.mockClear();
83 decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 5900);
84 expect(ctx.fillText).not.toHaveBeenCalled();
85 });
86
87 it("removes the previous key badge on password focus", () => {
88 const decorations = new RecordingDecorations(options, primaryColor);
89 const ctx = context();
90 decorations.apply({ type: "key", label: "A", held: true, width: 800 }, 0);
91 decorations.apply({ type: "key", label: null, held: true, width: 800 }, 1);
92 decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 2);
93 expect(ctx.fillText).not.toHaveBeenCalled();
94 });
95
96 it("honors independent opt-in flags", () => {
97 const decorations = new RecordingDecorations(
98 { ...options, showMousePresses: false },
99 primaryColor,
100 );
101 const ctx = context();
102 decorations.apply(pointer("down"), 0);
103 decorations.apply({ type: "key", label: "⌘C", held: true, width: 800 }, 0);
104 decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 1);
105 expect(ctx.ellipse).not.toHaveBeenCalled();
106 expect(ctx.fillText).toHaveBeenCalledOnce();
107 });
108});
109
110describe("detached recording compositor", () => {
111 afterEach(() => vi.unstubAllGlobals());
112
113 it("keeps native capture when both decorations are off", async () => {
114 vi.stubGlobal("document", {
115 createElement: () => {
116 throw new Error("must not allocate");
117 },
118 });
119 expect(
120 await createRecordingCompositor(
121 {} as MediaStream,
122 {
123 ...options,
124 showKeyPresses: false,
125 showMousePresses: false,
126 },
127 () => {
128 throw new Error("must not subscribe");
129 },
130 ),
131 ).toBeNull();
132 });
133
134 it.each([false, true])(
135 "releases the detached output on disposal or playback failure (%s)",
136 async (failPlayback) => {
137 const ctx = context();
138 const stop = vi.fn();
139 const unsubscribe = vi.fn();
140 const cancelFrame = vi.fn();
141 const source = {
142 getVideoTracks: () => [{ getSettings: () => ({ width: 800, height: 600 }) }],
143 } as unknown as MediaStream;
144 const stream = { getTracks: () => [{ stop }] } as unknown as MediaStream;
145 const canvas = { width: 0, height: 0, getContext: () => ctx, captureStream: () => stream };
146 const video = {
147 muted: false,
148 playsInline: false,
149 srcObject: null as MediaStream | null,
150 readyState: 2,
151 videoWidth: 800,
152 videoHeight: 600,
153 pause: vi.fn(),
154 play: async () => {
155 if (failPlayback) throw new Error("play failed");
156 },
157 requestVideoFrameCallback: () => 1,
158 cancelVideoFrameCallback: cancelFrame,
159 };
160 vi.stubGlobal("document", {
161 createElement: (tag: string) => (tag === "canvas" ? canvas : video),
162 });
163 vi.stubGlobal("window", { clearTimeout: vi.fn(), setTimeout: vi.fn() });
164 const compositor = createRecordingCompositor(source, options, () => unsubscribe);
165 if (failPlayback) await expect(compositor).rejects.toThrow("play failed");
166 else {
167 const result = await compositor;
168 expect(result?.stream).toBe(stream);
169 expect(ctx.drawImage).toHaveBeenCalledOnce();
170 result?.dispose();
171 result?.dispose();
172 }
173 expect(stop).toHaveBeenCalledOnce();
174 expect(unsubscribe).toHaveBeenCalledOnce();
175 expect(cancelFrame).toHaveBeenCalledWith(1);
176 expect(video.srcObject).toBeNull();
177 },
178 );
179});