diff --git a/server/web/e2e/crucible-bulk.spec.ts b/server/web/e2e/crucible-bulk.spec.ts index 35b8806..e2b4885 100644 --- a/server/web/e2e/crucible-bulk.spec.ts +++ b/server/web/e2e/crucible-bulk.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { loginToDashboard } from './fixtures'; +import { expectOnlineCrucibleCard, loginToDashboard } from './fixtures'; import { ensureLiveStubAgent, isLiveStubReady } from './live-stub'; import { E2E_STUB_AGENT_HOSTNAME, E2E_STUB_AGENT_ID } from './stub-agent'; @@ -22,8 +22,7 @@ test.describe('Crucible bulk command', () => { }); test('bulk pause on selected online node fires POST bulk-command', async ({ page }) => { - const card = page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }); - await expect(card.locator('.cn-status-dot.on')).toBeVisible({ timeout: 15_000 }); + const card = await expectOnlineCrucibleCard(page, E2E_STUB_AGENT_HOSTNAME); await card.click(); await expect(page.locator('.fleet-bulk-bar').getByText(/1 selected/)).toBeVisible({ timeout: 10_000, diff --git a/server/web/e2e/discover-spread-stub.ts b/server/web/e2e/discover-spread-stub.ts index 547dc52..c5350e6 100644 --- a/server/web/e2e/discover-spread-stub.ts +++ b/server/web/e2e/discover-spread-stub.ts @@ -44,7 +44,9 @@ export const E2E_DISCOVER_AGENT_HOSTNAME = E2E_DISCOVER_CHAIN_HOPS[0].hostname; export const E2E_DISCOVER_JOIN_LANE = E2E_DISCOVER_CHAIN_HOPS[0].joinLane; export const E2E_DISCOVER_JOIN_LABEL = E2E_DISCOVER_CHAIN_HOPS[0].joinLabel; -const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989'; +function e2eBaseURL(): string { + return process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989'; +} const STATS_INTERVAL_MS = 1_000; type HubMessage = { @@ -217,7 +219,9 @@ async function connectDiscoverSpreadHop( return () => { hopConnections.delete(hop.id); clearInterval(statsTimer); - ws.close(); + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(1000, 'e2e-teardown'); + } }; } @@ -233,11 +237,13 @@ export async function ensureDiscoverSpreadStub(request: APIRequestContext): Prom if (!serverReady) return false; const fleetSecret = await fetchFleetSecret(request); + const baseURL = e2eBaseURL(); for (const hop of E2E_DISCOVER_CHAIN_HOPS) { const disconnect = await connectDiscoverSpreadHop(baseURL, fleetSecret, hop); disconnectStubs.push(disconnect); } - await new Promise((r) => setTimeout(r, 3_000)); + // Allow agent_online, stats_batch (250ms coalesce), and DB upsert to settle. + await new Promise((r) => setTimeout(r, 4_000)); return true; })(); } diff --git a/server/web/e2e/discover-spread.spec.ts b/server/web/e2e/discover-spread.spec.ts index bd81aa8..94fc15c 100644 --- a/server/web/e2e/discover-spread.spec.ts +++ b/server/web/e2e/discover-spread.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { loginToDashboard } from './fixtures'; +import { expectOnlineCrucibleCard, loginToDashboard } from './fixtures'; import { ensureLiveStubAgent, isLiveStubReady } from './live-stub'; import { ensureDiscoverSpreadStub, @@ -14,9 +14,7 @@ import { E2E_STUB_AGENT_HOSTNAME, E2E_STUB_AGENT_ID } from './stub-agent'; async function openCrucibleSpreadTab(page: import('@playwright/test').Page, hostname: string) { await page.getByRole('link', { name: /Crucible/i }).click(); await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 }); - const card = page.locator('.crucible-node-card').filter({ hasText: hostname }); - await expect(card).toBeVisible({ timeout: 15_000 }); - await expect(card.locator('.cn-status-dot.on')).toBeVisible({ timeout: 15_000 }); + const card = await expectOnlineCrucibleCard(page, hostname); await card.click(); await expect( page.locator('.crucible-actions-card').getByText(new RegExp(`→ ${hostname}`)), @@ -116,14 +114,21 @@ test.describe('Crucible discover and spread E2E', () => { timeout: 15_000, }); + // Stub propagates join_lane stats to downstream hops with staggered delays. + await page.waitForTimeout(2_000); + for (const hop of [seed, leaf]) { - const card = page.locator('.crucible-node-card').filter({ hasText: hop.hostname }); - await expect(card).toBeVisible({ timeout: 15_000 }); - await expect(card.locator('.cn-status-dot.on')).toBeVisible({ timeout: 15_000 }); + // Crucible toggles multi-select — clear egress (and any prior hop) before focusing downstream. + const selectedCards = page.locator('.crucible-node-card.selected'); + for (let i = 0, n = await selectedCards.count(); i < n; i++) { + await selectedCards.first().click(); + } + const card = await expectOnlineCrucibleCard(page, hop.hostname); + await card.scrollIntoViewIfNeeded(); await card.click(); await expect( page.locator('.crucible-actions-card').getByText(new RegExp(`→ ${hop.hostname}`)), - ).toBeVisible({ timeout: 10_000 }); + ).toBeVisible({ timeout: 20_000 }); await page.getByRole('button', { name: 'LATERAL / SPREAD' }).click(); await expect(page.locator('.access-depth-panel')).toContainText(hop.joinLabel, { timeout: 20_000, diff --git a/server/web/e2e/fixtures.ts b/server/web/e2e/fixtures.ts index 9dd6c18..d9f1d39 100644 --- a/server/web/e2e/fixtures.ts +++ b/server/web/e2e/fixtures.ts @@ -1,4 +1,4 @@ -import { expect, type APIRequestContext, type Page } from '@playwright/test'; +import { expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'; /** Matches server/internal/api/integration_test.go testAuthUser / testAuthPass. */ export const E2E_USER = process.env.AETHERFORGE_E2E_USER || 'testuser'; @@ -65,19 +65,46 @@ export async function waitForServerHealth( return false; } +/** 6px status dots fail Playwright visibility — assert card online via class instead. */ +export async function expectOnlineCrucibleCard(page: Page, hostname: string): Promise { + const card = page.locator('.crucible-node-card').filter({ + has: page.locator('.cn-name', { hasText: hostname }), + }); + await expect(card).toBeVisible({ timeout: 20_000 }); + await expect(card).not.toHaveClass(/offline/, { timeout: 20_000 }); + await expect(card.locator('.cn-status-dot')).toHaveClass(/on/, { timeout: 5_000 }); + return card; +} + export async function loginToDashboard(page: Page): Promise { - for (let attempt = 0; attempt < 2; attempt++) { - await page.goto('/', { waitUntil: 'load', timeout: 30_000 }); + const commandDeck = page.getByRole('heading', { name: 'Command Deck' }); + + for (let attempt = 0; attempt < 3; attempt++) { + await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30_000 }); + + if (await commandDeck.isVisible().catch(() => false)) { + return; + } + + const loginHeading = page.getByRole('heading', { name: 'AetherForge' }); try { - await expect(page.getByRole('heading', { name: 'AetherForge' })).toBeVisible({ timeout: 20_000 }); - break; + await expect(loginHeading).toBeVisible({ timeout: 25_000 }); } catch (err) { - if (attempt === 1) throw err; - await page.waitForTimeout(1_500); + if (attempt === 2) throw err; + await page.waitForTimeout(2_000); + continue; + } + + await page.getByLabel('Username').fill(E2E_USER); + await page.getByLabel('Password').fill(E2E_PASS); + await page.getByRole('button', { name: /enter command deck/i }).click(); + + try { + await expect(commandDeck).toBeVisible({ timeout: 25_000 }); + return; + } catch (err) { + if (attempt === 2) throw err; + await page.waitForTimeout(2_000); } } - await page.getByLabel('Username').fill(E2E_USER); - await page.getByLabel('Password').fill(E2E_PASS); - await page.getByRole('button', { name: /enter command deck/i }).click(); - await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible({ timeout: 20_000 }); } diff --git a/server/web/e2e/global-teardown.ts b/server/web/e2e/global-teardown.ts index 723e3d2..5551a2d 100644 --- a/server/web/e2e/global-teardown.ts +++ b/server/web/e2e/global-teardown.ts @@ -1,5 +1,7 @@ +import { teardownDiscoverSpreadStub } from './discover-spread-stub'; import { teardownLiveStubAgent } from './live-stub'; export default function globalTeardown(): void { + teardownDiscoverSpreadStub(); teardownLiveStubAgent(); } diff --git a/server/web/e2e/live-stub.ts b/server/web/e2e/live-stub.ts index a5bf7da..c4a0ec1 100644 --- a/server/web/e2e/live-stub.ts +++ b/server/web/e2e/live-stub.ts @@ -2,7 +2,9 @@ import type { APIRequestContext } from '@playwright/test'; import { fetchFleetSecret, waitForServerHealth } from './fixtures'; import { connectStubAgent } from './stub-agent'; -const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989'; +function e2eBaseURL(): string { + return process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989'; +} let serverReady = false; let disconnectStub: (() => void) | null = null; @@ -17,7 +19,7 @@ export async function ensureLiveStubAgent(request: APIRequestContext): Promise setTimeout(r, 2_500)); return true; diff --git a/server/web/e2e/pages.spec.ts b/server/web/e2e/pages.spec.ts index 28f5936..1257d29 100644 --- a/server/web/e2e/pages.spec.ts +++ b/server/web/e2e/pages.spec.ts @@ -46,6 +46,6 @@ test.describe('Page smoke', () => { await page.getByRole('link', { name: /Forge/i }).click(); await expect(page.getByRole('heading', { level: 1, name: 'Forge', exact: true })).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole('heading', { name: 'Quick Forge' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Simple' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Simple', exact: true })).toBeVisible(); }); }); diff --git a/server/web/e2e/path-tracer.spec.ts b/server/web/e2e/path-tracer.spec.ts index b9ae7d7..c18c9e2 100644 --- a/server/web/e2e/path-tracer.spec.ts +++ b/server/web/e2e/path-tracer.spec.ts @@ -56,7 +56,7 @@ test.describe('Path Tracer E2E', () => { const card = page.locator('.pt-agent-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }); await expect(card).toBeVisible({ timeout: 15_000 }); await card.click(); - await page.getByRole('button', { name: /TRACE/i }).click(); + await page.getByRole('button', { name: '⬡ TRACE' }).click(); await expect(page.getByText('Spread Routes')).toBeVisible({ timeout: 15_000 }); await expect(page.getByText(/192\.168\.1\.0\/24/)).toBeVisible(); diff --git a/server/web/e2e/stub-agent.ts b/server/web/e2e/stub-agent.ts index fd7f22a..6961543 100644 --- a/server/web/e2e/stub-agent.ts +++ b/server/web/e2e/stub-agent.ts @@ -139,6 +139,8 @@ export async function connectStubAgent( return () => { clearInterval(statsTimer); - ws.close(); + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(1000, 'e2e-teardown'); + } }; }