| 1 | // @effect-diagnostics nodeBuiltinImport:off -- Isolated child-process streams; no desktop session is touched. |
| 2 | import * as NodeEvents from "node:events"; |
| 3 | import * as NodeStream from "node:stream"; |
| 4 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 5 | |
| 6 | const spawn = vi.hoisted(() => vi.fn()); |
| 7 | vi.mock("node:child_process", () => ({ spawn })); |
| 8 | import { startNativeCaptureFeedback } from "./NativeCaptureFeedback.ts"; |
| 9 | |
| 10 | const options = { |
| 11 | bounds: { x: -800, y: 0, width: 800, height: 600 }, |
| 12 | pid: 123, |
| 13 | flash: true, |
| 14 | animate: true, |
| 15 | }; |
| 16 | let child: NodeEvents.EventEmitter & { |
| 17 | stdin: NodeStream.PassThrough; |
| 18 | stdout: NodeStream.PassThrough; |
| 19 | kill: ReturnType<typeof vi.fn>; |
| 20 | }; |
| 21 | let commands: string[]; |
| 22 | beforeEach(() => { |
| 23 | vi.useFakeTimers(); |
| 24 | commands = []; |
| 25 | child = Object.assign(new NodeEvents.EventEmitter(), { |
| 26 | stdin: new NodeStream.PassThrough(), |
| 27 | stdout: new NodeStream.PassThrough(), |
| 28 | kill: vi.fn(), |
| 29 | }); |
| 30 | child.stdin.on("data", (value: Buffer) => commands.push(value.toString())); |
| 31 | spawn.mockReturnValue(child); |
| 32 | }); |
| 33 | afterEach(() => { |
| 34 | child.emit("close"); |
| 35 | vi.useRealTimers(); |
| 36 | }); |
| 37 | function event(value: unknown) { |
| 38 | child.stdout.write(`${JSON.stringify(value)}\n`); |
| 39 | } |
| 40 | |
| 41 | it("waits for compositor readiness and landing, then waits for cleanup on acknowledgement", async () => { |
| 42 | const starting = startNativeCaptureFeedback("/helper", "/private", options); |
| 43 | let ready = false; |
| 44 | void starting.then(() => { |
| 45 | ready = true; |
| 46 | }); |
| 47 | await Promise.resolve(); |
| 48 | expect(ready).toBe(false); |
| 49 | event({ event: "ready", animate: true }); |
| 50 | const feedback = (await starting)!; |
| 51 | expect(feedback.animationStarted).toBe(true); |
| 52 | const frame = { x: 0.1, y: 0.7, width: 0.2, height: 0.1 }; |
| 53 | const flight = feedback.animateTo('My "Draft"', frame); |
| 54 | let completed = false; |
| 55 | const complete = feedback.complete().then(() => { |
| 56 | completed = true; |
| 57 | }); |
| 58 | expect(commands.map((line) => JSON.parse(line))).toEqual([ |
| 59 | { command: "animate", title: 'My "Draft"', frame }, |
| 60 | ]); |
| 61 | event({ event: "landed" }); |
| 62 | await flight; |
| 63 | expect(commands.map((line) => JSON.parse(line))).toHaveLength(2); |
| 64 | expect(completed).toBe(false); |
| 65 | child.emit("close"); |
| 66 | await complete; |
| 67 | expect(completed).toBe(true); |
| 68 | expect(child.kill).not.toHaveBeenCalled(); |
| 69 | expect(vi.getTimerCount()).toBe(0); |
| 70 | }); |
| 71 | |
| 72 | it("doesn't claim a flight when the compositor disables motion; flash still works", async () => { |
| 73 | const starting = startNativeCaptureFeedback("/helper", "/private", options); |
| 74 | event({ event: "ready", animate: false }); |
| 75 | const feedback = (await starting)!; |
| 76 | expect(feedback.animationStarted).toBe(false); |
| 77 | await feedback.animateTo("Draft", { x: 0, y: 0, width: 1, height: 1 }); |
| 78 | expect(commands).toEqual([]); |
| 79 | const complete = feedback.complete(); |
| 80 | child.emit("close"); |
| 81 | await complete; |
| 82 | }); |
| 83 | |
| 84 | it("falls back on startup failure, malformed replies, and timeout without leaving timers", async () => { |
| 85 | const starting = startNativeCaptureFeedback("/helper", "/private", options); |
| 86 | child.stdout.write("not JSON\n"); |
| 87 | expect(commands).toEqual(['{"command":"close"}\n']); |
| 88 | await vi.advanceTimersByTimeAsync(1000); |
| 89 | expect(child.kill).toHaveBeenCalledOnce(); |
| 90 | child.emit("close"); |
| 91 | expect(await starting).toBeUndefined(); |
| 92 | expect(vi.getTimerCount()).toBe(0); |
| 93 | }); |
| 94 | |
| 95 | it("bounds a missing ready receipt and cancels an in-flight animation on compositor failure", async () => { |
| 96 | const timed = startNativeCaptureFeedback("/helper", "/private", options); |
| 97 | await vi.advanceTimersByTimeAsync(2000); |
| 98 | child.emit("close"); |
| 99 | expect(await timed).toBeUndefined(); |
| 100 | }); |
| 101 | |
| 102 | it("releases waiters if the overlay closes during flight", async () => { |
| 103 | const starting = startNativeCaptureFeedback("/helper", "/private", options); |
| 104 | event({ event: "ready", animate: true }); |
| 105 | const feedback = (await starting)!; |
| 106 | const flight = feedback.animateTo("Draft", { x: 0, y: 0, width: 1, height: 1 }); |
| 107 | event({ event: "done" }); |
| 108 | await flight; |
| 109 | child.emit("close"); |
| 110 | await feedback.complete(); |
| 111 | expect(vi.getTimerCount()).toBe(0); |
| 112 | }); |