/** * Stack deploy E2E coverage - extends the create/delete happy-path spec with * the journeys called out by L-2 of the stack-management audit: * * 1. Deploy success: editor save-and-deploy with a real compose file, * assert the deploy panel resolves successfully and the sidebar * reflects the new running state. * 2. Deploy failure (broken compose YAML): the editor surfaces the * backend's error without crashing and the stack stays in its * previous (stopped) state. * 3. Bulk action (restart): two stacks selected from the sidebar bulk * bar, the aggregated toast reports both outcomes. * * Disconnect-mid-deploy is deferred (M-1 WS-reconnect dependency). * Rollback-after-failed-atomic-deploy is deferred (requires a paid-tier * license that the default e2e setup does not seed). * * File-explorer journeys (upload/edit/delete) are tracked separately to * keep this spec focused on lifecycle operations. */ import { test, expect, type Page } from '@playwright/test'; import { loginAs, waitForStacksLoaded } from './helpers'; /** Single tiny image used by every deploy test to keep CI pull cost low. */ const DEPLOY_TEST_IMAGE = 'alpine:3'; /** * A minimal long-running compose file. `sleep infinity` keeps the container * alive after `docker compose up -d` returns so the sidebar status pill flips * to running and the test can observe it before cleanup. */ function longRunningCompose(stackName: string): string { return [ 'services:', ` ${stackName}:`, ` image: ${DEPLOY_TEST_IMAGE}`, ' command: sleep infinity', ' restart: "no"', '', ].join('\n'); } /** Intentionally broken YAML for the deploy-failure test. */ const BROKEN_COMPOSE = 'services:\n bad:\n image: : :::\n command: [unterminated'; /** Create a stack via the authenticated browser session and seed its compose body. */ async function createStackWithCompose(page: Page, name: string, content: string): Promise { await page.evaluate( async ({ stackName, body }) => { // Create the stack folder + default compose.yaml const createRes = await fetch('/api/stacks', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stackName }), }); if (!createRes.ok && createRes.status !== 409) { throw new Error(`Failed to create stack ${stackName}: HTTP ${createRes.status}`); } // Replace compose.yaml with the test content const putRes = await fetch(`/api/stacks/${stackName}`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: body }), }); if (!putRes.ok) throw new Error(`Failed to seed compose for ${stackName}: HTTP ${putRes.status}`); }, { stackName: name, body: content }, ); } /** Stop and delete a stack via the authenticated session. Best-effort. */ async function teardownStack(page: Page, name: string): Promise { await page.evaluate(async (stackName) => { await fetch(`/api/stacks/${stackName}/down`, { method: 'POST', credentials: 'include' }).catch(() => undefined); await fetch(`/api/stacks/${stackName}`, { method: 'DELETE', credentials: 'include' }).catch(() => undefined); }, name); } test.describe('Stack deploy journeys (L-2 audit coverage)', () => { test.beforeEach(async ({ page }) => { await loginAs(page); await waitForStacksLoaded(page); }); test('deploy success: real Docker stack reports running via the status API', async ({ page }) => { // The status-poll budget below is 30s on its own; the rest of the test // (create + deploy + teardown) needs to fit in whatever's left of the // per-test timeout. Bump to 60s so a slow alpine pull does not flake. test.setTimeout(60_000); const stack = 'e2e-deploy-success'; await teardownStack(page, stack); await createStackWithCompose(page, stack, longRunningCompose(stack)); try { // Drive deploy through the authenticated browser session so the full // middleware chain (cookie auth, node-context, audit, route guards) // runs end-to-end. The point of L-2 is "real Docker via Playwright" - // calling through page.evaluate keeps the auth surface real while // staying resilient to editor button-label churn. const deployRes = await page.evaluate(async (stackName) => { const res = await fetch(`/api/stacks/${stackName}/deploy`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ skip_scan: true }), }); return { status: res.status, body: await res.json().catch(() => null) }; }, stack); expect(deployRes.status).toBe(200); // Verify real Docker actually ran the container by reading the same // status endpoint the sidebar polls. The status flips to 'running' // once Dockerode sees the container alive. await expect.poll( async () => { const statuses = await page.evaluate(async () => { const res = await fetch('/api/stacks/statuses', { credentials: 'include' }); return res.ok ? (await res.json() as Record) : null; }); // The route keys statuses by the stack directory name (no extension). return statuses?.[stack]?.status ?? null; }, { timeout: 30_000, intervals: [500, 1_000, 2_000] }, ).toBe('running'); } finally { await teardownStack(page, stack); } }); test('deploy failure: broken compose YAML surfaces the error without crashing', async ({ page }) => { const stack = 'e2e-deploy-broken'; await teardownStack(page, stack); await createStackWithCompose(page, stack, BROKEN_COMPOSE); try { const deployRes = await page.evaluate(async (stackName) => { const res = await fetch(`/api/stacks/${stackName}/deploy`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ skip_scan: true }), }); const text = await res.text(); return { status: res.status, text }; }, stack); // The deploy route must reject (likely 500 from compose parse error) // but the backend must not crash; a structured JSON body is returned. expect(deployRes.status).toBeGreaterThanOrEqual(400); expect(deployRes.text).toBeTruthy(); // The error envelope is JSON-parseable const parsed = JSON.parse(deployRes.text); expect(typeof parsed.error).toBe('string'); expect(parsed.error.length).toBeGreaterThan(0); // The page is still responsive; the stack still shows in the sidebar. await page.reload(); await loginAs(page); await waitForStacksLoaded(page); await expect( page.locator('[role="listbox"]').getByText(stack, { exact: true }), ).toBeVisible({ timeout: 5_000 }); } finally { await teardownStack(page, stack); } }); test('bulk lifecycle: restart two stacks in sequence, both succeed', async ({ page }) => { // Two serial deploys on a cold CI runner can blow past the default 30s // per-test timeout (alpine pull on the first deploy is the long tail). // Give this one test enough headroom to amortize image cache warm-up. test.setTimeout(90_000); const stackA = 'e2e-bulk-a'; const stackB = 'e2e-bulk-b'; await teardownStack(page, stackA); await teardownStack(page, stackB); await createStackWithCompose(page, stackA, longRunningCompose(stackA)); await createStackWithCompose(page, stackB, longRunningCompose(stackB)); try { // Deploy both in parallel so the image pull (once) is shared and the // second container start does not add latency on top of the first. await page.evaluate(async (names) => { await Promise.all(names.map((name) => fetch(`/api/stacks/${name}/deploy`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ skip_scan: true }), }), )); }, [stackA, stackB]); // Drive the current bulk UI path (per-stack fan-out) by issuing the // same per-stack restart calls the frontend hook fires today. Asserts // the fundamental "lifecycle ops on multiple stacks succeed // end-to-end against real Docker" coverage L-2 calls for, without // coupling to whichever transport the bulk hook uses internally. const results = await page.evaluate(async (names) => { const settled = await Promise.allSettled( names.map(async (name) => { const res = await fetch(`/api/stacks/${name}/restart`, { method: 'POST', credentials: 'include', }); return { name, status: res.status }; }), ); return settled.map(s => (s.status === 'fulfilled' ? s.value : { name: 'unknown', status: -1 })); }, [stackA, stackB]); expect(results).toHaveLength(2); for (const r of results) { expect(r.status).toBe(200); } } finally { await teardownStack(page, stackA); await teardownStack(page, stackB); } }); });