From e19c068c98afd73f848cd50d00a3cb23473f54ec Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 15:56:40 +0000 Subject: [PATCH] e2e: smoke + state specs, CI-only against isolated dind stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit playwright.config.ts mirrors Flight-Ops shape (remote browser default ws://192.168.1.4:3000/, PW_REMOTE_BROWSER=0 local, E2E_BASE_URL/E2E_DEPLOY_CMD optional). smoke.spec.ts: landing page + scificn components + /api/version. state.spec.ts: mid-run docker compose restart via E2E_COMPOSE_ARGS, re-asserts the app serves (no auth — single player by design). --- e2e/ci-diagnostics.ts | 36 ++++++++++++++++++++++++++++++++++++ e2e/smoke.spec.ts | 21 +++++++++++++++++++++ e2e/state.spec.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ playwright.config.ts | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 e2e/ci-diagnostics.ts create mode 100644 e2e/smoke.spec.ts create mode 100644 e2e/state.spec.ts create mode 100644 playwright.config.ts diff --git a/e2e/ci-diagnostics.ts b/e2e/ci-diagnostics.ts new file mode 100644 index 0000000..1ae1c99 --- /dev/null +++ b/e2e/ci-diagnostics.ts @@ -0,0 +1,36 @@ +// CI-only connectivity diagnostics (globalSetup runs after the e2e stack is ready). +// Evidence for the Chromium ERR_SSL_PROTOCOL_ERROR hunt: compares node-side resolution/HTTP +// against Chromium's own navigation, hostname vs static IP. Never fails the run. +import { promises as dnsPromises } from 'node:dns'; +import { chromium } from '@playwright/test'; + +export default async function ciDiagnostics() { + if (process.env.PW_REMOTE_BROWSER !== '0') return; // local runs: no-op + const base = process.env.E2E_BASE_URL ?? ''; + try { + console.log('[diag] node resolve4(app):', JSON.stringify(await dnsPromises.resolve4('app').catch((e) => `failed: ${e.code}`))); + try { + const r = await fetch(`${base}/api/version`); + console.log('[diag] node fetch', base, '->', r.status, JSON.stringify(await r.json().catch(() => null))); + } catch (e) { + console.log('[diag] node fetch failed:', String(e)); + } + const browser = await chromium.launch(); + try { + for (const url of ['http://app:3000/', base]) { + const page = await (await browser.newContext()).newPage(); + try { + await page.goto(url, { timeout: 15_000 }); + console.log('[diag] chromium goto', url, '-> OK, title:', await page.title().catch(() => '?')); + } catch (e) { + console.log('[diag] chromium goto', url, '-> FAILED:', String(e).split('\n')[0]); + } + await page.context().close(); + } + } finally { + await browser.close(); + } + } catch (e) { + console.log('[diag] diagnostics error (non-fatal):', String(e)); + } +} diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..4489427 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; + +test('landing page loads with the SpaceWanderer title', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible(); +}); + +test('scificn-ui themed components render on the landing page', async ({ page }) => { + await page.goto('/'); + // Panel header badge + footer button prove the copy-paste component layer is wired in. + await expect(page.getByText('SYS:ONLINE')).toBeVisible(); + await expect(page.getByRole('button', { name: 'INITIATE' })).toBeVisible(); +}); + +test('GET /api/version reports the app version as JSON', async ({ page }) => { + const res = await page.request.get('/api/version'); + expect(res.status()).toBe(200); + expect(res.headers()['content-type']).toContain('application/json'); + const body = (await res.json()) as { version: string }; + expect(body.version).toMatch(/^\d+\.\d+\.\d+$/); +}); diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts new file mode 100644 index 0000000..0e0a046 --- /dev/null +++ b/e2e/state.spec.ts @@ -0,0 +1,43 @@ +import { execSync } from 'node:child_process'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; + +// Repo root — docker-compose.yml lives here; `docker compose restart` runs on +// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the +// isolated stack file + project (-f docker-compose.ci.yml -p space-wanderer-ci) instead of the default one. +const repoRoot = path.join(__dirname, '..'); +const composeArgs = process.env.E2E_COMPOSE_ARGS ?? ''; + +test('app survives a container restart (stack + volume persistence)', async ({ page }) => { + test.setTimeout(180_000); + + // Baseline: the app serves and reports its version. + const resBefore = await page.request.get('/api/version'); + expect(resBefore.status()).toBe(200); + const before = (await resBefore.json()) as { version: string }; + expect(before.version).toMatch(/^\d+\.\d+\.\d+$/); + + // Restart the app container mid-run — the named DB volume must survive it. + execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' }); + + // Wait for the app to come back serving. + let after: { version: string } | undefined; + await expect + .poll(async () => { + try { + const res = await page.request.get('/api/version'); + if (res.status() === 200) after = (await res.json()) as { version: string }; + return !!after; + } catch { + return false; // connection refused / 502 while the container starts + } + }, { timeout: 120_000, message: 'waiting for /api/version after docker compose restart' }) + .toBe(true); + + expect(after).toBeDefined(); + expect(after!.version).toBe(before.version); // same build, not a re-seeded stack + + // The page itself serves again with the landing title. + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible(); +}); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..132cb39 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from '@playwright/test'; + +// Browsers run on the shared remote Playwright server by default (AGENTS.md). +// PW_REMOTE_BROWSER=0 → local browsers instead (CI, or boxes without LAN access to 192.168.1.4). +const remoteBrowser = process.env.PW_REMOTE_BROWSER !== '0'; + +if (!process.env.PW_TEST_CONNECT_WS_ENDPOINT && remoteBrowser) { + process.env.PW_TEST_CONNECT_WS_ENDPOINT = 'ws://192.168.1.4:3000/'; +} + +// App under test. Default: the live compose instance behind Traefik, deployed manually +// before running the suite (AGENTS.md). When E2E_DEPLOY_CMD is set it runs as a webServer +// and the suite waits for baseURL to come up — CI uses this to target an isolated stack +// inside dind instead of production. All vars optional; unset = current local behavior. +const deployCmd = process.env.E2E_DEPLOY_CMD; +const baseUrl = process.env.E2E_BASE_URL ?? 'https://space-wanderer.dev.kkhost.pl'; + +export default defineConfig({ + testDir: './e2e', + globalSetup: './e2e/ci-diagnostics.ts', + // Serial execution: e2e/state.spec.ts restarts the compose container mid-run, + // which would break any other spec running concurrently in another worker. + workers: 1, + timeout: 300_000, + retries: 1, + use: { + baseURL: baseUrl, + trace: 'on-first-retry', + }, + webServer: deployCmd + ? { command: deployCmd, url: baseUrl, reuseExistingServer: true, timeout: 600_000 } + : undefined, +});