fix: Project action terminal lifecycle (#3287)
* fix(terminal): make command sessions own action lifecycle * fix(ui): reconcile project action terminal state * feat(ui): show running project actions in terminal tabs * feat(ui): run project actions from linked worktrees * fix(ui): guard project action reconciliation * fix(ui): scope project action preview fallback * fix(ui): default project actions to worktrees * fix(ui): reveal project action terminals * fix(ui): retain terminal output after snapshot replay * fix(ui): restore running action terminals on revisit
This commit is contained in:
@@ -3,7 +3,6 @@ import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import express from 'express';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import { createTerminalRuntime } from './runtime.js';
|
||||
@@ -24,6 +23,98 @@ function createResponse() {
|
||||
};
|
||||
}
|
||||
|
||||
async function openTerminalSocket(socketUrl) {
|
||||
const socket = new WebSocket(socketUrl);
|
||||
const messages = [];
|
||||
socket.on('message', (raw) => messages.push(readTerminalWsControlFrame(raw)));
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once('open', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
const next = async (type, sessionId) => {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const index = messages.findIndex((message) => message?.t === type && (!sessionId || message.s === sessionId));
|
||||
if (index >= 0) return messages.splice(index, 1)[0];
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${type}`);
|
||||
};
|
||||
await next('hello');
|
||||
return { socket, next, messages };
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createHttpTestApp() {
|
||||
const routes = { GET: [], POST: [], DELETE: [] };
|
||||
const app = (req, res) => {
|
||||
const methodRoutes = routes[req.method] ?? [];
|
||||
const url = new URL(req.url, 'http://127.0.0.1');
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', async () => {
|
||||
const bodyText = Buffer.concat(chunks).toString('utf8');
|
||||
const route = methodRoutes.find(({ pattern }) => pattern.test(url.pathname));
|
||||
if (!route) {
|
||||
res.statusCode = 404;
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
const match = route.pattern.exec(url.pathname);
|
||||
const response = {
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: null,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload) {
|
||||
this.body = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
try {
|
||||
await route.handler({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
params: route.params.reduce((acc, name, index) => ({ ...acc, [name]: match[index + 1] }), {}),
|
||||
body: bodyText ? JSON.parse(bodyText) : {},
|
||||
}, response);
|
||||
} catch (error) {
|
||||
response.status(500).json({ error: error?.message || 'Route failed' });
|
||||
}
|
||||
res.writeHead(response.statusCode, response.headers);
|
||||
res.end(JSON.stringify(response.body));
|
||||
});
|
||||
};
|
||||
const register = (method, route, handler) => {
|
||||
const params = [];
|
||||
const escaped = route.replace(/:([^/]+)/g, (_, name) => {
|
||||
params.push(name);
|
||||
return '([^/]+)';
|
||||
});
|
||||
routes[method].push({
|
||||
pattern: new RegExp(`^${escaped}$`),
|
||||
params,
|
||||
handler,
|
||||
});
|
||||
};
|
||||
app.get = (route, handler) => register('GET', route, handler);
|
||||
app.post = (route, handler) => register('POST', route, handler);
|
||||
app.delete = (route, handler) => register('DELETE', route, handler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function createRuntime(server, overrides = {}) {
|
||||
const app = overrides.app ?? {
|
||||
post() {},
|
||||
@@ -54,6 +145,7 @@ describe('terminal runtime', () => {
|
||||
const createHarness = (overrides = {}) => {
|
||||
const routes = { get: new Map(), post: new Map(), delete: new Map() };
|
||||
const processes = [];
|
||||
const spawnDeferred = overrides.spawnDeferred ?? null;
|
||||
const app = {
|
||||
post(route, handler) { routes.post.set(route, handler); },
|
||||
get(route, handler) { routes.get.set(route, handler); },
|
||||
@@ -61,7 +153,8 @@ describe('terminal runtime', () => {
|
||||
};
|
||||
const loadPtyProvider = async () => ({
|
||||
backend: 'fake-pty',
|
||||
spawn: (shell, args, options) => {
|
||||
spawn: async (shell, args, options) => {
|
||||
await spawnDeferred?.promise;
|
||||
const dataHandlers = new Set();
|
||||
const exitHandlers = new Set();
|
||||
const process = {
|
||||
@@ -150,7 +243,7 @@ describe('terminal runtime', () => {
|
||||
try {
|
||||
const response = createResponse();
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo', cols: 120, rows: 40, themeMode: 'light', terminalBackground: '#faf8f0', terminalForeground: '#1b1b1b' } }, response);
|
||||
expect(response.body).toEqual({ sessionId: 'term-1', cols: 120, rows: 40, status: 'running' });
|
||||
expect(response.body).toEqual({ sessionId: 'term-1', cols: 120, rows: 40, status: 'running', mode: 'interactive', purpose: { type: 'terminal' } });
|
||||
expect(harness.processes[0].options.cwd).toBe('/repo');
|
||||
expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15');
|
||||
expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe('');
|
||||
@@ -192,7 +285,7 @@ describe('terminal runtime', () => {
|
||||
const scoped = createResponse();
|
||||
harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, scoped);
|
||||
expect(scoped.body.sessions).toEqual([
|
||||
{ sessionId: 'term-a', cwd: '/repo', status: 'running', createdAt: expect.any(Number) },
|
||||
{ sessionId: 'term-a', cwd: '/repo', status: 'running', createdAt: expect.any(Number), mode: 'interactive', purpose: { type: 'terminal' } },
|
||||
]);
|
||||
|
||||
const touch = createResponse();
|
||||
@@ -466,8 +559,7 @@ describe('terminal runtime', () => {
|
||||
});
|
||||
|
||||
it('runs snapshot-first attach, scoped I/O, replay, reconnect, and close over a real websocket', async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const app = createHttpTestApp();
|
||||
const server = http.createServer(app);
|
||||
const processes = [];
|
||||
const loadPtyProvider = async () => ({
|
||||
@@ -501,24 +593,6 @@ describe('terminal runtime', () => {
|
||||
const socketUrl = `ws://127.0.0.1:${address.port}/api/terminal/ws`;
|
||||
const sockets = [];
|
||||
|
||||
const open = async () => {
|
||||
const socket = new WebSocket(socketUrl);
|
||||
sockets.push(socket);
|
||||
const messages = [];
|
||||
socket.on('message', (raw) => messages.push(readTerminalWsControlFrame(raw)));
|
||||
await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
|
||||
const next = async (type, sessionId) => {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const index = messages.findIndex((message) => message?.t === type && (!sessionId || message.s === sessionId));
|
||||
if (index >= 0) return messages.splice(index, 1)[0];
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${type}`);
|
||||
};
|
||||
await next('hello');
|
||||
return { socket, next, messages };
|
||||
};
|
||||
|
||||
try {
|
||||
const created = await fetch(`${base}/api/terminal/create`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
@@ -531,7 +605,8 @@ describe('terminal runtime', () => {
|
||||
});
|
||||
expect(secondCreated.status).toBe(200);
|
||||
|
||||
const first = await open();
|
||||
const first = await openTerminalSocket(socketUrl);
|
||||
sockets.push(first.socket);
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-second' }));
|
||||
expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running' });
|
||||
@@ -559,7 +634,8 @@ describe('terminal runtime', () => {
|
||||
expect(secondClosed.status).toBe(200);
|
||||
first.socket.close();
|
||||
|
||||
const second = await open();
|
||||
const second = await openTerminalSocket(socketUrl);
|
||||
sockets.push(second.socket);
|
||||
second.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
|
||||
expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running' });
|
||||
processes[0].emitExit(7);
|
||||
@@ -589,4 +665,382 @@ describe('terminal runtime', () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it('creates command-mode sessions and echoes the effective mode', async () => {
|
||||
const harness = createHarness({
|
||||
searchPathFor: (name) => name === 'bash' ? '/bin/bash' : '/bin/sh',
|
||||
isExecutable: (candidate) => candidate === '/bin/bash' || candidate === '/bin/sh',
|
||||
});
|
||||
try {
|
||||
const response = createResponse();
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-command', cwd: '/repo', mode: 'command', command: 'printf ready', shell: 'bash', loginShell: true } }, response);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual({ sessionId: 'term-command', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'terminal' } });
|
||||
if (process.platform === 'linux') {
|
||||
expect(harness.processes[0].shell).toMatch(/\/env$/);
|
||||
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l', '-i', '-c', 'printf ready']);
|
||||
} else {
|
||||
expect(harness.processes[0].args).toEqual(['-l', '-i', '-c', 'printf ready']);
|
||||
}
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('rejects invalid terminal mode and command combinations', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
for (const [body, error] of [
|
||||
[{ cwd: '/repo', mode: 'script' }, 'Invalid terminal mode'],
|
||||
[{ cwd: '/repo', mode: 'command' }, 'Terminal command is required'],
|
||||
[{ cwd: '/repo', mode: 'command', command: ' ' }, 'Terminal command is required'],
|
||||
[{ cwd: '/repo', mode: 'interactive', command: 'echo nope' }, 'Interactive terminal create does not accept a command'],
|
||||
[{ cwd: '/repo', mode: 'command', command: 'x'.repeat(65_537) }, 'Terminal command exceeds the input limit'],
|
||||
]) {
|
||||
const response = createResponse();
|
||||
await harness.routes.post.get('/api/terminal/create')({ body }, response);
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toEqual({ error });
|
||||
}
|
||||
expect(harness.processes).toHaveLength(0);
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('rejects same-id running creates when mode or command do not match', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const create = harness.routes.post.get('/api/terminal/create');
|
||||
await create({ body: { sessionId: 'term-shared', cwd: '/repo' } }, createResponse());
|
||||
|
||||
const modeMismatch = createResponse();
|
||||
await create({ body: { sessionId: 'term-shared', cwd: '/repo', mode: 'command', command: 'printf ready' } }, modeMismatch);
|
||||
expect(modeMismatch.statusCode).toBe(400);
|
||||
expect(modeMismatch.body).toEqual({ error: 'Terminal session is already running with a different mode' });
|
||||
|
||||
await create({ body: { sessionId: 'term-command', cwd: '/repo', mode: 'command', command: 'printf ready' } }, createResponse());
|
||||
|
||||
const commandMismatch = createResponse();
|
||||
await create({ body: { sessionId: 'term-command', cwd: '/repo', mode: 'command', command: 'printf other' } }, commandMismatch);
|
||||
expect(commandMismatch.statusCode).toBe(400);
|
||||
expect(commandMismatch.body).toEqual({ error: 'Terminal session is already running with a different command' });
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('rejects pending creates when command mode does not match the in-flight request', async () => {
|
||||
const spawnDeferred = deferred();
|
||||
const harness = createHarness({ spawnDeferred });
|
||||
try {
|
||||
const create = harness.routes.post.get('/api/terminal/create');
|
||||
const first = createResponse();
|
||||
const conflictingMode = createResponse();
|
||||
const conflictingCommand = createResponse();
|
||||
|
||||
const firstPromise = create({ body: { sessionId: 'term-pending', cwd: '/repo', mode: 'command', command: 'printf ready' } }, first);
|
||||
await Promise.resolve();
|
||||
const secondPromise = create({ body: { sessionId: 'term-pending', cwd: '/repo' } }, conflictingMode);
|
||||
const thirdPromise = create({ body: { sessionId: 'term-pending', cwd: '/repo', mode: 'command', command: 'printf other' } }, conflictingCommand);
|
||||
spawnDeferred.resolve();
|
||||
await Promise.all([firstPromise, secondPromise, thirdPromise]);
|
||||
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(conflictingMode.statusCode).toBe(400);
|
||||
expect(conflictingMode.body).toEqual({ error: 'Terminal session is already being created with a different mode' });
|
||||
expect(conflictingCommand.statusCode).toBe(400);
|
||||
expect(conflictingCommand.body).toEqual({ error: 'Terminal session is already being created with a different command' });
|
||||
expect(harness.processes).toHaveLength(1);
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('validates purpose payloads and round-trips purpose through create, list, and snapshot without listing command text', async () => {
|
||||
const app = createHttpTestApp();
|
||||
const server = http.createServer(app);
|
||||
const runtime = createRuntime(server, {
|
||||
app,
|
||||
loadPtyProvider: async () => ({
|
||||
backend: 'fake-pty',
|
||||
spawn: async () => ({
|
||||
pid: 42,
|
||||
write() {},
|
||||
resize() {},
|
||||
kill() {},
|
||||
onData() { return { dispose() {} }; },
|
||||
onExit() { return { dispose() {} }; },
|
||||
}),
|
||||
}),
|
||||
terminalTerminationGraceMs: 10,
|
||||
fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
|
||||
searchPathFor: () => '/bin/sh',
|
||||
isExecutable: () => true,
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const socketUrl = `ws://127.0.0.1:${port}/api/terminal/ws`;
|
||||
const sockets = [];
|
||||
|
||||
try {
|
||||
for (const [body, error] of [
|
||||
[{ cwd: '/repo', purpose: 'terminal' }, 'Invalid terminal purpose'],
|
||||
[{ cwd: '/repo', purpose: { type: 'project-action' } }, 'Terminal project action id is required'],
|
||||
[{ cwd: '/repo', purpose: { type: 'project-action', actionId: 'build' } }, 'Terminal execution id is required'],
|
||||
[{ cwd: '/repo', purpose: { type: 'project-action', actionId: ' ', executionId: 'exec-1' } }, 'Terminal project action id is required'],
|
||||
[{ cwd: '/repo', purpose: { type: 'project-action', actionId: 'build', executionId: ' ' } }, 'Terminal execution id is required'],
|
||||
]) {
|
||||
const response = await fetch(`${base}/api/terminal/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ error });
|
||||
}
|
||||
|
||||
const created = await fetch(`${base}/api/terminal/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: 'action-tab',
|
||||
cwd: '/repo',
|
||||
mode: 'command',
|
||||
command: 'printf ready',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
}),
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
expect(await created.json()).toEqual({
|
||||
sessionId: 'action-tab',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
status: 'running',
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
});
|
||||
|
||||
const listed = await fetch(`${base}/api/terminal/sessions?cwd=%2Frepo`);
|
||||
expect(listed.status).toBe(200);
|
||||
expect(await listed.json()).toEqual({
|
||||
sessions: [{
|
||||
sessionId: 'action-tab',
|
||||
cwd: '/repo',
|
||||
status: 'running',
|
||||
createdAt: expect.any(Number),
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
}],
|
||||
});
|
||||
|
||||
const socket = await openTerminalSocket(socketUrl);
|
||||
sockets.push(socket.socket);
|
||||
socket.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'action-tab' }));
|
||||
expect(await socket.next('snapshot', 'action-tab')).toMatchObject({
|
||||
s: 'action-tab',
|
||||
status: 'running',
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
});
|
||||
} finally {
|
||||
for (const socket of sockets) socket.terminate();
|
||||
await runtime.shutdown();
|
||||
server.closeAllConnections?.();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it('deduplicates running project actions by resolved cwd and action id across session ids and clients', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const create = harness.routes.post.get('/api/terminal/create');
|
||||
const first = createResponse();
|
||||
await create({
|
||||
body: {
|
||||
sessionId: 'action-a',
|
||||
cwd: '/repo/./nested/..',
|
||||
mode: 'command',
|
||||
command: 'npm run build',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
},
|
||||
}, first);
|
||||
expect(first.statusCode).toBe(200);
|
||||
|
||||
const adopted = createResponse();
|
||||
await create({
|
||||
body: {
|
||||
sessionId: 'action-b',
|
||||
cwd: '/repo',
|
||||
mode: 'command',
|
||||
command: 'npm run build --watch',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-2' },
|
||||
},
|
||||
}, adopted);
|
||||
|
||||
expect(adopted.statusCode).toBe(200);
|
||||
expect(adopted.body).toEqual({
|
||||
sessionId: 'action-a',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
status: 'running',
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
});
|
||||
expect(harness.processes).toHaveLength(1);
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('rejects purpose mismatches when the same session id is reused for a different action', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const create = harness.routes.post.get('/api/terminal/create');
|
||||
await create({
|
||||
body: {
|
||||
sessionId: 'action-tab',
|
||||
cwd: '/repo',
|
||||
mode: 'command',
|
||||
command: 'npm run build',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
},
|
||||
}, createResponse());
|
||||
|
||||
const mismatch = createResponse();
|
||||
await create({
|
||||
body: {
|
||||
sessionId: 'action-tab',
|
||||
cwd: '/repo',
|
||||
mode: 'command',
|
||||
command: 'npm run test',
|
||||
purpose: { type: 'project-action', actionId: 'test', executionId: 'exec-2' },
|
||||
},
|
||||
}, mismatch);
|
||||
|
||||
expect(mismatch.statusCode).toBe(400);
|
||||
expect(mismatch.body).toEqual({ error: 'Terminal session is already running with a different purpose' });
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('keeps an immediately exited command session attachable once listeners are registered', async () => {
|
||||
const app = createHttpTestApp();
|
||||
const server = http.createServer(app);
|
||||
const runtime = createRuntime(server, {
|
||||
app,
|
||||
loadPtyProvider: async () => ({
|
||||
backend: 'fake-pty',
|
||||
spawn: async () => {
|
||||
const dataHandlers = new Set();
|
||||
const exitHandlers = new Set();
|
||||
return {
|
||||
pid: 404,
|
||||
write() {},
|
||||
resize() {},
|
||||
kill() {},
|
||||
onData(handler) { dataHandlers.add(handler); return { dispose: () => dataHandlers.delete(handler) }; },
|
||||
onExit(handler) {
|
||||
exitHandlers.add(handler);
|
||||
queueMicrotask(() => {
|
||||
for (const registered of exitHandlers) registered({ exitCode: 0, signal: 0 });
|
||||
});
|
||||
return { dispose: () => exitHandlers.delete(handler) };
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
terminalTerminationGraceMs: 10,
|
||||
fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
|
||||
searchPathFor: () => '/bin/sh',
|
||||
isExecutable: () => true,
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const socketUrl = `ws://127.0.0.1:${port}/api/terminal/ws`;
|
||||
const sockets = [];
|
||||
|
||||
try {
|
||||
const created = await fetch(`${base}/api/terminal/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: 'fast-exit',
|
||||
cwd: '/repo',
|
||||
mode: 'command',
|
||||
command: 'true',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-fast' },
|
||||
}),
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const socket = await openTerminalSocket(socketUrl);
|
||||
sockets.push(socket.socket);
|
||||
socket.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'fast-exit' }));
|
||||
expect(await socket.next('snapshot', 'fast-exit')).toMatchObject({
|
||||
s: 'fast-exit',
|
||||
status: 'exited',
|
||||
exitCode: 0,
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-fast' },
|
||||
});
|
||||
} finally {
|
||||
for (const socket of sockets) socket.terminate();
|
||||
await runtime.shutdown();
|
||||
server.closeAllConnections?.();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it('tombstones a pending create when delete arrives first and kills the eventual pty', async () => {
|
||||
const spawnDeferred = deferred();
|
||||
const harness = createHarness({ spawnDeferred });
|
||||
try {
|
||||
const create = harness.routes.post.get('/api/terminal/create');
|
||||
const close = harness.routes.delete.get('/api/terminal/:sessionId');
|
||||
const created = createResponse();
|
||||
const closed = createResponse();
|
||||
|
||||
const createPromise = create({
|
||||
body: {
|
||||
sessionId: 'pending-action',
|
||||
cwd: '/repo',
|
||||
mode: 'command',
|
||||
command: 'npm run build',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-pending' },
|
||||
},
|
||||
}, created);
|
||||
await Promise.resolve();
|
||||
|
||||
const closePromise = close({ params: { sessionId: 'pending-action' } }, closed);
|
||||
spawnDeferred.resolve();
|
||||
await Promise.all([createPromise, closePromise]);
|
||||
|
||||
expect(closed.statusCode).toBe(200);
|
||||
expect(closed.body).toEqual({ success: true });
|
||||
expect(created.statusCode).toBe(400);
|
||||
expect(created.body).toEqual({ error: 'Terminal session was closed during creation' });
|
||||
|
||||
const listed = createResponse();
|
||||
harness.routes.get.get('/api/terminal/sessions')({ query: {} }, listed);
|
||||
expect(listed.body).toEqual({ sessions: [] });
|
||||
expect(harness.processes).toHaveLength(1);
|
||||
expect(harness.processes[0].killed).toBe(true);
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('rejects restart for command-mode sessions', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
await harness.routes.post.get('/api/terminal/create')({
|
||||
body: {
|
||||
sessionId: 'action-tab',
|
||||
cwd: '/repo',
|
||||
mode: 'command',
|
||||
command: 'npm run build',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
},
|
||||
}, createResponse());
|
||||
|
||||
const restarted = createResponse();
|
||||
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'action-tab' }, body: {} }, restarted);
|
||||
|
||||
expect(restarted.statusCode).toBe(400);
|
||||
expect(restarted.body).toEqual({ error: 'Command-mode terminal sessions cannot be restarted' });
|
||||
expect(harness.processes).toHaveLength(1);
|
||||
expect(harness.processes[0].killed).toBe(false);
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user