Files
sencho/e2e/stack-deploy.spec.ts
T
Anso 429f780f40 test(stacks): E2E coverage for deploy success, failure, and bulk lifecycle (#1192)
* test(stacks): E2E coverage for deploy success, failure, and bulk lifecycle

Extends e2e coverage per L-2 of the stack-management audit. The
existing stacks.spec.ts only covered create/delete and a double-click
guard - no journey ever exercised real Docker through the deploy
pipeline.

Adds e2e/stack-deploy.spec.ts with three journeys:

1. Deploy success: creates a stack with a real-but-tiny compose file
   (alpine:3 sleep infinity), drives deploy through the authenticated
   browser session, and asserts the /api/stacks/statuses route
   reports the container as 'running' inside 30 seconds. Exercises
   the full middleware chain, ComposeService spawn, Dockerode status
   lookup, and the cache-invalidate path.

2. Deploy failure: creates a stack with intentionally malformed YAML,
   asserts the deploy route rejects with a 4xx/5xx and a parseable
   JSON envelope (no crash, no opaque text), and verifies the
   sidebar still renders the stack after reload.

3. Bulk lifecycle: creates two stacks, deploys both, then restarts
   both via per-stack POSTs (the path the current frontend bulk hook
   takes; once H-3 lands the hook fans out through the new bulk
   endpoint but the user-visible outcome is identical). Asserts both
   restarts return 200.

Disconnect-mid-deploy journey is deferred (depends on M-1 WS
reconnect). Rollback-after-failed-atomic-deploy is deferred (needs 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.

Test design notes:
- Drives deploy through page.evaluate + fetch rather than UI buttons
  to stay resilient to editor-button label churn. Auth surface is
  still real (cookies, middleware chain, audit log) - only the click
  is bypassed. The audit's "exercise real Docker via Playwright"
  intent is satisfied.
- Each test calls teardownStack in a finally block so a failed test
  cannot strand a real container on the CI runner.
- Uses alpine:3 (smallest viable long-running image) to keep CI pull
  cost minimal. The image is shared across tests so registry pull
  amortizes after the first.

* test(stacks): bump E2E timeouts and parallelize bulk deploy

CI hit the default 30s per-test timeout on the bulk lifecycle test
because two serial deploys against a cold runner spent most of the
budget on the alpine pull. The deploy-success test passed the prior
run but had only ~0-5s of headroom outside its 30s status poll, so it
was a flake away from the same fate.

- bulk lifecycle: deploy both stacks in parallel (shared pull cache)
  and bump the per-test timeout to 90s.
- deploy success: bump per-test timeout to 60s so the 30s status poll
  is not racing setup and teardown.

The deploy failure test does no image pull (broken YAML rejects
upstream) so it keeps the default 30s.
2026-05-24 15:57:12 -04:00

225 lines
9.3 KiB
TypeScript

/**
* 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<void> {
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<void> {
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<string, { status: string }>) : 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);
}
});
});