| 1 | import { assert, beforeEach, it } from "@effect/vitest"; |
| 2 | import * as NodeEvents from "node:events"; |
| 3 | import * as NodeProcess from "node:process"; |
| 4 | import { vi } from "vite-plus/test"; |
| 5 | |
| 6 | const forkMock = vi.hoisted(() => |
| 7 | vi.fn< |
| 8 | ( |
| 9 | _path: string, |
| 10 | _args: ReadonlyArray<string>, |
| 11 | _options: { env?: NodeJS.ProcessEnv; execPath?: string }, |
| 12 | ) => unknown |
| 13 | >(), |
| 14 | ); |
| 15 | |
| 16 | vi.mock("node:child_process", () => ({ fork: forkMock })); |
| 17 | |
| 18 | import { |
| 19 | makeSnapShotAccessibilityProcessPool, |
| 20 | startSnapShotAccessibilityProcess, |
| 21 | } from "./SnapShotAccessibilityProcess.ts"; |
| 22 | |
| 23 | const makeWorker = () => |
| 24 | Object.assign(new NodeEvents.EventEmitter(), { |
| 25 | kill: vi.fn(() => true), |
| 26 | send: vi.fn(), |
| 27 | }); |
| 28 | let worker = makeWorker(); |
| 29 | const request = { |
| 30 | active: { |
| 31 | title: "Zoom Meeting", |
| 32 | bounds: { x: 10, y: 20, width: 800, height: 600 }, |
| 33 | owner: { processId: 42 }, |
| 34 | }, |
| 35 | platform: "darwin" as const, |
| 36 | sourceTitle: "Zoom Meeting", |
| 37 | imageSize: { width: 1_600, height: 1_200 }, |
| 38 | }; |
| 39 | |
| 40 | beforeEach(() => { |
| 41 | worker = makeWorker(); |
| 42 | forkMock.mockReset().mockReturnValue(worker); |
| 43 | }); |
| 44 | |
| 45 | it("keeps T3 alive when Zoom crashes accessibility extraction", async () => { |
| 46 | const process = startSnapShotAccessibilityProcess("accessibility.cjs"); |
| 47 | const read = process.read(request); |
| 48 | worker.emit("message", "ready"); |
| 49 | worker.emit("message", "started"); |
| 50 | await read.started; |
| 51 | worker.emit("exit", null, "SIGABRT"); |
| 52 | |
| 53 | assert.isUndefined(await read.result); |
| 54 | }); |
| 55 | |
| 56 | it("returns immediately when accessibility crashes before the screenshot is ready", async () => { |
| 57 | const process = startSnapShotAccessibilityProcess("accessibility.cjs"); |
| 58 | worker.emit("exit", null, "SIGABRT"); |
| 59 | |
| 60 | const read = process.read(request); |
| 61 | await read.started; |
| 62 | assert.isUndefined(await read.result); |
| 63 | }); |
| 64 | |
| 65 | it("returns accessibility extracted by the helper", async () => { |
| 66 | const process = startSnapShotAccessibilityProcess("accessibility.cjs"); |
| 67 | const read = process.read(request); |
| 68 | const context = { accessibleText: "Meeting controls" }; |
| 69 | worker.emit("message", "ready"); |
| 70 | worker.emit("message", "started"); |
| 71 | worker.emit("message", { type: "result", context }); |
| 72 | |
| 73 | await read.started; |
| 74 | assert.deepEqual(await read.result, context); |
| 75 | assert.strictEqual(forkMock.mock.calls[0]?.[2]?.env?.ELECTRON_RUN_AS_NODE, "1"); |
| 76 | assert.strictEqual(forkMock.mock.calls[0]?.[2]?.execPath, NodeProcess.execPath); |
| 77 | }); |
| 78 | |
| 79 | it("hands a warm helper to the capture and prewarms its replacement", async () => { |
| 80 | const warmWorker = makeWorker(); |
| 81 | const replacementWorker = makeWorker(); |
| 82 | forkMock.mockReset().mockReturnValueOnce(warmWorker).mockReturnValueOnce(replacementWorker); |
| 83 | const pool = makeSnapShotAccessibilityProcessPool("accessibility.cjs"); |
| 84 | |
| 85 | pool.warm(); |
| 86 | warmWorker.emit("message", "ready"); |
| 87 | const read = pool.read(request); |
| 88 | |
| 89 | assert.lengthOf(forkMock.mock.calls, 2); |
| 90 | assert.deepEqual(warmWorker.send.mock.calls[0]?.[0], request); |
| 91 | assert.lengthOf(replacementWorker.send.mock.calls, 0); |
| 92 | |
| 93 | warmWorker.emit("message", "started"); |
| 94 | warmWorker.emit("message", { type: "result", context: { accessibleText: "Ready" } }); |
| 95 | assert.deepEqual(await read.result, { accessibleText: "Ready" }); |
| 96 | |
| 97 | pool.close(); |
| 98 | assert.lengthOf(replacementWorker.kill.mock.calls, 1); |
| 99 | }); |
| 100 | |
| 101 | it("replaces a warm helper that exits before capture", async () => { |
| 102 | const exitedWorker = makeWorker(); |
| 103 | const captureWorker = makeWorker(); |
| 104 | const replacementWorker = makeWorker(); |
| 105 | forkMock |
| 106 | .mockReset() |
| 107 | .mockReturnValueOnce(exitedWorker) |
| 108 | .mockReturnValueOnce(captureWorker) |
| 109 | .mockReturnValueOnce(replacementWorker); |
| 110 | const pool = makeSnapShotAccessibilityProcessPool("accessibility.cjs"); |
| 111 | |
| 112 | pool.warm(); |
| 113 | exitedWorker.emit("exit", 1); |
| 114 | const read = pool.read(request); |
| 115 | captureWorker.emit("message", "ready"); |
| 116 | captureWorker.emit("message", "started"); |
| 117 | captureWorker.emit("message", { type: "result", context: { accessibleText: "Recovered" } }); |
| 118 | |
| 119 | assert.deepEqual(await read.result, { accessibleText: "Recovered" }); |
| 120 | assert.lengthOf(forkMock.mock.calls, 3); |
| 121 | assert.deepEqual(captureWorker.send.mock.calls[0]?.[0], request); |
| 122 | pool.close(); |
| 123 | }); |
| 124 | |
| 125 | it("allows cold startup to exceed the request-start budget", async () => { |
| 126 | vi.useFakeTimers(); |
| 127 | const process = startSnapShotAccessibilityProcess("accessibility.cjs"); |
| 128 | try { |
| 129 | const read = process.read(request); |
| 130 | await vi.advanceTimersByTimeAsync(1_500); |
| 131 | assert.lengthOf(worker.kill.mock.calls, 0); |
| 132 | worker.emit("message", "ready"); |
| 133 | assert.deepEqual(worker.send.mock.calls[0]?.[0], request); |
| 134 | worker.emit("message", "started"); |
| 135 | worker.emit("message", { type: "result", context: { accessibleText: "Ready" } }); |
| 136 | assert.deepEqual(await read.result, { accessibleText: "Ready" }); |
| 137 | } finally { |
| 138 | process.close(); |
| 139 | vi.useRealTimers(); |
| 140 | } |
| 141 | }); |