474 lines
20 KiB
TypeScript
474 lines
20 KiB
TypeScript
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 <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');
|
|
}
|
|
});
|
|
});
|