Playwright Testing Best Practices for VibeTunnel

Overview

This guide documents best practices for writing reliable, non-flaky Playwright tests for VibeTunnel, based on official Playwright documentation and community best practices.

Core Principles

1. Use Auto-Waiting Instead of Arbitrary Delays

❌ Bad: Arbitrary timeouts
await page.waitForTimeout(1000); // Don't do this!
✅ Good: Wait for specific conditions
// Wait for element to be visible
await page.waitForSelector('vibe-terminal', { state: 'visible' });

// Wait for loading indicator to disappear
await page.locator('.loading-spinner').waitFor({ state: 'hidden' });

// Wait for specific text to appear
await page.getByText('Session created').waitFor();

2. Use Web-First Assertions

Web-first assertions automatically wait and retry until the condition is met:
// These assertions auto-wait
await expect(page.locator('session-card')).toBeVisible();
await expect(page).toHaveURL(/\?session=/);
await expect(sessionCard).toContainText('RUNNING');

3. Prefer User-Facing Locators

Locator Priority (best to worst):
  1. getByRole() - semantic HTML roles
  2. getByText() - visible text content
  3. getByTestId() - explicit test IDs
  4. locator() with CSS - last resort
// Good examples
await page.getByRole('button', { name: 'Create Session' }).click();
await page.getByText('Session Name').fill('My Session');
await page.getByTestId('terminal-output').waitFor();

VibeTunnel-Specific Patterns

Waiting for Terminal Ready

Instead of arbitrary delays, wait for terminal indicators:
// Wait for terminal component to be visible
await page.waitForSelector('vibe-terminal', { state: 'visible' });

// Wait for terminal to have content or structure
await page.waitForFunction(() => {
  const terminal = document.querySelector('vibe-terminal');
  return terminal && (
    terminal.textContent?.trim().length > 0 ||
    !!terminal.shadowRoot ||
    !!terminal.querySelector('.xterm')
  );
});

Handling Session Creation

// Wait for navigation after session creation
await expect(page).toHaveURL(/\?session=/, { timeout: 2000 });

// Wait for terminal to be ready
await page.locator('vibe-terminal').waitFor({ state: 'visible' });

Managing Modal Animations

Instead of waiting for animations, wait for the modal state:
// Wait for modal to be fully visible
await page.locator('[role="dialog"]').waitFor({ state: 'visible' });

// Wait for modal to be completely gone
await page.locator('[role="dialog"]').waitFor({ state: 'hidden' });

Session List Updates

// Wait for session cards to update
await page.locator('session-card').first().waitFor();

// Wait for specific session by name
await page.locator(`session-card:has-text("${sessionName}")`).waitFor();

Common Anti-Patterns to Avoid

1. Storing Element References

// ❌ Bad: Element reference can become stale
const button = await page.$('button');
await doSomething();
await button.click(); // May fail!

// ✅ Good: Re-query element when needed
await doSomething();
await page.locator('button').click();

2. Assuming Immediate Availability

// ❌ Bad: No waiting
await page.goto('/');
await page.click('session-card'); // May not exist yet!

// ✅ Good: Wait for element
await page.goto('/');
await page.locator('session-card').waitFor();
await page.locator('session-card').click();

3. Fixed Sleep for Dynamic Content

// ❌ Bad: Arbitrary wait for data load
await page.click('#load-data');
await page.waitForTimeout(3000);

// ✅ Good: Wait for loading state
await page.click('#load-data');
await page.locator('.loading').waitFor({ state: 'hidden' });
// Or wait for results
await page.locator('[data-testid="results"]').waitFor();

Test Configuration

Timeouts

Configure appropriate timeouts in playwright.config.ts:
use: {
  // Global timeout for assertions
  expect: { timeout: 5000 },
  
  // Action timeout (click, fill, etc.)
  actionTimeout: 10000,
  
  // Navigation timeout
  navigationTimeout: 10000,
}

Test Isolation

Each test should be independent:
test.beforeEach(async ({ page }) => {
  // Fresh start for each test
  await page.goto('/');
  await page.waitForSelector('vibetunnel-app', { state: 'attached' });
});

Debugging Flaky Tests

1. Enable Trace Recording

// In playwright.config.ts
use: {
  trace: 'on-first-retry',
}

2. Use Debug Mode

# Run with headed browser and inspector
pnpm exec playwright test --debug

3. Add Strategic Logging

console.log('Waiting for terminal to be ready...');
await page.locator('vibe-terminal').waitFor();
console.log('Terminal is ready');

Terminal-Specific Patterns

Waiting for Terminal Output

// Wait for specific text in terminal
await page.waitForFunction(
  (searchText) => {
    const terminal = document.querySelector('vibe-terminal');
    return terminal?.textContent?.includes(searchText);
  },
  'Expected output'
);

Waiting for Shell Prompt

// Wait for prompt patterns
await page.waitForFunction(() => {
  const terminal = document.querySelector('vibe-terminal');
  const content = terminal?.textContent || '';
  return /[$>#%❯]\s*$/.test(content);
});

Handling Server-Side Terminals

When spawnWindow is false, terminals run server-side:
// Create session with server-side terminal
await sessionListPage.createNewSession(sessionName, false);

// Wait for WebSocket/SSE connection
await page.locator('vibe-terminal').waitFor({ state: 'visible' });

// Terminal content comes through WebSocket - no need for complex waits

Summary

  1. Never use waitForTimeout() - always wait for specific conditions
  2. Use web-first assertions that auto-wait
  3. Prefer semantic locators over CSS selectors
  4. Wait for observable conditions not arbitrary time
  5. Configure appropriate timeouts for your application
  6. Keep tests isolated and independent
  7. Use Playwright’s built-in debugging tools for flaky tests
By following these practices, tests will be more reliable, faster, and easier to maintain.