Files
dpsapi/e2e/tests/host/containers-real.spec.ts
T
bryanpedini 9a85e9acc8 feat(docker): add docker engine integration
Lazy Docker Engine client on the local socket listing running containers with a per-call timeout; unit tests and e2e tests against a fake Engine socket and real containers.
2026-10-06 10:52:25 +02:00

125 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* PLAN §12 core items 6, 7, 12 against the real Docker Engine: uniquely named, labelled alpine
* containers are started, stopped, restarted and removed, and GET /api/v1/containers (Playwright
* request and real curl) must reflect `docker ps` for exactly those containers. Assertions only
* look at the suite's own names/labels — the host runs unrelated containers.
*/
import {
test,
expect,
assertDbInvariants,
expectContainersResponse,
readOperations,
type HttpClient,
type RealDocker,
type ServerHandle,
type TestContainer,
} from '../../harness';
interface ApiContainer {
Id: string;
Names: string[];
Image: string;
Command: string;
Labels: Record<string, string>;
State: string;
Status: string;
}
/** Our containers in the API response, via Playwright and via curl (both must agree). */
async function ownViaApi(http: HttpClient, server: ServerHandle, token: string, docker: RealDocker): Promise<Map<string, ApiContainer>> {
const pw = expectContainersResponse<ApiContainer>(await http.get(server.url(), { token }));
const cu = await http.curl({ url: server.url(), token });
expect(cu.exitCode).toBe(0);
const viaCurl = expectContainersResponse<ApiContainer>(cu);
const own = (list: ApiContainer[]) => list.filter((c) => c.Labels?.['dpsapi.e2e.test'] === docker.testKey);
const a = own(pw).map((c) => c.Id).sort();
const b = own(viaCurl).map((c) => c.Id).sort();
expect(b, 'curl and Playwright see the same containers').toEqual(a);
// never stopped containers, for anyone
expect(pw.filter((c) => c.State !== 'running').map((c) => c.Names), 'only running containers are listed').toEqual([]);
return new Map(own(pw).map((c) => [c.Id, c]));
}
async function expectApiMatchesDocker(http: HttpClient, server: ServerHandle, token: string, docker: RealDocker, expected: TestContainer[]): Promise<Map<string, ApiContainer>> {
const api = await ownViaApi(http, server, token, docker);
const running = (await docker.runningIds()).sort();
expect([...api.keys()].sort(), 'API equals docker ps for our label').toEqual(running);
expect([...api.keys()].sort(), 'API equals the expected set').toEqual(expected.map((c) => c.id).sort());
return api;
}
test('started, stopped, restarted and removed containers are reflected (Playwright request and curl)', async ({ cli, startServer, http, docker }) => {
test.setTimeout(120_000);
const token = await cli.tokenNew('real docker');
const [sleeper, looper, tailer] = await Promise.all([
docker.run({ suffix: 'sleeper', command: ['sleep', '3600'], labels: { 'com.example.role': 'sleeper', 'unicode.label': 'naïve ☃' } }),
docker.run({ suffix: 'looper', command: ['sh', '-c', 'trap "exit 0" TERM; while :; do sleep 1; done'], labels: { 'com.example.role': 'looper' }, env: { FOO: 'bar' } }),
docker.run({ suffix: 'tail.er_2', command: ['tail', '-f', '/dev/null'], labels: { 'empty.value': '' } }),
]);
const server = await startServer({ docker: 'real', logLevel: 'DEBUG' });
const api = await expectApiMatchesDocker(http, server, token, docker, [sleeper, looper, tailer]);
for (const c of [sleeper, looper, tailer]) {
const got = api.get(c.id)!;
expect(got.Names).toEqual([`/${c.name}`]);
expect(got.Image).toBe('alpine:3.22');
expect(got.State).toBe('running');
expect(got.Status).toMatch(/^Up /);
for (const [k, v] of Object.entries(c.labels)) expect(got.Labels[k]).toBe(v);
}
expect(api.get(sleeper.id)!.Command).toContain('sleep 3600');
expect(api.get(tailer.id)!.Command).toContain('tail -f /dev/null');
await docker.stop(sleeper);
await expectApiMatchesDocker(http, server, token, docker, [looper, tailer]);
expect((await docker.listOwn()).find((c) => c.name === sleeper.name)!.state, 'stopped container still exists').toBe('exited');
await docker.remove(looper);
await expectApiMatchesDocker(http, server, token, docker, [tailer]);
await docker.start(sleeper);
await expectApiMatchesDocker(http, server, token, docker, [sleeper, tailer]);
await docker.remove(sleeper);
await docker.remove(tailer);
await expectApiMatchesDocker(http, server, token, docker, []);
expect(await docker.listOwn()).toEqual([]);
expect((await server.stop()).exitCode).toBe(0);
// 5 checkpoints × 2 clients
expect(readOperations(server.dbPath)).toHaveLength(10);
expect(server.logs().filter({ msg: 'docker request finished' }).every((r) => r.attrs.ok === 'true')).toBe(true);
assertDbInvariants(server.dbPath, { operationCount: 10 });
});
test('moderate real set: 15 containers with varied names, labels and commands', async ({ cli, startServer, http, docker }) => {
test.setTimeout(150_000);
const token = await cli.tokenNew();
const commands = [['sleep', '3600'], ['tail', '-f', '/dev/null'], ['sh', '-c', 'while :; do sleep 2; done'], ['top', '-b', '-d', '5']];
const set = await docker.runMany(15, (i) => ({
suffix: `m${String(i).padStart(2, '0')}${i % 3 === 0 ? '.dot' : i % 3 === 1 ? '_under' : '-dash'}`,
command: commands[i % commands.length],
labels: { 'com.example.index': String(i), [`com.example.k${i}`]: `v${i}` },
}));
const server = await startServer({ docker: 'real' });
const api = await expectApiMatchesDocker(http, server, token, docker, set);
for (const [i, c] of set.entries()) {
const got = api.get(c.id)!;
expect(got.Names).toEqual([`/${c.name}`]);
expect(got.Labels['com.example.index']).toBe(String(i));
}
// stop a third of them
const stopped = set.filter((_, i) => i % 3 === 0);
await Promise.all(stopped.map((c) => docker.stop(c, 1)));
await expectApiMatchesDocker(http, server, token, docker, set.filter((c) => !stopped.includes(c)));
// remove everything
await Promise.all(set.map((c) => docker.remove(c)));
await expectApiMatchesDocker(http, server, token, docker, []);
expect((await server.stop()).exitCode).toBe(0);
expect(readOperations(server.dbPath)).toHaveLength(6);
});