Stabilize Playwright E2E isolation

Merged Playwright E2E isolation fixes for issue #568 after local and CI verification.
This commit is contained in:
Brad Groux 2026-06-04 15:21:06 -07:00 committed by GitHub
parent 5b4948f34f
commit 93b8eec64a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 123 additions and 107 deletions

View file

@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test';
import { bypassAuth, cleanupRoutes } from './helpers/auth';
test.describe('Governance dashboard widgets', () => {
test.describe('Board sidebar widgets', () => {
test.beforeEach(async ({ page }) => {
await bypassAuth(page);
});
@ -10,25 +10,17 @@ test.describe('Governance dashboard widgets', () => {
await cleanupRoutes(page);
});
test('renders dashboard widgets on the board view', async ({ page }) => {
test('renders operational widgets on the board view', async ({ page }) => {
await page.goto('/', { timeout: 15_000 });
await expect(page.getByText('Loading dashboard…'))
.not.toBeVisible({ timeout: 15_000 })
.catch(() => {});
await expect(page.getByRole('region', { name: 'To Do' })).toBeVisible({
timeout: 15_000,
});
await expect(
page.getByRole('heading', { name: /dashboard|governance dashboard/i }).first()
).toBeVisible({ timeout: 15_000 });
await expect(
page.locator('text=/Filter|Filters|Time Range|Agent|Status/i').first()
).toBeVisible({ timeout: 15_000 });
await expect(
page
.locator('text=/Success Rate|Token Usage|Average Run Duration|Monthly Budget|Health/i')
.first()
).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Agent Registry' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Recent Status Changes' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Audit Trail' })).toBeVisible();
await expect(page.getByText('Monthly Budget')).toBeVisible();
});
});

View file

@ -1,45 +0,0 @@
import { test, expect } from '@playwright/test';
import { bypassAuth, cleanupRoutes } from './helpers/auth';
test.describe('Feedback panel', () => {
test.beforeEach(async ({ page }) => {
await bypassAuth(page);
});
test.afterEach(async ({ page }) => {
await cleanupRoutes(page);
});
test('renders feedback browse state and filters', async ({ page }) => {
await page.goto('/', { timeout: 15_000 });
await expect(page.getByRole('heading', { name: 'User Feedback' })).toBeVisible({
timeout: 15_000,
});
await page.getByRole('tab', { name: 'Browse' }).click();
await expect(page.getByPlaceholder('Filter by agent')).toBeVisible({ timeout: 15_000 });
await expect(
page
.locator('button[role="combobox"]')
.filter({ hasText: /Sentiment|All sentiments/i })
.first()
).toBeVisible();
await expect(
page
.locator('button[role="combobox"]')
.filter({ hasText: /Category|All categories/i })
.first()
).toBeVisible();
await expect(
page
.locator('button[role="combobox"]')
.filter({ hasText: /Status|All statuses|Unresolved|Resolved/i })
.first()
).toBeVisible();
await expect(
page.locator('text=/No feedback found\.|Loading…|Resolved|Unresolved/i').first()
).toBeVisible({ timeout: 15_000 });
});
});

View file

@ -22,10 +22,10 @@ test.describe('Health Check', () => {
await page.goto('/');
// Wait for columns to load — data fetches via API, so allow time for loading
const todoColumn = page.getByRole('region', { name: /To Do column/ });
const inProgressColumn = page.getByRole('region', { name: /In Progress column/ });
const blockedColumn = page.getByRole('region', { name: /Blocked column/ });
const doneColumn = page.getByRole('region', { name: /Done column/ });
const todoColumn = page.getByRole('region', { name: 'To Do' });
const inProgressColumn = page.getByRole('region', { name: 'In Progress' });
const blockedColumn = page.getByRole('region', { name: 'Blocked' });
const doneColumn = page.getByRole('region', { name: 'Done' });
await expect(todoColumn).toBeVisible({ timeout: 15_000 });
await expect(inProgressColumn).toBeVisible();

View file

@ -12,6 +12,29 @@ const ADMIN_KEY = process.env.VERITAS_ADMIN_KEY || 'dev-admin-key';
* Vite proxy issues (IPv4/IPv6 binding differences on macOS).
*/
const API_BASE = process.env.API_BASE_URL || 'http://127.0.0.1:3001';
const AGENT_STATUS_PATH = /\/api\/agent\/status(?:\?.*)?$/;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function unwrapApiData<T = unknown>(body: unknown): T {
if (isRecord(body) && 'data' in body) {
return body.data as T;
}
return body as T;
}
export function unwrapTaskList<T = { id: string; title?: string }>(body: unknown): T[] {
const data = unwrapApiData<unknown>(body);
if (Array.isArray(data)) return data as T[];
if (isRecord(data) && Array.isArray(data.tasks)) return data.tasks as T[];
if (isRecord(body) && Array.isArray(body.tasks)) return body.tasks as T[];
return [];
}
/**
* Bypass authentication for E2E tests.
@ -38,14 +61,34 @@ export async function bypassAuth(page: Page): Promise<void> {
})
);
// Keep browser E2E focused on user flows instead of live agent-status polling.
await page.route(AGENT_STATUS_PATH, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
status: 'idle',
subAgentCount: 0,
activeAgents: [],
lastUpdated: new Date().toISOString(),
},
meta: {
timestamp: new Date().toISOString(),
},
}),
})
);
// Add 429 retry interceptor for all API calls from the browser.
// The dev server has rate limiting which E2E tests can exceed.
await page.route('**/api/**', async (route: Route) => {
const url = route.request().url();
// Skip the auth/status route (already handled by the specific handler above).
// Skip routes already handled by specific handlers above.
// Use route.fallback() so Playwright passes the request to the next matching
// handler instead of hanging (routes are matched LIFO).
if (url.includes('/api/auth/status')) {
if (url.includes('/api/auth/status') || AGENT_STATUS_PATH.test(url)) {
await route.fallback();
return;
}
@ -138,7 +181,7 @@ export async function seedTestTask(
}
const createdBody = await response.json();
const task = (createdBody as { data?: Record<string, unknown> }).data ?? createdBody;
const task = unwrapApiData<Record<string, unknown>>(createdBody);
// The API creates tasks as 'todo' and only accepts git/worktree data on PATCH.
const targetStatus = desiredStatus ?? 'todo';
@ -163,7 +206,7 @@ export async function seedTestTask(
);
}
const patchedBody = await patchResponse.json();
return (patchedBody as { data?: Record<string, unknown> }).data ?? patchedBody;
return unwrapApiData<Record<string, unknown>>(patchedBody);
}
return task;

View file

@ -503,7 +503,7 @@ test.describe('v5 Mantine migration QA gate', () => {
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5_000 });
await page.getByRole('button', { name: 'Settings' }).click();
await page.getByRole('button', { name: 'Settings', exact: true }).click();
const settingsDialog = page.getByRole('dialog', { name: 'Settings' });
await expect(settingsDialog).toBeVisible({ timeout: 5_000 });
await settingsDialog.getByRole('tab', { name: 'Board' }).click();
@ -516,7 +516,7 @@ test.describe('v5 Mantine migration QA gate', () => {
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
await expect(page.getByRole('textbox', { name: 'Search tasks and docs' })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Search Veritas' })).toBeVisible();
await assertNoLegacyPrimitiveSlots(page);
await assertVisibleInteractiveControlsHaveNames(page);
await assertFocusRemainsInsideDialog(page, 'Search');
@ -561,6 +561,7 @@ test.describe('v5 Mantine migration QA gate', () => {
await page.setViewportSize(mobileViewport);
await page.goto('/', { timeout: 15_000 });
await page.getByRole('button', { name: 'Mobile board' }).click();
await expect(page.getByRole('region', { name: 'To Do' })).toBeVisible({
timeout: 15_000,
});
@ -580,7 +581,7 @@ test.describe('v5 Mantine migration QA gate', () => {
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5_000 });
await page.getByRole('button', { name: 'Settings' }).click();
await page.getByRole('button', { name: 'Settings', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
await assertNoHorizontalOverflow(page);
await assertMobileTouchTargets(page);

View file

@ -107,7 +107,9 @@ test.describe('mobile responsive flows', () => {
await detail.getByRole('tab', { name: 'Details' }).click();
await detail.getByPlaceholder(/Add a comment/).fill('Mobile comment submitted.');
await detail.getByRole('button', { name: 'Add Comment' }).click();
await expect(detail.getByText('Mobile comment submitted.')).toBeVisible();
await expect(
detail.locator('p', { hasText: 'Mobile comment submitted.' }).first()
).toBeVisible();
await detail.getByRole('button', { name: 'Close task details' }).click();
await expect(detail).not.toBeVisible();

View file

@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test';
import { bypassAuth, cleanupRoutes } from './helpers/auth';
test.describe('Prompt registry', () => {
test.describe('Template registry', () => {
test.beforeEach(async ({ page }) => {
await bypassAuth(page);
});
@ -14,11 +14,16 @@ test.describe('Prompt registry', () => {
await page.goto('/templates', { timeout: 15_000 });
await expect(page.getByRole('button', { name: 'Templates' })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Prompt Templates')).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole('heading', { name: 'Task Templates' })).toBeVisible({
timeout: 15_000,
});
await expect(page.getByRole('button', { name: /new template/i })).toBeVisible();
await expect(page.getByPlaceholder('Search templates...')).toBeVisible();
await expect(
page.locator('text=/No description|Variables:|Loading templates|Prompt Templates/i').first()
page
.locator('text=/No templates yet|No templates match|Loading templates|Task Templates/i')
.first()
).toBeVisible({ timeout: 15_000 });
});
});

View file

@ -1,5 +1,11 @@
import { test, expect } from '@playwright/test';
import { bypassAuth, deleteTask, cleanupRoutes } from './helpers/auth';
import {
bypassAuth,
deleteTask,
cleanupRoutes,
unwrapApiData,
unwrapTaskList,
} from './helpers/auth';
test.describe('Task Creation', () => {
const createdTaskIds: string[] = [];
@ -38,9 +44,21 @@ test.describe('Task Creation', () => {
const descInput = dialog.locator('#description');
await descInput.fill('This task was created by an E2E test');
// Submit the form
// Submit the form and capture the created task ID before UI assertions.
const submitBtn = dialog.locator('button[type="submit"]', { hasText: /Create/ });
await submitBtn.click();
const [createResponse] = await Promise.all([
page.waitForResponse(
(resp) => resp.url().includes('/api/tasks') && resp.request().method() === 'POST',
{ timeout: 10_000 }
),
submitBtn.click(),
]);
expect(createResponse.status()).toBeLessThan(400);
const createdFromResponse = unwrapApiData<{ id?: string }>(await createResponse.json());
if (createdFromResponse.id) {
createdTaskIds.push(createdFromResponse.id);
}
// Dialog should close after the API call completes
await expect(dialog).not.toBeVisible({ timeout: 15_000 });
@ -53,11 +71,9 @@ test.describe('Task Creation', () => {
const response = await page.request.get('/api/tasks', {
headers: { 'X-API-Key': process.env.VERITAS_ADMIN_KEY || 'dev-admin-key' },
});
const tasks = await response.json();
const created = (tasks as { id: string; title: string }[]).find(
(t) => t.title === 'E2E Created Task'
);
if (created) {
const tasks = unwrapTaskList<{ id: string; title: string }>(await response.json());
const created = tasks.find((t) => t.title === 'E2E Created Task');
if (created && !createdTaskIds.includes(created.id)) {
createdTaskIds.push(created.id);
}
});

View file

@ -28,7 +28,7 @@ test.describe('Task List', () => {
await page.goto('/');
// The kanban board should show the seeded task
const taskCard = page.locator(`text=E2E Visible Task`);
const taskCard = page.getByRole('heading', { name: 'E2E Visible Task' });
await expect(taskCard).toBeVisible({ timeout: 10_000 });
});
@ -43,7 +43,7 @@ test.describe('Task List', () => {
await page.goto('/');
// Find the in-progress column and verify the task is inside it
const inProgressCol = page.getByRole('region', { name: /In Progress column/ });
const inProgressCol = page.getByRole('region', { name: 'In Progress' });
await expect(inProgressCol).toBeVisible({ timeout: 15_000 });
await expect(inProgressCol.locator('text=E2E Column Check Task')).toBeVisible({
timeout: 10_000,
@ -54,7 +54,7 @@ test.describe('Task List', () => {
await page.goto('/');
// Wait for the board to load
await expect(page.getByRole('region', { name: /To Do column/ })).toBeVisible({
await expect(page.getByRole('region', { name: 'To Do' })).toBeVisible({
timeout: 15_000,
});

View file

@ -29,7 +29,7 @@ test.describe('Task Status Change', () => {
await page.goto('/');
// Verify the task is in the To Do column
const todoColumn = page.getByRole('region', { name: /To Do column/ });
const todoColumn = page.getByRole('region', { name: 'To Do' });
await expect(todoColumn.locator(`text=${uniqueTitle}`)).toBeVisible({
timeout: 15_000,
});
@ -40,10 +40,7 @@ test.describe('Task Status Change', () => {
const detailPanel = page.locator('[role="dialog"]');
await expect(detailPanel).toBeVisible({ timeout: 5_000 });
// The metadata section has a grid: Status | Type | Priority
// Status is the first Select in the grid
const statusSection = detailPanel.locator('label:has-text("Status")').locator('..');
const statusTrigger = statusSection.locator('button[role="combobox"]');
const statusTrigger = detailPanel.getByRole('combobox', { name: 'Status' });
await expect(statusTrigger).toBeVisible();
await statusTrigger.click();
@ -67,7 +64,7 @@ test.describe('Task Status Change', () => {
await expect(detailPanel).not.toBeVisible({ timeout: 3_000 });
// Wait for the task to move to the In Progress column
const inProgressColumn = page.getByRole('region', { name: /In Progress column/ });
const inProgressColumn = page.getByRole('region', { name: 'In Progress' });
await expect(inProgressColumn.locator(`text=${uniqueTitle}`)).toBeVisible({
timeout: 10_000,
});
@ -89,7 +86,7 @@ test.describe('Task Status Change', () => {
await page.goto('/');
// Verify the task starts in In Progress
const inProgressCol = page.getByRole('region', { name: /In Progress column/ });
const inProgressCol = page.getByRole('region', { name: 'In Progress' });
await expect(inProgressCol.locator(`text=${uniqueTitle}`)).toBeVisible({ timeout: 15_000 });
// Open the detail panel
@ -98,9 +95,7 @@ test.describe('Task Status Change', () => {
const detailPanel = page.locator('[role="dialog"]');
await expect(detailPanel).toBeVisible({ timeout: 5_000 });
// Find the Status dropdown
const statusSection = detailPanel.locator('label:has-text("Status")').locator('..');
const statusTrigger = statusSection.locator('button[role="combobox"]');
const statusTrigger = detailPanel.getByRole('combobox', { name: 'Status' });
await expect(statusTrigger).toBeVisible();
await statusTrigger.click();
@ -122,7 +117,7 @@ test.describe('Task Status Change', () => {
await page.keyboard.press('Escape');
// Verify the task moved to Done
const doneCol = page.getByRole('region', { name: /Done column/ });
const doneCol = page.getByRole('region', { name: 'Done' });
await expect(doneCol.locator(`text=${uniqueTitle}`)).toBeVisible({ timeout: 10_000 });
});
});

View file

@ -1,5 +1,6 @@
import { defineConfig, devices } from '@playwright/test';
import { readFileSync } from 'fs';
import { mkdtempSync, readFileSync } from 'fs';
import { tmpdir } from 'os';
import { resolve } from 'path';
// Load VERITAS_ADMIN_KEY from server/.env so E2E tests use the same key as the server
@ -15,6 +16,10 @@ if (!process.env.VERITAS_ADMIN_KEY) {
}
}
const e2eDataDir =
process.env.VERITAS_DATA_DIR ?? mkdtempSync(resolve(tmpdir(), 'veritas-kanban-e2e-'));
process.env.VERITAS_DATA_DIR = e2eDataDir;
/**
* Playwright E2E test configuration for Veritas Kanban.
*
@ -70,9 +75,11 @@ export default defineConfig({
timeout: 30_000,
env: {
VERITAS_ADMIN_KEY: process.env.VERITAS_ADMIN_KEY || 'dev-admin-key',
VERITAS_DATA_DIR: e2eDataDir,
VERITAS_DISABLE_WATCHERS: '1',
VERITAS_AUTH_LOCALHOST_BYPASS: 'true',
VERITAS_AUTH_LOCALHOST_ROLE: 'admin',
RATE_LIMIT_MAX: '10000',
},
},
{

View file

@ -16,8 +16,8 @@ import type { Request } from 'express';
* - authRateLimit 10 req / 15 min (login, token refresh)
* - uploadRateLimit 20 req / min (file uploads)
* - writeRateLimit 60 req / min (POST, PUT, PATCH, DELETE)
* - readRateLimit 300 req / min (GET requests)
* - apiRateLimit 300 req / min (global fallback, localhost exempt)
* - readRateLimit 300 req / min (GET requests, configurable with RATE_LIMIT_MAX)
* - apiRateLimit 300 req / min (global fallback, configurable with RATE_LIMIT_MAX, localhost exempt)
*/
// ── Configuration ──────────────────────────────────────────────────────────────
@ -25,7 +25,7 @@ import type { Request } from 'express';
/** Default rate limit (requests per minute) for general API access. */
const DEFAULT_API_LIMIT = 300;
/** Read override from environment, falling back to the default. */
/** API/read override from environment, falling back to the default. */
const API_LIMIT: number = (() => {
const env = process.env.RATE_LIMIT_MAX;
if (env) {
@ -37,7 +37,6 @@ const API_LIMIT: number = (() => {
// ── Helpers ────────────────────────────────────────────────────────────────────
/** Returns true when the request originates from localhost / loopback. */
/** Returns true when the request originates from localhost / loopback. */
function isLocalhost(req: Request): boolean {
// In production, never exempt localhost to avoid bypassing limits behind proxies.
@ -120,12 +119,13 @@ export const writeRateLimit = rateLimit({
});
/**
* Generous rate limiter for read operations: 300 req / min per IP.
* Generous rate limiter for read operations: 300 req / min per IP by default.
* Applied to: GET requests on resource endpoints.
* Override with RATE_LIMIT_MAX for high-volume test/dev scenarios.
* Localhost is NOT exempt consistent with other tiered limiters.
*/
export const readRateLimit = rateLimit({
limit: 300,
limit: API_LIMIT,
windowMs: 60_000,
message: 'Too many read requests. Please slow down.',
});