ci(web): Phase P6 — vite build in PR CI + headless E2E gate on trunk publishes (#1445)
ci/woodpecker/pr/ci Pipeline was successful

- 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
This commit is contained in:
fred
2026-08-27 09:30:38 -05:00
parent b5ee692843
commit 311b4dda59
15 changed files with 451 additions and 77 deletions
+4 -8
View File
@@ -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 });
});
});
+14 -24
View File
@@ -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 }) => {
+85
View File
@@ -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<void> {
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}`);
}
+6 -1
View File
@@ -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<void> {
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(() => {});
}
+14 -8
View File
@@ -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/);
+9 -17
View File
@@ -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 }) => {
+14 -7
View File
@@ -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.
});