mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 06:22:35 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef364b69fd | ||
|
|
4beccc3d61 | ||
|
|
33b3beaea0 | ||
|
|
e65af2fd7b | ||
|
|
5d63a9476c | ||
|
|
0d92c5d738 | ||
|
|
eb3cd06278 | ||
|
|
9beb7d3108 |
No files matched your search
@@ -1,11 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
runConnectScriptsSequential,
|
||||
selectScriptOverlayRun,
|
||||
setScriptRuns,
|
||||
waitForScriptRun,
|
||||
} from './scriptAutomationCoordinator.ts';
|
||||
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
||||
|
||||
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
|
||||
const runId = 'run-already-done';
|
||||
@@ -64,3 +67,182 @@ test('selectScriptOverlayRun does not resurface older completed runs after dismi
|
||||
dismissedRunIds.add(latestRun.runId);
|
||||
assert.equal(selectScriptOverlayRun([olderRun, latestRun], 'sess1', dismissedRunIds), undefined);
|
||||
});
|
||||
|
||||
test('runConnectScriptsSequential cancels only its own queued run and waits for stop', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
const sessionId = 'sess-connect-abort';
|
||||
let queuedRunId: string | undefined;
|
||||
let releaseStop: (() => void) | undefined;
|
||||
const scriptStopCalls: string[] = [];
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); },
|
||||
},
|
||||
});
|
||||
|
||||
setScriptRuns([{
|
||||
runId: 'unrelated-active-run',
|
||||
scriptId: 'manual-script',
|
||||
sessionId,
|
||||
status: 'running',
|
||||
startedAt: Date.now(),
|
||||
logs: [],
|
||||
}]);
|
||||
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
queuedRunId = params.runId;
|
||||
assert.equal(params.returnWhenQueued, true);
|
||||
return { runId: params.runId!, runIds: [params.runId!] };
|
||||
},
|
||||
scriptStop: (id: string) => new Promise((resolve) => {
|
||||
scriptStopCalls.push(id);
|
||||
releaseStop = () => resolve({ ok: true });
|
||||
}),
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
const controller = new AbortController();
|
||||
let stopCurrentRun: (() => Promise<void>) | null = null;
|
||||
const snippet: Snippet = {
|
||||
id: 'connect-script',
|
||||
label: 'Connect',
|
||||
command: 'nct.session.sleep(60)',
|
||||
kind: 'script',
|
||||
};
|
||||
|
||||
try {
|
||||
const running = runConnectScriptsSequential({
|
||||
scripts: [snippet],
|
||||
sessionId,
|
||||
signal: controller.signal,
|
||||
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
|
||||
});
|
||||
void running.catch(() => {});
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
for (let attempt = 0; attempt < 10 && scriptStopCalls.length === 0; attempt += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.deepEqual(scriptStopCalls, [queuedRunId]);
|
||||
assert.notEqual(queuedRunId, 'unrelated-active-run');
|
||||
assert.equal(typeof stopCurrentRun, 'function');
|
||||
|
||||
let settled = false;
|
||||
void running.finally(() => { settled = true; }).catch(() => {});
|
||||
await Promise.resolve();
|
||||
assert.equal(settled, false);
|
||||
|
||||
releaseStop?.();
|
||||
await assert.rejects(
|
||||
() => running,
|
||||
(error: unknown) => error instanceof DOMException && error.name === 'AbortError',
|
||||
);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
setScriptRuns([]);
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runConnectScriptsSequential retries the exact stop after a transient failure', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
const storage = new Map<string, string>();
|
||||
const stopCalls: string[] = [];
|
||||
let stopCurrentRun: (() => Promise<void>) | null = null;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); },
|
||||
},
|
||||
});
|
||||
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => ({ runId: params.runId!, runIds: [params.runId!] }),
|
||||
scriptStop: async (id: string) => {
|
||||
stopCalls.push(id);
|
||||
return { ok: stopCalls.length > 1 };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const running = runConnectScriptsSequential({
|
||||
scripts: [{ id: 'retry-stop', label: 'Retry stop', command: 'await nct.sleep(60)', kind: 'script' }],
|
||||
sessionId: 'sess-retry-stop',
|
||||
signal: controller.signal,
|
||||
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
|
||||
});
|
||||
void running.catch(() => {});
|
||||
for (let attempt = 0; attempt < 10 && !stopCurrentRun; attempt += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
controller.abort();
|
||||
await assert.rejects(() => running, /could not be stopped/);
|
||||
assert.equal(typeof stopCurrentRun, 'function');
|
||||
await stopCurrentRun!();
|
||||
assert.equal(stopCalls.length, 2);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
setScriptRuns([]);
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runConnectScriptsSequential does not treat a script error named Aborted as cancellation', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
const storage = new Map<string, string>();
|
||||
const scriptStopCalls: string[] = [];
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); },
|
||||
},
|
||||
});
|
||||
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
setScriptRuns([{
|
||||
runId: params.runId!,
|
||||
scriptId: params.scriptId,
|
||||
sessionId: params.sessionId!,
|
||||
status: 'failed',
|
||||
startedAt: Date.now() - 10,
|
||||
endedAt: Date.now(),
|
||||
error: 'Aborted',
|
||||
logs: [],
|
||||
}]);
|
||||
return { runId: params.runId!, runIds: [params.runId!] };
|
||||
},
|
||||
scriptStop: async (id: string) => {
|
||||
scriptStopCalls.push(id);
|
||||
return { ok: true };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => runConnectScriptsSequential({
|
||||
scripts: [{ id: 'fails', label: 'Fails', command: "throw new Error('Aborted')", kind: 'script' }],
|
||||
sessionId: 'sess-real-error',
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
/Aborted/,
|
||||
);
|
||||
assert.deepEqual(scriptStopCalls, []);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
setScriptRuns([]);
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
@@ -83,6 +83,8 @@ export function getActiveScriptRunForSession(sessionId: string): ScriptRun | und
|
||||
}
|
||||
|
||||
export async function runAutomationScript(params: {
|
||||
runId?: string;
|
||||
returnWhenQueued?: boolean;
|
||||
snippet: Snippet;
|
||||
sessionId: string;
|
||||
sessionIds?: string[];
|
||||
@@ -104,6 +106,8 @@ export async function runAutomationScript(params: {
|
||||
throw new Error('Script bridge unavailable');
|
||||
}
|
||||
return bridge.scriptRun({
|
||||
runId: params.runId,
|
||||
returnWhenQueued: params.returnWhenQueued,
|
||||
scriptId: params.snippet.id,
|
||||
scriptLabel: params.snippet.label,
|
||||
content: params.snippet.command,
|
||||
@@ -176,6 +180,7 @@ export async function runConnectScriptsSequential(params: {
|
||||
scripts: Snippet[];
|
||||
sessionId: string;
|
||||
signal?: AbortSignal;
|
||||
onCancelableRunChange?: (stopCurrentRun: (() => Promise<void>) | null) => void;
|
||||
onScriptStart?: (snippet: Snippet) => void;
|
||||
onScriptComplete?: (snippet: Snippet) => void;
|
||||
sessionMeta?: {
|
||||
@@ -185,18 +190,63 @@ export async function runConnectScriptsSequential(params: {
|
||||
username?: string;
|
||||
};
|
||||
}): Promise<void> {
|
||||
for (const snippet of params.scripts) {
|
||||
const throwIfAborted = () => {
|
||||
if (params.signal?.aborted) {
|
||||
throw new Error('Aborted');
|
||||
throw new DOMException('Connect script run cancelled', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
for (const snippet of params.scripts) {
|
||||
throwIfAborted();
|
||||
const runId = crypto.randomUUID();
|
||||
let stopPromise: Promise<void> | undefined;
|
||||
let stopped = false;
|
||||
params.onScriptStart?.(snippet);
|
||||
const { runId } = await runAutomationScript({
|
||||
const queueAccepted = runAutomationScript({
|
||||
runId,
|
||||
returnWhenQueued: true,
|
||||
snippet,
|
||||
sessionId: params.sessionId,
|
||||
sessionMeta: params.sessionMeta,
|
||||
});
|
||||
await waitForScriptRun(runId, { signal: params.signal });
|
||||
params.onScriptComplete?.(snippet);
|
||||
const stopThisRun = () => {
|
||||
if (stopPromise) return stopPromise;
|
||||
const attempt = queueAccepted.then(async () => {
|
||||
const result = await stopScriptRun(runId);
|
||||
if (!result.ok) {
|
||||
throw new Error(`Connect script run could not be stopped: ${runId}`);
|
||||
}
|
||||
stopped = true;
|
||||
});
|
||||
stopPromise = attempt;
|
||||
void attempt.catch(() => {
|
||||
if (stopPromise === attempt) stopPromise = undefined;
|
||||
});
|
||||
return attempt;
|
||||
};
|
||||
params.onCancelableRunChange?.(stopThisRun);
|
||||
const onAbort = () => {
|
||||
void stopThisRun().catch(() => {});
|
||||
};
|
||||
params.signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
try {
|
||||
await queueAccepted;
|
||||
throwIfAborted();
|
||||
await waitForScriptRun(runId, { signal: params.signal });
|
||||
params.onScriptComplete?.(snippet);
|
||||
} catch (err) {
|
||||
if (params.signal?.aborted) {
|
||||
await stopThisRun();
|
||||
throw new DOMException('Connect script run cancelled', 'AbortError');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
params.signal?.removeEventListener('abort', onAbort);
|
||||
if (!params.signal?.aborted || stopped) {
|
||||
params.onCancelableRunChange?.(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+107
-18
@@ -225,9 +225,16 @@ import {
|
||||
} from "./terminal/terminalRuntimeMount";
|
||||
import type { CreateXTermRuntimeContext } from "./terminal/runtime/createXTermRuntime";
|
||||
import { TerminalView } from "./terminal/TerminalView";
|
||||
import {
|
||||
cancelConnectAutomationBatch,
|
||||
createConnectAutomationBatch,
|
||||
trackConnectAutomationStop,
|
||||
type ConnectAutomationBatch,
|
||||
} from "./terminal/connectAutomationBatch";
|
||||
import {
|
||||
getInitialTerminalStatus,
|
||||
resolveTerminalVaultInitialized,
|
||||
shouldResetConnectAutomationOnReconnect,
|
||||
shouldSuppressHostStartupCommandOnReconnect,
|
||||
shouldStartTerminalBackend,
|
||||
} from "./terminal/restoredSessionGate";
|
||||
@@ -369,6 +376,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const connectScriptsConsumedRef = useRef(false);
|
||||
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
|
||||
const connectScriptsInFlightRef = useRef(false);
|
||||
const connectScriptsBatchRef = useRef<ConnectAutomationBatch | null>(null);
|
||||
const pendingScriptRunIdRef = useRef<string | null>(null);
|
||||
const pendingScriptHandledRef = useRef<Snippet | null>(null);
|
||||
const pendingScriptRef = useRef(pendingScript);
|
||||
@@ -2517,9 +2525,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!shouldEvaluateConnect && !hasPendingWork) return;
|
||||
if (connectScriptsInFlightRef.current) return;
|
||||
|
||||
const batch = createConnectAutomationBatch();
|
||||
connectScriptsBatchRef.current = batch;
|
||||
connectScriptsInFlightRef.current = true;
|
||||
const batchStillActive = () => (
|
||||
!batch.controller.signal.aborted && connectScriptsBatchRef.current === batch
|
||||
);
|
||||
|
||||
// Defer until xterm has rendered login output and the main-process output tap
|
||||
// has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer).
|
||||
let timerFired = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
timerFired = true;
|
||||
if (!batchStillActive()) return;
|
||||
const runPending = Boolean(pendingOne);
|
||||
const connectQueueNow = connectScriptsConsumedRef.current
|
||||
? []
|
||||
@@ -2548,6 +2566,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
})) {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
}
|
||||
if (connectScriptsBatchRef.current === batch) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2556,11 +2578,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
|
||||
connectScriptsInFlightRef.current = true;
|
||||
|
||||
void runConnectScriptsSequential({
|
||||
scripts: scriptsToRun,
|
||||
sessionId,
|
||||
signal: batch.controller.signal,
|
||||
onCancelableRunChange: (stopCurrentRun) => (
|
||||
trackConnectAutomationStop(batch, stopCurrentRun)
|
||||
),
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
@@ -2568,6 +2592,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
username: host.username,
|
||||
},
|
||||
onScriptComplete: (snippet) => {
|
||||
if (!batchStillActive()) return;
|
||||
if (snippet.id && connectIdsInBatch.has(snippet.id)) {
|
||||
connectScriptsCompletedIdsRef.current.add(snippet.id);
|
||||
}
|
||||
@@ -2581,6 +2606,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
if (!batchStillActive()) return;
|
||||
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
|
||||
const doneAfterRun = resolvedAfterRun.length === 0
|
||||
|| resolvedAfterRun.every(
|
||||
@@ -2591,6 +2617,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
})
|
||||
.catch(async (err) => {
|
||||
if (!batchStillActive()) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
|
||||
connectScriptsConsumedRef.current = true;
|
||||
@@ -2606,6 +2633,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
await runConnectScriptsSequential({
|
||||
scripts: [pendingScriptToMark],
|
||||
sessionId,
|
||||
signal: batch.controller.signal,
|
||||
onCancelableRunChange: (stopCurrentRun) => (
|
||||
trackConnectAutomationStop(batch, stopCurrentRun)
|
||||
),
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
@@ -2613,6 +2644,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
username: host.username,
|
||||
},
|
||||
onScriptComplete: (snippet) => {
|
||||
if (!batchStillActive()) return;
|
||||
if (snippet.id) {
|
||||
pendingScriptRunIdRef.current = snippet.id;
|
||||
} else {
|
||||
@@ -2621,16 +2653,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
},
|
||||
});
|
||||
} catch (pendingErr) {
|
||||
if (!batchStillActive()) return;
|
||||
const pendingMessage = pendingErr instanceof Error ? pendingErr.message : String(pendingErr);
|
||||
toast.error(pendingMessage.includes('Observer mode') ? t('scripts.observer.blocked') : pendingMessage);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
connectScriptsInFlightRef.current = false;
|
||||
if (
|
||||
!batch.controller.signal.aborted
|
||||
&& connectScriptsBatchRef.current === batch
|
||||
) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
});
|
||||
}, 400);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
if (!timerFired && connectScriptsBatchRef.current === batch) {
|
||||
batch.controller.abort();
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
} else if (statusRef.current !== "connected") {
|
||||
void cancelConnectAutomationBatch(batch).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [effectiveTerminalProtocol, host, isPendingScriptAlreadyHandled, moshShellReady, pendingScript, pendingScriptId, scriptSessionName, sessionId, snippets, status, t]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3358,20 +3406,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
if (!termRef.current) return;
|
||||
if (mode === "manual") {
|
||||
clearAutoReconnect();
|
||||
prepareRestoredReconnect();
|
||||
// A clone's first connection can fail (auth/host-key/transport) before the
|
||||
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
|
||||
// intent for non-restored sessions, so re-arm it here; the callback no-ops
|
||||
// once the cwd was consumed or when there is no pending inherited cwd.
|
||||
prepareInitialCwdIntent();
|
||||
} else {
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
|
||||
}
|
||||
// Claim the retry before awaiting close. A close/cancel/unmount during the
|
||||
// awaited backend cleanup invalidates this token and stops the continuation.
|
||||
// Claim the retry before awaiting either script cancellation or backend
|
||||
// cleanup. A second reconnect/cancel invalidates this continuation.
|
||||
const retryToken = Symbol("retry");
|
||||
retryTokenRef.current = retryToken;
|
||||
reconnectPreparationTokenRef.current = retryToken;
|
||||
@@ -3382,6 +3418,59 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const connectAutomationBatch = connectScriptsBatchRef.current;
|
||||
if (connectAutomationBatch) {
|
||||
try {
|
||||
await cancelConnectAutomationBatch(connectAutomationBatch);
|
||||
} catch (error) {
|
||||
finishReconnectPreparation();
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message);
|
||||
if (mode === "auto" && retryTokenStillCurrent()) {
|
||||
// Return to disconnected so the existing auto-reconnect loop can
|
||||
// schedule another attempt, including another exact stop request.
|
||||
updateStatus("disconnected");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!retryTokenStillCurrent()) {
|
||||
finishReconnectPreparation();
|
||||
return;
|
||||
}
|
||||
if (connectScriptsBatchRef.current === connectAutomationBatch) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "manual") {
|
||||
clearAutoReconnect();
|
||||
// Manual reconnect skips the disconnected status transition that normally
|
||||
// clears these refs, so onConnect scripts would otherwise stay consumed.
|
||||
if (shouldResetConnectAutomationOnReconnect(
|
||||
restoreState === "restored-disconnected" ? "restored" : "manual",
|
||||
)) {
|
||||
// The prior batch is now stopped, so its callbacks cannot mutate these
|
||||
// newly allocated guards for the fresh manual connection.
|
||||
connectScriptsConsumedRef.current = false;
|
||||
connectScriptsCompletedIdsRef.current = new Set();
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
prepareRestoredReconnect();
|
||||
// A clone's first connection can fail (auth/host-key/transport) before the
|
||||
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
|
||||
// intent for non-restored sessions, so re-arm it here; the callback no-ops
|
||||
// once the cwd was consumed or when there is no pending inherited cwd.
|
||||
prepareInitialCwdIntent();
|
||||
} else {
|
||||
// An automatic reconnect replaces the transport but remains the same user
|
||||
// session. Stop old automation above, then preserve the existing policy of
|
||||
// not starting onConnect scripts again on the replacement transport.
|
||||
connectScriptsConsumedRef.current = true;
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
|
||||
}
|
||||
|
||||
try {
|
||||
await cleanupSession();
|
||||
} catch (error) {
|
||||
|
||||
@@ -13,3 +13,10 @@ test("hidden selected theme uses the normal theme item row", () => {
|
||||
assert.match(source, /hiddenSelectedTheme && \(\s*<ThemeItem/);
|
||||
assert.doesNotMatch(source, /terminal\.hiddenTheme\.title[\s\S]*terminal\.hiddenTheme\.desc/);
|
||||
});
|
||||
|
||||
test("theme side panel tabs are vertically centered in the header", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1\.5 gap-0\.5 border-b'/,
|
||||
);
|
||||
});
|
||||
@@ -311,7 +311,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
>
|
||||
{/* Tab Bar */}
|
||||
<div
|
||||
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex px-1.5 gap-0.5 border-b')}
|
||||
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1.5 gap-0.5 border-b')}
|
||||
style={{ borderColor: 'var(--terminal-panel-border)' }}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
cancelConnectAutomationBatch,
|
||||
createConnectAutomationBatch,
|
||||
trackConnectAutomationStop,
|
||||
} from "./connectAutomationBatch.ts";
|
||||
|
||||
test("cancel waits for the stop operation registered by the abort listener", async () => {
|
||||
const batch = createConnectAutomationBatch();
|
||||
let releaseStop: (() => void) | undefined;
|
||||
let stopFinished = false;
|
||||
|
||||
batch.controller.signal.addEventListener("abort", () => {
|
||||
trackConnectAutomationStop(batch, () => new Promise<void>((resolve) => {
|
||||
releaseStop = () => {
|
||||
stopFinished = true;
|
||||
resolve();
|
||||
};
|
||||
}));
|
||||
}, { once: true });
|
||||
|
||||
const cancelling = cancelConnectAutomationBatch(batch);
|
||||
await Promise.resolve();
|
||||
assert.equal(stopFinished, false);
|
||||
releaseStop?.();
|
||||
await cancelling;
|
||||
assert.equal(stopFinished, true);
|
||||
});
|
||||
|
||||
test("cancel surfaces a backend stop failure", async () => {
|
||||
const batch = createConnectAutomationBatch();
|
||||
batch.controller.signal.addEventListener("abort", () => {
|
||||
trackConnectAutomationStop(batch, async () => {
|
||||
throw new Error("stop failed");
|
||||
});
|
||||
}, { once: true });
|
||||
|
||||
await assert.rejects(() => cancelConnectAutomationBatch(batch), /stop failed/);
|
||||
});
|
||||
|
||||
test("cancel can retry a stop operation after a transient failure", async () => {
|
||||
const batch = createConnectAutomationBatch();
|
||||
let attempts = 0;
|
||||
trackConnectAutomationStop(batch, async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error("temporary stop failure");
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => cancelConnectAutomationBatch(batch),
|
||||
/temporary stop failure/,
|
||||
);
|
||||
await cancelConnectAutomationBatch(batch);
|
||||
assert.equal(attempts, 2);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface ConnectAutomationBatch {
|
||||
controller: AbortController;
|
||||
stopCurrentRun: (() => Promise<void>) | null;
|
||||
}
|
||||
|
||||
export const createConnectAutomationBatch = (): ConnectAutomationBatch => ({
|
||||
controller: new AbortController(),
|
||||
stopCurrentRun: null,
|
||||
});
|
||||
|
||||
export const trackConnectAutomationStop = (
|
||||
batch: ConnectAutomationBatch,
|
||||
stopCurrentRun: (() => Promise<void>) | null,
|
||||
): void => {
|
||||
batch.stopCurrentRun = stopCurrentRun;
|
||||
};
|
||||
|
||||
export const cancelConnectAutomationBatch = async (
|
||||
batch: ConnectAutomationBatch,
|
||||
): Promise<void> => {
|
||||
batch.controller.abort();
|
||||
if (batch.stopCurrentRun) {
|
||||
await batch.stopCurrentRun();
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { readFileSync } from "node:fs";
|
||||
import {
|
||||
getInitialTerminalStatus,
|
||||
resolveTerminalVaultInitialized,
|
||||
shouldResetConnectAutomationOnReconnect,
|
||||
shouldSuppressHostStartupCommandOnReconnect,
|
||||
shouldStartTerminalBackend,
|
||||
} from "./restoredSessionGate.ts";
|
||||
@@ -62,6 +63,88 @@ test("host startup command policy distinguishes restored and automatic reconnect
|
||||
assert.equal(shouldSuppressHostStartupCommandOnReconnect("automatic"), true);
|
||||
});
|
||||
|
||||
test("connect automation reset policy distinguishes manual and automatic reconnects", () => {
|
||||
assert.equal(shouldResetConnectAutomationOnReconnect("manual"), true);
|
||||
assert.equal(shouldResetConnectAutomationOnReconnect("restored"), true);
|
||||
assert.equal(shouldResetConnectAutomationOnReconnect("automatic"), false);
|
||||
});
|
||||
|
||||
test("manual reconnect resets connect automation before opening a new session", () => {
|
||||
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const reconnectIndex = source.indexOf("const startReconnect = ");
|
||||
const cancelBatchIndex = source.indexOf(
|
||||
"await cancelConnectAutomationBatch(connectAutomationBatch)",
|
||||
reconnectIndex,
|
||||
);
|
||||
const manualBranchIndex = source.indexOf('if (mode === "manual")', reconnectIndex);
|
||||
const stopFailureRetryIndex = source.indexOf(
|
||||
'if (mode === "auto" && retryTokenStillCurrent())',
|
||||
cancelBatchIndex,
|
||||
);
|
||||
const stopFailureDisconnectedIndex = source.indexOf(
|
||||
'updateStatus("disconnected")',
|
||||
stopFailureRetryIndex,
|
||||
);
|
||||
const resetConsumedIndex = source.indexOf(
|
||||
"connectScriptsConsumedRef.current = false",
|
||||
manualBranchIndex,
|
||||
);
|
||||
const resetCompletedIndex = source.indexOf(
|
||||
"connectScriptsCompletedIdsRef.current = new Set()",
|
||||
manualBranchIndex,
|
||||
);
|
||||
const resetInFlightIndex = source.indexOf(
|
||||
"connectScriptsInFlightRef.current = false",
|
||||
manualBranchIndex,
|
||||
);
|
||||
const connectingIndex = source.indexOf('updateStatus("connecting")', manualBranchIndex);
|
||||
const autoElseIndex = source.indexOf("} else {", manualBranchIndex);
|
||||
const autoSuppressIndex = source.indexOf(
|
||||
'shouldSuppressHostStartupCommandOnReconnect("automatic")',
|
||||
autoElseIndex,
|
||||
);
|
||||
|
||||
assert.notEqual(reconnectIndex, -1);
|
||||
assert.notEqual(cancelBatchIndex, -1);
|
||||
assert.notEqual(manualBranchIndex, -1);
|
||||
assert.notEqual(stopFailureRetryIndex, -1);
|
||||
assert.notEqual(stopFailureDisconnectedIndex, -1);
|
||||
assert.notEqual(resetConsumedIndex, -1);
|
||||
assert.notEqual(resetCompletedIndex, -1);
|
||||
assert.notEqual(resetInFlightIndex, -1);
|
||||
assert.notEqual(connectingIndex, -1);
|
||||
assert.notEqual(autoElseIndex, -1);
|
||||
assert.notEqual(autoSuppressIndex, -1);
|
||||
assert.ok(
|
||||
cancelBatchIndex < manualBranchIndex
|
||||
&& cancelBatchIndex < resetConsumedIndex
|
||||
&& cancelBatchIndex < resetCompletedIndex
|
||||
&& cancelBatchIndex < resetInFlightIndex,
|
||||
"every reconnect must await onConnect cancellation before manual guards are cleared",
|
||||
);
|
||||
assert.ok(
|
||||
stopFailureRetryIndex < stopFailureDisconnectedIndex
|
||||
&& stopFailureDisconnectedIndex < manualBranchIndex,
|
||||
"automatic reconnect must return to disconnected when stopping the old batch fails",
|
||||
);
|
||||
assert.ok(
|
||||
resetConsumedIndex < connectingIndex && resetCompletedIndex < connectingIndex,
|
||||
"manual reconnect must clear connect-automation consumption before status becomes connecting",
|
||||
);
|
||||
assert.ok(
|
||||
autoElseIndex < autoSuppressIndex && autoSuppressIndex < connectingIndex,
|
||||
"automatic reconnect must keep the existing connect-automation consumption decision",
|
||||
);
|
||||
assert.ok(
|
||||
!source.slice(autoElseIndex, connectingIndex).includes("connectScriptsConsumedRef.current = false"),
|
||||
"automatic reconnect must not reset connect-automation refs",
|
||||
);
|
||||
assert.match(
|
||||
source.slice(autoElseIndex, connectingIndex),
|
||||
/connectScriptsConsumedRef\.current = true/,
|
||||
"automatic reconnect must stop the old batch without queuing it again",
|
||||
);
|
||||
});
|
||||
test("restored disconnected sessions still create a terminal runtime before backend startup", () => {
|
||||
const source = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
|
||||
const runtimeIndex = source.indexOf("const runtime = createXTermRuntime");
|
||||
|
||||
@@ -21,3 +21,11 @@ export const shouldStartTerminalBackend = (): boolean => isVaultInitialized();
|
||||
export const shouldSuppressHostStartupCommandOnReconnect = (
|
||||
mode: TerminalReconnectMode,
|
||||
): boolean => mode === "automatic";
|
||||
|
||||
/**
|
||||
* Manual and restored reconnects are a fresh "connect" for automation scripts.
|
||||
* Automatic reconnect after a drop must not re-run onConnect scripts mid-session.
|
||||
*/
|
||||
export const shouldResetConnectAutomationOnReconnect = (
|
||||
mode: TerminalReconnectMode,
|
||||
): boolean => mode !== "automatic";
|
||||
@@ -39,10 +39,15 @@ const pendingScreenSnapshots = new Map();
|
||||
const scriptLogTokens = new Map();
|
||||
/** @type {Map<string, Promise<void>>} */
|
||||
const sessionRunChains = new Map();
|
||||
/** @type {Map<string, number>} */
|
||||
const queuedRunGenerations = new Map();
|
||||
/** @type {Map<string, number>} */
|
||||
const cancelledQueuedRunGenerations = new Map();
|
||||
/** @type {Map<string, { abort: (reason?: Error) => void }>} */
|
||||
const runAbortControls = new Map();
|
||||
/** @type {Map<string, { connected?: boolean, name?: string, hostname?: string, username?: string }>} */
|
||||
const rendererSessionMetaById = new Map();
|
||||
let scriptRuntimeGeneration = 0;
|
||||
let disposeTerminalDataTap = null;
|
||||
let disposeWorkerOutputTap = null;
|
||||
let disposeWorkerSessionClosed = null;
|
||||
@@ -78,6 +83,9 @@ function enqueueSessionRun(sessionId, task) {
|
||||
}
|
||||
|
||||
function init(deps) {
|
||||
scriptRuntimeGeneration += 1;
|
||||
queuedRunGenerations.clear();
|
||||
cancelledQueuedRunGenerations.clear();
|
||||
for (const [runId, control] of runAbortControls.entries()) {
|
||||
const run = runs.get(runId);
|
||||
if (run && !run.endedAt) {
|
||||
@@ -593,6 +601,8 @@ async function runScriptOnSession({
|
||||
|
||||
async function handleScriptRun(_event, payload = {}) {
|
||||
const {
|
||||
runId: requestedRunId,
|
||||
returnWhenQueued = false,
|
||||
scriptId,
|
||||
scriptLabel,
|
||||
content,
|
||||
@@ -612,22 +622,51 @@ async function handleScriptRun(_event, payload = {}) {
|
||||
if (!content || !String(content).trim()) {
|
||||
throw new Error("Script content is empty");
|
||||
}
|
||||
if (requestedRunId !== undefined && targets.length !== 1) {
|
||||
throw new Error("A caller-provided runId requires exactly one target session");
|
||||
}
|
||||
if (returnWhenQueued && targets.length !== 1) {
|
||||
throw new Error("returnWhenQueued requires exactly one target session");
|
||||
}
|
||||
|
||||
const runIds = [];
|
||||
const queueRun = (sid) => {
|
||||
const runId = randomUUID();
|
||||
const generation = scriptRuntimeGeneration;
|
||||
const runId = requestedRunId === undefined ? randomUUID() : String(requestedRunId).trim();
|
||||
if (!runId || runId.length > 128) {
|
||||
throw new Error("Invalid script run id");
|
||||
}
|
||||
if (runs.has(runId) || queuedRunGenerations.has(runId)) {
|
||||
throw new Error(`Script run id already exists: ${runId}`);
|
||||
}
|
||||
runIds.push(runId);
|
||||
return enqueueSessionRun(sid, () => runScriptOnSession({
|
||||
runId,
|
||||
scriptId,
|
||||
scriptLabel,
|
||||
sessionId: sid,
|
||||
content,
|
||||
permissionMode,
|
||||
sessionMeta: payload.sessionMeta,
|
||||
}));
|
||||
queuedRunGenerations.set(runId, generation);
|
||||
return enqueueSessionRun(sid, () => {
|
||||
if (queuedRunGenerations.get(runId) === generation) {
|
||||
queuedRunGenerations.delete(runId);
|
||||
}
|
||||
if (generation !== scriptRuntimeGeneration) return undefined;
|
||||
if (cancelledQueuedRunGenerations.get(runId) === generation) {
|
||||
cancelledQueuedRunGenerations.delete(runId);
|
||||
return undefined;
|
||||
}
|
||||
return runScriptOnSession({
|
||||
runId,
|
||||
scriptId,
|
||||
scriptLabel,
|
||||
sessionId: sid,
|
||||
content,
|
||||
permissionMode,
|
||||
sessionMeta: payload.sessionMeta,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (returnWhenQueued) {
|
||||
void queueRun(targets[0]).catch(() => {});
|
||||
return { runIds, runId: runIds[0] };
|
||||
}
|
||||
|
||||
if (mode === "sequential") {
|
||||
for (const sid of targets) {
|
||||
await queueRun(sid);
|
||||
@@ -641,7 +680,12 @@ async function handleScriptRun(_event, payload = {}) {
|
||||
|
||||
function handleScriptStop(_event, payload = {}) {
|
||||
const run = runs.get(payload.runId);
|
||||
if (!run) return { ok: false };
|
||||
if (!run) {
|
||||
const generation = queuedRunGenerations.get(payload.runId);
|
||||
if (generation === undefined) return { ok: false };
|
||||
cancelledQueuedRunGenerations.set(payload.runId, generation);
|
||||
return { ok: true };
|
||||
}
|
||||
run.aborted = true;
|
||||
run.paused = false;
|
||||
run.status = "failed";
|
||||
|
||||
@@ -202,6 +202,90 @@ test("same session script runs are serialized through the session mutex", async
|
||||
assert.deepEqual(writeOrder, ["slow-run", "fast-run"]);
|
||||
});
|
||||
|
||||
test("a caller can cancel its queued script by run id without stopping the active script", async () => {
|
||||
const handlers = new Map();
|
||||
const writes = [];
|
||||
let activeDialogRequestId;
|
||||
|
||||
scriptBridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: {
|
||||
app: { getVersion: () => "test", getPath: () => process.cwd() },
|
||||
},
|
||||
terminalBridge: {
|
||||
writeToSession(_event, payload) {
|
||||
writes.push(String(payload.data || ""));
|
||||
},
|
||||
},
|
||||
terminalWorkerManager: null,
|
||||
getMainWindow: () => ({
|
||||
webContents: {
|
||||
send(channel, payload) {
|
||||
if (channel === "netcatty:script:dialog-request") {
|
||||
activeDialogRequestId = payload.requestId;
|
||||
}
|
||||
if (channel === "netcatty:script:screen-snapshot-request") {
|
||||
setImmediate(() => {
|
||||
handlers.get("netcatty:script:screen-snapshot-response")({}, {
|
||||
requestId: payload.requestId,
|
||||
snapshot: { rows: 24, cols: 80, currentRow: 0, lines: [] },
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
scriptBridge.registerHandlers({
|
||||
handle(channel, handler) {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
});
|
||||
|
||||
const runHandler = handlers.get("netcatty:script:run");
|
||||
const activeRun = runHandler({}, {
|
||||
runId: "unrelated-active-run",
|
||||
scriptId: "manual",
|
||||
sessionId: "session-queued-cancel",
|
||||
content: `
|
||||
await nct.dialog.confirm('hold queue');
|
||||
await nct.screen.sendLine('active-finished');
|
||||
`,
|
||||
permissionMode: "auto",
|
||||
});
|
||||
await waitUntil(() => activeDialogRequestId);
|
||||
|
||||
const queued = await runHandler({}, {
|
||||
runId: "queued-connect-run",
|
||||
returnWhenQueued: true,
|
||||
scriptId: "connect",
|
||||
sessionId: "session-queued-cancel",
|
||||
content: "await nct.screen.sendLine('queued-ran');",
|
||||
permissionMode: "auto",
|
||||
});
|
||||
assert.deepEqual(queued, {
|
||||
runId: "queued-connect-run",
|
||||
runIds: ["queued-connect-run"],
|
||||
});
|
||||
assert.deepEqual(
|
||||
await handlers.get("netcatty:script:stop")({}, { runId: "queued-connect-run" }),
|
||||
{ ok: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
await handlers.get("netcatty:script:dialog-response")({}, {
|
||||
requestId: activeDialogRequestId,
|
||||
value: true,
|
||||
}),
|
||||
{ ok: true },
|
||||
);
|
||||
await activeRun;
|
||||
await delay(30);
|
||||
|
||||
assert.ok(writes.some((data) => data.includes("active-finished")));
|
||||
assert.ok(!writes.some((data) => data.includes("queued-ran")));
|
||||
});
|
||||
|
||||
test("stopping a script releases the session queue so it can run again", async () => {
|
||||
const handlers = new Map();
|
||||
const sentRunUpdates = [];
|
||||
@@ -358,6 +442,172 @@ test("reinitializing the script bridge drops an accepted old-run sendLine tail",
|
||||
assert.deepEqual(replacementWrites, []);
|
||||
});
|
||||
|
||||
test("reinitializing the script bridge invalidates accepted queued runs", async () => {
|
||||
const handlers = new Map();
|
||||
const oldWrites = [];
|
||||
const replacementWrites = [];
|
||||
let activeDialogRequestId;
|
||||
|
||||
scriptBridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: {
|
||||
app: { getVersion: () => "test", getPath: () => process.cwd() },
|
||||
},
|
||||
terminalBridge: {
|
||||
writeToSession(_event, payload) { oldWrites.push(String(payload.data || "")); },
|
||||
},
|
||||
terminalWorkerManager: null,
|
||||
getMainWindow: () => ({
|
||||
webContents: {
|
||||
send(channel, payload) {
|
||||
if (channel === "netcatty:script:dialog-request") {
|
||||
activeDialogRequestId = payload.requestId;
|
||||
}
|
||||
if (channel === "netcatty:script:screen-snapshot-request") {
|
||||
setImmediate(() => {
|
||||
handlers.get("netcatty:script:screen-snapshot-response")({}, {
|
||||
requestId: payload.requestId,
|
||||
snapshot: { rows: 24, cols: 80, currentRow: 0, lines: [] },
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
scriptBridge.registerHandlers({
|
||||
handle(channel, handler) { handlers.set(channel, handler); },
|
||||
});
|
||||
|
||||
const runHandler = handlers.get("netcatty:script:run");
|
||||
const activeRun = runHandler({}, {
|
||||
runId: "active-before-reinit",
|
||||
scriptId: "active-before-reinit",
|
||||
sessionId: "session-reinit-queue",
|
||||
content: "await nct.dialog.confirm('hold queue');",
|
||||
permissionMode: "auto",
|
||||
});
|
||||
await waitUntil(() => activeDialogRequestId);
|
||||
await runHandler({}, {
|
||||
runId: "queued-before-reinit",
|
||||
returnWhenQueued: true,
|
||||
scriptId: "queued-before-reinit",
|
||||
sessionId: "session-reinit-queue",
|
||||
content: "await nct.screen.sendLine('stale-queued-write');",
|
||||
permissionMode: "auto",
|
||||
});
|
||||
|
||||
scriptBridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: {
|
||||
app: { getVersion: () => "test", getPath: () => process.cwd() },
|
||||
},
|
||||
terminalBridge: {
|
||||
writeToSession(_event, payload) { replacementWrites.push(String(payload.data || "")); },
|
||||
},
|
||||
terminalWorkerManager: null,
|
||||
getMainWindow: () => null,
|
||||
});
|
||||
|
||||
await activeRun;
|
||||
await delay(60);
|
||||
assert.deepEqual(oldWrites, []);
|
||||
assert.deepEqual(replacementWrites, []);
|
||||
});
|
||||
|
||||
test("an old queued run cannot unregister a reused run id after reinitialization", async () => {
|
||||
const handlers = new Map();
|
||||
const writes = [];
|
||||
let oldDialogRequestId;
|
||||
let replacementDialogRequestId;
|
||||
|
||||
const makeMainWindow = (setDialogRequestId) => () => ({
|
||||
webContents: {
|
||||
send(channel, payload) {
|
||||
if (channel === "netcatty:script:dialog-request") {
|
||||
setDialogRequestId(payload.requestId);
|
||||
}
|
||||
if (channel === "netcatty:script:screen-snapshot-request") {
|
||||
setImmediate(() => {
|
||||
handlers.get("netcatty:script:screen-snapshot-response")({}, {
|
||||
requestId: payload.requestId,
|
||||
snapshot: { rows: 24, cols: 80, currentRow: 0, lines: [] },
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
const baseDeps = {
|
||||
sessions: new Map(),
|
||||
electronModule: {
|
||||
app: { getVersion: () => "test", getPath: () => process.cwd() },
|
||||
},
|
||||
terminalBridge: {
|
||||
writeToSession(_event, payload) { writes.push(String(payload.data || "")); },
|
||||
},
|
||||
terminalWorkerManager: null,
|
||||
};
|
||||
|
||||
scriptBridge.init({
|
||||
...baseDeps,
|
||||
getMainWindow: makeMainWindow((id) => { oldDialogRequestId = id; }),
|
||||
});
|
||||
scriptBridge.registerHandlers({
|
||||
handle(channel, handler) { handlers.set(channel, handler); },
|
||||
});
|
||||
|
||||
const runHandler = handlers.get("netcatty:script:run");
|
||||
const oldActive = runHandler({}, {
|
||||
runId: "old-active-for-reuse",
|
||||
sessionId: "session-reinit-reuse",
|
||||
content: "await nct.dialog.confirm('hold old queue');",
|
||||
permissionMode: "auto",
|
||||
});
|
||||
await waitUntil(() => oldDialogRequestId);
|
||||
await runHandler({}, {
|
||||
runId: "reused-queued-id",
|
||||
returnWhenQueued: true,
|
||||
sessionId: "session-reinit-reuse",
|
||||
content: "await nct.screen.sendLine('stale-reused-write');",
|
||||
permissionMode: "auto",
|
||||
});
|
||||
|
||||
scriptBridge.init({
|
||||
...baseDeps,
|
||||
getMainWindow: makeMainWindow((id) => { replacementDialogRequestId = id; }),
|
||||
});
|
||||
const replacementActive = runHandler({}, {
|
||||
runId: "replacement-active-for-reuse",
|
||||
sessionId: "session-reinit-reuse",
|
||||
content: "await nct.dialog.confirm('hold replacement queue');",
|
||||
permissionMode: "auto",
|
||||
});
|
||||
await runHandler({}, {
|
||||
runId: "reused-queued-id",
|
||||
returnWhenQueued: true,
|
||||
sessionId: "session-reinit-reuse",
|
||||
content: "await nct.screen.sendLine('replacement-reused-write');",
|
||||
permissionMode: "auto",
|
||||
});
|
||||
|
||||
await oldActive;
|
||||
await waitUntil(() => replacementDialogRequestId);
|
||||
assert.deepEqual(
|
||||
await handlers.get("netcatty:script:stop")({}, { runId: "reused-queued-id" }),
|
||||
{ ok: true },
|
||||
);
|
||||
await handlers.get("netcatty:script:dialog-response")({}, {
|
||||
requestId: replacementDialogRequestId,
|
||||
value: true,
|
||||
});
|
||||
await replacementActive;
|
||||
await delay(60);
|
||||
|
||||
assert.ok(!writes.some((data) => data.includes("stale-reused-write")));
|
||||
assert.ok(!writes.some((data) => data.includes("replacement-reused-write")));
|
||||
});
|
||||
|
||||
test("late startup snapshots from a stopped script do not seed the next run", async () => {
|
||||
const handlers = new Map();
|
||||
const writes = [];
|
||||
|
||||
+4
@@ -117,6 +117,10 @@ export interface ScriptDialogRequest {
|
||||
}
|
||||
|
||||
export interface ScriptRunParams {
|
||||
/** Optional caller-generated id so a queued run can be cancelled before it starts. */
|
||||
runId?: string;
|
||||
/** Return after the run enters the backend queue instead of waiting for completion. */
|
||||
returnWhenQueued?: boolean;
|
||||
scriptId?: string;
|
||||
scriptLabel?: string;
|
||||
content: string;
|
||||
|
||||
Reference in new issue
Block a user