This commit is contained in:
190
tests/e2e/room-parallel-agents.spec.ts
Normal file
190
tests/e2e/room-parallel-agents.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* P6 regression — Room canvas visualises multiple parallel sub-agents
|
||||
* correctly, without cross-contamination.
|
||||
*
|
||||
* Mocks the adapter's /health handshake and replaces window.EventSource
|
||||
* with a stub that emits one `subagent_status` event carrying two
|
||||
* simultaneously-running agents. Then asserts the Room renders two
|
||||
* distinct tiles with the right role badges + running status.
|
||||
*
|
||||
* Pairs with `apps/web/src/lib/room-state-reducer.test.ts` (12 unit
|
||||
* tests covering the reducer directly). This E2E adds the render-path
|
||||
* guarantee on top.
|
||||
*
|
||||
* Run: npx playwright test tests/e2e/room-parallel-agents.spec.ts --reporter=list
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
const BASE = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
|
||||
|
||||
// Canonical test payload — two agents in one status event, different roles.
|
||||
const AGENT_ALPHA_ID = 'agent-alpha-p6';
|
||||
const AGENT_BETA_ID = 'agent-beta-p6';
|
||||
|
||||
const MOCK_ROSTER = {
|
||||
type: 'subagent_status',
|
||||
workspaceId: 'default',
|
||||
agents: [
|
||||
{
|
||||
id: AGENT_ALPHA_ID,
|
||||
name: 'Alpha Researcher',
|
||||
role: 'researcher',
|
||||
status: 'running',
|
||||
task: 'Scout the competitive landscape for bee pollinator tech',
|
||||
toolsUsed: ['web_search'],
|
||||
startedAt: Date.now() - 30_000,
|
||||
},
|
||||
{
|
||||
id: AGENT_BETA_ID,
|
||||
name: 'Beta Coder',
|
||||
role: 'coder',
|
||||
status: 'running',
|
||||
task: 'Draft the TypeScript API surface for the honey-ledger module',
|
||||
toolsUsed: ['read_file'],
|
||||
startedAt: Date.now() - 15_000,
|
||||
},
|
||||
],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
async function installSseMock(page: Page) {
|
||||
// Adapter gates subscribe() on `_connected`, which flips to true only
|
||||
// after a successful /health call. Mock it.
|
||||
await page.route('**/health', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ wsToken: 'p6-ws-token', authToken: 'p6-auth-token' }),
|
||||
});
|
||||
});
|
||||
|
||||
// Replace EventSource with a stub that dispatches our mock roster on
|
||||
// the `subagent_status` named event type. Installed via addInitScript
|
||||
// so it lands before the adapter ever calls `new EventSource(...)`.
|
||||
await page.addInitScript((roster) => {
|
||||
type Listener = (e: MessageEvent) => void;
|
||||
interface MockWindow {
|
||||
__p6MockSources?: unknown[];
|
||||
EventSource: unknown;
|
||||
localStorage: Storage;
|
||||
}
|
||||
const win = window as unknown as MockWindow;
|
||||
|
||||
class MockEventSource {
|
||||
url: string;
|
||||
readyState = 1; // OPEN
|
||||
onerror: ((e: Event) => void) | null = null;
|
||||
onmessage: ((e: MessageEvent) => void) | null = null;
|
||||
onopen: ((e: Event) => void) | null = null;
|
||||
private listeners: Record<string, Listener[]> = {};
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
win.__p6MockSources = (win.__p6MockSources || []);
|
||||
win.__p6MockSources.push(this);
|
||||
|
||||
// Give React a moment to mount and the adapter to call addEventListener.
|
||||
setTimeout(() => {
|
||||
const ls = this.listeners['subagent_status'] ?? [];
|
||||
const evt = new MessageEvent('subagent_status', {
|
||||
data: JSON.stringify(roster),
|
||||
});
|
||||
for (const l of ls) l(evt);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: Listener) {
|
||||
(this.listeners[type] ??= []).push(listener);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: Listener) {
|
||||
this.listeners[type] = (this.listeners[type] ?? []).filter((l) => l !== listener);
|
||||
}
|
||||
|
||||
close() { this.readyState = 2; }
|
||||
|
||||
// Test helper — re-emit the same roster, useful for cross-contamination check.
|
||||
__reemit() {
|
||||
const ls = this.listeners['subagent_status'] ?? [];
|
||||
const evt = new MessageEvent('subagent_status', { data: JSON.stringify(roster) });
|
||||
for (const l of ls) l(evt);
|
||||
}
|
||||
}
|
||||
|
||||
win.EventSource = MockEventSource;
|
||||
// Skip boot screen so we get to Desktop immediately.
|
||||
try {
|
||||
window.localStorage.setItem('waggle-booted', 'true');
|
||||
} catch { /* storage might be unavailable */ }
|
||||
}, MOCK_ROSTER);
|
||||
}
|
||||
|
||||
async function dismissOverlay(page: Page) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const overlay = page.locator('.fixed.backdrop-blur-sm').first();
|
||||
if (!(await overlay.isVisible({ timeout: 500 }).catch(() => false))) break;
|
||||
const startBtn = page.locator('button:has-text("Start Working")').first();
|
||||
if (await startBtn.isVisible({ timeout: 400 }).catch(() => false)) {
|
||||
await startBtn.click({ force: true });
|
||||
} else {
|
||||
await page.mouse.click(5, 5);
|
||||
}
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
}
|
||||
|
||||
function routeWithSkip(route: string) {
|
||||
const sep = route.includes('?') ? '&' : '?';
|
||||
return `${BASE}${route}${sep}skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`;
|
||||
}
|
||||
|
||||
async function openRoom(page: Page) {
|
||||
await page.goto(routeWithSkip('/room'), { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('[data-testid="room-root"]')).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
test.describe('Room — parallel agent visualization (P6)', () => {
|
||||
test('renders two simultaneous agents with distinct tiles', async ({ page }) => {
|
||||
await installSseMock(page);
|
||||
await openRoom(page);
|
||||
await dismissOverlay(page);
|
||||
|
||||
// Both tiles present, keyed by agent id.
|
||||
const alpha = page.locator(`[data-testid="room-agent-tile"][data-agent-id="${AGENT_ALPHA_ID}"]`);
|
||||
const beta = page.locator(`[data-testid="room-agent-tile"][data-agent-id="${AGENT_BETA_ID}"]`);
|
||||
await expect(alpha).toBeVisible({ timeout: 5000 });
|
||||
await expect(beta).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Live count chip reflects both.
|
||||
await expect(page.locator('[data-testid="room-live-count"]')).toContainText('2 live');
|
||||
|
||||
// Total tile count = exactly 2 (no duplicates, no phantom tiles).
|
||||
const allTiles = page.locator('[data-testid="room-agent-tile"]');
|
||||
await expect(allTiles).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('role badges do not cross-contaminate between simultaneous agents', async ({ page }) => {
|
||||
await installSseMock(page);
|
||||
await openRoom(page);
|
||||
await dismissOverlay(page);
|
||||
|
||||
const alpha = page.locator(`[data-testid="room-agent-tile"][data-agent-id="${AGENT_ALPHA_ID}"]`);
|
||||
const beta = page.locator(`[data-testid="room-agent-tile"][data-agent-id="${AGENT_BETA_ID}"]`);
|
||||
await expect(alpha).toBeVisible({ timeout: 5000 });
|
||||
await expect(beta).toBeVisible();
|
||||
|
||||
// Each tile carries its own role attribute — verify independence.
|
||||
await expect(alpha).toHaveAttribute('data-agent-role', 'researcher');
|
||||
await expect(beta).toHaveAttribute('data-agent-role', 'coder');
|
||||
|
||||
// Both are running concurrently.
|
||||
await expect(alpha).toHaveAttribute('data-agent-status', 'running');
|
||||
await expect(beta).toHaveAttribute('data-agent-status', 'running');
|
||||
|
||||
// Task bodies are distinct — one tile's task text must not leak into the other.
|
||||
await expect(alpha).toContainText('Scout the competitive landscape');
|
||||
await expect(beta).toContainText('honey-ledger module');
|
||||
await expect(alpha).not.toContainText('honey-ledger');
|
||||
await expect(beta).not.toContainText('Scout the competitive');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user