From 311b4dda599e6d9133169f6339ce7adbe6ef2e39 Mon Sep 17 00:00:00 2001 From: fred Date: Thu, 27 Aug 2026 09:30:38 -0500 Subject: [PATCH] =?UTF-8?q?ci(web):=20Phase=20P6=20=E2=80=94=20vite=20buil?= =?UTF-8?q?d=20in=20PR=20CI=20+=20headless=20E2E=20gate=20on=20trunk=20pub?= =?UTF-8?q?lishes=20(#1445)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ci.yml: build step (vite build via turbo) runs on every PR pipeline after test - publish.yml: e2e step boots the gateway from built dist (HOME/cwd-isolated throwaway PGlite) and runs the Playwright suite headless inside mcr.microsoft.com/playwright:v1.58.2-noble against the SPA bundle served exactly as production serves it; both kaniko publishes now gate on e2e - serve-spa.ts: strip query strings before asset resolution; immutable cache-control for hashed assets (onSend hook); e2e spec covers both - e2e suite hardened: globalSetup seeds admin+member through real bootstrap/better-auth APIs (Origin header for CSRF), loginAs waits for the post-login redirect (fixes 27-skipped race), stale #152-era assertions rewritten to the current command-driven UI, strict-mode violations fixed with level-1 heading queries and .or() auto-retrying locators - verify-release mirrors the new build stage; playwright artifacts ignored --- .gitignore | 4 + .woodpecker/ci.yml | 12 ++ .woodpecker/publish.yml | 81 ++++++++++ apps/gateway/src/spa/serve-spa.e2e.spec.ts | 178 +++++++++++++++++++++ apps/gateway/src/spa/serve-spa.ts | 26 ++- apps/web/e2e/admin.spec.ts | 12 +- apps/web/e2e/chat.spec.ts | 38 ++--- apps/web/e2e/global-setup.ts | 85 ++++++++++ apps/web/e2e/helpers/auth.ts | 7 +- apps/web/e2e/navigation.spec.ts | 22 ++- apps/web/e2e/projects.spec.ts | 26 ++- apps/web/playwright.config.ts | 21 ++- eslint.config.mjs | 1 - scripts/verify-release.mjs | 6 +- scripts/verify-release.test.mjs | 9 +- 15 files changed, 451 insertions(+), 77 deletions(-) create mode 100644 apps/gateway/src/spa/serve-spa.e2e.spec.ts create mode 100644 apps/web/e2e/global-setup.ts diff --git a/.gitignore b/.gitignore index 611a9907..74f888d6 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ infra/step-ca/dev-password # traversal error: ... .timestamp-*.mjs: No such file or directory" when the # file vanished mid-scan. Ignoring them removes the race. *.timestamp-*.mjs + +# Playwright run artifacts (#1445, P6 E2E gate) +apps/web/test-results/ +apps/web/playwright-report/ diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 34fb9235..a50029b8 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -254,6 +254,18 @@ steps: depends_on: - typecheck + # Canonical verify:release stage `build` (#1445, P6): every PR proves the + # full workspace build — including the SPA `vite build` — before merge, + # instead of leaving build breakage to surface post-merge in publish.yml's + # verify step. Same canonical command the publish pipeline's build step runs. + build: + image: *node_image + commands: + - *enable_pnpm + - pnpm build + depends_on: + - typecheck + services: ci-postgres: image: pgvector/pgvector:pg17 diff --git a/.woodpecker/publish.yml b/.woodpecker/publish.yml index 0b1afdc4..1a330302 100644 --- a/.woodpecker/publish.yml +++ b/.woodpecker/publish.yml @@ -407,6 +407,83 @@ steps: - build - verify + # #1445 (P6): headless Playwright E2E gate on every trunk merge. Boots the + # real gateway on the embedded PGlite path (no DATABASE_URL, no services) + # serving the built SPA bundle via WEB_DIST_DIR — the exact serving path the + # gateway image ships (docker/gateway.Dockerfile sets WEB_DIST_DIR to the + # baked bundle), which keeps #1407's parity guarantee: the image build steps + # below depend on this gate, so a bundle that fails E2E never publishes. + # + # Image pinned to the @playwright/test version in pnpm-lock.yaml so the + # image's bundled browsers match the workspace driver exactly (bump the two + # together). The step installs nothing: it reuses the workspace node_modules + # from `install` and the dist outputs from `build` — the gateway's runtime + # dependency path is pure JS/WASM (PGlite is WASM, postgres-js is pure JS), + # so the alpine-installed modules run unchanged under this glibc image. + # depends_on publish-next-npm per the #1411 serialization invariant: this + # step reads the workspace and must never run inside the manifest-transform + # window. + e2e: + image: mcr.microsoft.com/playwright:v1.58.2-noble + environment: + # Test-only value for this step's throwaway embedded database; the + # gateway refuses to boot without one. Not a credential. + BETTER_AUTH_SECRET: ci-e2e-throwaway-value + GATEWAY_PORT: '14242' + PLAYWRIGHT_BASE_URL: http://localhost:14242 + commands: + - corepack enable + - | + export WEB_DIST_DIR="$(pwd)/apps/web/dist" + if [ ! -f "$WEB_DIST_DIR/index.html" ]; then + echo "[e2e] FATAL: $WEB_DIST_DIR/index.html missing — did the build step run?" >&2 + exit 1 + fi + # Boot the gateway from the built dist, cwd- AND HOME-isolated: the + # local-tier PGlite database lives under $HOME/.config/mosaic/gateway/ + # (database.module.ts), not under cwd, so HOME must point at the + # throwaway dir too or the run would share a database with anything + # else in the container's home. + GATEWAY_RUN_DIR="$(mktemp -d /tmp/e2e-gateway.XXXXXX)" + (cd "$GATEWAY_RUN_DIR" && export HOME="$GATEWAY_RUN_DIR" && exec node "$OLDPWD/apps/gateway/dist/main.js") > /tmp/gateway.log 2>&1 & + GATEWAY_PID=$! + ready=0 + for i in $(seq 1 90); do + if node -e "fetch('http://localhost:14242/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"; then + ready=1 + break + fi + if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then + echo "[e2e] FATAL: gateway process exited during startup" >&2 + cat /tmp/gateway.log >&2 + exit 1 + fi + echo "[e2e] waiting for gateway ($i/90)..." + sleep 1 + done + if [ "$ready" -ne 1 ]; then + echo "[e2e] FATAL: gateway did not become ready in 90s" >&2 + cat /tmp/gateway.log >&2 + exit 1 + fi + echo "[e2e] gateway ready; running Playwright suite" + set +e + pnpm --filter @mosaicstack/web exec playwright test + E2E_EXIT=$? + set -e + kill "$GATEWAY_PID" 2>/dev/null || true + if [ "$E2E_EXIT" -ne 0 ]; then + echo "[e2e] FATAL: Playwright suite failed (exit $E2E_EXIT); gateway log follows" >&2 + tail -100 /tmp/gateway.log >&2 + fi + exit "$E2E_EXIT" + depends_on: + - build + - verify + # #1411: never read the workspace inside publish-next-npm's + # manifest-transform window. + - publish-next-npm + # TODO: Uncomment when ready to publish to npmjs.org # publish-npmjs: # image: *node_image @@ -466,6 +543,8 @@ steps: # ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the # serialization invariant; add it to every new workspace consumer. - publish-next-npm + # #1445 (P6): a bundle that fails the E2E gate never publishes an image. + - e2e build-appservice: image: gcr.io/kaniko-project/executor:debug @@ -510,3 +589,5 @@ steps: # ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the # serialization invariant; add it to every new workspace consumer. - publish-next-npm + # #1445 (P6): a bundle that fails the E2E gate never publishes an image. + - e2e diff --git a/apps/gateway/src/spa/serve-spa.e2e.spec.ts b/apps/gateway/src/spa/serve-spa.e2e.spec.ts new file mode 100644 index 00000000..a8b044c9 --- /dev/null +++ b/apps/gateway/src/spa/serve-spa.e2e.spec.ts @@ -0,0 +1,178 @@ +/** + * E2E integration test — SPA static serving (Phase P5 cutover, #1444; tests + * added in P6, #1445, review follow-up SF1 on PR #1453). + * + * Boots a real Nest+Fastify app the way main.ts does (mountSpaStatic after the + * controllers) against a fixture dist directory, and pins the serving + * contract: + * + * 1. `/` and client-side deep links fall back to index.html. + * 2. Declared API routes win over the catch-all. + * 3. Unknown backend paths (/api, /mcp, /socket.io) are JSON 404s, never the + * SPA page — including with a query string (`/api?x=1`). + * 4. Static files are served exactly; hashed /assets/ files get immutable + * cache headers, everything else revalidates (max-age=0). + * 5. WEB_DIST_DIR unset disables SPA serving entirely. + * 6. WEB_DIST_DIR pointing at a directory without index.html fails at boot. + */ + +import 'reflect-metadata'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import { Test } from '@nestjs/testing'; +import { Controller, Get, type INestApplication } from '@nestjs/common'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import request from 'supertest'; +import { mountSpaStatic } from './serve-spa.js'; + +const INDEX_HTML = 'mosaic spa fixture\n'; +const ASSET_JS = 'console.log("hashed asset");\n'; + +@Controller('api/spa-test') +class SpaTestController { + @Get('ping') + ping(): { ok: boolean } { + return { ok: true }; + } +} + +async function createApp(): Promise { + const moduleRef = await Test.createTestingModule({ + controllers: [SpaTestController], + }).compile(); + + const app = moduleRef.createNestApplication(new FastifyAdapter()); + await app.init(); + // Mirror main.ts ordering: SPA mounting happens after the app (and its + // controllers) exist, before listen. + await mountSpaStatic(app as NestFastifyApplication); + await (app as NestFastifyApplication).getHttpAdapter().getInstance().ready(); + return app; +} + +describe('SPA static serving — fixture dist dir', () => { + let app: INestApplication; + let distDir: string; + let previousWebDistDir: string | undefined; + + beforeAll(async () => { + distDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-fixture-')); + await writeFile(path.join(distDir, 'index.html'), INDEX_HTML); + await writeFile(path.join(distDir, 'favicon.svg'), '\n'); + await mkdir(path.join(distDir, 'assets'), { recursive: true }); + await writeFile(path.join(distDir, 'assets', 'app-abc123.js'), ASSET_JS); + + previousWebDistDir = process.env['WEB_DIST_DIR']; + process.env['WEB_DIST_DIR'] = distDir; + app = await createApp(); + }); + + afterAll(async () => { + if (previousWebDistDir === undefined) { + delete process.env['WEB_DIST_DIR']; + } else { + process.env['WEB_DIST_DIR'] = previousWebDistDir; + } + await app.close(); + await rm(distDir, { recursive: true, force: true }); + }); + + it('serves index.html at /', async () => { + const res = await request(app.getHttpServer()).get('/'); + expect(res.status).toBe(200); + expect(res.text).toBe(INDEX_HTML); + expect(res.headers['content-type']).toContain('text/html'); + }); + + it('falls back to index.html for client-side deep links', async () => { + for (const deepLink of ['/chat', '/projects/42', '/settings']) { + const res = await request(app.getHttpServer()).get(deepLink); + expect(res.status, deepLink).toBe(200); + expect(res.text, deepLink).toBe(INDEX_HTML); + } + }); + + it('declared API routes win over the SPA catch-all', async () => { + const res = await request(app.getHttpServer()).get('/api/spa-test/ping'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + }); + + it('unknown backend paths are JSON 404s, never the SPA page', async () => { + for (const backendPath of ['/api/nope', '/api', '/mcp/nope', '/socket.io/nope']) { + const res = await request(app.getHttpServer()).get(backendPath); + expect(res.status, backendPath).toBe(404); + expect(res.headers['content-type'], backendPath).toContain('application/json'); + expect(res.body, backendPath).toMatchObject({ error: 'Not Found', statusCode: 404 }); + } + }); + + it('a backend path with a query string is still a backend 404 (/api?x=1)', async () => { + const res = await request(app.getHttpServer()).get('/api?x=1'); + expect(res.status).toBe(404); + expect(res.headers['content-type']).toContain('application/json'); + }); + + it('serves static files exactly', async () => { + const res = await request(app.getHttpServer()).get('/favicon.svg'); + expect(res.status).toBe(200); + // supertest buffers image/svg+xml as a Buffer body, not res.text. + const body = res.text || (res.body as Buffer).toString('utf8'); + expect(body).toBe('\n'); + }); + + it('hashed /assets/ files get immutable cache headers', async () => { + const res = await request(app.getHttpServer()).get('/assets/app-abc123.js'); + expect(res.status).toBe(200); + expect(res.text).toBe(ASSET_JS); + expect(res.headers['cache-control']).toBe('public, max-age=31536000, immutable'); + }); + + it('index.html and non-asset files revalidate (no immutable caching)', async () => { + for (const revalidating of ['/', '/chat', '/favicon.svg']) { + const res = await request(app.getHttpServer()).get(revalidating); + expect(res.headers['cache-control'], revalidating).not.toContain('immutable'); + } + }); + + it('non-GET unmatched requests keep the stock 404 (catch-all is GET/HEAD only)', async () => { + const res = await request(app.getHttpServer()).post('/chat'); + expect(res.status).toBe(404); + expect(res.text).not.toContain('mosaic spa fixture'); + }); +}); + +describe('SPA static serving — configuration edges', () => { + it('WEB_DIST_DIR unset disables SPA serving', async () => { + const previous = process.env['WEB_DIST_DIR']; + delete process.env['WEB_DIST_DIR']; + try { + const app = await createApp(); + const res = await request(app.getHttpServer()).get('/chat'); + expect(res.status).toBe(404); + await app.close(); + } finally { + if (previous !== undefined) { + process.env['WEB_DIST_DIR'] = previous; + } + } + }); + + it('WEB_DIST_DIR without index.html fails at boot', async () => { + const emptyDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-empty-')); + const previous = process.env['WEB_DIST_DIR']; + process.env['WEB_DIST_DIR'] = emptyDir; + try { + await expect(createApp()).rejects.toThrow(/index\.html.*does not exist/); + } finally { + if (previous === undefined) { + delete process.env['WEB_DIST_DIR']; + } else { + process.env['WEB_DIST_DIR'] = previous; + } + await rm(emptyDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/gateway/src/spa/serve-spa.ts b/apps/gateway/src/spa/serve-spa.ts index bda3617f..ba91b356 100644 --- a/apps/gateway/src/spa/serve-spa.ts +++ b/apps/gateway/src/spa/serve-spa.ts @@ -8,7 +8,12 @@ import type { NestFastifyApplication } from '@nestjs/platform-fastify'; const BACKEND_PREFIXES = ['/api', '/mcp', '/socket.io'] as const; function isBackendPath(url: string): boolean { - return BACKEND_PREFIXES.some((prefix) => url === prefix || url.startsWith(`${prefix}/`)); + // Match on the path only: `/api?x=1` is a backend request, and the query + // string must never turn it into an SPA fallback. + const pathOnly = url.split('?', 1)[0] ?? url; + return BACKEND_PREFIXES.some( + (prefix) => pathOnly === prefix || pathOnly.startsWith(`${prefix}/`), + ); } /** @@ -39,8 +44,7 @@ export async function mountSpaStatic(app: NestFastifyApplication): Promise // Default cache semantics: public, max-age=0 with ETag/Last-Modified, so // every response revalidates (304 when unchanged). Always correct, including - // for index.html after a deploy; immutable caching for hashed /assets/ files - // is a P6 optimization. + // for index.html after a deploy. await app.register( fastifyStatic as never, { @@ -50,11 +54,25 @@ export async function mountSpaStatic(app: NestFastifyApplication): Promise } as never, ); + const fastify = app.getHttpAdapter().getInstance(); + + // Files under /assets/ carry a content hash in their name (Vite emits them + // that way), so they get long-lived immutable caching: a changed file is a + // new URL, never a stale cache hit. An onSend hook rather than the plugin's + // `setHeaders` option, because @fastify/static applies its own computed + // cache-control (reply.headers) after calling setHeaders, overriding it. + fastify.addHook('onSend', (req, reply, payload, done) => { + const pathOnly = (req.raw.url ?? '').split('?', 1)[0] ?? ''; + if (reply.statusCode === 200 && pathOnly.startsWith('/assets/')) { + void reply.header('cache-control', 'public, max-age=31536000, immutable'); + } + done(null, payload); + }); + // A wildcard route, not setNotFoundHandler: Nest installs its own not-found // handler during init and Fastify allows only one. find-my-way matches // most-specific-first, so every declared route (API, static files) wins over // this catch-all; non-GET unmatched requests keep Fastify's stock 404. - const fastify = app.getHttpAdapter().getInstance(); fastify.get('/*', (req, reply) => { const url = req.raw.url ?? ''; if (isBackendPath(url)) { diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts index 9b0d8c16..bac519f0 100644 --- a/apps/web/e2e/admin.spec.ts +++ b/apps/web/e2e/admin.spec.ts @@ -31,15 +31,11 @@ test.describe('Admin page — admin user', () => { await page.goto('/admin'); await page.getByRole('button', { name: /system health/i }).click(); // Health cards or loading indicator should appear - const hasLoading = await page + const loadingOrCard = page .getByText(/loading health/i) - .isVisible() - .catch(() => false); - const hasCard = await page - .getByText(/database/i) - .isVisible() - .catch(() => false); - expect(hasLoading || hasCard).toBe(true); + .or(page.getByText(/database/i)) + .first(); + await expect(loadingOrCard).toBeVisible({ timeout: 10_000 }); }); }); diff --git a/apps/web/e2e/chat.spec.ts b/apps/web/e2e/chat.spec.ts index d908d73d..51d106d5 100644 --- a/apps/web/e2e/chat.spec.ts +++ b/apps/web/e2e/chat.spec.ts @@ -9,37 +9,27 @@ test.describe('Chat page', () => { test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests'); }); - test('chat page loads and shows the welcome message or conversation list', async ({ page }) => { + test('chat page loads and shows the conversation area', async ({ page }) => { await page.goto('/chat'); - // Either there are conversations listed or the welcome empty-state is shown - const hasWelcome = await page - .getByRole('heading', { name: /welcome to mosaic chat/i }) - .isVisible() - .catch(() => false); - const hasConversationPanel = await page - .locator('[data-testid="conversation-list"], nav, aside') - .first() - .isVisible() - .catch(() => false); - - expect(hasWelcome || hasConversationPanel).toBe(true); + await expect(page.getByRole('heading', { level: 1, name: /chat/i })).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByRole('log', { name: /conversation/i })).toBeVisible(); }); - test('new conversation button is visible', async ({ page }) => { + test('message composer input is visible', async ({ page }) => { await page.goto('/chat'); - // "Start new conversation" button or a "+" button in the sidebar - const newConvButton = page.getByRole('button', { name: /new conversation|start new/i }).first(); - await expect(newConvButton).toBeVisible({ timeout: 10_000 }); + await expect(page.getByLabel('Message')).toBeVisible({ timeout: 10_000 }); }); - test('clicking new conversation shows a chat input area', async ({ page }) => { + test('command panel lists /new and exposes the run controls', async ({ page }) => { await page.goto('/chat'); - // Find any button that creates a new conversation - const newBtn = page.getByRole('button', { name: /new conversation|start new/i }).first(); - await newBtn.click(); - // After creating, a text input for sending messages should appear - const chatInput = page.getByRole('textbox').or(page.locator('textarea')).first(); - await expect(chatInput).toBeVisible({ timeout: 10_000 }); + // Conversations are command-driven: /new starts one via the commands panel. + const commandList = page.getByRole('list', { name: /available commands/i }); + await expect(commandList).toBeVisible({ timeout: 10_000 }); + await expect(commandList.getByText('/new', { exact: true })).toBeVisible(); + await expect(page.getByLabel('Command name')).toBeVisible(); + await expect(page.getByRole('button', { name: /run command/i })).toBeVisible(); }); test('sidebar navigation is present on chat page', async ({ page }) => { diff --git a/apps/web/e2e/global-setup.ts b/apps/web/e2e/global-setup.ts new file mode 100644 index 00000000..0e0c85e1 --- /dev/null +++ b/apps/web/e2e/global-setup.ts @@ -0,0 +1,85 @@ +import type { FullConfig } from '@playwright/test'; +import { ADMIN_USER, TEST_USER } from './helpers/auth.js'; + +/** + * Seed the E2E users through the gateway's real APIs (#1445, P6). + * + * On a fresh database (CI boots the gateway on the embedded PGlite path): + * 1. POST /api/bootstrap/setup creates ADMIN_USER as the first admin. + * 2. The admin signs in and creates TEST_USER via the better-auth admin API. + * + * Against an environment that already has users (needsSetup=false), seeding is + * skipped entirely: the specs keep their own skip-when-login-fails guards, so + * a live environment stays usable as a test target without mutation. + * + * On a fresh database, any seeding failure throws and fails the whole run: an + * E2E gate whose authenticated suites silently skip would pass while proving + * nothing. + */ +export default async function globalSetup(config: FullConfig): Promise { + const baseURL = config.projects[0]?.use?.baseURL ?? 'http://localhost:14242'; + + const statusRes = await fetch(`${baseURL}/api/bootstrap/status`); + if (!statusRes.ok) { + throw new Error(`GET /api/bootstrap/status returned ${statusRes.status} — is the gateway up?`); + } + const status = (await statusRes.json()) as { needsSetup: boolean }; + if (!status.needsSetup) { + console.info('[e2e setup] users already exist; skipping seed'); + return; + } + + const setupRes = await fetch(`${baseURL}/api/bootstrap/setup`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: ADMIN_USER.name, + email: ADMIN_USER.email, + password: ADMIN_USER.password, + }), + }); + if (!setupRes.ok) { + throw new Error( + `POST /api/bootstrap/setup failed (${setupRes.status}): ${await setupRes.text()}`, + ); + } + console.info(`[e2e setup] bootstrap admin created: ${ADMIN_USER.email}`); + + // better-auth's CSRF protection rejects requests without an Origin header + // (403 MISSING_OR_NULL_ORIGIN), so the server-side fetches here send the + // gateway's own origin — the same value a browser tab on the SPA would send. + const authHeaders = { 'content-type': 'application/json', origin: baseURL }; + + const signInRes = await fetch(`${baseURL}/api/auth/sign-in/email`, { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ email: ADMIN_USER.email, password: ADMIN_USER.password }), + }); + if (!signInRes.ok) { + throw new Error(`admin sign-in failed (${signInRes.status}): ${await signInRes.text()}`); + } + const cookies = signInRes.headers + .getSetCookie() + .map((cookie) => cookie.split(';', 1)[0]) + .join('; '); + if (!cookies) { + throw new Error('admin sign-in returned no session cookie'); + } + + const createRes = await fetch(`${baseURL}/api/auth/admin/create-user`, { + method: 'POST', + headers: { ...authHeaders, cookie: cookies }, + body: JSON.stringify({ + name: TEST_USER.name, + email: TEST_USER.email, + password: TEST_USER.password, + role: 'member', + }), + }); + if (!createRes.ok) { + throw new Error( + `POST /api/auth/admin/create-user failed (${createRes.status}): ${await createRes.text()}`, + ); + } + console.info(`[e2e setup] test user created: ${TEST_USER.email}`); +} diff --git a/apps/web/e2e/helpers/auth.ts b/apps/web/e2e/helpers/auth.ts index b1b1428f..12163d40 100644 --- a/apps/web/e2e/helpers/auth.ts +++ b/apps/web/e2e/helpers/auth.ts @@ -13,11 +13,16 @@ export const ADMIN_USER = { }; /** - * Fill the login form and submit. Waits for navigation after success. + * Fill the login form and submit, then wait for the post-login redirect to + * /chat. On failed login the wait times out and is swallowed: the page stays + * on /login, and the callers' `test.skip(!url.includes('/chat'))` guards see + * that. Without this wait, every guard read page.url() before the redirect + * happened and skipped its suite even when login succeeded (#1445). */ export async function loginAs(page: Page, email: string, password: string): Promise { await page.goto('/login'); await page.getByLabel('Email').fill(email); await page.getByLabel('Password').fill(password); await page.getByRole('button', { name: /sign in/i }).click(); + await page.waitForURL(/\/chat/, { timeout: 10_000 }).catch(() => {}); } diff --git a/apps/web/e2e/navigation.spec.ts b/apps/web/e2e/navigation.spec.ts index 58cbc819..a15819d4 100644 --- a/apps/web/e2e/navigation.spec.ts +++ b/apps/web/e2e/navigation.spec.ts @@ -8,9 +8,12 @@ test.describe('Sidebar navigation', () => { test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests'); }); - test('sidebar shows Mosaic brand link', async ({ page }) => { + test('sidebar shows the Mosaic brand', async ({ page }) => { await page.goto('/chat'); - await expect(page.getByRole('link', { name: /mosaic/i }).first()).toBeVisible(); + // The brand block is a logo image plus "Mosaic / Mission Control" text, + // not a link. + await expect(page.getByRole('img', { name: /mosaic logo/i })).toBeVisible(); + await expect(page.getByText('Mission Control')).toBeVisible(); }); test('Chat nav link navigates to /chat', async ({ page }) => { @@ -48,11 +51,12 @@ test.describe('Sidebar navigation', () => { test('active link is visually highlighted', async ({ page }) => { await page.goto('/chat'); - // The active link should have a distinct class — check that the Chat link - // has the active style class (bg-blue-600/20 text-blue-400) + // The sidebar marks the active item with `font-medium` (plus an inline + // primary-color style); inactive items get the hover class instead. const chatLink = page.getByRole('link', { name: /^chat$/i }).first(); - const cls = await chatLink.getAttribute('class'); - expect(cls).toContain('blue'); + const projectsLink = page.getByRole('link', { name: /^projects$/i }).first(); + await expect(chatLink).toHaveClass(/font-medium/); + await expect(projectsLink).not.toHaveClass(/font-medium/); }); }); @@ -67,11 +71,13 @@ test.describe('Route transitions', () => { await page.goto('/chat'); await expect(page).toHaveURL(/\/chat/); + // level: 1 — empty-state h2s ("No projects yet") also match the loose + // patterns, and a two-element match is a strict-mode violation. await page.goto('/projects'); - await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: /projects/i })).toBeVisible(); await page.goto('/settings'); - await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: /settings/i })).toBeVisible(); await page.goto('/chat'); await expect(page).toHaveURL(/\/chat/); diff --git a/apps/web/e2e/projects.spec.ts b/apps/web/e2e/projects.spec.ts index 6059d770..3181e383 100644 --- a/apps/web/e2e/projects.spec.ts +++ b/apps/web/e2e/projects.spec.ts @@ -10,7 +10,11 @@ test.describe('Projects page', () => { test('projects page loads with heading', async ({ page }) => { await page.goto('/projects'); - await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible({ timeout: 10_000 }); + // level: 1 — the "No projects yet" empty-state h2 also matches /projects/i + // and a two-element match is a strict-mode violation. + await expect(page.getByRole('heading', { level: 1, name: /projects/i })).toBeVisible({ + timeout: 10_000, + }); }); test('shows empty state or project cards when loaded', async ({ page }) => { @@ -18,23 +22,11 @@ test.describe('Projects page', () => { // Wait for loading state to clear await expect(page.getByText(/loading projects/i)).not.toBeVisible({ timeout: 10_000 }); - const hasProjects = await page + const cardsOrEmpty = page .locator('[class*="grid"]') - .isVisible() - .catch(() => false); - const hasEmpty = await page - .getByText(/no projects yet/i) - .isVisible() - .catch(() => false); - - expect(hasProjects || hasEmpty).toBe(true); - }); - - test('shows Active Mission section', async ({ page }) => { - await page.goto('/projects'); - await expect(page.getByRole('heading', { name: /active mission/i })).toBeVisible({ - timeout: 10_000, - }); + .or(page.getByText(/no projects yet/i)) + .first(); + await expect(cardsOrEmpty).toBeVisible({ timeout: 10_000 }); }); test('sidebar navigation is present', async ({ page }) => { diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index aaf04204..72bd3762 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -1,23 +1,30 @@ import { defineConfig, devices } from '@playwright/test'; /** - * Playwright E2E configuration for Mosaic web app. + * Playwright E2E configuration for the Mosaic web SPA. * - * Assumes: - * - Next.js web app running on http://localhost:3000 - * - NestJS gateway running on http://localhost:14242 + * Assumes the NestJS gateway is already running on http://localhost:14242 and + * serving the built SPA bundle (WEB_DIST_DIR pointing at apps/web/dist) — the + * same serving path production uses (Phase P5, #1444). Override the target + * with PLAYWRIGHT_BASE_URL. + * + * global-setup seeds the E2E users through the real bootstrap and admin APIs + * when the database is empty; against an already-populated environment it + * seeds nothing. * * Run with: pnpm --filter @mosaicstack/web test:e2e */ export default defineConfig({ testDir: './e2e', + globalSetup: './e2e/global-setup.ts', fullyParallel: true, forbidOnly: !!process.env['CI'], retries: process.env['CI'] ? 2 : 0, workers: process.env['CI'] ? 1 : undefined, - reporter: 'html', + // CI needs the verdict in the step log; the html report is a local tool. + reporter: process.env['CI'] ? 'list' : 'html', use: { - baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:3000', + baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:14242', trace: 'on-first-retry', screenshot: 'only-on-failure', }, @@ -27,6 +34,6 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'] }, }, ], - // Do NOT auto-start the dev server — tests assume it is already running. + // Do NOT auto-start a server — tests assume the gateway is already running. // webServer is intentionally omitted so tests can run against a live env. }); diff --git a/eslint.config.mjs b/eslint.config.mjs index acfed05f..3af1ad2e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,7 +7,6 @@ export default tseslint.config( ignores: [ '**/dist/**', '**/node_modules/**', - '**/.next/**', '**/coverage/**', '**/drizzle.config.ts', '**/framework/**', diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index f35edf14..b8b34e4b 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -21,10 +21,10 @@ // lint | lint | pnpm lint // format | format | pnpm format:check // test | test | pnpm test -// build | publish.yml build | pnpm build +// build | build (#1445, P6) | pnpm build (also publish.yml build) // quality-rails | (canonical-only) | the TS quality-rails evaluator -// | | (RI-N4, QC-19 monorepo subject). Like -// | | `build`, this stage has no ci.yml +// | | (RI-N4, QC-19 monorepo subject). +// | | The one stage with no ci.yml // | | mirror; it is implemented by // | | importing the evaluator CLI rather // | | than duplicating its presence logic. diff --git a/scripts/verify-release.test.mjs b/scripts/verify-release.test.mjs index c614a7c9..96dcf13c 100644 --- a/scripts/verify-release.test.mjs +++ b/scripts/verify-release.test.mjs @@ -228,9 +228,10 @@ steps: function assertStagesMirrorCi(stages, ci) { const canonical = Object.fromEntries(stages.map((stage) => [stage.name, stage.commands])); - // The complete mandatory set, in gate order. `quality-rails` is a - // canonical-only stage (RI-N4, QC-19): like `build`, it has no ci.yml - // mirror to match — its contract is asserted separately below. + // The complete mandatory set, in gate order. `quality-rails` is the one + // canonical-only stage (RI-N4, QC-19) with no ci.yml mirror to match — its + // contract is asserted separately below. `build` gained a ci.yml mirror in + // #1445 (P6) and is enforced with the other pnpm stages. assert.deepEqual( stages.map((stage) => stage.name), [ @@ -257,7 +258,7 @@ function assertStagesMirrorCi(stages, ci) { // pnpm stages: ci.yml commands minus `corepack enable` must be exactly the // canonical stage commands. - for (const stepName of ['typecheck', 'lint', 'format']) { + for (const stepName of ['typecheck', 'lint', 'format', 'build']) { assert.deepEqual( ci.steps[stepName].commands.filter((command) => command !== 'corepack enable'), canonical[stepName],