[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
354
application/state/useScriptRecorder.test.ts
Normal file
354
application/state/useScriptRecorder.test.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
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<typeof useScriptRecorder>;
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((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<string, unknown>;
|
||||
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<typeof useScriptRecorder> | 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<Recorder["stopRecording"]>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<void>;
|
||||
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<string, unknown>;
|
||||
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<ReturnType<Recorder["stopRecording"]>>;
|
||||
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());
|
||||
});
|
||||
Reference in New Issue
Block a user