File size: 10,777 Bytes
1dbc34b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 | # E2E Testing Guide
Best practices and patterns for writing reliable, non-flaky Playwright e2e tests in this codebase.
## Core Principles
1. **No arbitrary timeouts** - Never use `page.waitForTimeout()`. Always wait for specific conditions.
2. **Use data-testid attributes** - Prefer `[data-testid="..."]` selectors over CSS classes or text content.
3. **Clean up after tests** - Use unique temp directories and clean them up in `afterAll`.
4. **Test isolation** - Each test should be independent and not rely on state from other tests.
## Setting Up Test State
### Use Setup Utilities (Recommended)
Use the provided utility functions to set up localStorage state. These utilities hide the internal store structure and version details, making tests more maintainable.
```typescript
import { setupWelcomeView, setupRealProject } from './utils';
// Show welcome view with workspace directory configured
await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR });
// Show welcome view with recent projects
await setupWelcomeView(page, {
workspaceDir: TEST_TEMP_DIR,
recentProjects: [
{
id: 'project-123',
name: 'My Project',
path: '/path/to/project',
lastOpened: new Date().toISOString(),
},
],
});
// Set up a real project on the filesystem
await setupRealProject(page, projectPath, projectName, {
setAsCurrent: true, // Opens board view (default)
});
```
### Why Use Utilities Instead of Raw localStorage
1. **Version management** - Store versions are centralized in one place
2. **Less brittle** - If store structure changes, update one file instead of every test
3. **Cleaner tests** - Focus on test logic, not setup boilerplate
4. **Type safety** - Utilities provide typed interfaces for test data
### Manual LocalStorage Setup (Advanced)
If you need custom setup not covered by utilities, use `page.addInitScript()`.
Store versions are defined in `tests/utils/project/setup.ts`:
- `APP_STORE`: version 2 (matches `app-store.ts`)
- `SETUP_STORE`: version 0 (matches `setup-store.ts` default)
### Temp Directory Management
Create unique temp directories for test isolation:
```typescript
import { createTempDirPath, cleanupTempDir } from './utils';
const TEST_TEMP_DIR = createTempDirPath('my-test-name');
test.describe('My Tests', () => {
test.beforeAll(async () => {
if (!fs.existsSync(TEST_TEMP_DIR)) {
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
}
});
test.afterAll(async () => {
cleanupTempDir(TEST_TEMP_DIR);
});
});
```
### Git isolation: never use the main project path
E2E tests must **never** use the workspace/repo root (the project you're developing in) as the project path. The app and server can run git commands (checkout, worktree add, merge, etc.) on the current project; if that path is the main repo, tests can leave it in a different branch or with merge conflicts.
- **Allowed:** Paths under `tests/` (e.g. `createTempDirPath('...')` or `tests/fixtures/projectA`) or under `os.tmpdir()`.
- **Not allowed:** Workspace root or any path outside `tests/` or temp.
`setupRealProject` and `setupProjectWithFixture` enforce this: they throw if the project path is the workspace root or outside the allowed bases. Use `createTempDirPath()` for test-specific project dirs and the fixture path for fixture-based tests.
## Waiting for Elements
### Prefer `toBeVisible()` over `waitForSelector()`
```typescript
// Good - uses Playwright's auto-waiting with expect
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
// Avoid - manual waiting
await page.waitForSelector('[data-testid="welcome-view"]');
```
### Wait for page load after navigation
**Important:** Use `load` state, NOT `networkidle`. This app has persistent connections (websockets, polling) that prevent the network from ever becoming "idle", causing `networkidle` to timeout.
```typescript
await page.goto('/');
await page.waitForLoadState('load');
// Then wait for specific elements to verify the page is ready
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
```
**Why not `networkidle`?**
- `networkidle` requires no network activity for 500ms
- Modern SPAs with real-time features (websockets, polling, SSE) never reach this state
- Using `networkidle` causes 30+ second timeouts and flaky tests
- The `load` state fires when the page finishes loading, which is sufficient
- Always follow up with element visibility checks for reliability
### Use appropriate timeouts
- Quick UI updates: 5000ms (default)
- Page loads/navigation: 10000ms
- Async operations (API calls, file system): 15000ms
```typescript
// Fast UI element
await expect(button).toBeVisible();
// Page load
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
// Async operation completion
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
```
## Element Selection
### Use data-testid attributes
```typescript
// Good - stable selector
const button = page.locator('[data-testid="create-new-project"]');
// Avoid - brittle selectors
const button = page.locator('.btn-primary');
const button = page.getByText('Create');
```
### Scope selectors when needed
When text appears in multiple places, scope to a parent:
```typescript
// Bad - might match multiple elements
await expect(page.getByText(projectName)).toBeVisible();
// Good - scoped to specific container
await expect(page.locator('[data-testid="project-selector"]').getByText(projectName)).toBeVisible();
```
### Handle strict mode violations
If a selector matches multiple elements:
```typescript
// Use .first() if you need the first match
await page.locator('[data-testid="item"]').first().click();
// Or scope to a unique parent
await page.locator('[data-testid="sidebar"]').locator('[data-testid="item"]').click();
```
## Clicking Elements
### Always verify visibility before clicking
```typescript
const button = page.locator('[data-testid="submit"]');
await expect(button).toBeVisible();
await button.click();
```
### Handle dialogs that may close quickly
Some dialogs may appear briefly or auto-close. Don't rely on clicking them:
```typescript
// Instead of trying to close a dialog that might disappear:
// await expect(dialog).toBeVisible();
// await closeButton.click(); // May fail if dialog closes first
// Just verify the end state:
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
```
## Filesystem Verification
Verify files were created after async operations:
```typescript
// Wait for UI to confirm operation completed first
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
// Then verify filesystem
const projectPath = path.join(TEST_TEMP_DIR, projectName);
expect(fs.existsSync(projectPath)).toBe(true);
const appSpecPath = path.join(projectPath, '.automaker', 'app_spec.txt');
expect(fs.existsSync(appSpecPath)).toBe(true);
const content = fs.readFileSync(appSpecPath, 'utf-8');
expect(content).toContain(projectName);
```
## Test Structure
### Use descriptive test names
```typescript
test('should create a new blank project from welcome view', async ({ page }) => {
// ...
});
```
### Group related tests with describe blocks
```typescript
test.describe('Project Creation', () => {
test('should create a new blank project from welcome view', ...);
test('should create a project from template', ...);
});
```
### Use serial mode when tests depend on each other
```typescript
test.describe.configure({ mode: 'serial' });
```
## Common Patterns
### Waiting for either of two outcomes
When multiple outcomes are possible (e.g., dialog or direct navigation):
```typescript
// Wait for either the dialog or the board view
await Promise.race([
initDialog.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {}),
boardView.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {}),
]);
// Then handle whichever appeared
if (await initDialog.isVisible()) {
await closeButton.click();
}
await expect(boardView).toBeVisible();
```
### Generating unique test data
```typescript
const projectName = `test-project-${Date.now()}`;
```
## Running Tests
```bash
# Run all tests
npm run test
# Run specific test file
npm run test -- project-creation.spec.ts
# Run with headed browser (see what's happening)
npm run test:headed -- project-creation.spec.ts
# Run multiple times to check for flakiness
npm run test -- project-creation.spec.ts --repeat-each=5
```
## Debugging Failed Tests
1. Check the screenshot in `test-results/`
2. Read the error context markdown file in `test-results/`
3. Run with `--headed` to watch the test
4. Add `await page.pause()` to pause execution at a specific point
## Common Pitfalls
### Timeout on `waitForLoadState('networkidle')`
If tests timeout waiting for network idle, the app likely has persistent connections. Use `load` state instead:
```typescript
// Bad - will timeout with persistent connections
await page.waitForLoadState('networkidle');
// Good - completes when page loads
await page.waitForLoadState('load');
await expect(page.locator('[data-testid="my-element"]')).toBeVisible();
```
### Port conflicts
If you see "Port 3108 is already in use", kill the process:
```bash
lsof -ti:3108 | xargs kill -9
```
## Available Test Utilities
Import from `./utils`:
### State Setup Utilities
- `setupWelcomeView(page, options?)` - Set up empty state showing welcome view
- `options.workspaceDir` - Pre-configure workspace directory
- `options.recentProjects` - Add projects to recent list (not current)
- `setupRealProject(page, path, name, options?)` - Set up state with a real filesystem project
- `options.setAsCurrent` - Open board view (default: true)
- `options.additionalProjects` - Add more projects to list
- `setupMockProject(page)` - Set up mock project for unit-style tests
- `setupComplete(page)` - Mark setup wizard as complete
### Filesystem Utilities
- `createTempDirPath(prefix)` - Create unique temp directory path
- `cleanupTempDir(path)` - Remove temp directory
- `createTestGitRepo(tempDir)` - Create a git repo for testing
### Waiting Utilities
- `waitForNetworkIdle(page)` - Wait for page to load (uses `load` state, not `networkidle`)
- `waitForElement(page, testId)` - Wait for element by test ID
- `waitForBoardView(page)` - Navigate to board and wait for it to be visible
### Async File Verification
Use `expect().toPass()` for polling filesystem operations:
```typescript
await expect(async () => {
expect(fs.existsSync(filePath)).toBe(true);
}).toPass({ timeout: 10000 });
```
See `tests/utils/index.ts` for the full list of available utilities.
|