Files
spiceflow/e2e/tests/tmux-terminal.spec.ts
Adam Jeniski 5171059692 Add session eject feature and terminal UX improvements
- Add eject button for tmux sessions (keeps tmux running, removes from Spiceflow)
- Add refresh button to session settings for all providers
- Improve terminal controls: larger buttons, more zoom levels (50-150%), copy selection, paste clipboard, enter key
- Fix session navigation: properly reload when switching between sessions
- Update tmux screen presets to larger dimensions (fullscreen 260x48, desktop 120x48, landscape 80x24)
- Add testing documentation to CLAUDE.md
- Refactor E2E tests to use API-based cleanup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:37:06 -05:00

293 lines
12 KiB
TypeScript

import { test, expect } from '@playwright/test';
import { E2E_BACKEND_URL } from '../playwright.config';
// Helper to delete a specific session by ID via API
async function deleteSession(request: import('@playwright/test').APIRequestContext, sessionId: string) {
try {
await request.delete(`${E2E_BACKEND_URL}/api/sessions/${encodeURIComponent(sessionId)}`);
console.log(`[Cleanup] Deleted session: ${sessionId}`);
} catch (e) {
console.log(`[Cleanup] Failed to delete session ${sessionId}:`, e);
}
}
test.describe('Tmux Terminal Session', () => {
test('create tmux session and run shell commands', async ({ page, request }) => {
// Track session ID for cleanup
let createdSessionId: string | null = null;
// Enable console logging for debugging
page.on('console', (msg) => {
console.log(`[Browser ${msg.type()}]`, msg.text());
});
// Log network requests to /api
page.on('request', (request) => {
if (request.url().includes('/api')) {
console.log(`[Request] ${request.method()} ${request.url()}`);
}
});
page.on('response', (response) => {
if (response.url().includes('/api')) {
console.log(`[Response] ${response.status()} ${response.url()}`);
}
});
// 1. Navigate to homepage
await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i);
// 2. Click the + button to open new session menu
const createButton = page.locator('button[title="New Session"]');
await expect(createButton).toBeVisible();
await createButton.click();
// 3. Select Terminal (tmux) from the dropdown
const tmuxOption = page.locator('button:has-text("Terminal (tmux)")');
await expect(tmuxOption).toBeVisible();
await tmuxOption.click();
// 4. Wait for navigation to session page
await page.waitForURL(/\/session\/.+/);
const sessionUrl = page.url();
createdSessionId = decodeURIComponent(sessionUrl.split('/session/')[1]);
console.log('[Test] Navigated to session page:', sessionUrl);
// 5. Wait for terminal view to load (should see the terminal pre element)
const terminalOutput = page.locator('pre.text-green-400');
await expect(terminalOutput).toBeVisible({ timeout: 10000 });
console.log('[Test] Terminal view loaded');
// 6. Verify the session status indicator shows active
const statusIndicator = page.locator('.bg-green-500').first();
await expect(statusIndicator).toBeVisible({ timeout: 5000 });
console.log('[Test] Session is active');
// 7. Verify the terminal badge shows "TERMINAL"
const terminalBadge = page.locator('text=TERMINAL');
await expect(terminalBadge).toBeVisible();
// 8. Find the command input (hidden input for keyboard capture)
const commandInput = page.locator('input[aria-label="Terminal input"]');
await expect(commandInput).toBeEnabled();
// 9. Run `pwd` command - use type() to trigger keydown events
await commandInput.focus();
await page.keyboard.type('pwd');
await page.keyboard.press('Enter');
console.log('[Test] Sent pwd command');
// 10. Wait for output and verify it contains a path (starts with /)
await page.waitForTimeout(1000); // Give time for command to execute
let terminalContent = await terminalOutput.textContent();
console.log('[Test] Terminal content after pwd:', terminalContent);
// The output should contain a path starting with /
await expect(async () => {
terminalContent = await terminalOutput.textContent();
expect(terminalContent).toMatch(/\/[a-zA-Z]/);
}).toPass({ timeout: 5000 });
console.log('[Test] pwd output verified - contains path');
// 11. Run `ls -al` command
await commandInput.focus();
await page.keyboard.type('ls -al');
await page.keyboard.press('Enter');
console.log('[Test] Sent ls -al command');
// 12. Wait for output and verify it contains typical ls -al output
await page.waitForTimeout(1000); // Give time for command to execute
await expect(async () => {
terminalContent = await terminalOutput.textContent();
// ls -al output typically contains "total" at the start and permission strings like "drwx"
expect(terminalContent).toMatch(/total \d+/);
}).toPass({ timeout: 5000 });
console.log('[Test] ls -al output verified - contains "total" line');
// 13. Verify the output contains directory entries with permissions
await expect(async () => {
terminalContent = await terminalOutput.textContent();
// Should see permission patterns like drwx or -rw-
expect(terminalContent).toMatch(/[d-][rwx-]{9}/);
}).toPass({ timeout: 5000 });
console.log('[Test] ls -al output verified - contains permission strings');
// 14. Verify the output contains the . and .. directory entries
terminalContent = await terminalOutput.textContent();
// The . entry appears at end of line as " .\n" or " ." followed by newline
expect(terminalContent).toMatch(/ \.$/m);
console.log('[Test] ls -al output verified - contains current directory entry');
console.log('[Test] Tmux terminal test completed successfully');
// 15. Cleanup: Delete only the session we created
if (createdSessionId) {
await deleteSession(request, createdSessionId);
}
console.log('[Test] Cleanup: Test session deleted');
});
test('deleting tmux session kills the tmux process', async ({ page, request }) => {
// Track session ID for cleanup
let createdSessionId: string | null = null;
// 1. Navigate to homepage and create a tmux session
await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i);
const createButton = page.locator('button[title="New Session"]');
await createButton.click();
const tmuxOption = page.locator('button:has-text("Terminal (tmux)")');
await tmuxOption.click();
// Wait for navigation to session page
await page.waitForURL(/\/session\/.+/);
const sessionUrl = page.url();
// URL decode the session ID since it contains special characters
createdSessionId = decodeURIComponent(sessionUrl.split('/session/')[1]);
console.log('[Test] Created session:', createdSessionId);
// 2. Wait for terminal to load and session to be active
const terminalOutput = page.locator('pre.text-green-400');
await expect(terminalOutput).toBeVisible({ timeout: 10000 });
// 3. For ephemeral tmux sessions, the session ID IS the tmux session name
const tmuxSessionName = createdSessionId;
console.log('[Test] Tmux session name:', tmuxSessionName);
// 4. Verify the tmux session exists by running a command
const commandInput = page.locator('input[aria-label="Terminal input"]');
await expect(commandInput).toBeEnabled({ timeout: 5000 });
await commandInput.focus();
await page.keyboard.type('echo "session-alive"');
await page.keyboard.press('Enter');
await page.waitForTimeout(500);
// 5. Delete the session we created via API
await deleteSession(request, createdSessionId!);
console.log('[Test] Test session deleted from API');
// 6. Verify the tmux session was killed by checking the API
// The session should no longer exist
const sessionResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${encodeURIComponent(createdSessionId!)}`);
expect(sessionResponse.status()).toBe(404);
console.log('[Test] Session no longer exists in API');
// 7. Verify the tmux session is no longer alive
// We can check this by trying to get terminal content - it should fail
const terminalCheckResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${encodeURIComponent(createdSessionId!)}/terminal`);
expect(terminalCheckResponse.status()).toBe(404);
console.log('[Test] Tmux session properly cleaned up');
});
test('eject tmux session removes from spiceflow but keeps tmux running', async ({ page, request }) => {
// Track session ID for cleanup
let createdSessionId: string | null = null;
let ejectedSessionName: string | null = null;
// 1. Navigate to homepage and create a tmux session
await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i);
const createButton = page.locator('button[title="New Session"]');
await createButton.click();
const tmuxOption = page.locator('button:has-text("Terminal (tmux)")');
await tmuxOption.click();
// Wait for navigation to session page
await page.waitForURL(/\/session\/.+/);
const sessionUrl = page.url();
createdSessionId = decodeURIComponent(sessionUrl.split('/session/')[1]);
console.log('[Test] Created session:', createdSessionId);
// 2. Wait for terminal to load and session to be active
const terminalOutput = page.locator('pre.text-green-400');
await expect(terminalOutput).toBeVisible({ timeout: 10000 });
// 3. Run a command to verify terminal is working
const commandInput = page.locator('input[aria-label="Terminal input"]');
await expect(commandInput).toBeEnabled({ timeout: 5000 });
await commandInput.focus();
await page.keyboard.type('echo "eject-test-marker"');
await page.keyboard.press('Enter');
await page.waitForTimeout(500);
// Verify command output appeared
await expect(async () => {
const content = await terminalOutput.textContent();
expect(content).toContain('eject-test-marker');
}).toPass({ timeout: 5000 });
console.log('[Test] Terminal is working');
// 4. Handle the alert that will appear after eject
page.on('dialog', async (dialog) => {
console.log('[Test] Alert message:', dialog.message());
// Extract the new session name from the message
// Message format: "Session ejected. You can attach to it with: tmux attach -t <name>"
const match = dialog.message().match(/tmux attach -t (\S+)/);
if (match) {
ejectedSessionName = match[1];
}
await dialog.accept();
});
// 5. Open settings menu and click eject
const settingsButton = page.locator('button[title="Session settings"]');
await settingsButton.click();
const ejectButton = page.locator('text=Eject session');
await expect(ejectButton).toBeVisible();
await ejectButton.click();
console.log('[Test] Clicked eject button');
// 6. Wait for navigation back to home page
await page.waitForURL('/');
console.log('[Test] Navigated back to home');
// 7. Verify the session is no longer in the list
await page.waitForTimeout(500); // Give time for UI to update
const sessionCards = page.locator(`a[href="/session/${encodeURIComponent(createdSessionId!)}"]`);
await expect(sessionCards).toHaveCount(0);
console.log('[Test] Session removed from list');
// 8. Verify the ejected session still exists as an external tmux session
// Open the import menu to check external sessions
const importButton = page.locator('button[title="Import tmux session"]');
await importButton.click();
// Wait for the dropdown to load
await page.waitForTimeout(1000);
// The ejected session should now appear in the import list
// The name should be without the spiceflow- prefix
const externalSessionItem = page.locator('.min-w-\\[200px\\] button').filter({ hasText: /.+/ });
const externalSessionTexts = await externalSessionItem.allTextContents();
console.log('[Test] External sessions:', externalSessionTexts);
// Verify at least one external session exists (our ejected one)
expect(externalSessionTexts.length).toBeGreaterThan(0);
console.log('[Test] Ejected session is available for import');
// 9. Cleanup: Import the ejected session back and delete it via UI
if (ejectedSessionName) {
// Click on the ejected session in the import dropdown to import it
const ejectedSessionButton = page.locator(`button:has-text("${ejectedSessionName}")`);
await ejectedSessionButton.click();
console.log('[Test] Clicked to import ejected session');
// Wait for navigation to the imported session
await page.waitForURL(/\/session\/.+/);
const importedSessionUrl = page.url();
const importedSessionId = decodeURIComponent(importedSessionUrl.split('/session/')[1]);
console.log('[Test] Imported session:', importedSessionId);
// Delete the imported session via API (which will kill the tmux session)
await deleteSession(request, importedSessionId);
console.log('[Test] Cleaned up imported session');
}
});
});