[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:
970
application/state/useOutputTriggers.test.ts
Normal file
970
application/state/useOutputTriggers.test.ts
Normal file
@@ -0,0 +1,970 @@
|
||||
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 {
|
||||
createDeferredOutputTriggerEventProcessor,
|
||||
createOutputTriggerScanBuffer,
|
||||
droppedOutputOverflowMayAffectAlternateScreenState,
|
||||
findMatchEndingAfter,
|
||||
hasApplicableOutputTriggerSnippet,
|
||||
inspectDroppedOutputOverflowAlternateScreenState,
|
||||
useOutputTriggers,
|
||||
} from './useOutputTriggers.ts';
|
||||
import { createTerminalOutputTriggerFilter } from '@/domain/terminalOutputTriggerFilter.ts';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
|
||||
test('findMatchEndingAfter skips stale overlap matches and finds current output', () => {
|
||||
const text = 'NETCATTY_TRIGGER_PROBE old\r\nprompt# NETCATTY_TRIGGER_PROBE: command not found\r\n';
|
||||
const minEndOffset = 'NETCATTY_TRIGGER_PROBE old\r\n'.length;
|
||||
const match = findMatchEndingAfter(text, 'NETCATTY_TRIGGER_PROBE', minEndOffset);
|
||||
assert.deepEqual(match, {
|
||||
value: 'NETCATTY_TRIGGER_PROBE',
|
||||
endOffset: 'NETCATTY_TRIGGER_PROBE old\r\nprompt# NETCATTY_TRIGGER_PROBE'.length,
|
||||
});
|
||||
});
|
||||
|
||||
test('findMatchEndingAfter supports regex patterns', () => {
|
||||
const text = 'old done\r\nservice ready: 200\r\n';
|
||||
const minEndOffset = 'old done\r\n'.length;
|
||||
const match = findMatchEndingAfter(text, 'ready:\\s+\\d+', minEndOffset);
|
||||
assert.deepEqual(match, {
|
||||
value: 'ready: 200',
|
||||
endOffset: 'old done\r\nservice ready: 200'.length,
|
||||
});
|
||||
});
|
||||
|
||||
test('findMatchEndingAfter returns null when matches are only stale', () => {
|
||||
const text = 'NETCATTY_TRIGGER_PROBE old\r\nnew output\r\n';
|
||||
const minEndOffset = 'NETCATTY_TRIGGER_PROBE old\r\n'.length;
|
||||
assert.equal(findMatchEndingAfter(text, 'NETCATTY_TRIGGER_PROBE', minEndOffset), null);
|
||||
});
|
||||
|
||||
test('hasApplicableOutputTriggerSnippet ignores non-output-trigger snippets', () => {
|
||||
const snippets: Snippet[] = [
|
||||
{
|
||||
id: 'plain',
|
||||
label: 'Plain',
|
||||
command: 'echo hello',
|
||||
kind: 'snippet',
|
||||
},
|
||||
{
|
||||
id: 'manual-script',
|
||||
label: 'Manual',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'manual',
|
||||
triggerPattern: 'READY',
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), false);
|
||||
});
|
||||
|
||||
test('hasApplicableOutputTriggerSnippet requires a runnable output trigger for this host', () => {
|
||||
const snippets: Snippet[] = [
|
||||
{
|
||||
id: 'wrong-host',
|
||||
label: 'Wrong host',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
targets: ['host-b'],
|
||||
},
|
||||
{
|
||||
id: 'missing-pattern',
|
||||
label: 'Missing pattern',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), false);
|
||||
|
||||
snippets.push({
|
||||
id: 'right-host',
|
||||
label: 'Right host',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
targets: ['host-a'],
|
||||
});
|
||||
|
||||
assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), true);
|
||||
});
|
||||
|
||||
test('hasApplicableOutputTriggerSnippet follows the host current group', () => {
|
||||
const snippets: Snippet[] = [{
|
||||
id: 'group-trigger',
|
||||
label: 'Group trigger',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
targetGroups: ['Production'],
|
||||
}];
|
||||
assert.equal(
|
||||
hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Production/Web' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Staging' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('equivalent host objects do not reset a pending output trigger match', async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const snippets: Snippet[] = [{
|
||||
id: 'stable-host-trigger',
|
||||
label: 'Stable host trigger',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
targetGroups: ['Production'],
|
||||
}];
|
||||
let appendOutput: ((chunk: string) => void) | undefined;
|
||||
let runCount = 0;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const onRunScript = () => {
|
||||
runCount += 1;
|
||||
};
|
||||
|
||||
const Probe = ({ host }: { host: { id: string; group: string } }) => {
|
||||
appendOutput = useOutputTriggers({
|
||||
sessionId: 'session-1',
|
||||
host,
|
||||
snippets,
|
||||
onRunScript,
|
||||
}).appendOutput;
|
||||
return null;
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe, {
|
||||
host: { id: 'host-a', group: 'Production/Web' },
|
||||
}));
|
||||
});
|
||||
try {
|
||||
await act(async () => {
|
||||
appendOutput?.('REA');
|
||||
renderer?.update(React.createElement(Probe, {
|
||||
host: { id: 'host-a', group: 'Production/Web' },
|
||||
}));
|
||||
appendOutput?.('DY');
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
});
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
|
||||
assert.equal(runCount, 1);
|
||||
});
|
||||
|
||||
test('output trigger scan buffer consumes scanned content and only keeps overlap', () => {
|
||||
const buffer = createOutputTriggerScanBuffer(4);
|
||||
|
||||
assert.deepEqual(buffer.append('abcdef'), {
|
||||
text: 'abcdef',
|
||||
minEndOffset: 0,
|
||||
baseOffset: 0,
|
||||
});
|
||||
|
||||
assert.deepEqual(buffer.append('gh'), {
|
||||
text: 'cdefgh',
|
||||
minEndOffset: 4,
|
||||
baseOffset: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('output trigger scan buffer scans all new large output instead of only the tail', () => {
|
||||
const buffer = createOutputTriggerScanBuffer(4);
|
||||
|
||||
assert.deepEqual(buffer.append('abc'), {
|
||||
text: 'abc',
|
||||
minEndOffset: 0,
|
||||
baseOffset: 0,
|
||||
});
|
||||
|
||||
assert.deepEqual(buffer.append('defghijkl'), {
|
||||
text: 'abcdefghijkl',
|
||||
minEndOffset: 3,
|
||||
baseOffset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('deferred output trigger processor schedules output instead of processing synchronously', () => {
|
||||
const processed: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
processOutput: (chunk) => processed.push(chunk),
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
assert.deepEqual(processed, []);
|
||||
assert.equal(scheduled.length, 1);
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(processed, ['READY']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor coalesces output while preserving input order', () => {
|
||||
const events: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
processOutput: (chunk) => events.push(`output:${chunk}`),
|
||||
processInput: (data) => events.push(`input:${data}`),
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('he');
|
||||
processor.enqueueOutput('llo');
|
||||
processor.enqueueInput('cmd');
|
||||
processor.enqueueOutput('world');
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(events, ['output:hello', 'input:cmd', 'output:world']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor splits large output and yields between flushes', () => {
|
||||
const processed: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 8,
|
||||
processOutput: (chunk) => processed.push(chunk),
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('abcdefghijkl');
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(processed, ['abcd', 'efgh']);
|
||||
assert.equal(scheduled.length, 1);
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(processed, ['abcd', 'efgh', 'ijkl']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor splits large input and yields between flushes', () => {
|
||||
const processed: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 8,
|
||||
processOutput: () => {},
|
||||
processInput: (chunk) => processed.push(chunk),
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueInput('abcdefghijkl');
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(processed, ['abcd', 'efgh']);
|
||||
assert.equal(scheduled.length, 1);
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(processed, ['abcd', 'efgh', 'ijkl']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor caps pending input and resets input state', () => {
|
||||
const processed: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
let inputOverflows = 0;
|
||||
let inputOverflowLines = 0;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 8,
|
||||
maxPendingInputBytes: 8,
|
||||
processOutput: () => {},
|
||||
processInput: (chunk) => processed.push(chunk),
|
||||
processInputOverflow: (lineCount) => {
|
||||
inputOverflows += 1;
|
||||
inputOverflowLines += lineCount;
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueInput('abcdefghijkl');
|
||||
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
assert.equal(inputOverflows, 1);
|
||||
assert.equal(inputOverflowLines, 1);
|
||||
|
||||
assert.deepEqual(processed, ['efgh', 'ijkl']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor reports dropped input echo line count', () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
const overflowLines: number[] = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 16,
|
||||
maxOutputBytesPerFlush: 32,
|
||||
maxPendingInputBytes: 5,
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
processInputOverflow: (lineCount) => {
|
||||
overflowLines.push(lineCount);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueInput('one\r\ntwo\nthree');
|
||||
processor.flush();
|
||||
|
||||
assert.deepEqual(overflowLines, [3]);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor reports more than 1024 dropped input echo lines', () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
const overflowLines: number[] = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 16,
|
||||
maxOutputBytesPerFlush: 32,
|
||||
maxPendingInputBytes: 4,
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
processInputOverflow: (lineCount) => {
|
||||
overflowLines.push(lineCount);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueInput(`${'x\n'.repeat(1100)}tail`);
|
||||
processor.flush();
|
||||
|
||||
assert.deepEqual(overflowLines, [1101]);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor includes retained input lines in overflow echo suppression', () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
const overflowLines: number[] = [];
|
||||
const retainedInput = 'READY c\nREADY d\n';
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 64,
|
||||
maxOutputBytesPerFlush: 128,
|
||||
maxPendingInputBytes: retainedInput.length,
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
processInputOverflow: (lineCount) => {
|
||||
overflowLines.push(lineCount);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueInput(`READY a\nREADY b\n${retainedInput}`);
|
||||
processor.flush();
|
||||
|
||||
assert.deepEqual(overflowLines, [4]);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor accumulates repeated input overflows before flush', () => {
|
||||
const overflowLines: number[] = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxPendingInputBytes: 0,
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
processInputOverflow: (lineCount) => {
|
||||
overflowLines.push(lineCount);
|
||||
},
|
||||
schedule: () => () => {},
|
||||
});
|
||||
|
||||
processor.enqueueInput('one\ntwo\n');
|
||||
processor.enqueueInput('three\nfour\n');
|
||||
processor.flush();
|
||||
|
||||
assert.deepEqual(overflowLines, [4]);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor counts input split across overflow boundary as one line', () => {
|
||||
const overflowLines: number[] = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxPendingInputBytes: 'def\n'.length,
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
processInputOverflow: (lineCount) => {
|
||||
overflowLines.push(lineCount);
|
||||
},
|
||||
schedule: () => () => {},
|
||||
});
|
||||
|
||||
processor.enqueueInput('abcdef\n');
|
||||
processor.flush();
|
||||
|
||||
assert.deepEqual(overflowLines, [1]);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor resets input overflow accounting after flush', () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
const overflowLines: number[] = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxPendingInputBytes: 0,
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
processInputOverflow: (lineCount) => {
|
||||
overflowLines.push(lineCount);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueInput('one\ntwo\n');
|
||||
scheduled.shift()?.();
|
||||
processor.enqueueInput('three\n');
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(overflowLines, [2, 1]);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor preserves output before later input overflow', () => {
|
||||
const processed: string[] = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxPendingInputBytes: 0,
|
||||
processOutput: (chunk) => {
|
||||
processed.push(`output:${chunk}`);
|
||||
},
|
||||
processInput: () => {},
|
||||
processInputOverflow: (lineCount) => {
|
||||
processed.push(`overflow:${lineCount}`);
|
||||
},
|
||||
schedule: () => () => {},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('READY\n');
|
||||
processor.enqueueInput('abcdef\n');
|
||||
processor.flush();
|
||||
|
||||
assert.deepEqual(processed, ['output:READY\n', 'overflow:1']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor suppresses retained multi-line input echo after overflow', () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
const filter = createTerminalOutputTriggerFilter();
|
||||
const retainedInput = 'READY c\nREADY d\n';
|
||||
const fullInput = `READY a\nREADY b\n${retainedInput}`;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 64,
|
||||
maxOutputBytesPerFlush: 128,
|
||||
maxPendingInputBytes: retainedInput.length,
|
||||
processOutput: () => {},
|
||||
processInput: (chunk) => {
|
||||
filter.noteUserInput(chunk);
|
||||
},
|
||||
processInputOverflow: (lineCount) => {
|
||||
filter.markInputEchoUncertain(lineCount);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueInput(fullInput);
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
const result = filter.processServerChunk(`${fullInput}server READY\n`);
|
||||
assert.equal(result.scannableText, 'server READY\n');
|
||||
});
|
||||
|
||||
test('deferred output trigger processor keeps pending scan output bounded', () => {
|
||||
const processed: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 64,
|
||||
maxPendingOutputBytes: 8,
|
||||
pendingOutputOverlapChars: 4,
|
||||
processOutput: (chunk) => processed.push(chunk),
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('x'.repeat(80));
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
assert.equal(processor.getPendingOutputBytes(), 12);
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.equal(processed.join('').length, 12);
|
||||
assert.equal(processed.join('').endsWith('READY'), true);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor defers dropped output maintenance work', () => {
|
||||
const dropped: string[] = [];
|
||||
const processed: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 8,
|
||||
maxPendingOutputBytes: 8,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: (chunk) => dropped.push(chunk),
|
||||
processOutput: (chunk) => processed.push(chunk),
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('x'.repeat(40));
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
assert.deepEqual(dropped, []);
|
||||
assert.deepEqual(processed, []);
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.equal(dropped.join('').length, 8);
|
||||
assert.deepEqual(processed, []);
|
||||
assert.equal(scheduled.length, 1);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor applies output metadata in queue order', () => {
|
||||
const scanned: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
let suppressed = false;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 64,
|
||||
maxOutputBytesPerFlush: 64,
|
||||
processOutput: (chunk, meta) => {
|
||||
if (meta?.droppedOutputMayAffectTerminalState) {
|
||||
suppressed = true;
|
||||
}
|
||||
if (!suppressed) {
|
||||
scanned.push(chunk);
|
||||
}
|
||||
},
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('READY');
|
||||
processor.enqueueOutput('AFTER_DROP', { droppedOutputMayAffectTerminalState: true });
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(scanned, ['READY']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor preserves metadata when trimmed output is fully dropped', () => {
|
||||
const scanned: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
let suppressed = false;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 64,
|
||||
maxOutputBytesPerFlush: 64,
|
||||
maxPendingOutputBytes: 5,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: (_chunk, meta) => {
|
||||
if (meta?.droppedOutputMayAffectTerminalState) {
|
||||
suppressed = true;
|
||||
}
|
||||
},
|
||||
processOutput: (chunk) => {
|
||||
if (!suppressed) {
|
||||
scanned.push(chunk);
|
||||
}
|
||||
},
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('DROP', { droppedOutputMayAffectTerminalState: true });
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
assert.deepEqual(scanned, []);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor caps dropped maintenance before retained output', () => {
|
||||
const dropped: string[] = [];
|
||||
const processed: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 8,
|
||||
maxPendingOutputBytes: 8,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: (chunk) => dropped.push(chunk),
|
||||
processOutput: (chunk) => processed.push(chunk),
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('x'.repeat(200));
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
scheduled.shift()?.();
|
||||
assert.equal(dropped.join('').length, 8);
|
||||
assert.deepEqual(processed, []);
|
||||
|
||||
scheduled.shift()?.();
|
||||
assert.equal(processed.join('').endsWith('READY'), true);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor inspects dropped overflow with retained context', () => {
|
||||
let mayAffectAlternateScreen = false;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputBytesPerFlush: 5,
|
||||
maxPendingOutputBytes: 0,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: () => {},
|
||||
processDroppedOutputOverflow: (overflow) => {
|
||||
mayAffectAlternateScreen ||= droppedOutputOverflowMayAffectAlternateScreenState(overflow);
|
||||
},
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
schedule: () => () => {},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('\x1b[?10');
|
||||
processor.enqueueOutput('49hREADY');
|
||||
|
||||
assert.equal(mayAffectAlternateScreen, true);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor does not carry dropped context across real output', () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
let mayAffectAlternateScreen = false;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 8,
|
||||
maxOutputBytesPerFlush: 3,
|
||||
maxPendingOutputBytes: 1,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: () => {},
|
||||
processDroppedOutputOverflow: (overflow) => {
|
||||
mayAffectAlternateScreen ||= droppedOutputOverflowMayAffectAlternateScreenState(overflow);
|
||||
},
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('\x1b[?Z');
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
processor.enqueueOutput('1049hREADY');
|
||||
|
||||
assert.equal(mayAffectAlternateScreen, false);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor suppresses retained scans after dropped maintenance overflow', () => {
|
||||
const scannable: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const filter = createTerminalOutputTriggerFilter();
|
||||
let suppressScan = false;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 8,
|
||||
maxPendingOutputBytes: 8,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: (chunk) => {
|
||||
filter.processServerChunk(chunk);
|
||||
},
|
||||
processDroppedOutputOverflow: (overflow) => {
|
||||
if (droppedOutputOverflowMayAffectAlternateScreenState(overflow)) {
|
||||
suppressScan = true;
|
||||
}
|
||||
},
|
||||
processOutput: (chunk) => {
|
||||
const result = filter.processServerChunk(chunk);
|
||||
if (!suppressScan && result.scannableText && !result.alternateScreenActive) {
|
||||
scannable.push(result.scannableText);
|
||||
}
|
||||
},
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput(`${'x'.repeat(40)}\x1b[?1049h${'y'.repeat(40)}`);
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
assert.equal(suppressScan, true);
|
||||
assert.deepEqual(scannable, []);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor can recover from dropped split alternate-screen leave', () => {
|
||||
const scannable: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const filter = createTerminalOutputTriggerFilter();
|
||||
const recoveryFilter = createTerminalOutputTriggerFilter();
|
||||
let suppressScan = true;
|
||||
const maybeRecover = (chunk: string) => {
|
||||
const recovery = recoveryFilter.processServerChunk(chunk);
|
||||
if (recovery.meta.alternateScreenAction === 'leave' && !recovery.alternateScreenActive) {
|
||||
suppressScan = false;
|
||||
recoveryFilter.reset();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 16,
|
||||
maxOutputBytesPerFlush: 16,
|
||||
maxPendingOutputBytes: 8,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: (chunk) => {
|
||||
filter.processServerChunk(chunk);
|
||||
if (suppressScan) {
|
||||
maybeRecover(chunk);
|
||||
}
|
||||
},
|
||||
processOutput: (chunk) => {
|
||||
const result = filter.processServerChunk(chunk);
|
||||
if (suppressScan) {
|
||||
maybeRecover(chunk);
|
||||
return;
|
||||
}
|
||||
if (result.scannableText && !result.alternateScreenActive) {
|
||||
scannable.push(result.scannableText);
|
||||
}
|
||||
},
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('\x1b[?10');
|
||||
processor.enqueueOutput('49lREADY');
|
||||
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
assert.equal(suppressScan, false);
|
||||
assert.deepEqual(scannable, []);
|
||||
|
||||
processor.enqueueOutput('NEXT');
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
assert.deepEqual(scannable, ['NEXT']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor keeps scanning after plain dropped maintenance overflow', () => {
|
||||
const scannable: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const filter = createTerminalOutputTriggerFilter();
|
||||
let suppressScan = false;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 8,
|
||||
maxPendingOutputBytes: 8,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: (chunk) => {
|
||||
filter.processServerChunk(chunk);
|
||||
},
|
||||
processDroppedOutputOverflow: (overflow) => {
|
||||
if (droppedOutputOverflowMayAffectAlternateScreenState(overflow)) {
|
||||
suppressScan = true;
|
||||
}
|
||||
},
|
||||
processOutput: (chunk) => {
|
||||
const result = filter.processServerChunk(chunk);
|
||||
if (!suppressScan && result.scannableText && !result.alternateScreenActive) {
|
||||
scannable.push(result.scannableText);
|
||||
}
|
||||
},
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('x'.repeat(200));
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
assert.equal(suppressScan, false);
|
||||
assert.equal(scannable.join('').endsWith('READY'), true);
|
||||
});
|
||||
|
||||
test('dropped output overflow detection ignores color sequences but catches alternate screen candidates', () => {
|
||||
assert.equal(droppedOutputOverflowMayAffectAlternateScreenState({
|
||||
retainedPrefix: 'plain',
|
||||
discardedSuffix: '\x1b[31mcolored text',
|
||||
}), false);
|
||||
assert.equal(droppedOutputOverflowMayAffectAlternateScreenState({
|
||||
retainedPrefix: '\x1b[?10',
|
||||
discardedSuffix: '49hmore output',
|
||||
}), true);
|
||||
assert.equal(droppedOutputOverflowMayAffectAlternateScreenState({
|
||||
retainedPrefix: 'plain',
|
||||
discardedSuffix: '\x1b[?1;1049l',
|
||||
}), true);
|
||||
assert.equal(droppedOutputOverflowMayAffectAlternateScreenState({
|
||||
retainedPrefix: `before\x1b[?1;2;3;4;5;6;7;8;9;10`,
|
||||
discardedSuffix: ';1049hafter',
|
||||
}), true);
|
||||
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
|
||||
retainedPrefix: '\x1b[?1049h',
|
||||
discardedSuffix: `${'x'.repeat(5000)}\x1b[?1;1049l`,
|
||||
}), {
|
||||
mayAffectAlternateScreen: true,
|
||||
mayAffectScanState: false,
|
||||
finalAction: 'leave',
|
||||
});
|
||||
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
|
||||
retainedPrefix: 'plain',
|
||||
discardedSuffix: `${'x'.repeat(3000)}\x1b[?1049h${'y'.repeat(3000)}`,
|
||||
}), {
|
||||
mayAffectAlternateScreen: true,
|
||||
mayAffectScanState: false,
|
||||
finalAction: undefined,
|
||||
});
|
||||
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
|
||||
retainedPrefix: 'plain',
|
||||
discardedSuffix: `\x1b[?1049l${'x'.repeat(3000)}\x1b[?1049h${'y'.repeat(3000)}`,
|
||||
}), {
|
||||
mayAffectAlternateScreen: true,
|
||||
mayAffectScanState: false,
|
||||
finalAction: undefined,
|
||||
});
|
||||
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
|
||||
retainedPrefix: 'plain',
|
||||
discardedSuffix: `${'x'.repeat(5000)}\x1b[?1049lplain\x1b[?10`,
|
||||
}), {
|
||||
mayAffectAlternateScreen: true,
|
||||
mayAffectScanState: true,
|
||||
finalAction: undefined,
|
||||
});
|
||||
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
|
||||
retainedPrefix: 'plain',
|
||||
discardedSuffix: `\x1b[?1049l${'x'.repeat(2500)}\x1b[?10`,
|
||||
}), {
|
||||
mayAffectAlternateScreen: true,
|
||||
mayAffectScanState: true,
|
||||
finalAction: undefined,
|
||||
});
|
||||
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
|
||||
retainedPrefix: '\x1b[31',
|
||||
discardedSuffix: 'mREADY',
|
||||
}), {
|
||||
mayAffectAlternateScreen: false,
|
||||
mayAffectScanState: true,
|
||||
finalAction: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('deferred output trigger processor preserves filter state for trimmed output', () => {
|
||||
const scannable: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const filter = createTerminalOutputTriggerFilter();
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 64,
|
||||
maxPendingOutputBytes: 8,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: (chunk) => {
|
||||
filter.processServerChunk(chunk);
|
||||
},
|
||||
processOutput: (chunk) => {
|
||||
const result = filter.processServerChunk(chunk);
|
||||
if (result.scannableText && !result.alternateScreenActive) {
|
||||
scannable.push(result.scannableText);
|
||||
}
|
||||
},
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput(`\x1b[?1049h${'x'.repeat(80)}`);
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
assert.equal(processor.getPendingOutputBytes(), 8);
|
||||
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
assert.deepEqual(scannable, []);
|
||||
});
|
||||
Reference in New Issue
Block a user