|
| 1 | +import { EventEmitter } from "node:events"; |
| 2 | +import { PassThrough } from "node:stream"; |
| 3 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 4 | + |
| 5 | +/** |
| 6 | + * The cast on `actual` is written out in the factory rather than shared in a |
| 7 | + * helper: `vi.mock` calls are HOISTED above every top-level statement, so a |
| 8 | + * module-scope helper is still in its temporal dead zone when the factory runs. |
| 9 | + */ |
| 10 | +type WithDefault = { default?: Record<string, unknown> }; |
| 11 | + |
| 12 | +const mocks = vi.hoisted(() => ({ |
| 13 | + cursor: { x: 0, y: 0 }, |
| 14 | + nearestDisplay: { |
| 15 | + scaleFactor: 2, |
| 16 | + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, |
| 17 | + }, |
| 18 | + isTrustedAccessibilityClient: vi.fn(() => true), |
| 19 | + accessSync: vi.fn(), |
| 20 | +})); |
| 21 | + |
| 22 | +vi.mock("node:child_process", async (importOriginal) => { |
| 23 | + const actual = await importOriginal<typeof import("node:child_process")>(); |
| 24 | + const spawn = vi.fn(); |
| 25 | + return { ...actual, spawn, default: { ...((actual as WithDefault).default ?? {}), spawn } }; |
| 26 | +}); |
| 27 | + |
| 28 | +vi.mock("node:fs", async (importOriginal) => { |
| 29 | + const actual = await importOriginal<typeof import("node:fs")>(); |
| 30 | + return { |
| 31 | + ...actual, |
| 32 | + accessSync: mocks.accessSync, |
| 33 | + default: { ...((actual as WithDefault).default ?? {}), accessSync: mocks.accessSync }, |
| 34 | + }; |
| 35 | +}); |
| 36 | + |
| 37 | +vi.mock("electron", () => ({ |
| 38 | + systemPreferences: { isTrustedAccessibilityClient: mocks.isTrustedAccessibilityClient }, |
| 39 | + screen: { |
| 40 | + getCursorScreenPoint: () => mocks.cursor, |
| 41 | + getDisplayNearestPoint: () => mocks.nearestDisplay, |
| 42 | + }, |
| 43 | +})); |
| 44 | + |
| 45 | +import { spawn } from "node:child_process"; |
| 46 | +import type { Rectangle } from "electron"; |
| 47 | +import { MacNativeCursorRecordingSession } from "./macNativeCursorRecordingSession"; |
| 48 | + |
| 49 | +/** Minimal stand-in for the cursor helper: stdio pipes plus kill bookkeeping. */ |
| 50 | +class FakeHelper extends EventEmitter { |
| 51 | + stdout = new PassThrough(); |
| 52 | + stderr = new PassThrough(); |
| 53 | + killed = false; |
| 54 | + |
| 55 | + kill() { |
| 56 | + this.killed = true; |
| 57 | + return true; |
| 58 | + } |
| 59 | + |
| 60 | + /** Feeds one NDJSON line, the way the real helper emits them. */ |
| 61 | + emitEvent(event: Record<string, unknown>) { |
| 62 | + this.stdout.write(`${JSON.stringify(event)}\n`); |
| 63 | + } |
| 64 | +} |
| 65 | + |
| 66 | +const spawnMock = vi.mocked(spawn); |
| 67 | +let helper: FakeHelper; |
| 68 | + |
| 69 | +beforeEach(() => { |
| 70 | + helper = new FakeHelper(); |
| 71 | + spawnMock.mockReset(); |
| 72 | + spawnMock.mockReturnValue(helper as unknown as ReturnType<typeof spawn>); |
| 73 | + mocks.cursor = { x: 0, y: 0 }; |
| 74 | + mocks.nearestDisplay = { |
| 75 | + scaleFactor: 2, |
| 76 | + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, |
| 77 | + }; |
| 78 | + mocks.isTrustedAccessibilityClient.mockReset(); |
| 79 | + mocks.isTrustedAccessibilityClient.mockReturnValue(true); |
| 80 | + mocks.accessSync.mockReset(); |
| 81 | + vi.spyOn(console, "warn").mockImplementation(() => undefined); |
| 82 | + vi.spyOn(console, "error").mockImplementation(() => undefined); |
| 83 | +}); |
| 84 | + |
| 85 | +afterEach(() => { |
| 86 | + vi.restoreAllMocks(); |
| 87 | +}); |
| 88 | + |
| 89 | +const WINDOW_BOUNDS: Rectangle = { x: 200, y: 100, width: 1000, height: 800 }; |
| 90 | + |
| 91 | +async function recordWithHelper( |
| 92 | + getDisplayBounds: () => Rectangle | null, |
| 93 | + sample: (emit: (event: Record<string, unknown>) => void) => void, |
| 94 | +) { |
| 95 | + const session = new MacNativeCursorRecordingSession({ |
| 96 | + getDisplayBounds, |
| 97 | + maxSamples: 20, |
| 98 | + sampleIntervalMs: 33, |
| 99 | + startTimeMs: 1_000, |
| 100 | + }); |
| 101 | + const pending = session.start(); |
| 102 | + await Promise.resolve(); |
| 103 | + helper.emitEvent({ type: "ready", timestampMs: 1_000 }); |
| 104 | + await pending; |
| 105 | + sample((event) => helper.emitEvent(event)); |
| 106 | + return session.stop(); |
| 107 | +} |
| 108 | + |
| 109 | +function click(timestampMs: number) { |
| 110 | + return { type: "sample", timestampMs, leftButtonPressed: true }; |
| 111 | +} |
| 112 | + |
| 113 | +describe("MacNativeCursorRecordingSession capture bounds", () => { |
| 114 | + it("places click hotspots on the top and bottom of a window with a non-zero origin", async () => { |
| 115 | + // 10px inside each edge. A display-sized frame, or any fixed y shift, misses both. |
| 116 | + mocks.cursor = { x: 400, y: 110 }; |
| 117 | + const data = await recordWithHelper( |
| 118 | + () => WINDOW_BOUNDS, |
| 119 | + (emit) => { |
| 120 | + emit(click(1_100)); |
| 121 | + mocks.cursor = { x: 400, y: 890 }; |
| 122 | + emit(click(1_200)); |
| 123 | + }, |
| 124 | + ); |
| 125 | + |
| 126 | + expect(data.samples[0]).toMatchObject({ |
| 127 | + cx: 0.2, |
| 128 | + cy: 10 / 800, |
| 129 | + visible: true, |
| 130 | + interactionType: "click", |
| 131 | + }); |
| 132 | + expect(data.samples[1]).toMatchObject({ |
| 133 | + cx: 0.2, |
| 134 | + cy: 790 / 800, |
| 135 | + visible: true, |
| 136 | + interactionType: "click", |
| 137 | + }); |
| 138 | + }); |
| 139 | + |
| 140 | + it("replaces the source frame with later bounds and leaves a display frame unshifted", async () => { |
| 141 | + // The picker frame sits below the pixels. The helper's capture rect replaces it; |
| 142 | + // the next sample must not keep the old origin. A full display then maps as itself. |
| 143 | + let bounds: Rectangle = { x: 200, y: 180, width: 1000, height: 800 }; |
| 144 | + mocks.cursor = { x: 700, y: 500 }; |
| 145 | + const data = await recordWithHelper( |
| 146 | + () => bounds, |
| 147 | + (emit) => { |
| 148 | + emit(click(1_100)); |
| 149 | + bounds = { x: 200, y: 120, width: 1000, height: 860 }; |
| 150 | + emit(click(1_200)); |
| 151 | + bounds = { x: 0, y: 0, width: 1920, height: 1080 }; |
| 152 | + mocks.cursor = { x: 960, y: 540 }; |
| 153 | + emit(click(1_300)); |
| 154 | + }, |
| 155 | + ); |
| 156 | + |
| 157 | + expect(data.samples[0].cy).toBeCloseTo((500 - 180) / 800, 5); |
| 158 | + expect(data.samples[1].cx).toBeCloseTo((700 - 200) / 1000, 5); |
| 159 | + expect(data.samples[1].cy).toBeCloseTo((500 - 120) / 860, 5); |
| 160 | + expect(data.samples[2]).toMatchObject({ cx: 0.5, cy: 0.5 }); |
| 161 | + }); |
| 162 | + |
| 163 | + it("rebases samples collected before the authoritative bounds arrive", async () => { |
| 164 | + let bounds: Rectangle = { x: 0, y: 0, width: 1920, height: 1080 }; |
| 165 | + mocks.cursor = { x: 400, y: 300 }; |
| 166 | + const session = new MacNativeCursorRecordingSession({ |
| 167 | + getDisplayBounds: () => bounds, |
| 168 | + maxSamples: 20, |
| 169 | + sampleIntervalMs: 33, |
| 170 | + startTimeMs: 1_000, |
| 171 | + }); |
| 172 | + const pending = session.start(); |
| 173 | + await Promise.resolve(); |
| 174 | + helper.emitEvent({ type: "ready", timestampMs: 1_000 }); |
| 175 | + await pending; |
| 176 | + helper.emitEvent(click(1_100)); |
| 177 | + |
| 178 | + bounds = WINDOW_BOUNDS; |
| 179 | + session.updateCaptureBounds(bounds); |
| 180 | + |
| 181 | + const data = await session.stop(); |
| 182 | + expect(data.samples[0]).toMatchObject({ |
| 183 | + cx: 0.2, |
| 184 | + cy: 0.25, |
| 185 | + visible: true, |
| 186 | + interactionType: "click", |
| 187 | + }); |
| 188 | + }); |
| 189 | + |
| 190 | + it("falls back to the display under the pointer when no capture bounds exist", async () => { |
| 191 | + // No helper on disk: position-only sampling, and nothing has reported a capture rect. |
| 192 | + mocks.accessSync.mockImplementation(() => { |
| 193 | + throw new Error("ENOENT"); |
| 194 | + }); |
| 195 | + mocks.nearestDisplay = { |
| 196 | + scaleFactor: 2, |
| 197 | + bounds: { x: 0, y: 0, width: 1440, height: 900 }, |
| 198 | + }; |
| 199 | + mocks.cursor = { x: 720, y: 450 }; |
| 200 | + const session = new MacNativeCursorRecordingSession({ |
| 201 | + getDisplayBounds: () => null, |
| 202 | + maxSamples: 5, |
| 203 | + sampleIntervalMs: 60_000, |
| 204 | + startTimeMs: 1_000, |
| 205 | + }); |
| 206 | + |
| 207 | + await session.start(); |
| 208 | + const data = await session.stop(); |
| 209 | + |
| 210 | + expect(spawnMock).not.toHaveBeenCalled(); |
| 211 | + expect(data.provider).toBe("none"); |
| 212 | + expect(data.samples[0]).toMatchObject({ cx: 0.5, cy: 0.5, visible: true }); |
| 213 | + }); |
| 214 | + |
| 215 | + it("clamps a brief exit and hides the pointer only after a sustained one", async () => { |
| 216 | + // Outside the window, above its top edge. The first two stay visible at the |
| 217 | + // clamp; the third crosses the hide threshold. One sample back inside clears it. |
| 218 | + mocks.cursor = { x: 1_400, y: 40 }; |
| 219 | + const data = await recordWithHelper( |
| 220 | + () => WINDOW_BOUNDS, |
| 221 | + (emit) => { |
| 222 | + emit({ type: "sample", timestampMs: 1_100 }); |
| 223 | + emit({ type: "sample", timestampMs: 1_133 }); |
| 224 | + emit({ type: "sample", timestampMs: 1_166 }); |
| 225 | + mocks.cursor = { x: 700, y: 500 }; |
| 226 | + emit({ type: "sample", timestampMs: 1_200 }); |
| 227 | + }, |
| 228 | + ); |
| 229 | + |
| 230 | + expect(data.samples.slice(0, 3).map((sample) => sample.visible)).toEqual([true, true, false]); |
| 231 | + expect(data.samples[0]).toMatchObject({ cx: 1, cy: 0 }); |
| 232 | + expect(data.samples[2]).toMatchObject({ cx: 1, cy: 0, visible: false }); |
| 233 | + expect(data.samples[3]).toMatchObject({ cx: 0.5, cy: 0.5, visible: true }); |
| 234 | + }); |
| 235 | +}); |
0 commit comments