e2e: smoke + state specs, CI-only against isolated dind stack
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).
This commit is contained in:
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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+$/);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user