feat(FN-2488): add terminal refresh fallbacks for terminal startup failures
- Add refresh-page actions alongside retry/reinitialize in TerminalModal bootstrap and xterm init error states - Add terminal error action-group styling so multiple recovery buttons render cleanly - Extend TerminalModal tests to cover new refresh controls and verify window reload behavior - Remove unused TaskLogEntry import and drop the stale hooks eslint suppression in dashboard-tui app
This commit is contained in:
@@ -46,7 +46,6 @@ import type {
|
||||
FileEntry,
|
||||
FileReadResult,
|
||||
TaskDetailData,
|
||||
TaskLogEntry,
|
||||
TaskEvent,
|
||||
} from "./state.js";
|
||||
import { SECTION_ORDER } from "./state.js";
|
||||
@@ -1175,7 +1174,6 @@ function TaskDetailScreen({
|
||||
cancelled = true;
|
||||
unsub();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectPath, task.id]);
|
||||
|
||||
// When auto-follow is on and new log entries arrive, reset scroll to bottom.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os from "node:os";
|
||||
import v8 from "node:v8";
|
||||
import { execSync } from "node:child_process";
|
||||
import { LogRingBuffer } from "./log-ring-buffer.js";
|
||||
import type { LogEntry } from "./log-ring-buffer.js";
|
||||
import type {
|
||||
|
||||
@@ -696,6 +696,14 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.terminal-error-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.terminal-retry-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -808,6 +808,10 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
setXtermReady(false);
|
||||
}, []);
|
||||
|
||||
const handleRefreshPage = useCallback(() => {
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getStatusIndicator = () => {
|
||||
@@ -967,14 +971,24 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
<div className="terminal-loading" data-testid="terminal-bootstrap-error">
|
||||
<div className="terminal-error-content">
|
||||
<span>Failed to start terminal: {bootstrapError}</span>
|
||||
<button
|
||||
className="terminal-retry-btn"
|
||||
onClick={retryBootstrap}
|
||||
data-testid="terminal-retry-btn"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
</button>
|
||||
<div className="terminal-error-actions">
|
||||
<button
|
||||
className="terminal-retry-btn"
|
||||
onClick={retryBootstrap}
|
||||
data-testid="terminal-retry-btn"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
className="terminal-retry-btn"
|
||||
onClick={handleRefreshPage}
|
||||
data-testid="terminal-bootstrap-refresh-btn"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Refresh page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -982,14 +996,24 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
<div className="terminal-loading" data-testid="terminal-xterm-init-error">
|
||||
<div className="terminal-error-content">
|
||||
<span>Terminal UI failed to initialize: {xtermInitError}</span>
|
||||
<button
|
||||
className="terminal-retry-btn"
|
||||
onClick={handleReinitialize}
|
||||
data-testid="terminal-reinit-btn"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Reinitialize
|
||||
</button>
|
||||
<div className="terminal-error-actions">
|
||||
<button
|
||||
className="terminal-retry-btn"
|
||||
onClick={handleReinitialize}
|
||||
data-testid="terminal-reinit-btn"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Reinitialize
|
||||
</button>
|
||||
<button
|
||||
className="terminal-retry-btn"
|
||||
onClick={handleRefreshPage}
|
||||
data-testid="terminal-xterm-refresh-btn"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Refresh page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("TerminalModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error with retry button when bootstrap fails instead of stuck loading", async () => {
|
||||
it("shows error with retry and refresh buttons when bootstrap fails instead of stuck loading", async () => {
|
||||
const mockRetryBootstrap = vi.fn();
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
@@ -164,13 +164,17 @@ describe("TerminalModal", () => {
|
||||
expect(screen.queryByTestId("terminal-loading")).toBeNull();
|
||||
});
|
||||
|
||||
// Should show the bootstrap error state with retry button
|
||||
// Should show the bootstrap error state with retry + refresh buttons
|
||||
expect(screen.getByTestId("terminal-bootstrap-error")).toBeTruthy();
|
||||
expect(screen.getByText(/Failed to start terminal: Server unreachable/)).toBeTruthy();
|
||||
|
||||
const retryBtn = screen.getByTestId("terminal-retry-btn");
|
||||
expect(retryBtn).toBeTruthy();
|
||||
expect(retryBtn.textContent).toContain("Retry");
|
||||
|
||||
const refreshBtn = screen.getByTestId("terminal-bootstrap-refresh-btn");
|
||||
expect(refreshBtn).toBeTruthy();
|
||||
expect(refreshBtn.textContent).toContain("Refresh page");
|
||||
});
|
||||
|
||||
it("retry button calls retryBootstrap from the hook", async () => {
|
||||
@@ -191,6 +195,42 @@ describe("TerminalModal", () => {
|
||||
expect(mockRetryBootstrap).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bootstrap refresh button reloads the page", async () => {
|
||||
const mockRetryBootstrap = vi.fn();
|
||||
const reloadMock = vi.fn();
|
||||
const originalWindow = globalThis.window;
|
||||
const patchedWindow = Object.create(originalWindow) as Window & typeof globalThis;
|
||||
|
||||
Object.defineProperty(patchedWindow, "location", {
|
||||
value: {
|
||||
...originalWindow.location,
|
||||
reload: reloadMock,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
(globalThis as { window: Window & typeof globalThis }).window = patchedWindow;
|
||||
|
||||
try {
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
tabs: [],
|
||||
activeTab: null,
|
||||
bootstrapError: "Connection refused",
|
||||
retryBootstrap: mockRetryBootstrap,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const refreshBtn = screen.getByTestId("terminal-bootstrap-refresh-btn");
|
||||
fireEvent.click(refreshBtn);
|
||||
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
(globalThis as { window: Window & typeof globalThis }).window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
it("clears error state and shows terminal after successful retry", async () => {
|
||||
const mockRetryBootstrap = vi.fn();
|
||||
|
||||
@@ -703,7 +743,7 @@ describe("TerminalModal", () => {
|
||||
|
||||
// --- xterm initialization watchdog tests ---
|
||||
describe("xterm initialization watchdog", () => {
|
||||
it("shows xterm init error overlay when xterm constructor throws", async () => {
|
||||
it("shows xterm init error overlay with reinitialize and refresh actions when xterm constructor throws", async () => {
|
||||
// Override the mock to throw on construction
|
||||
const { Terminal } = await import("@xterm/xterm");
|
||||
const OrigTerminal = Terminal;
|
||||
@@ -722,11 +762,15 @@ describe("TerminalModal", () => {
|
||||
expect(screen.getByText(/Terminal UI failed to initialize/)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Should have a reinitialize button
|
||||
// Should have both reinitialize + refresh fallback actions
|
||||
const reinitBtn = screen.getByTestId("terminal-reinit-btn");
|
||||
expect(reinitBtn).toBeTruthy();
|
||||
expect(reinitBtn.textContent).toContain("Reinitialize");
|
||||
|
||||
const refreshBtn = screen.getByTestId("terminal-xterm-refresh-btn");
|
||||
expect(refreshBtn).toBeTruthy();
|
||||
expect(refreshBtn.textContent).toContain("Refresh page");
|
||||
|
||||
// Restore original Terminal
|
||||
(throwingModule as any).Terminal = OrigTerminal;
|
||||
});
|
||||
@@ -768,6 +812,44 @@ describe("TerminalModal", () => {
|
||||
(throwingModule as any).Terminal = OrigTerminal;
|
||||
});
|
||||
|
||||
it("xterm init refresh button reloads the page", async () => {
|
||||
const reloadMock = vi.fn();
|
||||
const originalWindow = globalThis.window;
|
||||
const patchedWindow = Object.create(originalWindow) as Window & typeof globalThis;
|
||||
|
||||
Object.defineProperty(patchedWindow, "location", {
|
||||
value: {
|
||||
...originalWindow.location,
|
||||
reload: reloadMock,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
(globalThis as { window: Window & typeof globalThis }).window = patchedWindow;
|
||||
|
||||
try {
|
||||
const xtermModule = await import("@xterm/xterm");
|
||||
const OrigTerminal = xtermModule.Terminal;
|
||||
|
||||
(xtermModule as any).Terminal = vi.fn().mockImplementation(() => {
|
||||
throw new Error("xterm constructor failed");
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-xterm-init-error")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-xterm-refresh-btn"));
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
(xtermModule as any).Terminal = OrigTerminal;
|
||||
} finally {
|
||||
(globalThis as { window: Window & typeof globalThis }).window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
it("shows timeout error when xterm initialization exceeds XTERM_INIT_TIMEOUT_MS", async () => {
|
||||
// This test uses vi.isolateModules to override the @xterm/xterm mock
|
||||
// for this test only, making the dynamic import hang so the watchdog fires.
|
||||
|
||||
@@ -107,6 +107,15 @@ describe("dev-server-port-detect", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores node inspector diagnostic URLs", () => {
|
||||
expect(
|
||||
detectPortFromLogLine("Starting inspector on 127.0.0.1:9229 failed: address already in use"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
detectPortFromLogLine("Debugger listening on ws://127.0.0.1:9229/abc"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("detectPortFromLogs searches from latest line to oldest", () => {
|
||||
const result = detectPortFromLogs([
|
||||
"old: http://localhost:3000/",
|
||||
|
||||
@@ -137,6 +137,12 @@ function detectGenericUrl(line: string): PortDetectionResult | null {
|
||||
return withSource("generic-url", genericUrlMatch[1]);
|
||||
}
|
||||
|
||||
function isInspectorDiagnosticLine(line: string): boolean {
|
||||
return /\b(?:inspector|debugger)\b/i.test(line)
|
||||
|| /\b(node:)?\s*--inspect(?:-brk)?\b/i.test(line)
|
||||
|| /\bws:\/\/(?:127\.0\.0\.1|localhost):\d{2,5}\b/i.test(line);
|
||||
}
|
||||
|
||||
function detectGenericPortLine(line: string): PortDetectionResult | null {
|
||||
const keywordPortMatch = line.match(/\b(?:ready|listening|started|available|compiled|running|server)\b[^\d]{0,50}(?:on\s+)?(?:port\s*[:=]?\s*)?(\d{2,5})\b/i);
|
||||
if (!keywordPortMatch) {
|
||||
@@ -168,7 +174,7 @@ export function detectPortFromLogLine(line: string): PortDetectionResult | null
|
||||
}
|
||||
|
||||
const cleanLine = stripAnsi(line).trim();
|
||||
if (!cleanLine) {
|
||||
if (!cleanLine || isInspectorDiagnosticLine(cleanLine)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,37 @@
|
||||
|
||||
// Set increased heap size (8GB) to prevent OOM during initial build/start
|
||||
const MEMORY_MB = process.env.FUSION_DEV_MEMORY_MB || "8192";
|
||||
process.env.NODE_OPTIONS = `--max-old-space-size=${MEMORY_MB} ${process.env.NODE_OPTIONS || ""}`.trim();
|
||||
|
||||
// Spawn the actual dev command with all arguments passed through
|
||||
const { spawn } = await import("child_process");
|
||||
const args = process.argv.slice(2);
|
||||
const rawArgs = process.argv.slice(2);
|
||||
|
||||
// --inspect / --inspect-brk / --inspect=PORT enables the Node inspector and
|
||||
// auto-dumps a heap snapshot just before the heap limit is hit. Strip these
|
||||
// from forwarded args so they don't reach the dashboard CLI parser; they go
|
||||
// into NODE_OPTIONS instead so tsx's child node process picks them up.
|
||||
const inspectFlags = [];
|
||||
const args = [];
|
||||
for (const a of rawArgs) {
|
||||
if (a === "--inspect" || a === "--inspect-brk" || a.startsWith("--inspect=") || a.startsWith("--inspect-brk=")) {
|
||||
inspectFlags.push(a);
|
||||
} else {
|
||||
args.push(a);
|
||||
}
|
||||
}
|
||||
if (inspectFlags.length > 0) {
|
||||
// 3 = take up to 3 snapshots as we approach the heap limit. Files land in
|
||||
// CWD as Heap.YYYYMMDD.HHMMSS.PID.NNN.heapsnapshot
|
||||
inspectFlags.push("--heapsnapshot-near-heap-limit=3");
|
||||
console.log(`[dev-with-memory] inspector enabled: ${inspectFlags.join(" ")}`);
|
||||
}
|
||||
|
||||
// Base NODE_OPTIONS applied to every spawned node process (build + run).
|
||||
// Inspector flags are NOT here — they go only on the final tsx run, otherwise
|
||||
// `pnpm build` would grab port 9229 first and tsx would fail to bind.
|
||||
const baseNodeOptions = `--max-old-space-size=${MEMORY_MB} ${process.env.NODE_OPTIONS || ""}`.trim();
|
||||
process.env.NODE_OPTIONS = baseNodeOptions;
|
||||
const runNodeOptions = `${baseNodeOptions} ${inspectFlags.join(" ")}`.trim();
|
||||
|
||||
// In dev we bind the dashboard to 0.0.0.0 so the server is reachable from
|
||||
// mobile devices and other machines on the LAN for testing. Production
|
||||
@@ -32,11 +58,23 @@ if (forwardedArgs.length === 0) {
|
||||
const pnpm = spawn("pnpm", ["build"], { stdio: "inherit", shell: true });
|
||||
pnpm.on("close", (code) => {
|
||||
if (code !== 0) process.exit(code ?? 1);
|
||||
const tsx = spawn("pnpm", ["exec", "tsx", "packages/cli/src/bin.ts"], { stdio: "inherit", shell: true });
|
||||
const tsx = spawn("node_modules/.bin/tsx", ["packages/cli/src/bin.ts"], {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
env: { ...process.env, NODE_OPTIONS: runNodeOptions },
|
||||
});
|
||||
tsx.on("close", (c) => process.exit(c ?? 1));
|
||||
});
|
||||
} else {
|
||||
// Forward all arguments (e.g., "dashboard", "task list", etc.)
|
||||
const cmd = spawn("pnpm", ["build", "&&", "pnpm", "exec", "tsx", "packages/cli/src/bin.ts", ...forwardedArgs], { stdio: "inherit", shell: true });
|
||||
cmd.on("close", (c) => process.exit(c ?? 1));
|
||||
// Build first (without inspector), then exec the CLI with inspector flags.
|
||||
const build = spawn("pnpm", ["build"], { stdio: "inherit", shell: true });
|
||||
build.on("close", (code) => {
|
||||
if (code !== 0) process.exit(code ?? 1);
|
||||
const tsx = spawn("node_modules/.bin/tsx", ["packages/cli/src/bin.ts", ...forwardedArgs], {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
env: { ...process.env, NODE_OPTIONS: runNodeOptions },
|
||||
});
|
||||
tsx.on("close", (c) => process.exit(c ?? 1));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user