import { test, expect, type BrowserContext } 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('copy selection from terminal works', async ({ page, request, context }) => { // Grant clipboard permissions await context.grantPermissions(['clipboard-read', 'clipboard-write']); // Track session ID for cleanup let createdSessionId: string | null = null; // 1. Navigate to homepage and create a tmux session await page.goto('/'); 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 const terminalOutput = page.locator('pre.text-green-400'); await expect(terminalOutput).toBeVisible({ timeout: 10000 }); // 3. Run a command with unique output const uniqueMarker = `COPY-TEST-${Date.now()}`; const commandInput = page.locator('input[aria-label="Terminal input"]'); await expect(commandInput).toBeEnabled({ timeout: 5000 }); await commandInput.focus(); await page.keyboard.type(`echo "${uniqueMarker}"`); await page.keyboard.press('Enter'); console.log('[Test] Sent echo command with marker:', uniqueMarker); // Wait for output await expect(async () => { const content = await terminalOutput.textContent(); expect(content).toContain(uniqueMarker); }).toPass({ timeout: 5000 }); console.log('[Test] Marker appeared in terminal'); // 4. Clear the clipboard first await page.evaluate(() => navigator.clipboard.writeText('')); // 5. Select the marker text by triple-clicking on the line containing it // First, find the position of the marker in the terminal const terminalBox = await terminalOutput.boundingBox(); expect(terminalBox).not.toBeNull(); // Get the terminal content to find the marker position const content = await terminalOutput.textContent(); const lines = content?.split('\n') || []; const markerLineIndex = lines.findIndex(line => line.includes(uniqueMarker) && !line.includes('echo')); console.log('[Test] Marker found on line index:', markerLineIndex); if (markerLineIndex >= 0 && terminalBox) { // Calculate approximate Y position of the marker line // Assuming ~20px per line (adjust based on actual font size) const lineHeight = 20; const yOffset = markerLineIndex * lineHeight + lineHeight / 2 + 12; // +12 for padding // Triple-click to select the entire line await page.mouse.click(terminalBox.x + 50, terminalBox.y + yOffset, { clickCount: 3 }); console.log('[Test] Triple-clicked to select line at y offset:', yOffset); // Give a moment for the selection to register and copy to happen await page.waitForTimeout(200); // 6. Verify clipboard contains the marker const clipboardContent = await page.evaluate(() => navigator.clipboard.readText()); console.log('[Test] Clipboard content:', clipboardContent); // The clipboard should contain the marker (the entire line might be selected) expect(clipboardContent).toContain(uniqueMarker); console.log('[Test] Copy selection test passed - clipboard contains marker'); } else { throw new Error(`Could not find marker line in terminal output`); } // 7. Cleanup if (createdSessionId) { await deleteSession(request, createdSessionId); } console.log('[Test] Cleanup complete'); }); test('clear command removes prior terminal content', 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(); createdSessionId = decodeURIComponent(sessionUrl.split('/session/')[1]); console.log('[Test] Created session:', createdSessionId); // 2. Wait for terminal to load const terminalOutput = page.locator('pre.text-green-400'); await expect(terminalOutput).toBeVisible({ timeout: 10000 }); const commandInput = page.locator('input[aria-label="Terminal input"]'); await expect(commandInput).toBeEnabled({ timeout: 5000 }); // 3. Run commands that produce distinctive output const marker1 = `BEFORE-CLEAR-MARKER-${Date.now()}`; await commandInput.focus(); await page.keyboard.type(`echo "${marker1}"`); await page.keyboard.press('Enter'); console.log('[Test] Sent echo command with marker1:', marker1); // Wait for marker to appear await expect(async () => { const content = await terminalOutput.textContent(); expect(content).toContain(marker1); }).toPass({ timeout: 5000 }); console.log('[Test] Marker1 appeared in terminal'); // 4. Run clear command await commandInput.focus(); await page.keyboard.type('clear'); await page.keyboard.press('Enter'); console.log('[Test] Sent clear command'); // 5. Wait a moment for clear to process and verify marker is gone await page.waitForTimeout(1000); // The old marker should no longer be visible after clear await expect(async () => { const content = await terminalOutput.textContent(); expect(content).not.toContain(marker1); }).toPass({ timeout: 5000 }); console.log('[Test] Marker1 no longer visible after clear'); // 6. Run another command to prove terminal still works const marker2 = `AFTER-CLEAR-MARKER-${Date.now()}`; await commandInput.focus(); await page.keyboard.type(`echo "${marker2}"`); await page.keyboard.press('Enter'); console.log('[Test] Sent echo command with marker2:', marker2); // Wait for marker2 to appear await expect(async () => { const content = await terminalOutput.textContent(); expect(content).toContain(marker2); }).toPass({ timeout: 5000 }); console.log('[Test] Marker2 appeared in terminal'); // 7. Wait for the 5-second periodic full frame refresh // The bug causes old content to leak back after a full frame console.log('[Test] Waiting 6 seconds for periodic full frame refresh...'); await page.waitForTimeout(6000); // 8. Verify marker1 has not leaked back const finalContent = await terminalOutput.textContent(); console.log('[Test] Final terminal content length:', finalContent?.length); // The old marker should still NOT be visible after the full frame refresh expect(finalContent).not.toContain(marker1); console.log('[Test] Marker1 still not visible after full frame refresh - clear works correctly!'); // marker2 should still be there expect(finalContent).toContain(marker2); console.log('[Test] Marker2 still visible'); // 9. Cleanup if (createdSessionId) { await deleteSession(request, createdSessionId); } console.log('[Test] Cleanup complete'); }); 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 " 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'); } }); });