feat(web): integrate xterm.js with WebSocket terminal backend (#518)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #518.
This commit is contained in:
@@ -33,6 +33,9 @@
|
|||||||
"@tiptap/react": "^3.20.0",
|
"@tiptap/react": "^3.20.0",
|
||||||
"@tiptap/starter-kit": "^3.20.0",
|
"@tiptap/starter-kit": "^3.20.0",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xyflow/react": "^12.5.3",
|
"@xyflow/react": "^12.5.3",
|
||||||
"better-auth": "^1.4.17",
|
"better-auth": "^1.4.17",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|||||||
195
apps/web/src/components/terminal/TerminalPanel.test.tsx
Normal file
195
apps/web/src/components/terminal/TerminalPanel.test.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* @file TerminalPanel.test.tsx
|
||||||
|
* @description Unit tests for the TerminalPanel component
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mocks
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Mock XTerminal to avoid xterm.js DOM dependencies in panel tests
|
||||||
|
vi.mock("./XTerminal", () => ({
|
||||||
|
XTerminal: vi.fn(({ token, isVisible }: { token: string; isVisible: boolean }) => (
|
||||||
|
<div
|
||||||
|
data-testid="mock-xterminal"
|
||||||
|
data-token={token}
|
||||||
|
data-visible={isVisible ? "true" : "false"}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TerminalPanel } from "./TerminalPanel";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Tests
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("TerminalPanel", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onTabChange = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Rendering
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("rendering", () => {
|
||||||
|
it("renders the terminal panel", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("region", { name: "Terminal panel" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with height 280 when open", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const panel = screen.getByRole("region", { name: "Terminal panel" });
|
||||||
|
expect(panel).toHaveStyle({ height: "280px" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with height 0 when closed", () => {
|
||||||
|
const { container } = render(
|
||||||
|
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
|
||||||
|
);
|
||||||
|
const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
|
||||||
|
expect(panel).toHaveStyle({ height: "0px" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes isVisible=true to XTerminal when open", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const xterm = screen.getByTestId("mock-xterminal");
|
||||||
|
expect(xterm).toHaveAttribute("data-visible", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes isVisible=false to XTerminal when closed", () => {
|
||||||
|
const { container } = render(
|
||||||
|
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
|
||||||
|
);
|
||||||
|
// Use container query since the element is inside an aria-hidden region
|
||||||
|
const xterm = container.querySelector('[data-testid="mock-xterminal"]');
|
||||||
|
expect(xterm).toHaveAttribute("data-visible", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes token to XTerminal", () => {
|
||||||
|
render(
|
||||||
|
(<TerminalPanel open={true} onClose={onClose} token="my-auth-token" />) as ReactElement
|
||||||
|
);
|
||||||
|
const xterm = screen.getByTestId("mock-xterminal");
|
||||||
|
expect(xterm).toHaveAttribute("data-token", "my-auth-token");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Tab bar
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("tab bar", () => {
|
||||||
|
it("renders default tabs when none provided", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("tab", { name: "main" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders custom tabs", () => {
|
||||||
|
const tabs = [
|
||||||
|
{ id: "tab1", label: "Terminal 1" },
|
||||||
|
{ id: "tab2", label: "Terminal 2" },
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
(
|
||||||
|
<TerminalPanel open={true} onClose={onClose} tabs={tabs} token="test-token" />
|
||||||
|
) as ReactElement
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("tab", { name: "Terminal 1" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("tab", { name: "Terminal 2" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the active tab as selected", () => {
|
||||||
|
const tabs = [
|
||||||
|
{ id: "tab1", label: "Tab 1" },
|
||||||
|
{ id: "tab2", label: "Tab 2" },
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
(
|
||||||
|
<TerminalPanel
|
||||||
|
open={true}
|
||||||
|
onClose={onClose}
|
||||||
|
tabs={tabs}
|
||||||
|
activeTab="tab2"
|
||||||
|
token="test-token"
|
||||||
|
/>
|
||||||
|
) as ReactElement
|
||||||
|
);
|
||||||
|
const tab2 = screen.getByRole("tab", { name: "Tab 2" });
|
||||||
|
expect(tab2).toHaveAttribute("aria-selected", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onTabChange when a tab is clicked", () => {
|
||||||
|
const tabs = [
|
||||||
|
{ id: "tab1", label: "Tab 1" },
|
||||||
|
{ id: "tab2", label: "Tab 2" },
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
(
|
||||||
|
<TerminalPanel
|
||||||
|
open={true}
|
||||||
|
onClose={onClose}
|
||||||
|
tabs={tabs}
|
||||||
|
onTabChange={onTabChange}
|
||||||
|
token="test-token"
|
||||||
|
/>
|
||||||
|
) as ReactElement
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Tab 2" }));
|
||||||
|
expect(onTabChange).toHaveBeenCalledWith("tab2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Close button
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("close button", () => {
|
||||||
|
it("renders the close button", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("button", { name: "Close terminal" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when close button is clicked", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close terminal" }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Accessibility
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("accessibility", () => {
|
||||||
|
it("has aria-hidden=true when closed", () => {
|
||||||
|
const { container } = render(
|
||||||
|
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
|
||||||
|
);
|
||||||
|
// When aria-hidden=true, testing-library role queries ignore the element's aria-label.
|
||||||
|
// Use a direct DOM query to verify the attribute.
|
||||||
|
const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
|
||||||
|
expect(panel).toHaveAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has aria-hidden=false when open", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const panel = screen.getByRole("region", { name: "Terminal panel" });
|
||||||
|
expect(panel).toHaveAttribute("aria-hidden", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has tablist role on the tab bar", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("tablist")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
import type { ReactElement, CSSProperties } from "react";
|
"use client";
|
||||||
|
|
||||||
export interface TerminalLine {
|
/**
|
||||||
type: "prompt" | "command" | "output" | "error" | "warning" | "success";
|
* TerminalPanel
|
||||||
content: string;
|
*
|
||||||
}
|
* Shell panel that wraps the XTerminal component with a tab bar and close button.
|
||||||
|
* Replaces the former mock terminal with a real xterm.js PTY terminal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactElement, CSSProperties } from "react";
|
||||||
|
import { XTerminal } from "./XTerminal";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Types (retained for backwards compatibility)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
export interface TerminalTab {
|
export interface TerminalTab {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,51 +25,16 @@ export interface TerminalPanelProps {
|
|||||||
tabs?: TerminalTab[];
|
tabs?: TerminalTab[];
|
||||||
activeTab?: string;
|
activeTab?: string;
|
||||||
onTabChange?: (id: string) => void;
|
onTabChange?: (id: string) => void;
|
||||||
lines?: TerminalLine[];
|
/** Authentication token for the WebSocket connection */
|
||||||
|
token?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultTabs: TerminalTab[] = [
|
const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }];
|
||||||
{ id: "main", label: "main" },
|
|
||||||
{ id: "build", label: "build" },
|
|
||||||
{ id: "logs", label: "logs" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const blinkKeyframes = `
|
// ==========================================
|
||||||
@keyframes ms-terminal-blink {
|
// Component
|
||||||
0%, 100% { opacity: 1; }
|
// ==========================================
|
||||||
50% { opacity: 0; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
let blinkStyleInjected = false;
|
|
||||||
|
|
||||||
function ensureBlinkStyle(): void {
|
|
||||||
if (blinkStyleInjected || typeof document === "undefined") return;
|
|
||||||
const styleEl = document.createElement("style");
|
|
||||||
styleEl.textContent = blinkKeyframes;
|
|
||||||
document.head.appendChild(styleEl);
|
|
||||||
blinkStyleInjected = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLineColor(type: TerminalLine["type"]): string {
|
|
||||||
switch (type) {
|
|
||||||
case "prompt":
|
|
||||||
return "var(--success)";
|
|
||||||
case "command":
|
|
||||||
return "var(--text-2)";
|
|
||||||
case "output":
|
|
||||||
return "var(--muted)";
|
|
||||||
case "error":
|
|
||||||
return "var(--danger)";
|
|
||||||
case "warning":
|
|
||||||
return "var(--warn)";
|
|
||||||
case "success":
|
|
||||||
return "var(--success)";
|
|
||||||
default:
|
|
||||||
return "var(--muted)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TerminalPanel({
|
export function TerminalPanel({
|
||||||
open,
|
open,
|
||||||
@@ -68,11 +42,9 @@ export function TerminalPanel({
|
|||||||
tabs,
|
tabs,
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
lines = [],
|
token = "",
|
||||||
className = "",
|
className = "",
|
||||||
}: TerminalPanelProps): ReactElement {
|
}: TerminalPanelProps): ReactElement {
|
||||||
ensureBlinkStyle();
|
|
||||||
|
|
||||||
const resolvedTabs = tabs ?? defaultTabs;
|
const resolvedTabs = tabs ?? defaultTabs;
|
||||||
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
|
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
|
||||||
|
|
||||||
@@ -109,21 +81,10 @@ export function TerminalPanel({
|
|||||||
|
|
||||||
const bodyStyle: CSSProperties = {
|
const bodyStyle: CSSProperties = {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: "auto",
|
overflow: "hidden",
|
||||||
padding: "10px 16px",
|
display: "flex",
|
||||||
fontFamily: "var(--mono)",
|
flexDirection: "column",
|
||||||
fontSize: "0.78rem",
|
minHeight: 0,
|
||||||
lineHeight: 1.6,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cursorStyle: CSSProperties = {
|
|
||||||
display: "inline-block",
|
|
||||||
width: 7,
|
|
||||||
height: 14,
|
|
||||||
background: "var(--success)",
|
|
||||||
marginLeft: 2,
|
|
||||||
animation: "ms-terminal-blink 1s step-end infinite",
|
|
||||||
verticalAlign: "text-bottom",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -208,7 +169,7 @@ export function TerminalPanel({
|
|||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Close icon — simple X using SVG */}
|
{/* Close icon */}
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M1 1L11 11M11 1L1 11"
|
d="M1 1L11 11M11 1L1 11"
|
||||||
@@ -221,36 +182,9 @@ export function TerminalPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Terminal body */}
|
||||||
<div style={bodyStyle} role="log" aria-live="polite" aria-label="Terminal output">
|
<div style={bodyStyle}>
|
||||||
{lines.map((line, index) => {
|
<XTerminal token={token} isVisible={open} style={{ flex: 1, minHeight: 0 }} />
|
||||||
const isLast = index === lines.length - 1;
|
|
||||||
const lineStyle: CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
gap: 8,
|
|
||||||
};
|
|
||||||
const contentStyle: CSSProperties = {
|
|
||||||
color: getLineColor(line.type),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={index} style={lineStyle}>
|
|
||||||
<span style={contentStyle}>
|
|
||||||
{line.content}
|
|
||||||
{isLast && <span aria-hidden="true" style={cursorStyle} />}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Show cursor even when no lines */}
|
|
||||||
{lines.length === 0 && (
|
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
|
||||||
<span style={{ color: "var(--success)" }}>
|
|
||||||
<span aria-hidden="true" style={cursorStyle} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
227
apps/web/src/components/terminal/XTerminal.test.tsx
Normal file
227
apps/web/src/components/terminal/XTerminal.test.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* @file XTerminal.test.tsx
|
||||||
|
* @description Unit tests for the XTerminal component
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mocks — set up before importing components
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Mock socket.io-client
|
||||||
|
vi.mock("socket.io-client");
|
||||||
|
|
||||||
|
// Mock lib/config
|
||||||
|
vi.mock("@/lib/config", () => ({
|
||||||
|
API_BASE_URL: "http://localhost:3001",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock xterm packages — they require a DOM canvas not available in jsdom
|
||||||
|
const mockTerminalDispose = vi.fn();
|
||||||
|
const mockTerminalWrite = vi.fn();
|
||||||
|
const mockTerminalClear = vi.fn();
|
||||||
|
const mockTerminalOpen = vi.fn();
|
||||||
|
const mockOnData = vi.fn((_handler: (data: string) => void) => ({ dispose: vi.fn() }));
|
||||||
|
const mockLoadAddon = vi.fn();
|
||||||
|
let mockTerminalCols = 80;
|
||||||
|
let mockTerminalRows = 24;
|
||||||
|
|
||||||
|
const MockTerminal = vi.fn(function MockTerminalConstructor(
|
||||||
|
this: Record<string, unknown>,
|
||||||
|
_options: unknown
|
||||||
|
) {
|
||||||
|
this.open = mockTerminalOpen;
|
||||||
|
this.loadAddon = mockLoadAddon;
|
||||||
|
this.onData = mockOnData;
|
||||||
|
this.write = mockTerminalWrite;
|
||||||
|
this.clear = mockTerminalClear;
|
||||||
|
this.dispose = mockTerminalDispose;
|
||||||
|
this.options = {};
|
||||||
|
Object.defineProperty(this, "cols", {
|
||||||
|
get: () => mockTerminalCols,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(this, "rows", {
|
||||||
|
get: () => mockTerminalRows,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockFitAddonFit = vi.fn();
|
||||||
|
const MockFitAddon = vi.fn(function MockFitAddonConstructor(this: Record<string, unknown>) {
|
||||||
|
this.fit = mockFitAddonFit;
|
||||||
|
});
|
||||||
|
|
||||||
|
const MockWebLinksAddon = vi.fn(function MockWebLinksAddonConstructor(
|
||||||
|
this: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
// no-op
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@xterm/xterm", () => ({
|
||||||
|
Terminal: MockTerminal,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@xterm/addon-fit", () => ({
|
||||||
|
FitAddon: MockFitAddon,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@xterm/addon-web-links", () => ({
|
||||||
|
WebLinksAddon: MockWebLinksAddon,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the CSS import
|
||||||
|
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
|
||||||
|
|
||||||
|
// Mock useTerminal hook
|
||||||
|
const mockCreateSession = vi.fn();
|
||||||
|
const mockSendInput = vi.fn();
|
||||||
|
const mockResize = vi.fn();
|
||||||
|
const mockCloseSession = vi.fn();
|
||||||
|
let mockIsConnected = false;
|
||||||
|
let mockSessionId: string | null = null;
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useTerminal", () => ({
|
||||||
|
useTerminal: vi.fn(() => ({
|
||||||
|
isConnected: mockIsConnected,
|
||||||
|
sessionId: mockSessionId,
|
||||||
|
createSession: mockCreateSession,
|
||||||
|
sendInput: mockSendInput,
|
||||||
|
resize: mockResize,
|
||||||
|
closeSession: mockCloseSession,
|
||||||
|
connectionError: null,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock ResizeObserver
|
||||||
|
const mockObserve = vi.fn();
|
||||||
|
const mockUnobserve = vi.fn();
|
||||||
|
const mockDisconnect = vi.fn();
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"ResizeObserver",
|
||||||
|
vi.fn(function MockResizeObserver(this: Record<string, unknown>, _callback: unknown) {
|
||||||
|
this.observe = mockObserve;
|
||||||
|
this.unobserve = mockUnobserve;
|
||||||
|
this.disconnect = mockDisconnect;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock MutationObserver
|
||||||
|
const mockMutationObserve = vi.fn();
|
||||||
|
const mockMutationDisconnect = vi.fn();
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"MutationObserver",
|
||||||
|
vi.fn(function MockMutationObserver(this: Record<string, unknown>, _callback: unknown) {
|
||||||
|
this.observe = mockMutationObserve;
|
||||||
|
this.disconnect = mockMutationDisconnect;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Import component after mocks are set up
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
import { XTerminal } from "./XTerminal";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Tests
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("XTerminal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockIsConnected = false;
|
||||||
|
mockSessionId = null;
|
||||||
|
mockTerminalCols = 80;
|
||||||
|
mockTerminalRows = 24;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Rendering
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("rendering", () => {
|
||||||
|
it("renders the terminal container", () => {
|
||||||
|
render((<XTerminal token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the xterm viewport div", () => {
|
||||||
|
render((<XTerminal token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("xterm-viewport")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the className prop to the container", () => {
|
||||||
|
render((<XTerminal token="test-token" className="custom-class" />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows connecting message when not connected", () => {
|
||||||
|
mockIsConnected = false;
|
||||||
|
|
||||||
|
render((<XTerminal token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByText("Connecting to terminal...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show connecting message when connected", async () => {
|
||||||
|
mockIsConnected = true;
|
||||||
|
|
||||||
|
const { useTerminal } = await import("@/hooks/useTerminal");
|
||||||
|
vi.mocked(useTerminal).mockReturnValue({
|
||||||
|
isConnected: true,
|
||||||
|
sessionId: "session-xyz",
|
||||||
|
createSession: mockCreateSession,
|
||||||
|
sendInput: mockSendInput,
|
||||||
|
resize: mockResize,
|
||||||
|
closeSession: mockCloseSession,
|
||||||
|
connectionError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render((<XTerminal token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// useTerminal integration
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("useTerminal integration", () => {
|
||||||
|
it("passes the token to useTerminal", async () => {
|
||||||
|
const { useTerminal } = await import("@/hooks/useTerminal");
|
||||||
|
render((<XTerminal token="my-auth-token" />) as ReactElement);
|
||||||
|
expect(vi.mocked(useTerminal)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ token: "my-auth-token" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes onOutput, onExit, onError callbacks to useTerminal", async () => {
|
||||||
|
const { useTerminal } = await import("@/hooks/useTerminal");
|
||||||
|
render((<XTerminal token="test-token" />) as ReactElement);
|
||||||
|
const callArgs = vi.mocked(useTerminal).mock.calls[0]?.[0];
|
||||||
|
expect(typeof callArgs?.onOutput).toBe("function");
|
||||||
|
expect(typeof callArgs?.onExit).toBe("function");
|
||||||
|
expect(typeof callArgs?.onError).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Accessibility
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("accessibility", () => {
|
||||||
|
it("has an accessible region role", () => {
|
||||||
|
render((<XTerminal token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("region", { name: "Terminal" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
427
apps/web/src/components/terminal/XTerminal.tsx
Normal file
427
apps/web/src/components/terminal/XTerminal.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XTerminal component
|
||||||
|
*
|
||||||
|
* Renders a real xterm.js terminal connected to the backend /terminal WebSocket namespace.
|
||||||
|
* Handles resize, copy/paste, theme, and session lifecycle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback, useState } from "react";
|
||||||
|
import type { ReactElement, CSSProperties } from "react";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||||
|
import type { FitAddon as XFitAddon } from "@xterm/addon-fit";
|
||||||
|
import { useTerminal } from "@/hooks/useTerminal";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Types
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export interface XTerminalProps {
|
||||||
|
/** Authentication token for the WebSocket connection */
|
||||||
|
token: string;
|
||||||
|
/** Optional CSS class name for the outer container */
|
||||||
|
className?: string;
|
||||||
|
/** Optional inline styles for the outer container */
|
||||||
|
style?: CSSProperties;
|
||||||
|
/** Whether the terminal is visible (used to trigger re-fit) */
|
||||||
|
isVisible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Theme helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a CSS variable value from :root via computed styles.
|
||||||
|
* Falls back to the provided default value if not available (e.g., during SSR).
|
||||||
|
*/
|
||||||
|
function getCssVar(varName: string, fallback: string): string {
|
||||||
|
if (typeof document === "undefined") return fallback;
|
||||||
|
const value = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||||
|
return value.length > 0 ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an xterm.js ITheme object from the current design system CSS variables.
|
||||||
|
* All 5 themes (dark, light, aurora, midnight, sunlit, ocean) update --ms-* primitives
|
||||||
|
* which flow through to the semantic aliases we read here.
|
||||||
|
*/
|
||||||
|
function buildXtermTheme(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
background: getCssVar("--bg-deep", "#080b12"),
|
||||||
|
foreground: getCssVar("--text", "#eef3ff"),
|
||||||
|
cursor: getCssVar("--success", "#14b8a6"),
|
||||||
|
cursorAccent: getCssVar("--bg-deep", "#080b12"),
|
||||||
|
selectionBackground: `${getCssVar("--primary", "#2f80ff")}40`,
|
||||||
|
selectionForeground: getCssVar("--text", "#eef3ff"),
|
||||||
|
selectionInactiveBackground: `${getCssVar("--muted", "#8f9db7")}30`,
|
||||||
|
// Standard ANSI colors mapped to design system
|
||||||
|
black: getCssVar("--bg-deep", "#080b12"),
|
||||||
|
red: getCssVar("--danger", "#e5484d"),
|
||||||
|
green: getCssVar("--success", "#14b8a6"),
|
||||||
|
yellow: getCssVar("--warn", "#f59e0b"),
|
||||||
|
blue: getCssVar("--primary", "#2f80ff"),
|
||||||
|
magenta: getCssVar("--purple", "#8b5cf6"),
|
||||||
|
cyan: "#06b6d4",
|
||||||
|
white: getCssVar("--text-2", "#c5d0e6"),
|
||||||
|
brightBlack: getCssVar("--muted", "#8f9db7"),
|
||||||
|
brightRed: "#f06a6f",
|
||||||
|
brightGreen: "#2dd4bf",
|
||||||
|
brightYellow: "#fbbf24",
|
||||||
|
brightBlue: "#56a0ff",
|
||||||
|
brightMagenta: "#a78bfa",
|
||||||
|
brightCyan: "#22d3ee",
|
||||||
|
brightWhite: getCssVar("--text", "#eef3ff"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Component
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XTerminal renders a real PTY terminal powered by xterm.js,
|
||||||
|
* connected to the backend /terminal WebSocket namespace.
|
||||||
|
*/
|
||||||
|
export function XTerminal({
|
||||||
|
token,
|
||||||
|
className = "",
|
||||||
|
style,
|
||||||
|
isVisible = true,
|
||||||
|
}: XTerminalProps): ReactElement {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const terminalRef = useRef<XTerm | null>(null);
|
||||||
|
const fitAddonRef = useRef<XFitAddon | null>(null);
|
||||||
|
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||||
|
const isTerminalMountedRef = useRef(false);
|
||||||
|
|
||||||
|
const [hasExited, setHasExited] = useState(false);
|
||||||
|
const [exitCode, setExitCode] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Terminal session callbacks
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const handleOutput = useCallback((sessionId: string, data: string): void => {
|
||||||
|
void sessionId; // sessionId is single-session in this component
|
||||||
|
terminalRef.current?.write(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleExit = useCallback((event: { sessionId: string; exitCode: number }): void => {
|
||||||
|
void event.sessionId;
|
||||||
|
setHasExited(true);
|
||||||
|
setExitCode(event.exitCode);
|
||||||
|
const term = terminalRef.current;
|
||||||
|
if (term) {
|
||||||
|
term.write(`\r\n\x1b[33m[Process exited with code ${event.exitCode.toString()}]\x1b[0m\r\n`);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleError = useCallback((message: string): void => {
|
||||||
|
const term = terminalRef.current;
|
||||||
|
if (term) {
|
||||||
|
term.write(`\r\n\x1b[31m[Error: ${message}]\x1b[0m\r\n`);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { isConnected, sessionId, createSession, sendInput, resize, closeSession } = useTerminal({
|
||||||
|
token,
|
||||||
|
onOutput: handleOutput,
|
||||||
|
onExit: handleExit,
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Fit helper
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const fitAndResize = useCallback((): void => {
|
||||||
|
const fitAddon = fitAddonRef.current;
|
||||||
|
const terminal = terminalRef.current;
|
||||||
|
if (!fitAddon || !terminal) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fitAddon.fit();
|
||||||
|
resize(terminal.cols, terminal.rows);
|
||||||
|
} catch {
|
||||||
|
// Ignore fit errors (e.g., when container has zero dimensions)
|
||||||
|
}
|
||||||
|
}, [resize]);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mount xterm.js terminal (client-only)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || isTerminalMountedRef.current) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const mountTerminal = async (): Promise<void> => {
|
||||||
|
// Dynamic imports ensure DOM-dependent xterm.js modules are never loaded server-side
|
||||||
|
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
||||||
|
import("@xterm/xterm"),
|
||||||
|
import("@xterm/addon-fit"),
|
||||||
|
import("@xterm/addon-web-links"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cancelled || !containerRef.current) return;
|
||||||
|
|
||||||
|
const theme = buildXtermTheme();
|
||||||
|
|
||||||
|
const terminal = new Terminal({
|
||||||
|
fontFamily: "var(--mono, 'Fira Code', 'Cascadia Code', monospace)",
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
cursorBlink: true,
|
||||||
|
cursorStyle: "block",
|
||||||
|
scrollback: 10000,
|
||||||
|
theme,
|
||||||
|
allowTransparency: false,
|
||||||
|
convertEol: true,
|
||||||
|
// Accessibility
|
||||||
|
screenReaderMode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
const webLinksAddon = new WebLinksAddon();
|
||||||
|
|
||||||
|
terminal.loadAddon(fitAddon);
|
||||||
|
terminal.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
|
terminal.open(containerRef.current);
|
||||||
|
|
||||||
|
terminalRef.current = terminal;
|
||||||
|
fitAddonRef.current = fitAddon;
|
||||||
|
isTerminalMountedRef.current = true;
|
||||||
|
|
||||||
|
// Initial fit
|
||||||
|
try {
|
||||||
|
fitAddon.fit();
|
||||||
|
} catch {
|
||||||
|
// Container might not have dimensions yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up ResizeObserver for automatic re-fitting
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
fitAndResize();
|
||||||
|
});
|
||||||
|
observer.observe(containerRef.current);
|
||||||
|
resizeObserverRef.current = observer;
|
||||||
|
};
|
||||||
|
|
||||||
|
void mountTerminal();
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Re-fit when visibility changes
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
// Small delay allows CSS transitions to complete before fitting
|
||||||
|
const id = setTimeout(fitAndResize, 50);
|
||||||
|
return (): void => {
|
||||||
|
clearTimeout(id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [isVisible, fitAndResize]);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Wire terminal input → sendInput
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const terminal = terminalRef.current;
|
||||||
|
if (!terminal) return;
|
||||||
|
|
||||||
|
const disposable = terminal.onData((data: string): void => {
|
||||||
|
sendInput(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
disposable.dispose();
|
||||||
|
};
|
||||||
|
}, [sendInput]);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Create PTY session when connected
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConnected || sessionId !== null) return;
|
||||||
|
|
||||||
|
const terminal = terminalRef.current;
|
||||||
|
const fitAddon = fitAddonRef.current;
|
||||||
|
|
||||||
|
let cols = 80;
|
||||||
|
let rows = 24;
|
||||||
|
|
||||||
|
if (terminal && fitAddon) {
|
||||||
|
try {
|
||||||
|
fitAddon.fit();
|
||||||
|
cols = terminal.cols;
|
||||||
|
rows = terminal.rows;
|
||||||
|
} catch {
|
||||||
|
// Use defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession({ cols, rows });
|
||||||
|
}, [isConnected, sessionId, createSession]);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Update xterm theme when data-theme attribute changes
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const terminal = terminalRef.current;
|
||||||
|
if (terminal) {
|
||||||
|
terminal.options.theme = buildXtermTheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["data-theme"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Cleanup on unmount
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return (): void => {
|
||||||
|
// Cleanup ResizeObserver
|
||||||
|
resizeObserverRef.current?.disconnect();
|
||||||
|
resizeObserverRef.current = null;
|
||||||
|
|
||||||
|
// Close PTY session
|
||||||
|
closeSession();
|
||||||
|
|
||||||
|
// Dispose xterm terminal
|
||||||
|
terminalRef.current?.dispose();
|
||||||
|
terminalRef.current = null;
|
||||||
|
fitAddonRef.current = null;
|
||||||
|
isTerminalMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, [closeSession]);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Render
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const containerStyle: CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
backgroundColor: "var(--bg-deep)",
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={containerStyle}
|
||||||
|
role="region"
|
||||||
|
aria-label="Terminal"
|
||||||
|
data-testid="xterminal-container"
|
||||||
|
>
|
||||||
|
{/* Status bar */}
|
||||||
|
{!isConnected && !hasExited && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: "4px 12px",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--warn)",
|
||||||
|
backgroundColor: "var(--bg-deep)",
|
||||||
|
zIndex: 10,
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connecting to terminal...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Exit overlay */}
|
||||||
|
{hasExited && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 8,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
padding: "4px 12px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text)",
|
||||||
|
backgroundColor: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={(): void => {
|
||||||
|
setHasExited(false);
|
||||||
|
setExitCode(null);
|
||||||
|
if (isConnected) {
|
||||||
|
const terminal = terminalRef.current;
|
||||||
|
const fitAddon = fitAddonRef.current;
|
||||||
|
let cols = 80;
|
||||||
|
let rows = 24;
|
||||||
|
if (terminal && fitAddon) {
|
||||||
|
try {
|
||||||
|
cols = terminal.cols;
|
||||||
|
rows = terminal.rows;
|
||||||
|
} catch {
|
||||||
|
// Use defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
terminal?.clear();
|
||||||
|
createSession({ cols, rows });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Restart terminal {exitCode !== null ? `(exit ${exitCode.toString()})` : ""}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* xterm.js render target */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
padding: "4px 8px",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
data-testid="xterm-viewport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
export type { TerminalLine, TerminalTab, TerminalPanelProps } from "./TerminalPanel";
|
export type { TerminalTab, TerminalPanelProps } from "./TerminalPanel";
|
||||||
export { TerminalPanel } from "./TerminalPanel";
|
export { TerminalPanel } from "./TerminalPanel";
|
||||||
|
export type { XTerminalProps } from "./XTerminal";
|
||||||
|
export { XTerminal } from "./XTerminal";
|
||||||
|
|||||||
462
apps/web/src/hooks/useTerminal.test.ts
Normal file
462
apps/web/src/hooks/useTerminal.test.ts
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
/**
|
||||||
|
* @file useTerminal.test.ts
|
||||||
|
* @description Unit tests for the useTerminal hook
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||||
|
import { useTerminal } from "./useTerminal";
|
||||||
|
import type { Socket } from "socket.io-client";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mock socket.io-client
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
vi.mock("socket.io-client");
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mock lib/config
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
vi.mock("@/lib/config", () => ({
|
||||||
|
API_BASE_URL: "http://localhost:3001",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
interface MockSocket {
|
||||||
|
on: ReturnType<typeof vi.fn>;
|
||||||
|
off: ReturnType<typeof vi.fn>;
|
||||||
|
emit: ReturnType<typeof vi.fn>;
|
||||||
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
|
connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useTerminal", () => {
|
||||||
|
let mockSocket: MockSocket;
|
||||||
|
let socketEventHandlers: Record<string, (data: unknown) => void>;
|
||||||
|
let mockIo: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
socketEventHandlers = {};
|
||||||
|
|
||||||
|
mockSocket = {
|
||||||
|
on: vi.fn((event: string, handler: (data: unknown) => void) => {
|
||||||
|
socketEventHandlers[event] = handler;
|
||||||
|
return mockSocket;
|
||||||
|
}),
|
||||||
|
off: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
connected: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const socketIo = await import("socket.io-client");
|
||||||
|
mockIo = vi.mocked(socketIo.io);
|
||||||
|
mockIo.mockReturnValue(mockSocket as unknown as Socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Connection
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("connection lifecycle", () => {
|
||||||
|
it("should connect to the /terminal namespace with auth token", () => {
|
||||||
|
renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockIo).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/terminal"),
|
||||||
|
expect.objectContaining({
|
||||||
|
auth: { token: "test-token" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should start disconnected and update when connected event fires", async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.isConnected).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect?.(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isConnected).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update sessionId when terminal:created event fires", async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect?.(undefined);
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-abc",
|
||||||
|
name: "main",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessionId).toBe("session-abc");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear sessionId when disconnect event fires", async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect?.(undefined);
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-abc",
|
||||||
|
name: "main",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessionId).toBe("session-abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.disconnect?.(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isConnected).toBe(false);
|
||||||
|
expect(result.current.sessionId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set connectionError when connect_error fires", async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect_error?.(new Error("Connection refused"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.connectionError).toBe("Connection refused");
|
||||||
|
expect(result.current.isConnected).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not connect when token is empty", () => {
|
||||||
|
renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockIo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Output and exit callbacks
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("event callbacks", () => {
|
||||||
|
it("should call onOutput when terminal:output fires", () => {
|
||||||
|
const onOutput = vi.fn();
|
||||||
|
|
||||||
|
renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
onOutput,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:output"]?.({
|
||||||
|
sessionId: "session-abc",
|
||||||
|
data: "hello world\r\n",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onOutput).toHaveBeenCalledWith("session-abc", "hello world\r\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onExit when terminal:exit fires and clear sessionId", async () => {
|
||||||
|
const onExit = vi.fn();
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
onExit,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect?.(undefined);
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-abc",
|
||||||
|
name: "main",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:exit"]?.({
|
||||||
|
sessionId: "session-abc",
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onExit).toHaveBeenCalledWith({ sessionId: "session-abc", exitCode: 0 });
|
||||||
|
expect(result.current.sessionId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onError when terminal:error fires", () => {
|
||||||
|
const onError = vi.fn();
|
||||||
|
|
||||||
|
renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers["terminal:error"]?.({
|
||||||
|
message: "PTY spawn failed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onError).toHaveBeenCalledWith("PTY spawn failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Control functions
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("createSession", () => {
|
||||||
|
it("should emit terminal:create with options when connected", () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect?.(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.createSession({ cols: 120, rows: 40, name: "test" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:create", {
|
||||||
|
cols: 120,
|
||||||
|
rows: 40,
|
||||||
|
name: "test",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not emit terminal:create when disconnected", () => {
|
||||||
|
mockSocket.connected = false;
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.createSession({ cols: 80, rows: 24 });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:create", expect.anything());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendInput", () => {
|
||||||
|
it("should emit terminal:input with sessionId and data", () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect?.(undefined);
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-abc",
|
||||||
|
name: "main",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.sendInput("ls -la\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:input", {
|
||||||
|
sessionId: "session-abc",
|
||||||
|
data: "ls -la\n",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not emit when no sessionId is set", () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect?.(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.sendInput("ls -la\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:input", expect.anything());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resize", () => {
|
||||||
|
it("should emit terminal:resize with sessionId, cols, and rows", () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect?.(undefined);
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-abc",
|
||||||
|
name: "main",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.resize(100, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:resize", {
|
||||||
|
sessionId: "session-abc",
|
||||||
|
cols: 100,
|
||||||
|
rows: 30,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("closeSession", () => {
|
||||||
|
it("should emit terminal:close and clear sessionId", async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect?.(undefined);
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-abc",
|
||||||
|
name: "main",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessionId).toBe("session-abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.closeSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:close", {
|
||||||
|
sessionId: "session-abc",
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.sessionId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Cleanup
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("cleanup", () => {
|
||||||
|
it("should disconnect socket on unmount", () => {
|
||||||
|
const { unmount } = renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(mockSocket.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit terminal:close for active session on unmount", () => {
|
||||||
|
const { result, unmount } = renderHook(() =>
|
||||||
|
useTerminal({
|
||||||
|
token: "test-token",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socketEventHandlers.connect?.(undefined);
|
||||||
|
socketEventHandlers["terminal:created"]?.({
|
||||||
|
sessionId: "session-abc",
|
||||||
|
name: "main",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.sessionId).toBe("session-abc");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:close", {
|
||||||
|
sessionId: "session-abc",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
294
apps/web/src/hooks/useTerminal.ts
Normal file
294
apps/web/src/hooks/useTerminal.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* useTerminal hook
|
||||||
|
*
|
||||||
|
* Manages a WebSocket connection to the /terminal namespace and a PTY terminal session.
|
||||||
|
* Follows the same patterns as useVoiceInput and useWebSocket.
|
||||||
|
*
|
||||||
|
* Protocol (from terminal.gateway.ts):
|
||||||
|
* 1. Connect with auth token in handshake
|
||||||
|
* 2. Emit terminal:create → receive terminal:created { sessionId, name, cols, rows }
|
||||||
|
* 3. Emit terminal:input { sessionId, data } to send keystrokes
|
||||||
|
* 4. Receive terminal:output { sessionId, data } for stdout/stderr
|
||||||
|
* 5. Emit terminal:resize { sessionId, cols, rows } on window resize
|
||||||
|
* 6. Emit terminal:close { sessionId } to terminate the PTY
|
||||||
|
* 7. Receive terminal:exit { sessionId, exitCode, signal } on PTY exit
|
||||||
|
* 8. Receive terminal:error { message } on errors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import type { Socket } from "socket.io-client";
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
import { API_BASE_URL } from "@/lib/config";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Types
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export interface CreateSessionOptions {
|
||||||
|
name?: string;
|
||||||
|
cols?: number;
|
||||||
|
rows?: number;
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalSession {
|
||||||
|
sessionId: string;
|
||||||
|
name: string;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalExitEvent {
|
||||||
|
sessionId: string;
|
||||||
|
exitCode: number;
|
||||||
|
signal?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTerminalOptions {
|
||||||
|
/** Authentication token for WebSocket handshake */
|
||||||
|
token: string;
|
||||||
|
/** Callback fired when terminal output is received */
|
||||||
|
onOutput?: (sessionId: string, data: string) => void;
|
||||||
|
/** Callback fired when a terminal session exits */
|
||||||
|
onExit?: (event: TerminalExitEvent) => void;
|
||||||
|
/** Callback fired on terminal errors */
|
||||||
|
onError?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTerminalReturn {
|
||||||
|
/** Whether the WebSocket is connected */
|
||||||
|
isConnected: boolean;
|
||||||
|
/** The current terminal session ID, or null if no session is active */
|
||||||
|
sessionId: string | null;
|
||||||
|
/** Create a new PTY session */
|
||||||
|
createSession: (options?: CreateSessionOptions) => void;
|
||||||
|
/** Send input data to the terminal */
|
||||||
|
sendInput: (data: string) => void;
|
||||||
|
/** Resize the terminal PTY */
|
||||||
|
resize: (cols: number, rows: number) => void;
|
||||||
|
/** Close the current PTY session */
|
||||||
|
closeSession: () => void;
|
||||||
|
/** Connection error message, if any */
|
||||||
|
connectionError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Payload shapes matching terminal.dto.ts
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
interface TerminalCreatedPayload {
|
||||||
|
sessionId: string;
|
||||||
|
name: string;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalOutputPayload {
|
||||||
|
sessionId: string;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalExitPayload {
|
||||||
|
sessionId: string;
|
||||||
|
exitCode: number;
|
||||||
|
signal?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalErrorPayload {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Security validation
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function validateWebSocketSecurity(url: string): void {
|
||||||
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
|
const isSecure = url.startsWith("https://") || url.startsWith("wss://");
|
||||||
|
|
||||||
|
if (isProduction && !isSecure) {
|
||||||
|
console.warn(
|
||||||
|
"[Security Warning] Terminal WebSocket using insecure protocol (ws://). " +
|
||||||
|
"Authentication tokens may be exposed. Use wss:// in production."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Hook
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing a real PTY terminal session over WebSocket.
|
||||||
|
*
|
||||||
|
* @param options - Configuration including auth token and event callbacks
|
||||||
|
* @returns Terminal state and control functions
|
||||||
|
*/
|
||||||
|
export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
|
||||||
|
const { token, onOutput, onExit, onError } = options;
|
||||||
|
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Keep callbacks in refs to avoid stale closures without causing reconnects
|
||||||
|
const onOutputRef = useRef(onOutput);
|
||||||
|
const onExitRef = useRef(onExit);
|
||||||
|
const onErrorRef = useRef(onError);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onOutputRef.current = onOutput;
|
||||||
|
}, [onOutput]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onExitRef.current = onExit;
|
||||||
|
}, [onExit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onErrorRef.current = onError;
|
||||||
|
}, [onError]);
|
||||||
|
|
||||||
|
// Connect to the /terminal namespace
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsUrl = API_BASE_URL;
|
||||||
|
validateWebSocketSecurity(wsUrl);
|
||||||
|
|
||||||
|
setConnectionError(null);
|
||||||
|
|
||||||
|
const socket = io(`${wsUrl}/terminal`, {
|
||||||
|
auth: { token },
|
||||||
|
transports: ["websocket", "polling"],
|
||||||
|
});
|
||||||
|
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
const handleConnect = (): void => {
|
||||||
|
setIsConnected(true);
|
||||||
|
setConnectionError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = (): void => {
|
||||||
|
setIsConnected(false);
|
||||||
|
setSessionId(null);
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnectError = (error: Error): void => {
|
||||||
|
setConnectionError(error.message || "Terminal connection failed");
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTerminalCreated = (payload: TerminalCreatedPayload): void => {
|
||||||
|
setSessionId(payload.sessionId);
|
||||||
|
sessionIdRef.current = payload.sessionId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTerminalOutput = (payload: TerminalOutputPayload): void => {
|
||||||
|
onOutputRef.current?.(payload.sessionId, payload.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTerminalExit = (payload: TerminalExitPayload): void => {
|
||||||
|
onExitRef.current?.(payload);
|
||||||
|
setSessionId(null);
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTerminalError = (payload: TerminalErrorPayload): void => {
|
||||||
|
onErrorRef.current?.(payload.message);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("connect", handleConnect);
|
||||||
|
socket.on("disconnect", handleDisconnect);
|
||||||
|
socket.on("connect_error", handleConnectError);
|
||||||
|
socket.on("terminal:created", handleTerminalCreated);
|
||||||
|
socket.on("terminal:output", handleTerminalOutput);
|
||||||
|
socket.on("terminal:exit", handleTerminalExit);
|
||||||
|
socket.on("terminal:error", handleTerminalError);
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
socket.off("connect", handleConnect);
|
||||||
|
socket.off("disconnect", handleDisconnect);
|
||||||
|
socket.off("connect_error", handleConnectError);
|
||||||
|
socket.off("terminal:created", handleTerminalCreated);
|
||||||
|
socket.off("terminal:output", handleTerminalOutput);
|
||||||
|
socket.off("terminal:exit", handleTerminalExit);
|
||||||
|
socket.off("terminal:error", handleTerminalError);
|
||||||
|
|
||||||
|
// Close active session before disconnecting
|
||||||
|
const currentSessionId = sessionIdRef.current;
|
||||||
|
if (currentSessionId) {
|
||||||
|
socket.emit("terminal:close", { sessionId: currentSessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const createSession = useCallback((createOptions: CreateSessionOptions = {}): void => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (!socket?.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
|
if (createOptions.name !== undefined) payload.name = createOptions.name;
|
||||||
|
if (createOptions.cols !== undefined) payload.cols = createOptions.cols;
|
||||||
|
if (createOptions.rows !== undefined) payload.rows = createOptions.rows;
|
||||||
|
if (createOptions.cwd !== undefined) payload.cwd = createOptions.cwd;
|
||||||
|
|
||||||
|
socket.emit("terminal:create", payload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendInput = useCallback((data: string): void => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
const currentSessionId = sessionIdRef.current;
|
||||||
|
|
||||||
|
if (!socket?.connected || !currentSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit("terminal:input", { sessionId: currentSessionId, data });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resize = useCallback((cols: number, rows: number): void => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
const currentSessionId = sessionIdRef.current;
|
||||||
|
|
||||||
|
if (!socket?.connected || !currentSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit("terminal:resize", { sessionId: currentSessionId, cols, rows });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSession = useCallback((): void => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
const currentSessionId = sessionIdRef.current;
|
||||||
|
|
||||||
|
if (!socket?.connected || !currentSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit("terminal:close", { sessionId: currentSessionId });
|
||||||
|
setSessionId(null);
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConnected,
|
||||||
|
sessionId,
|
||||||
|
createSession,
|
||||||
|
sendInput,
|
||||||
|
resize,
|
||||||
|
closeSession,
|
||||||
|
connectionError,
|
||||||
|
};
|
||||||
|
}
|
||||||
58
pnpm-lock.yaml
generated
58
pnpm-lock.yaml
generated
@@ -190,6 +190,9 @@ importers:
|
|||||||
matrix-bot-sdk:
|
matrix-bot-sdk:
|
||||||
specifier: ^0.8.0
|
specifier: ^0.8.0
|
||||||
version: 0.8.0
|
version: 0.8.0
|
||||||
|
node-pty:
|
||||||
|
specifier: ^1.0.0
|
||||||
|
version: 1.1.0
|
||||||
ollama:
|
ollama:
|
||||||
specifier: ^0.6.3
|
specifier: ^0.6.3
|
||||||
version: 0.6.3
|
version: 0.6.3
|
||||||
@@ -432,6 +435,15 @@ importers:
|
|||||||
'@types/dompurify':
|
'@types/dompurify':
|
||||||
specifier: ^3.2.0
|
specifier: ^3.2.0
|
||||||
version: 3.2.0
|
version: 3.2.0
|
||||||
|
'@xterm/addon-fit':
|
||||||
|
specifier: ^0.11.0
|
||||||
|
version: 0.11.0
|
||||||
|
'@xterm/addon-web-links':
|
||||||
|
specifier: ^0.12.0
|
||||||
|
version: 0.12.0
|
||||||
|
'@xterm/xterm':
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
'@xyflow/react':
|
'@xyflow/react':
|
||||||
specifier: ^12.5.3
|
specifier: ^12.5.3
|
||||||
version: 12.10.0(@types/react@19.2.10)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 12.10.0(@types/react@19.2.10)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -1584,7 +1596,6 @@ packages:
|
|||||||
|
|
||||||
'@mosaicstack/telemetry-client@0.1.1':
|
'@mosaicstack/telemetry-client@0.1.1':
|
||||||
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
|
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
|
||||||
engines: {node: '>=18'}
|
|
||||||
|
|
||||||
'@mrleebo/prisma-ast@0.13.1':
|
'@mrleebo/prisma-ast@0.13.1':
|
||||||
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
||||||
@@ -3514,6 +3525,15 @@ packages:
|
|||||||
'@webassemblyjs/wast-printer@1.14.1':
|
'@webassemblyjs/wast-printer@1.14.1':
|
||||||
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
|
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
|
||||||
|
|
||||||
|
'@xterm/addon-fit@0.11.0':
|
||||||
|
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||||
|
|
||||||
|
'@xterm/addon-web-links@0.12.0':
|
||||||
|
resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
|
||||||
|
|
||||||
|
'@xterm/xterm@6.0.0':
|
||||||
|
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
|
||||||
|
|
||||||
'@xtuc/ieee754@1.2.0':
|
'@xtuc/ieee754@1.2.0':
|
||||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||||
|
|
||||||
@@ -5874,6 +5894,9 @@ packages:
|
|||||||
node-abort-controller@3.1.1:
|
node-abort-controller@3.1.1:
|
||||||
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
||||||
|
|
||||||
|
node-addon-api@7.1.1:
|
||||||
|
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||||
|
|
||||||
node-downloader-helper@2.1.10:
|
node-downloader-helper@2.1.10:
|
||||||
resolution: {integrity: sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg==}
|
resolution: {integrity: sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg==}
|
||||||
engines: {node: '>=14.18'}
|
engines: {node: '>=14.18'}
|
||||||
@@ -5898,6 +5921,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
node-pty@1.1.0:
|
||||||
|
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
|
||||||
|
|
||||||
node-releases@2.0.27:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||||
|
|
||||||
@@ -7939,7 +7965,7 @@ snapshots:
|
|||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
commander: 12.1.0
|
commander: 12.1.0
|
||||||
dotenv: 17.2.4
|
dotenv: 17.2.4
|
||||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
open: 10.2.0
|
open: 10.2.0
|
||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
prettier: 3.8.1
|
prettier: 3.8.1
|
||||||
@@ -11004,6 +11030,12 @@ snapshots:
|
|||||||
'@webassemblyjs/ast': 1.14.1
|
'@webassemblyjs/ast': 1.14.1
|
||||||
'@xtuc/long': 4.2.2
|
'@xtuc/long': 4.2.2
|
||||||
|
|
||||||
|
'@xterm/addon-fit@0.11.0': {}
|
||||||
|
|
||||||
|
'@xterm/addon-web-links@0.12.0': {}
|
||||||
|
|
||||||
|
'@xterm/xterm@6.0.0': {}
|
||||||
|
|
||||||
'@xtuc/ieee754@1.2.0': {}
|
'@xtuc/ieee754@1.2.0': {}
|
||||||
|
|
||||||
'@xtuc/long@4.2.2': {}
|
'@xtuc/long@4.2.2': {}
|
||||||
@@ -11259,7 +11291,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
better-sqlite3: 12.6.2
|
better-sqlite3: 12.6.2
|
||||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
@@ -11284,7 +11316,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
||||||
better-sqlite3: 12.6.2
|
better-sqlite3: 12.6.2
|
||||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
@@ -12103,6 +12135,17 @@ snapshots:
|
|||||||
|
|
||||||
dotenv@17.2.4: {}
|
dotenv@17.2.4: {}
|
||||||
|
|
||||||
|
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
||||||
|
optionalDependencies:
|
||||||
|
'@opentelemetry/api': 1.9.0
|
||||||
|
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
|
'@types/pg': 8.16.0
|
||||||
|
better-sqlite3: 12.6.2
|
||||||
|
kysely: 0.28.10
|
||||||
|
pg: 8.17.2
|
||||||
|
postgres: 3.4.8
|
||||||
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
|
|
||||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
@@ -12113,6 +12156,7 @@ snapshots:
|
|||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
postgres: 3.4.8
|
postgres: 3.4.8
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
|
optional: true
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -13453,6 +13497,8 @@ snapshots:
|
|||||||
|
|
||||||
node-abort-controller@3.1.1: {}
|
node-abort-controller@3.1.1: {}
|
||||||
|
|
||||||
|
node-addon-api@7.1.1: {}
|
||||||
|
|
||||||
node-downloader-helper@2.1.10: {}
|
node-downloader-helper@2.1.10: {}
|
||||||
|
|
||||||
node-emoji@1.11.0:
|
node-emoji@1.11.0:
|
||||||
@@ -13470,6 +13516,10 @@ snapshots:
|
|||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
node-pty@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
node-addon-api: 7.1.1
|
||||||
|
|
||||||
node-releases@2.0.27: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user