feat(FN-772): fix terminal startup and prompt delivery reliability
- Fix terminal service to ensure prompt is reliably delivered on startup and reconnect - Add regression tests for terminal reconnect and first-paint behavior - Remove dead code: unused global-settings tests, styles.css, Header/MissionManager tests - Update terminal README with reliable prompt delivery guarantee documentation - Add useTerminal hook tests and TerminalModal component tests
This commit is contained in:
@@ -459,4 +459,121 @@ describe("TerminalService", () => {
|
||||
svc.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resize suppression data preservation", () => {
|
||||
it("queues data emitted during resize and delivers it after debounce", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const dataListener = vi.fn();
|
||||
service.onData(dataListener);
|
||||
|
||||
const createResult = await service.createSession();
|
||||
expect(createResult.success).toBe(true);
|
||||
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
|
||||
const session = createResult.session;
|
||||
|
||||
// Start a resize — this sets resizeInProgress = true for 150ms
|
||||
service.resize(session.id, 120, 40, true);
|
||||
|
||||
// Emit data while resize is in progress
|
||||
mockPtyProcess._onDataCallback?.("prompt$ ");
|
||||
|
||||
// Data should NOT be delivered yet (suppressed)
|
||||
expect(dataListener).not.toHaveBeenCalled();
|
||||
|
||||
// But scrollback should contain the data
|
||||
expect(service.getScrollback(session.id)).toContain("prompt$ ");
|
||||
|
||||
// Advance past the 150ms resize debounce
|
||||
vi.advanceTimersByTime(160);
|
||||
|
||||
// Now the suppressed data should be flushed through the normal path.
|
||||
// The flush is throttled (OUTPUT_THROTTLE_MS = 4ms), so advance a bit more.
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
// Data should have been delivered to subscribers
|
||||
expect(dataListener).toHaveBeenCalledWith(session.id, "prompt$ ");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("delivers multiple data chunks suppressed during resize", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const dataListener = vi.fn();
|
||||
service.onData(dataListener);
|
||||
|
||||
const createResult = await service.createSession();
|
||||
expect(createResult.success).toBe(true);
|
||||
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
|
||||
const session = createResult.session;
|
||||
|
||||
// Start a resize
|
||||
service.resize(session.id, 80, 24, true);
|
||||
|
||||
// Emit multiple data chunks while suppressed
|
||||
mockPtyProcess._onDataCallback?.("line1\n");
|
||||
mockPtyProcess._onDataCallback?.("line2\n");
|
||||
mockPtyProcess._onDataCallback?.("line3\n");
|
||||
|
||||
// Nothing delivered yet
|
||||
expect(dataListener).not.toHaveBeenCalled();
|
||||
|
||||
// Advance past resize debounce + flush throttle
|
||||
vi.advanceTimersByTime(160);
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
// All suppressed data should be delivered as one concatenated chunk
|
||||
expect(dataListener).toHaveBeenCalledTimes(1);
|
||||
expect(dataListener).toHaveBeenCalledWith(session.id, "line1\nline2\nline3\n");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("scrollback includes data even while resize is in progress", async () => {
|
||||
const createResult = await service.createSession();
|
||||
expect(createResult.success).toBe(true);
|
||||
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
|
||||
const session = createResult.session;
|
||||
|
||||
// Start a resize
|
||||
service.resize(session.id, 120, 40, true);
|
||||
|
||||
// Emit data while suppressed
|
||||
mockPtyProcess._onDataCallback?.("important output");
|
||||
|
||||
// Scrollback should always contain the data
|
||||
const scrollback = service.getScrollback(session.id);
|
||||
expect(scrollback).toContain("important output");
|
||||
});
|
||||
|
||||
it("does not lose data when resize debounce fires before flush", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const dataListener = vi.fn();
|
||||
service.onData(dataListener);
|
||||
|
||||
const createResult = await service.createSession();
|
||||
expect(createResult.success).toBe(true);
|
||||
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
|
||||
const session = createResult.session;
|
||||
|
||||
// Start a resize
|
||||
service.resize(session.id, 100, 30, true);
|
||||
|
||||
// Emit data during suppression
|
||||
mockPtyProcess._onDataCallback?.("shell prompt> ");
|
||||
|
||||
// Advance exactly to the resize debounce boundary
|
||||
vi.advanceTimersByTime(150);
|
||||
|
||||
// The resize debounce should have moved suppressed data to outputBuffer
|
||||
// and scheduled a flush. Advance past the flush throttle.
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
expect(dataListener).toHaveBeenCalledWith(session.id, "shell prompt> ");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,6 +152,16 @@ export interface TerminalSession {
|
||||
flushTimeout: NodeJS.Timeout | null;
|
||||
resizeInProgress: boolean;
|
||||
resizeDebounceTimeout: NodeJS.Timeout | null;
|
||||
/**
|
||||
* PTY output queued during resize suppression.
|
||||
* Instead of discarding data that arrives while `resizeInProgress` is true,
|
||||
* we buffer it here and flush it to clients once the resize debounce completes.
|
||||
* This prevents the initial shell prompt (and other output) from being lost
|
||||
* when it falls inside the 150 ms resize-suppression window.
|
||||
*/
|
||||
resizeSuppressedBuffer: string;
|
||||
/** Internal flush callback set by createSession; used by resize debounce */
|
||||
_flushOutput: (() => void) | null;
|
||||
}
|
||||
|
||||
export interface TerminalOptions {
|
||||
@@ -503,6 +513,8 @@ export class TerminalService extends EventEmitter {
|
||||
flushTimeout: null,
|
||||
resizeInProgress: false,
|
||||
resizeDebounceTimeout: null,
|
||||
resizeSuppressedBuffer: "",
|
||||
_flushOutput: null,
|
||||
};
|
||||
|
||||
this.sessions.set(id, session);
|
||||
@@ -534,6 +546,10 @@ export class TerminalService extends EventEmitter {
|
||||
this.emit("data", id, dataToSend);
|
||||
};
|
||||
|
||||
// Store reference so the resize debounce can trigger a flush of
|
||||
// suppressed output through the same throttled path.
|
||||
session._flushOutput = flushOutput;
|
||||
|
||||
// Forward data events with throttling
|
||||
ptyProcess.onData((data: string) => {
|
||||
// Always append to scrollback buffer so no output is lost
|
||||
@@ -542,9 +558,11 @@ export class TerminalService extends EventEmitter {
|
||||
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
|
||||
}
|
||||
|
||||
// During resize, buffer to scrollback only — suppress delivery to avoid
|
||||
// rendering artifacts, but don't drop the data entirely
|
||||
// During resize, buffer to scrollback only — suppress immediate delivery
|
||||
// to avoid rendering artifacts, but queue the data so it is flushed to
|
||||
// clients once the resize debounce completes (no data loss).
|
||||
if (session.resizeInProgress) {
|
||||
session.resizeSuppressedBuffer += data;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -568,6 +586,8 @@ export class TerminalService extends EventEmitter {
|
||||
clearTimeout(session.resizeDebounceTimeout);
|
||||
session.resizeDebounceTimeout = null;
|
||||
}
|
||||
session._flushOutput = null;
|
||||
session.resizeSuppressedBuffer = "";
|
||||
this.sessions.delete(id);
|
||||
this.exitCallbacks.forEach((cb) => cb(id, exitCode ?? 0));
|
||||
this.emit("exit", id, exitCode ?? 0);
|
||||
@@ -637,6 +657,18 @@ export class TerminalService extends EventEmitter {
|
||||
session.resizeDebounceTimeout = setTimeout(() => {
|
||||
session.resizeInProgress = false;
|
||||
session.resizeDebounceTimeout = null;
|
||||
|
||||
// Flush any data that was suppressed during the resize window.
|
||||
// This ensures the initial shell prompt (and any other output that
|
||||
// landed inside the suppression window) is delivered to clients
|
||||
// rather than being silently dropped.
|
||||
if (session.resizeSuppressedBuffer.length > 0) {
|
||||
session.outputBuffer += session.resizeSuppressedBuffer;
|
||||
session.resizeSuppressedBuffer = "";
|
||||
if (!session.flushTimeout && session._flushOutput) {
|
||||
session.flushTimeout = setTimeout(session._flushOutput, OUTPUT_THROTTLE_MS);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user