refactor(web): extract reusable XtermSession from WorkspaceTerminal
This commit is contained in:
@@ -1,67 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { ITheme, Terminal } from '@xterm/xterm';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
import { scheduleTerminalFits } from './terminal-fit';
|
||||
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
const TERMINAL_FONT =
|
||||
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
|
||||
|
||||
type Status = 'connecting' | 'connected' | 'closed' | 'error' | 'unconfigured';
|
||||
|
||||
const darkTheme: ITheme = {
|
||||
background: '#080e14',
|
||||
foreground: '#eef3f5',
|
||||
cursor: '#1fb895',
|
||||
cursorAccent: '#080e14',
|
||||
selectionBackground: '#1fb89544',
|
||||
black: '#10171e',
|
||||
red: '#f3625d',
|
||||
green: '#8fd6b4',
|
||||
yellow: '#e3b341',
|
||||
blue: '#6aa6ff',
|
||||
magenta: '#b692e8',
|
||||
cyan: '#5fd0e0',
|
||||
white: '#cdd6dc',
|
||||
brightBlack: '#93a1a9',
|
||||
brightRed: '#f3625d',
|
||||
brightGreen: '#8fd6b4',
|
||||
brightYellow: '#e3b341',
|
||||
brightBlue: '#6aa6ff',
|
||||
brightMagenta: '#b692e8',
|
||||
brightCyan: '#5fd0e0',
|
||||
brightWhite: '#eef3f5',
|
||||
};
|
||||
|
||||
const lightTheme: ITheme = {
|
||||
background: '#f7fbfa',
|
||||
foreground: '#0d1218',
|
||||
cursor: '#007560',
|
||||
cursorAccent: '#f7fbfa',
|
||||
selectionBackground: '#00756033',
|
||||
black: '#0d1218',
|
||||
red: '#d73337',
|
||||
green: '#2f8f6e',
|
||||
yellow: '#9a6b00',
|
||||
blue: '#2f6bd8',
|
||||
magenta: '#7c4dd1',
|
||||
cyan: '#0f7d92',
|
||||
white: '#26323c',
|
||||
brightBlack: '#555f68',
|
||||
brightRed: '#d73337',
|
||||
brightGreen: '#2f8f6e',
|
||||
brightYellow: '#9a6b00',
|
||||
brightBlue: '#2f6bd8',
|
||||
brightMagenta: '#7c4dd1',
|
||||
brightCyan: '#0f7d92',
|
||||
brightWhite: '#0d1218',
|
||||
};
|
||||
import { XtermSession } from './xterm-session';
|
||||
|
||||
export const WorkspaceTerminal = ({
|
||||
jobId,
|
||||
@@ -69,198 +8,10 @@ export const WorkspaceTerminal = ({
|
||||
}: {
|
||||
jobId: string;
|
||||
active: boolean;
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const themeIsLight = resolvedTheme === 'light';
|
||||
const [status, setStatus] = useState<Status>('connecting');
|
||||
const [errorText, setErrorText] = useState<string>();
|
||||
const [reconnectKey, setReconnectKey] = useState(0);
|
||||
|
||||
// Update the live terminal's theme without tearing down the session.
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme;
|
||||
}
|
||||
}, [themeIsLight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
// Read through a function so TS doesn't narrow `aborted` to a constant after
|
||||
// the first guard (it changes asynchronously, on cleanup).
|
||||
const isAborted = () => signal.aborted;
|
||||
let ws: WebSocket | undefined;
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const start = async () => {
|
||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all(
|
||||
[
|
||||
import('@xterm/xterm'),
|
||||
import('@xterm/addon-fit'),
|
||||
import('@xterm/addon-web-links'),
|
||||
],
|
||||
);
|
||||
if (isAborted()) return;
|
||||
|
||||
setStatus('connecting');
|
||||
setErrorText(undefined);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`/api/agent-jobs/${jobId}/terminal-token`, {
|
||||
signal,
|
||||
});
|
||||
} catch {
|
||||
if (!isAborted()) setStatus('error');
|
||||
return;
|
||||
}
|
||||
if (isAborted()) return;
|
||||
if (response.status === 503) {
|
||||
setStatus('unconfigured');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setStatus('error');
|
||||
setErrorText(await response.text().catch(() => undefined));
|
||||
return;
|
||||
}
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
|
||||
const term = new Terminal({
|
||||
fontFamily: TERMINAL_FONT,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
theme: themeIsLight ? lightTheme : darkTheme,
|
||||
allowProposedApi: true,
|
||||
scrollback: 5000,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
term.open(container);
|
||||
termRef.current = term;
|
||||
|
||||
const sendResize = () => {
|
||||
if (ws?.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(
|
||||
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
|
||||
);
|
||||
};
|
||||
|
||||
scheduleTerminalFits({
|
||||
fit: () => {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
// ignore transient layout errors
|
||||
}
|
||||
},
|
||||
sendResize,
|
||||
fontsReady: document.fonts.ready,
|
||||
loadNerdFont: () =>
|
||||
document.fonts
|
||||
.load("16px 'Symbols Nerd Font Mono'", '\ue0b0')
|
||||
.then(() => {
|
||||
if (!isAborted()) term.refresh(0, term.rows - 1);
|
||||
}),
|
||||
raf: (cb) => requestAnimationFrame(cb),
|
||||
isAborted,
|
||||
});
|
||||
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => {
|
||||
if (isAborted()) return;
|
||||
setStatus('connected');
|
||||
sendResize();
|
||||
term.focus();
|
||||
};
|
||||
ws.onmessage = (event: MessageEvent<ArrayBuffer | string>) => {
|
||||
if (typeof event.data === 'string') term.write(event.data);
|
||||
else term.write(new Uint8Array(event.data));
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (!isAborted()) setStatus('closed');
|
||||
};
|
||||
ws.onerror = () => {
|
||||
if (!isAborted()) setStatus('error');
|
||||
};
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
|
||||
});
|
||||
term.onResize(() => sendResize());
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
// ignore transient layout errors
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
};
|
||||
|
||||
void start();
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
resizeObserver?.disconnect();
|
||||
ws?.close();
|
||||
termRef.current?.dispose();
|
||||
termRef.current = null;
|
||||
};
|
||||
// resolvedTheme intentionally excluded: handled by the theme effect above.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, jobId, reconnectKey]);
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full min-h-0 flex-col'>
|
||||
<div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{status === 'connected'
|
||||
? 'Connected · workspace shell'
|
||||
: status === 'connecting'
|
||||
? 'Connecting…'
|
||||
: status === 'closed'
|
||||
? 'Session ended'
|
||||
: status === 'unconfigured'
|
||||
? 'Terminal not configured'
|
||||
: 'Connection error'}
|
||||
</p>
|
||||
{status === 'closed' || status === 'error' ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setReconnectKey((key) => key + 1)}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{status === 'unconfigured' ? (
|
||||
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
|
||||
The terminal is not enabled on this deployment.
|
||||
<br />
|
||||
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
|
||||
</div>
|
||||
) : (
|
||||
<div className='min-h-0 flex-1 overflow-hidden bg-[#080e14] p-2'>
|
||||
<div ref={containerRef} className='h-full w-full' />
|
||||
{errorText ? (
|
||||
<p className='text-destructive mt-2 px-1 text-xs break-all'>
|
||||
{errorText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}) => (
|
||||
<XtermSession
|
||||
active={active}
|
||||
tokenUrl={`/api/agent-jobs/${jobId}/terminal-token`}
|
||||
sessionKey={jobId}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client';
|
||||
|
||||
import type { ITheme, Terminal } from '@xterm/xterm';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
import { scheduleTerminalFits } from './terminal-fit';
|
||||
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
const TERMINAL_FONT =
|
||||
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
|
||||
|
||||
export type XtermStatus =
|
||||
| 'idle'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'closed'
|
||||
| 'error'
|
||||
| 'unconfigured';
|
||||
|
||||
const darkTheme: ITheme = {
|
||||
background: '#080e14',
|
||||
foreground: '#eef3f5',
|
||||
cursor: '#1fb895',
|
||||
cursorAccent: '#080e14',
|
||||
selectionBackground: '#1fb89544',
|
||||
black: '#10171e',
|
||||
red: '#f3625d',
|
||||
green: '#8fd6b4',
|
||||
yellow: '#e3b341',
|
||||
blue: '#6aa6ff',
|
||||
magenta: '#b692e8',
|
||||
cyan: '#5fd0e0',
|
||||
white: '#cdd6dc',
|
||||
brightBlack: '#93a1a9',
|
||||
brightRed: '#f3625d',
|
||||
brightGreen: '#8fd6b4',
|
||||
brightYellow: '#e3b341',
|
||||
brightBlue: '#6aa6ff',
|
||||
brightMagenta: '#b692e8',
|
||||
brightCyan: '#5fd0e0',
|
||||
brightWhite: '#eef3f5',
|
||||
};
|
||||
|
||||
const lightTheme: ITheme = {
|
||||
background: '#f7fbfa',
|
||||
foreground: '#0d1218',
|
||||
cursor: '#007560',
|
||||
cursorAccent: '#f7fbfa',
|
||||
selectionBackground: '#00756033',
|
||||
black: '#0d1218',
|
||||
red: '#d73337',
|
||||
green: '#2f8f6e',
|
||||
yellow: '#9a6b00',
|
||||
blue: '#2f6bd8',
|
||||
magenta: '#7c4dd1',
|
||||
cyan: '#0f7d92',
|
||||
white: '#26323c',
|
||||
brightBlack: '#555f68',
|
||||
brightRed: '#d73337',
|
||||
brightGreen: '#2f8f6e',
|
||||
brightYellow: '#9a6b00',
|
||||
brightBlue: '#2f6bd8',
|
||||
brightMagenta: '#7c4dd1',
|
||||
brightCyan: '#0f7d92',
|
||||
brightWhite: '#0d1218',
|
||||
};
|
||||
|
||||
export const XtermSession = ({
|
||||
active,
|
||||
tokenUrl,
|
||||
sessionKey,
|
||||
waitingLabel,
|
||||
copyOnSelect,
|
||||
}: {
|
||||
active: boolean;
|
||||
tokenUrl: string;
|
||||
sessionKey: string;
|
||||
waitingLabel?: string;
|
||||
copyOnSelect?: boolean;
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const themeIsLight = resolvedTheme === 'light';
|
||||
const [status, setStatus] = useState<XtermStatus>('connecting');
|
||||
const [errorText, setErrorText] = useState<string>();
|
||||
const [reconnectKey, setReconnectKey] = useState(0);
|
||||
|
||||
// Update the live terminal's theme without tearing down the session.
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme;
|
||||
}
|
||||
}, [themeIsLight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
// Read through a function so TS doesn't narrow `aborted` to a constant after
|
||||
// the first guard (it changes asynchronously, on cleanup).
|
||||
const isAborted = () => signal.aborted;
|
||||
let ws: WebSocket | undefined;
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const start = async () => {
|
||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all(
|
||||
[
|
||||
import('@xterm/xterm'),
|
||||
import('@xterm/addon-fit'),
|
||||
import('@xterm/addon-web-links'),
|
||||
],
|
||||
);
|
||||
if (isAborted()) return;
|
||||
|
||||
setStatus('connecting');
|
||||
setErrorText(undefined);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(tokenUrl, { signal });
|
||||
} catch {
|
||||
if (!isAborted()) setStatus('error');
|
||||
return;
|
||||
}
|
||||
if (isAborted()) return;
|
||||
if (response.status === 503) {
|
||||
setStatus('unconfigured');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setStatus('error');
|
||||
setErrorText(await response.text().catch(() => undefined));
|
||||
return;
|
||||
}
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
|
||||
const term = new Terminal({
|
||||
fontFamily: TERMINAL_FONT,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
theme: themeIsLight ? lightTheme : darkTheme,
|
||||
allowProposedApi: true,
|
||||
scrollback: 5000,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
term.open(container);
|
||||
termRef.current = term;
|
||||
|
||||
// Task 11 will wire clipboard copy-on-select behavior here. Accept the
|
||||
// prop now so callers (and the effect deps) are stable across that change.
|
||||
if (copyOnSelect) {
|
||||
term.onSelectionChange(() => {
|
||||
// no-op placeholder until Task 11
|
||||
});
|
||||
}
|
||||
|
||||
const sendResize = () => {
|
||||
if (ws?.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(
|
||||
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
|
||||
);
|
||||
};
|
||||
|
||||
scheduleTerminalFits({
|
||||
fit: () => {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
// ignore transient layout errors
|
||||
}
|
||||
},
|
||||
sendResize,
|
||||
fontsReady: document.fonts.ready,
|
||||
loadNerdFont: () =>
|
||||
document.fonts
|
||||
.load("16px 'Symbols Nerd Font Mono'", '')
|
||||
.then(() => {
|
||||
if (!isAborted()) term.refresh(0, term.rows - 1);
|
||||
}),
|
||||
raf: (cb) => requestAnimationFrame(cb),
|
||||
isAborted,
|
||||
});
|
||||
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => {
|
||||
if (isAborted()) return;
|
||||
setStatus('connected');
|
||||
sendResize();
|
||||
term.focus();
|
||||
};
|
||||
ws.onmessage = (event: MessageEvent<ArrayBuffer | string>) => {
|
||||
if (typeof event.data === 'string') term.write(event.data);
|
||||
else term.write(new Uint8Array(event.data));
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (!isAborted()) setStatus('closed');
|
||||
};
|
||||
ws.onerror = () => {
|
||||
if (!isAborted()) setStatus('error');
|
||||
};
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
|
||||
});
|
||||
term.onResize(() => sendResize());
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
// ignore transient layout errors
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
};
|
||||
|
||||
void start();
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
resizeObserver?.disconnect();
|
||||
ws?.close();
|
||||
termRef.current?.dispose();
|
||||
termRef.current = null;
|
||||
};
|
||||
// resolvedTheme intentionally excluded: handled by the theme effect above.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, sessionKey, reconnectKey]);
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full min-h-0 flex-col'>
|
||||
<div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{status === 'connected'
|
||||
? 'Connected · workspace shell'
|
||||
: status === 'connecting'
|
||||
? 'Connecting…'
|
||||
: status === 'closed'
|
||||
? 'Session ended'
|
||||
: status === 'unconfigured'
|
||||
? 'Terminal not configured'
|
||||
: 'Connection error'}
|
||||
</p>
|
||||
{status === 'closed' || status === 'error' ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setReconnectKey((key) => key + 1)}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{status === 'unconfigured' ? (
|
||||
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
|
||||
The terminal is not enabled on this deployment.
|
||||
<br />
|
||||
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
|
||||
</div>
|
||||
) : !active && waitingLabel ? (
|
||||
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
|
||||
{waitingLabel}
|
||||
</div>
|
||||
) : (
|
||||
<div className='min-h-0 flex-1 overflow-hidden bg-[#080e14] p-2'>
|
||||
<div ref={containerRef} className='h-full w-full' />
|
||||
{errorText ? (
|
||||
<p className='text-destructive mt-2 px-1 text-xs break-all'>
|
||||
{errorText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { XtermSession } from '../../src/components/agent-workspace/xterm-session';
|
||||
|
||||
const { openSpy, wsInstances, fetchSpy } = vi.hoisted(() => ({
|
||||
openSpy: vi.fn(),
|
||||
wsInstances: [] as Array<{ url: string }>,
|
||||
fetchSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
class FakeTerminal {
|
||||
cols = 80;
|
||||
rows = 24;
|
||||
options: Record<string, unknown> = {};
|
||||
open = openSpy;
|
||||
loadAddon = vi.fn();
|
||||
onData = vi.fn();
|
||||
onResize = vi.fn();
|
||||
onSelectionChange = vi.fn();
|
||||
dispose = vi.fn();
|
||||
refresh = vi.fn();
|
||||
write = vi.fn();
|
||||
focus = vi.fn();
|
||||
constructor(opts: Record<string, unknown>) {
|
||||
this.options = opts ?? {};
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }));
|
||||
vi.mock('@xterm/addon-fit', () => ({
|
||||
FitAddon: class {
|
||||
fit = vi.fn();
|
||||
},
|
||||
}));
|
||||
vi.mock('@xterm/addon-web-links', () => ({
|
||||
WebLinksAddon: class {},
|
||||
}));
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: () => ({ resolvedTheme: 'dark' }),
|
||||
}));
|
||||
|
||||
class FakeWebSocket {
|
||||
static OPEN = 1;
|
||||
url: string;
|
||||
readyState = 1;
|
||||
binaryType = '';
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: unknown) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
send = vi.fn();
|
||||
close = vi.fn();
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
wsInstances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wsInstances.length = 0;
|
||||
openSpy.mockClear();
|
||||
fetchSpy.mockReset();
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ url: 'ws://x' }),
|
||||
text: async () => '',
|
||||
});
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket);
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
},
|
||||
);
|
||||
Object.defineProperty(document, 'fonts', {
|
||||
configurable: true,
|
||||
value: {
|
||||
ready: Promise.resolve(),
|
||||
load: () => Promise.resolve([]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('XtermSession', () => {
|
||||
it('shows the waiting label and does not open a WebSocket while inactive', async () => {
|
||||
render(
|
||||
<XtermSession
|
||||
active={false}
|
||||
tokenUrl='/api/box/terminal-token'
|
||||
sessionKey='box-1'
|
||||
waitingLabel='Workspace is asleep.'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Workspace is asleep.')).toBeInTheDocument();
|
||||
// Give any (unexpected) async connect a chance to run.
|
||||
await Promise.resolve();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(wsInstances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fetches the token URL and opens a WebSocket to the returned url when active', async () => {
|
||||
render(
|
||||
<XtermSession
|
||||
active
|
||||
tokenUrl='/api/box/terminal-token'
|
||||
sessionKey='box-1'
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/api/box/terminal-token',
|
||||
expect.objectContaining({ signal: expect.anything() }),
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(wsInstances).toHaveLength(1));
|
||||
expect(wsInstances[0]?.url).toBe('ws://x');
|
||||
expect(openSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user