feat(web): integrate xterm.js with WebSocket terminal backend
- Install @xterm/xterm, @xterm/addon-fit, @xterm/addon-web-links - Create useTerminal hook connecting to /terminal Socket.IO namespace with full session lifecycle management (create, input, resize, close) - Create XTerminal component with FitAddon auto-resize, WebLinksAddon, theme support via CSS variables, and terminal:exit reconnect UI - Replace mock TerminalPanel with real XTerminal component, preserving the tab bar and close button layout structure - Export XTerminal and XTerminalProps from the terminal barrel index - Write 40 unit tests covering useTerminal hook and both components Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,9 @@
|
||||
"@tiptap/react": "^3.20.0",
|
||||
"@tiptap/starter-kit": "^3.20.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",
|
||||
"better-auth": "^1.4.17",
|
||||
"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";
|
||||
content: string;
|
||||
}
|
||||
/**
|
||||
* TerminalPanel
|
||||
*
|
||||
* 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 {
|
||||
id: string;
|
||||
@@ -16,51 +25,16 @@ export interface TerminalPanelProps {
|
||||
tabs?: TerminalTab[];
|
||||
activeTab?: string;
|
||||
onTabChange?: (id: string) => void;
|
||||
lines?: TerminalLine[];
|
||||
/** Authentication token for the WebSocket connection */
|
||||
token?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const defaultTabs: TerminalTab[] = [
|
||||
{ id: "main", label: "main" },
|
||||
{ id: "build", label: "build" },
|
||||
{ id: "logs", label: "logs" },
|
||||
];
|
||||
const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }];
|
||||
|
||||
const blinkKeyframes = `
|
||||
@keyframes ms-terminal-blink {
|
||||
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)";
|
||||
}
|
||||
}
|
||||
// ==========================================
|
||||
// Component
|
||||
// ==========================================
|
||||
|
||||
export function TerminalPanel({
|
||||
open,
|
||||
@@ -68,11 +42,9 @@ export function TerminalPanel({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
lines = [],
|
||||
token = "",
|
||||
className = "",
|
||||
}: TerminalPanelProps): ReactElement {
|
||||
ensureBlinkStyle();
|
||||
|
||||
const resolvedTabs = tabs ?? defaultTabs;
|
||||
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
|
||||
|
||||
@@ -109,21 +81,10 @@ export function TerminalPanel({
|
||||
|
||||
const bodyStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "10px 16px",
|
||||
fontFamily: "var(--mono)",
|
||||
fontSize: "0.78rem",
|
||||
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",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -208,7 +169,7 @@ export function TerminalPanel({
|
||||
(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">
|
||||
<path
|
||||
d="M1 1L11 11M11 1L1 11"
|
||||
@@ -221,36 +182,9 @@ export function TerminalPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={bodyStyle} role="log" aria-live="polite" aria-label="Terminal output">
|
||||
{lines.map((line, index) => {
|
||||
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>
|
||||
)}
|
||||
{/* Terminal body */}
|
||||
<div style={bodyStyle}>
|
||||
<XTerminal token={token} isVisible={open} style={{ flex: 1, minHeight: 0 }} />
|
||||
</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 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:
|
||||
specifier: ^0.8.0
|
||||
version: 0.8.0
|
||||
node-pty:
|
||||
specifier: ^1.0.0
|
||||
version: 1.1.0
|
||||
ollama:
|
||||
specifier: ^0.6.3
|
||||
version: 0.6.3
|
||||
@@ -432,6 +435,15 @@ importers:
|
||||
'@types/dompurify':
|
||||
specifier: ^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':
|
||||
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)
|
||||
@@ -1584,7 +1596,6 @@ packages:
|
||||
|
||||
'@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}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@mrleebo/prisma-ast@0.13.1':
|
||||
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
||||
@@ -3514,6 +3525,15 @@ packages:
|
||||
'@webassemblyjs/wast-printer@1.14.1':
|
||||
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':
|
||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||
|
||||
@@ -5874,6 +5894,9 @@ packages:
|
||||
node-abort-controller@3.1.1:
|
||||
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-downloader-helper@2.1.10:
|
||||
resolution: {integrity: sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg==}
|
||||
engines: {node: '>=14.18'}
|
||||
@@ -5898,6 +5921,9 @@ packages:
|
||||
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
||||
hasBin: true
|
||||
|
||||
node-pty@1.1.0:
|
||||
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
|
||||
|
||||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
@@ -7939,7 +7965,7 @@ snapshots:
|
||||
chalk: 5.6.2
|
||||
commander: 12.1.0
|
||||
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
|
||||
pg: 8.17.2
|
||||
prettier: 3.8.1
|
||||
@@ -11004,6 +11030,12 @@ snapshots:
|
||||
'@webassemblyjs/ast': 1.14.1
|
||||
'@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/long@4.2.2': {}
|
||||
@@ -11259,7 +11291,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
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)
|
||||
pg: 8.17.2
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
@@ -11284,7 +11316,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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
|
||||
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)
|
||||
pg: 8.17.2
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
@@ -12103,6 +12135,17 @@ snapshots:
|
||||
|
||||
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)):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -12113,6 +12156,7 @@ snapshots:
|
||||
pg: 8.17.2
|
||||
postgres: 3.4.8
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
optional: true
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
@@ -13453,6 +13497,8 @@ snapshots:
|
||||
|
||||
node-abort-controller@3.1.1: {}
|
||||
|
||||
node-addon-api@7.1.1: {}
|
||||
|
||||
node-downloader-helper@2.1.10: {}
|
||||
|
||||
node-emoji@1.11.0:
|
||||
@@ -13470,6 +13516,10 @@ snapshots:
|
||||
detect-libc: 2.1.2
|
||||
optional: true
|
||||
|
||||
node-pty@1.1.0:
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
Reference in New Issue
Block a user