Files
sencho/e2e/default-stack-template-no-fixed-port.spec.ts
Anso 8ba88755b1 fix(stacks): default Empty template ships ports block commented out (#1189)
The Empty branch of the Create Stack flow wrote a compose.yaml whose
first service bound the host's port 8080 by default. On any workstation
already running something on 8080 (traefik, caddy, librespeed, another
nginx, etc.) the very first deploy failed at the docker compose
networking step with "Bind for 0.0.0.0:8080 failed: port is already
allocated", which made the day-one experience feel broken right after
F-2 (PR #1168) tightened the dialog itself.

The boilerplate in FileSystemService.createStack now emits the ports
block commented out plus a one-line hint above it. A fresh deploy
binds no host port, so the container starts cleanly on any host; the
user uncomments the two-line block when they're ready to expose the
container. The deterministic shape (no probe-and-write, no random port,
no preflight scan) avoids the TOCTOU race that a free-port probe would
have left between template creation and the actual compose up.

Adds backend/src/__tests__/file-system-service-create-stack.test.ts
(8 cases): directory + file creation, structural YAML assertions via
yaml.parse to lock in the no-live-ports invariant, raw-text regex to
lock in the commented hint, and the already-exists rejection path.
FileSystemService.createStack had no coverage before this change.

Adds e2e/default-stack-template-no-fixed-port.spec.ts (1 case): drives
the dialog through Create, reads the resulting compose via the
in-browser apiFetch, and asserts the live + commented invariants
end-to-end.

docs/features/stack-management.mdx Empty bullet rewritten to describe
the minimal skeleton and the commented ports block instead of calling
it "blank".

Resolves: F-3 in the v1.0 audit tracker.
2026-05-24 01:06:22 -04:00

63 lines
2.9 KiB
TypeScript

/**
* F-3 regression: creating an Empty stack via the dialog must produce a
* compose.yaml whose host port binding is commented out, so the first
* deploy never collides with whatever already owns the host's port 8080.
*/
import { test, expect } from '@playwright/test';
import { loginAs, waitForStacksLoaded } from './helpers';
const TEST_STACK = `e2e-default-template-${Date.now()}`;
async function deleteStackViaApi(page: import('@playwright/test').Page, name: string) {
await page.evaluate(async (stackName) => {
await fetch(`/api/stacks/${stackName}`, { method: 'DELETE', credentials: 'include' }).catch(() => { });
}, name);
}
async function readComposeViaApi(page: import('@playwright/test').Page, name: string): Promise<string> {
return page.evaluate(async (stackName) => {
const res = await fetch(
`/api/stacks/${stackName}/files/content?path=compose.yaml`,
{ credentials: 'include', cache: 'no-store' }
);
if (!res.ok) throw new Error(`read compose failed: HTTP ${res.status}`);
const body = (await res.json()) as { content?: string };
if (typeof body.content !== 'string') throw new Error('compose response missing content field');
return body.content;
}, name);
}
test.describe('Default Empty-stack template', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page);
await waitForStacksLoaded(page);
await deleteStackViaApi(page, TEST_STACK);
});
test.afterEach(async ({ page }) => {
await deleteStackViaApi(page, TEST_STACK);
});
test('ships with the ports block commented out so first deploy never collides on 8080', async ({ page }) => {
await page.getByRole('button', { name: 'Create Stack' }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
await page.locator('#create-stack-name').fill(TEST_STACK);
await page.locator('[role="dialog"]').getByRole('button', { name: 'Create' }).click();
await expect(page.getByRole('dialog')).toBeHidden({ timeout: 8_000 });
await expect(page.getByText(`Stack "${TEST_STACK}" created.`)).toBeVisible({ timeout: 5_000 });
const compose = await readComposeViaApi(page, TEST_STACK);
expect(compose, 'no uncommented ports: line').not.toMatch(/^(?![ \t]*#)[ \t]*ports:/m);
expect(compose, 'no uncommented host-port mapping').not.toMatch(/^(?![ \t]*#)[ \t]*-[ \t]+["']?8080:80["']?/m);
expect(compose, 'commented ports: hint present').toMatch(/^[ \t]*#[ \t]*ports:[ \t]*$/m);
expect(compose, 'commented port mapping present').toMatch(/^[ \t]*#[ \t]*-[ \t]*"8080:80"[ \t]*$/m);
expect(compose, 'commented hint sentence above the block').toMatch(/^[ \t]*#[ \t]+Uncomment to expose a host port:[ \t]*$/m);
expect(compose, 'live image line preserved').toMatch(/^[ \t]*image:[ \t]+nginx:latest[ \t]*$/m);
expect(compose, 'live restart line preserved').toMatch(/^[ \t]*restart:[ \t]+always[ \t]*$/m);
});
});