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>
This commit is contained in:
+150
-82
@@ -1,31 +1,20 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { E2E_BACKEND_URL } from '../playwright.config';
|
||||
|
||||
// Helper to clean up any existing tmux sessions via UI
|
||||
async function cleanupTmuxSessions(page: import('@playwright/test').Page) {
|
||||
await page.goto('/');
|
||||
|
||||
// Delete all tmux session cards
|
||||
while (true) {
|
||||
const tmuxCards = page.locator('a.card').filter({ has: page.locator('span:text-is("tmux")') });
|
||||
const count = await tmuxCards.count();
|
||||
if (count === 0) break;
|
||||
|
||||
// Set up one-time dialog handler for this deletion
|
||||
page.once('dialog', async (dialog) => {
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
const deleteButton = tmuxCards.first().locator('button[title="Delete session"]');
|
||||
await deleteButton.click();
|
||||
await page.waitForTimeout(500); // Wait for deletion to process
|
||||
// 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 }) => {
|
||||
// Clean up any stale tmux sessions first
|
||||
await cleanupTmuxSessions(page);
|
||||
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) => {
|
||||
@@ -61,7 +50,7 @@ test.describe('Tmux Terminal Session', () => {
|
||||
// 4. Wait for navigation to session page
|
||||
await page.waitForURL(/\/session\/.+/);
|
||||
const sessionUrl = page.url();
|
||||
const sessionId = decodeURIComponent(sessionUrl.split('/session/')[1]);
|
||||
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)
|
||||
@@ -78,14 +67,14 @@ test.describe('Tmux Terminal Session', () => {
|
||||
const terminalBadge = page.locator('text=TERMINAL');
|
||||
await expect(terminalBadge).toBeVisible();
|
||||
|
||||
// 8. Find the command input
|
||||
const commandInput = page.locator('input[placeholder*="Type command"]');
|
||||
await expect(commandInput).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
|
||||
await commandInput.fill('pwd');
|
||||
await commandInput.press('Enter');
|
||||
// 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 /)
|
||||
@@ -101,8 +90,9 @@ test.describe('Tmux Terminal Session', () => {
|
||||
console.log('[Test] pwd output verified - contains path');
|
||||
|
||||
// 11. Run `ls -al` command
|
||||
await commandInput.fill('ls -al');
|
||||
await commandInput.press('Enter');
|
||||
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
|
||||
@@ -131,30 +121,16 @@ test.describe('Tmux Terminal Session', () => {
|
||||
|
||||
console.log('[Test] Tmux terminal test completed successfully');
|
||||
|
||||
// 15. Cleanup: Delete the session via UI
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveTitle(/Spiceflow/i);
|
||||
|
||||
// Find the tmux session card and delete it
|
||||
const tmuxCards = page.locator('a.card').filter({ has: page.locator('span:text-is("tmux")') });
|
||||
await expect(tmuxCards).toHaveCount(1);
|
||||
|
||||
// Set up one-time dialog handler
|
||||
page.once('dialog', async (dialog) => {
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
const deleteButton = tmuxCards.first().locator('button[title="Delete session"]');
|
||||
await deleteButton.click();
|
||||
|
||||
// Wait for no tmux cards to remain
|
||||
await expect(tmuxCards).toHaveCount(0, { timeout: 5000 });
|
||||
console.log('[Test] Cleanup: Session deleted');
|
||||
// 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 }) => {
|
||||
// Clean up any stale tmux sessions first
|
||||
await cleanupTmuxSessions(page);
|
||||
// Track session ID for cleanup
|
||||
let createdSessionId: string | null = null;
|
||||
|
||||
// 1. Navigate to homepage and create a tmux session
|
||||
await page.goto('/');
|
||||
@@ -170,55 +146,147 @@ test.describe('Tmux Terminal Session', () => {
|
||||
await page.waitForURL(/\/session\/.+/);
|
||||
const sessionUrl = page.url();
|
||||
// URL decode the session ID since it contains special characters
|
||||
const sessionId = decodeURIComponent(sessionUrl.split('/session/')[1]);
|
||||
console.log('[Test] Created session:', sessionId);
|
||||
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 = sessionId;
|
||||
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[placeholder*="Type command"]');
|
||||
await commandInput.fill('echo "session-alive"');
|
||||
await commandInput.press('Enter');
|
||||
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. Go back to home page
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveTitle(/Spiceflow/i);
|
||||
// 5. Delete the session we created via API
|
||||
await deleteSession(request, createdSessionId!);
|
||||
console.log('[Test] Test session deleted from API');
|
||||
|
||||
// 6. Find and delete the session by provider badge (more generic than session ID)
|
||||
const tmuxCards = page.locator('a.card').filter({ has: page.locator('span:text-is("tmux")') });
|
||||
await expect(tmuxCards).toHaveCount(1);
|
||||
|
||||
// Set up one-time dialog handler to accept the confirmation
|
||||
page.once('dialog', async (dialog) => {
|
||||
console.log('[Test] Dialog appeared:', dialog.message());
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
// Click the delete button (X icon) on the session card
|
||||
const deleteButton = tmuxCards.first().locator('button[title="Delete session"]');
|
||||
await deleteButton.click();
|
||||
|
||||
// 7. Wait for session to be deleted (no more tmux cards)
|
||||
await expect(tmuxCards).toHaveCount(0, { timeout: 5000 });
|
||||
console.log('[Test] Session deleted from UI');
|
||||
|
||||
// 8. Verify the tmux session was killed by checking the 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/${sessionId}`);
|
||||
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');
|
||||
|
||||
// 9. Verify the tmux session is no longer alive
|
||||
// 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/${sessionId}/terminal`);
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user