import assert from "node:assert/strict"; import test from "node:test"; import React from "react"; import { act, create, type ReactTestRenderer } from "react-test-renderer"; import { MAX_PENDING_SCRIPT_RECORDING_INPUT_CHARS, SCRIPT_RECORDING_LIMIT_EVENT, useScriptRecorder, } from "./useScriptRecorder"; type Recorder = ReturnType; function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } async function flushReact() { await act(async () => { await new Promise((resolve) => setImmediate(resolve)); }); } test("oversized unsubmitted recording input stops explicitly and preserves earlier steps", async (t) => { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); const eventTarget = new EventTarget() as EventTarget & Record; let stopCalls = 0; Object.assign(eventTarget, { netcatty: { scriptRecordingStart: async () => ({ ok: true }), scriptRecordingStop: async () => { stopCalls += 1; return { steps: [{ type: "send", value: "kept" }], code: "await nct.screen.sendLine('kept');", }; }, }, setInterval, clearInterval, }); Object.defineProperty(globalThis, "window", { configurable: true, value: eventTarget }); t.after(() => { if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window"); }); let recorder: ReturnType | null = null; let renderer: ReactTestRenderer | null = null; let limitDetail: { sessionId: string; code: string } | null = null; eventTarget.addEventListener(SCRIPT_RECORDING_LIMIT_EVENT, (event) => { limitDetail = (event as CustomEvent<{ sessionId: string; code: string }>).detail; }); function Probe() { recorder = useScriptRecorder("session-1"); return null; } await act(async () => { renderer = create(React.createElement(Probe)); }); await act(async () => { await recorder!.startRecording(); }); assert.equal(recorder!.isRecording, true); await act(async () => { recorder!.recordInput("x".repeat(MAX_PENDING_SCRIPT_RECORDING_INPUT_CHARS + 1)); await new Promise((resolve) => setImmediate(resolve)); }); assert.equal(stopCalls, 1); assert.equal(recorder!.isRecording, false); assert.equal(limitDetail?.sessionId, "session-1"); assert.equal(limitDetail?.code, "await nct.screen.sendLine('kept');"); await act(async () => renderer!.unmount()); }); test("automatic stop remains closed across an elapsed-time rerender and ignores later input", async (t) => { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); const stopResult = deferred<{ steps: []; code: string }>(); const intervalCallbacks = new Set<() => void>(); let stopCalls = 0; const appended: Array<{ type: string; value?: unknown }> = []; const eventTarget = new EventTarget() as EventTarget & Record; Object.assign(eventTarget, { netcatty: { scriptRecordingStart: async () => ({ ok: true }), scriptRecordingStop: () => { stopCalls += 1; return stopResult.promise; }, scriptRecordingAppendStep: async (_sessionId: string, step: { type: string; value?: unknown }) => { appended.push(step); return { stopped: false }; }, }, setInterval: (callback: () => void) => { intervalCallbacks.add(callback); return callback; }, clearInterval: (callback: () => void) => intervalCallbacks.delete(callback), }); Object.defineProperty(globalThis, "window", { configurable: true, value: eventTarget }); t.after(() => { if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window"); }); let recorder: Recorder | null = null; let renderer: ReactTestRenderer | null = null; function Probe() { recorder = useScriptRecorder("session-1"); return null; } await act(async () => { renderer = create(React.createElement(Probe)); }); await act(async () => { await recorder!.startRecording(); }); const elapsedTick = [...intervalCallbacks][0]; assert.ok(elapsedTick); act(() => { recorder!.recordInput("x".repeat(MAX_PENDING_SCRIPT_RECORDING_INPUT_CHARS + 1)); }); assert.equal(stopCalls, 1); assert.equal(recorder!.isRecording, false); await act(async () => { elapsedTick(); }); await act(async () => { recorder!.recordInput("must-not-be-buffered"); await recorder!.recordEnter(); }); assert.deepEqual(appended, []); stopResult.resolve({ steps: [], code: "" }); await flushReact(); await act(async () => renderer!.unmount()); }); test("repeated oversized input issues only one automatic stop", async (t) => { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); const stopResult = deferred<{ steps: []; code: string }>(); const intervalCallbacks = new Set<() => void>(); let stopCalls = 0; const eventTarget = new EventTarget() as EventTarget & Record; Object.assign(eventTarget, { netcatty: { scriptRecordingStart: async () => ({ ok: true }), scriptRecordingStop: () => { stopCalls += 1; return stopResult.promise; }, }, setInterval: (callback: () => void) => { intervalCallbacks.add(callback); return callback; }, clearInterval: (callback: () => void) => intervalCallbacks.delete(callback), }); Object.defineProperty(globalThis, "window", { configurable: true, value: eventTarget }); t.after(() => { if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window"); }); let recorder: Recorder | null = null; let renderer: ReactTestRenderer | null = null; function Probe() { recorder = useScriptRecorder("session-1"); return null; } await act(async () => { renderer = create(React.createElement(Probe)); }); await act(async () => { await recorder!.startRecording(); }); const elapsedTick = [...intervalCallbacks][0]; assert.ok(elapsedTick); act(() => { recorder!.recordInput("x".repeat(MAX_PENDING_SCRIPT_RECORDING_INPUT_CHARS + 1)); }); await act(async () => { elapsedTick(); }); act(() => { recorder!.recordInput("y".repeat(MAX_PENDING_SCRIPT_RECORDING_INPUT_CHARS + 1)); }); let manualStopPromise!: ReturnType; act(() => { manualStopPromise = recorder!.stopRecording(); }); assert.equal(stopCalls, 1); stopResult.resolve({ steps: [], code: "" }); await act(async () => { await manualStopPromise; }); await act(async () => renderer!.unmount()); }); test("automatic stop failure stays stopped and a later recording can start", async (t) => { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); const firstStop = deferred<{ steps: []; code: string }>(); let startCalls = 0; let stopCalls = 0; const limitEvents: Array<{ steps: unknown[]; code: string }> = []; const eventTarget = new EventTarget() as EventTarget & Record; Object.assign(eventTarget, { netcatty: { scriptRecordingStart: async () => { startCalls += 1; return { ok: true }; }, scriptRecordingStop: () => { stopCalls += 1; return firstStop.promise; }, }, setInterval, clearInterval, }); eventTarget.addEventListener(SCRIPT_RECORDING_LIMIT_EVENT, (event) => { limitEvents.push((event as CustomEvent<{ steps: unknown[]; code: string }>).detail); }); Object.defineProperty(globalThis, "window", { configurable: true, value: eventTarget }); t.after(() => { if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window"); }); let recorder: Recorder | null = null; let renderer: ReactTestRenderer | null = null; function Probe() { recorder = useScriptRecorder("session-1"); return null; } await act(async () => { renderer = create(React.createElement(Probe)); }); await act(async () => { await recorder!.startRecording(); }); act(() => { recorder!.recordInput("x".repeat(MAX_PENDING_SCRIPT_RECORDING_INPUT_CHARS + 1)); }); assert.equal(recorder!.isRecording, false); firstStop.reject(new Error("stop failed")); await flushReact(); assert.equal(recorder!.isRecording, false); assert.equal(stopCalls, 1); assert.deepEqual(limitEvents.map(({ steps, code }) => ({ steps, code })), [{ steps: [], code: "" }]); await act(async () => { await recorder!.startRecording(); }); assert.equal(startCalls, 2); assert.equal(recorder!.isRecording, true); await act(async () => renderer!.unmount()); }); test("restart waits for an in-flight automatic stop and is not closed by its completion", async (t) => { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); const stopResult = deferred<{ steps: []; code: string }>(); let startCalls = 0; const eventTarget = new EventTarget() as EventTarget & Record; Object.assign(eventTarget, { netcatty: { scriptRecordingStart: async () => { startCalls += 1; return { ok: true }; }, scriptRecordingStop: () => stopResult.promise, }, setInterval, clearInterval, }); Object.defineProperty(globalThis, "window", { configurable: true, value: eventTarget }); t.after(() => { if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window"); }); let recorder: Recorder | null = null; let renderer: ReactTestRenderer | null = null; function Probe() { recorder = useScriptRecorder("session-1"); return null; } await act(async () => { renderer = create(React.createElement(Probe)); }); await act(async () => { await recorder!.startRecording(); }); act(() => { recorder!.recordInput("x".repeat(MAX_PENDING_SCRIPT_RECORDING_INPUT_CHARS + 1)); }); let restartPromise!: Promise; act(() => { restartPromise = recorder!.startRecording(); }); await flushReact(); assert.equal(startCalls, 1); stopResult.resolve({ steps: [], code: "" }); await act(async () => { await restartPromise; }); assert.equal(startCalls, 2); assert.equal(recorder!.isRecording, true); await act(async () => renderer!.unmount()); }); test("manual stop still returns its recording and permits a clean restart", async (t) => { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); let startCalls = 0; let stopCalls = 0; const eventTarget = new EventTarget() as EventTarget & Record; Object.assign(eventTarget, { netcatty: { scriptRecordingStart: async () => { startCalls += 1; return { ok: true }; }, scriptRecordingStop: async () => { stopCalls += 1; return { steps: [{ type: "send", value: "kept" }], code: "await nct.screen.sendLine('kept');", }; }, }, setInterval, clearInterval, }); Object.defineProperty(globalThis, "window", { configurable: true, value: eventTarget }); t.after(() => { if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window"); }); let recorder: Recorder | null = null; let renderer: ReactTestRenderer | null = null; function Probe() { recorder = useScriptRecorder("session-1"); return null; } await act(async () => { renderer = create(React.createElement(Probe)); }); await act(async () => { await recorder!.startRecording(); }); let result!: Awaited>; await act(async () => { result = await recorder!.stopRecording(); }); assert.equal(stopCalls, 1); assert.equal(recorder!.isRecording, false); assert.equal(result.code, "await nct.screen.sendLine('kept');"); await act(async () => { await recorder!.startRecording(); }); assert.equal(startCalls, 2); assert.equal(recorder!.isRecording, true); await act(async () => renderer!.unmount()); });