mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 20:00:08 +00:00
429f780f40
* 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.
225 lines
9.3 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|