Files
sencho/e2e/gitops-digest-drift.spec.ts
Anso b689733447 refactor(gitops): one layered status across the Drift tab, Git source sheet, and application view (#2063)
* refactor(gitops): one layered status across the Drift tab, Git source sheet, and application view

The GitOps state used to be a stack of tinted cards on five surfaces: a
posture card, approval chips, a card per facet, a card per target, and a
caveats box, restating the same state several times. Each surface now
shows one status: a single answer (with its resolving button, such as
Review update or Retry), a quiet row naming each stage that applies, and
an Evidence control holding recorded authority, policy lines, targets,
and anything Sencho could not prove.

- Stages that say nothing about an application (unbound placement, an
  absent rollout) are left out of the row instead of printed as "n/a".
- Evidence is never silently lost: the answer carries a marker for
  partial evidence, unreached nodes, and unproven items, and every
  attention reason is kept, the first in the answer and the rest in the
  evidence.
- The Git source sheet's Status section drops from about 450px to about
  170px, and its separate source-state and last-applied rows go; the
  header now says "never applied" until something is.
- The application view no longer shows the posture twice.

Adds a domain-free StatusPath primitive, a pure status model, and a
GitOpsStatus composer; retires the facet-card and approval-chip
components and keeps the policy lines. Docs, the tutorial, and the two
affected screenshots are updated.

* test(gitops): open the status evidence in the digest drift spec

The per-service digests now sit under the Evidence control of the GitOps
status, so the spec opens it before looking for them.
2026-10-02 18:30:16 -04:00

626 lines
27 KiB
TypeScript

/**
* Digest drift on a real Blueprint deployment, and what each policy does about it.
*
* A tag is a promise, not an identity, so the drift this exercises is created
* the way it happens in the field: the compose pins an approved image digest,
* the running containers are then replaced with a different image, and Sencho
* must report the per-service digest difference, show both digests on the Drift
* tab, and leave the approved identity where it was instead of adopting whatever
* is actually running.
*
* Two policies are covered against that same drift, because the interesting part
* is what happens next. Observe reports it and leaves the row drifted, which is
* what the first test asserts. Enforce has to take that same drifted row and put
* it back: the second test watches the row return to Active with no drift
* summary and the workload running the approved digest again, which is the whole
* cycle rather than only its detection. It switches the policy on *after* the
* drift is recorded, so the repair lands on a pass later than the one that
* recorded it, and the pass that picks a drifted row back up is genuinely
* exercised instead of being raced.
*
* The approved digest is discovered from `nginx:alpine` and the digest the
* workload is moved to is discovered from `nginx:1.27`, both of them real
* upstream tags of one repository. A locally invented repository cannot work
* here, for a reason that showed up as a CI-only failure: the observation
* filters the running image's repo digests by the authored repository, so an
* invented repository never yields an observed digest at all, and a reference no
* registry knows can only be satisfied by whatever the local Docker version
* feels like doing with it. Pinning a real repository by digest keeps the
* approved side resolvable from the local content store without a registry call,
* and keeps the moved side a real digest difference rather than an unresolved
* one.
*
* Needs the Docker CLI and Compose plugin, because making a running workload
* diverge is a Docker operation. A machine with no `docker` executable at all
* skips the spec outside CI only: in CI the runner is required to have it, and a
* silent skip there would hide a broken environment. A Docker that is installed
* but unusable, including a missing Compose plugin, fails loudly instead of
* skipping, so a broken environment never reads as a pass.
*/
import { execFileSync } from 'node:child_process';
import { writeFileSync, rmSync } from 'node:fs';
import { test, expect, type Page } from '@playwright/test';
import { loginAs } from './helpers';
const DOCKER_TIMEOUT_MS = 60_000;
const REQUEST_TIMEOUT_MS = 15_000;
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/i;
function isMissingExecutable(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT';
}
function dockerAvailable(): boolean {
try {
execFileSync('docker', ['version', '--format', '{{.Server.Version}}'], { timeout: DOCKER_TIMEOUT_MS, stdio: 'pipe' });
execFileSync('docker', ['compose', 'version'], { timeout: DOCKER_TIMEOUT_MS, stdio: 'pipe' });
return true;
} catch (error) {
if (isMissingExecutable(error)) return false;
// Present but unusable is an environment fault, not a reason to skip.
throw error;
}
}
function imageRepository(image: string): string {
const withoutDigest = image.split('@')[0] ?? image;
const lastSlash = withoutDigest.lastIndexOf('/');
const lastColon = withoutDigest.lastIndexOf(':');
return lastColon > lastSlash ? withoutDigest.slice(0, lastColon) : withoutDigest;
}
/**
* Every repo digest the local image carries for its own repository. The backend
* selects observed digests the same way, by repository, so the spec reads them
* the same way instead of trusting Docker's ordering.
*/
function repoDigests(image: string): string[] {
const repo = imageRepository(image);
const raw = execFileSync(
'docker',
['image', 'inspect', image, '--format', '{{json .RepoDigests}}'],
{ timeout: DOCKER_TIMEOUT_MS, stdio: 'pipe' },
)
.toString()
.trim();
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) throw new Error(`${image} reported no repo digests`);
const digests: string[] = [];
for (const entry of parsed) {
if (typeof entry !== 'string') continue;
const at = entry.indexOf('@');
if (at < 0) continue;
const entryRepo = imageRepository(entry.slice(0, at));
const digest = entry.slice(at + 1);
if (entryRepo !== repo || !DIGEST_RE.test(digest) || digests.includes(digest)) continue;
digests.push(digest);
}
if (digests.length === 0) throw new Error(`${image} has no repo digest for ${repo}`);
return digests;
}
/**
* The image reference the project's running container was created from, read
* from the daemon. Compose normalizes the reference it stores, so callers match
* on the digest rather than on the whole string.
*/
function containerImageReference(project: string, service: string): string {
const containers = execFileSync(
'docker',
[
'ps',
'--filter', `label=com.docker.compose.project=${project.toLowerCase()}`,
'--filter', `label=com.docker.compose.service=${service}`,
'--filter', 'status=running',
'--format', '{{.ID}}',
],
{ timeout: DOCKER_TIMEOUT_MS, stdio: 'pipe' },
)
.toString()
.trim()
.split('\n')
.filter((line) => line.length > 0);
// Exactly one, so a stray duplicate or a one-off container can never be
// mistaken for the workload this compose file defines.
if (containers.length !== 1) {
throw new Error(
`expected one running ${service} container for project ${project}, found ${containers.length}`,
);
}
const [container] = containers;
if (container === undefined) {
throw new Error(`expected one running ${service} container for project ${project}, found none`);
}
return execFileSync('docker', ['inspect', '--format', '{{.Config.Image}}', container], {
timeout: DOCKER_TIMEOUT_MS,
stdio: 'pipe',
})
.toString()
.trim();
}
async function jsonRequest<T>(
page: Page,
url: string,
init: { method?: string; body?: unknown } = {},
): Promise<{ status: number; body: T }> {
return page.evaluate(
async ({ url, method, payload, timeout }) => {
const res = await fetch(url, {
method,
credentials: 'include',
signal: AbortSignal.timeout(timeout),
...(payload === null
? {}
: { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }),
});
// A 204 carries no body, so parse defensively rather than failing cleanup
// on the response shape.
const text = await res.text();
return { status: res.status, body: (text ? JSON.parse(text) : null) as T };
},
{ url, method: init.method ?? 'GET', payload: init.body === undefined ? null : init.body, timeout: REQUEST_TIMEOUT_MS },
);
}
/** Reads a 200 or the test fails with the server's own words, not a timeout. */
function expectOk<T>(response: { status: number; body: T }, what: string): T {
expect(response.status, `${what}: ${JSON.stringify(response.body)}`).toBe(200);
return response.body;
}
interface NodeRow {
id: number;
type: string;
compose_dir: string;
}
interface ServiceEvidence {
serviceName: string;
platformDigest: string | null;
}
interface DeploymentRow {
status: string;
drift_summary: string | null;
}
interface PreviewPayload {
planFingerprint: string;
gitopsFingerprint: string | null;
confirmableActions: unknown[];
}
interface ApplyPayload {
error?: string;
code?: string;
}
interface BlueprintDetail {
deployments: DeploymentRow[];
gitopsRevision?: { targets?: TargetRow[] } | null;
}
interface TargetRow {
observedArtifactIdentity?: { kind: string; services?: ServiceEvidence[] } | null;
artifact?: { expected?: { services?: ServiceEvidence[] } | null } | null;
}
interface DriftPayload {
gitopsRevision?: {
targets?: TargetRow[];
} | null;
}
/**
* Preview the plan and confirm it, the way an operator does.
*
* Confirming against a live Blueprint is a two-step dance: the tick can re-save
* the row between the preview and the confirm, which the server refuses with
* PREVIEW_STALE. That one case is worth retrying with a fresh preview, because
* the UI asks the operator to confirm again. Any other failure is reported as it
* is, since retrying it would only repeat it.
*/
async function confirmPlan(page: Page, blueprintId: number): Promise<void> {
let applied: { status: number; body: ApplyPayload } | null = null;
for (let attempt = 0; attempt < 4; attempt += 1) {
const preview = expectOk(
await jsonRequest<PreviewPayload>(page, `/api/blueprints/${blueprintId}/preview`),
'previewing the plan',
);
const result = await jsonRequest<ApplyPayload>(page, `/api/blueprints/${blueprintId}/apply`, {
method: 'POST',
body: {
planFingerprint: preview.planFingerprint,
// The apply confirms the authority evidence the preview displayed, so
// what it showed has to be echoed back with the plan.
...(preview.gitopsFingerprint ? { gitopsFingerprint: preview.gitopsFingerprint } : {}),
actions: preview.confirmableActions,
},
});
applied = result;
if (result.status === 200) break;
if (result.status !== 409 || result.body.code !== 'PREVIEW_STALE') break;
}
expect(applied?.status, `applying the plan: ${JSON.stringify(applied?.body)}`).toBe(200);
}
test.describe('GitOps digest drift', () => {
test.skip(!dockerAvailable() && !process.env.CI, 'Docker with the Compose plugin is not available');
// The reconciler ticks once a minute, so the drift poll covers a tick and the
// observation that follows it, and the baseline poll covers the compose run.
// These are ceilings rather than expected durations: the happy path takes about
// half a minute, and the CI job has a whole-suite budget to protect. Both tests
// here are reconciler-driven, so each carries its own ceiling and the repair's
// ceiling covers a second tick on top of the detection it waits for.
test.setTimeout(360_000);
const stamp = Date.now();
// One stack name per case, not one per file. Both cases deploy a real compose
// project under this name, so a shared one would make the second case collide
// with whatever the first case's teardown left behind, and the failure would
// surface as a confusing container count rather than as the leak it is.
const blueprintNameFor = (caseName: string) => `e2e-digest-${caseName}-${stamp}`;
// Two real tags of one repository, pinned by digest and then swapped, so the
// approved reference resolves locally and the observed digest matches the
// authored repository. The digests themselves are read from the daemon, never
// assumed, because either tag can move.
const approvedImage = 'nginx:alpine';
const movedImage = 'nginx:1.27';
const overrideFile = `/tmp/e2e-digest-override-${stamp}.yaml`;
let blueprintId: number | null = null;
// Set by each case as it starts, so teardown can look for the containers that
// case created even when the case failed before it created the blueprint.
let blueprintName = '';
test.beforeAll(() => {
execFileSync('docker', ['pull', '--quiet', approvedImage], { timeout: DOCKER_TIMEOUT_MS, stdio: 'pipe' });
execFileSync('docker', ['pull', '--quiet', movedImage], { timeout: DOCKER_TIMEOUT_MS, stdio: 'pipe' });
});
test.afterAll(() => {
rmSync(overrideFile, { force: true });
});
test('a replaced image digest is reported per service with both digests, and the approved identity holds', async ({ page }) => {
await loginAs(page);
blueprintName = blueprintNameFor('observe');
const approvedDigests = repoDigests(approvedImage);
const movedDigests = repoDigests(movedImage);
const [pinned] = approvedDigests;
if (pinned === undefined) throw new Error(`${approvedImage} reported no repo digest`);
const shared = movedDigests.filter((digest) => approvedDigests.includes(digest));
// Two tags of one repository can resolve to the same manifest, which would
// make the moved-image scenario a no-op. Failing here names the cause
// instead of leaving it to surface as a poll timeout.
expect(
shared,
`${approvedImage} and ${movedImage} resolve to the same digest, so nothing would drift`,
).toEqual([]);
const approvedRef = `nginx@${pinned}`;
const compose = `services:\n web:\n image: ${approvedRef}\n`;
const nodes = expectOk(await jsonRequest<NodeRow[]>(page, '/api/nodes'), 'listing nodes');
const local = nodes.find((node) => node.type === 'local');
expect(local, 'expected a local node to target').toBeTruthy();
const stackDir = `${local!.compose_dir}/${blueprintName}`;
const created = await jsonRequest<{ id?: number; error?: string }>(page, '/api/blueprints', {
method: 'POST',
body: {
name: blueprintName,
compose_content: compose,
selector: { type: 'nodes', ids: [local!.id] },
drift_mode: 'observe',
},
});
expect(created.status, `creating the blueprint: ${JSON.stringify(created.body)}`).toBe(201);
if (typeof created.body.id !== 'number') {
throw new Error(`the create response carried no blueprint id: ${JSON.stringify(created.body)}`);
}
blueprintId = created.body.id;
// Confirming a plan is a two-step dance against a live blueprint: the tick
// can re-save the row between the preview and the confirm, which the server
// refuses with PREVIEW_STALE. That one case is worth retrying with a fresh
// preview, because the UI asks the operator to confirm again. Any other
// failure is reported as it is, since retrying it would only repeat it.
await confirmPlan(page, blueprintId);
// Only the projection is read after its poll, so it is the only field that
// needs to outlive the callbacks that fill it.
const state: { projected: DriftPayload['gitopsRevision'] } = { projected: null };
// The baseline has to be proven, not assumed, because a deployment row goes
// active before any drift check runs, and a mismatch that was already there
// at deploy time would otherwise be credited to the override below.
//
// The proof is read from the daemon rather than from the projection on
// purpose. The projection's runtime observation is only recorded on a
// reconciler tick, so waiting for it would spend a whole extra minute to
// learn something the daemon already knows, and the CI job has a whole-suite
// budget to protect. What the container was actually created from is the
// ground truth for "what is running", and it is one fast call.
await expect
.poll(
async () => {
const detail = expectOk(
await jsonRequest<BlueprintDetail>(page, `/api/blueprints/${blueprintId}`),
'reading the blueprint',
);
const approved =
detail.gitopsRevision?.targets?.[0]?.artifact?.expected?.services?.[0]?.platformDigest ?? null;
return approved === pinned && detail.deployments.some((row) => row.status === 'active');
},
{ timeout: 150_000, intervals: [1_000] },
)
.toBe(true);
const runningBefore = containerImageReference(blueprintName, 'web');
expect(
runningBefore,
'the deploy must run the approved reference, or the override proves nothing',
).toContain(`nginx@${pinned}`);
// Replace the running workload under the same project. The compose on disk
// still pins the approved digest, so the only thing that changed is what is
// actually running.
writeFileSync(overrideFile, `services:\n web:\n image: ${movedImage}\n`);
execFileSync(
'docker',
[
'compose', '-p', blueprintName,
'-f', `${stackDir}/compose.yaml`,
'-f', overrideFile,
'up', '-d', '--force-recreate', '--pull', 'never',
],
{ timeout: DOCKER_TIMEOUT_MS, stdio: 'pipe' },
);
// The reconciler observes the replaced workload on its next pass, up to a
// minute out, and records the deployment as drifted when the running digest
// no longer matches the approved one.
await expect
.poll(
async () => {
const drift = expectOk(
await jsonRequest<DriftPayload>(page, `/api/stacks/${blueprintName}/drift`),
'reading drift',
);
state.projected = drift.gitopsRevision;
const observed =
state.projected?.targets?.[0]?.observedArtifactIdentity?.services?.[0]?.platformDigest
?? null;
const detail = expectOk(
await jsonRequest<BlueprintDetail>(page, `/api/blueprints/${blueprintId}`),
'reading the blueprint',
);
const drifted = detail.deployments.some((row) => row.status === 'drifted');
return observed !== null && movedDigests.includes(observed) && drifted;
},
{ timeout: 150_000, intervals: [2_000] },
)
.toBe(true);
const expectedServices = state.projected?.targets?.[0]?.artifact?.expected?.services ?? [];
const observedServices =
state.projected?.targets?.[0]?.observedArtifactIdentity?.services ?? [];
expect(expectedServices.length, 'expected per-service digests in the projection').toBeGreaterThan(0);
expect(observedServices.length, 'expected observed per-service digests in the projection').toBeGreaterThan(0);
// The approved identity did not move: only what is running did. The
// canonical drift list does not carry this case today; the deployment row
// and the per-service comparison are where it is reported.
const running = observedServices[0]?.platformDigest ?? null;
if (running === null) throw new Error('the observation recorded no digest to compare');
expect(expectedServices[0]?.platformDigest, 'the expectation never moved').toBe(pinned);
expect(running, 'the report names the digest that is actually running').not.toBe(pinned);
// The Drift tab shows the same comparison, per service, with both digests.
await page.goto(`/nodes/local/stacks/${blueprintName}`);
await page.getByRole('tab', { name: 'Anatomy' }).click();
await page.getByRole('tab', { name: 'Drift' }).click();
// The per-service digests are evidence behind the answer, so they sit under Evidence.
await page.getByRole('button', { name: 'Evidence' }).click();
const digestRow = page.getByTestId('gitops-digest-row').first();
await expect(digestRow).toBeVisible({ timeout: 20_000 });
await expect(digestRow).toHaveAttribute('data-state', 'drifted');
await expect(digestRow).toContainText('approved');
await expect(digestRow).toContainText('running');
const short = (digest: string) => digest.slice('sha256:'.length, 'sha256:'.length + 12);
await expect(digestRow).toContainText(short(pinned));
await expect(digestRow).toContainText(short(running));
// The moved image is reported, never adopted: the expectation stays pinned to
// the digest the compose declared, so the running image cannot become the
// approved identity anywhere in the projection.
const afterUi = expectOk(
await jsonRequest<DriftPayload>(page, `/api/stacks/${blueprintName}/drift`),
'reading drift after the UI check',
);
const stillApproved =
afterUi.gitopsRevision?.targets?.[0]?.artifact?.expected?.services?.[0]?.platformDigest;
expect(stillApproved, 'reading the expectation must not adopt the running image').toBe(pinned);
});
test('an Enforce Blueprint repairs a moved image back to the approved digest and reconverges', async ({ page }) => {
await loginAs(page);
blueprintName = blueprintNameFor('enforce');
const approvedDigests = repoDigests(approvedImage);
const movedDigests = repoDigests(movedImage);
const [pinned] = approvedDigests;
if (pinned === undefined) throw new Error(`${approvedImage} reported no repo digest`);
expect(
movedDigests.filter((digest) => approvedDigests.includes(digest)),
`${approvedImage} and ${movedImage} resolve to the same digest, so nothing would drift`,
).toEqual([]);
const approvedRef = `nginx@${pinned}`;
const compose = `services:\n web:\n image: ${approvedRef}\n`;
const nodes = expectOk(await jsonRequest<NodeRow[]>(page, '/api/nodes'), 'listing nodes');
const local = nodes.find((node) => node.type === 'local');
expect(local, 'expected a local node to target').toBeTruthy();
const stackDir = `${local!.compose_dir}/${blueprintName}`;
// Observe first, then switch to Enforce once the drift is on the row. Going
// straight to Enforce would let the tick that detects the drift repair it in
// the same pass, so the row would never be observably drifted and the pass
// that picks a drifted row back up would go unproven. This way the row is
// provably drifted under Enforce before anything may repair it.
const created = await jsonRequest<{ id?: number; error?: string }>(page, '/api/blueprints', {
method: 'POST',
body: {
name: blueprintName,
compose_content: compose,
selector: { type: 'nodes', ids: [local!.id] },
drift_mode: 'observe',
},
});
expect(created.status, `creating the blueprint: ${JSON.stringify(created.body)}`).toBe(201);
if (typeof created.body.id !== 'number') {
throw new Error(`the create response carried no blueprint id: ${JSON.stringify(created.body)}`);
}
blueprintId = created.body.id;
await confirmPlan(page, blueprintId);
// The baseline has to be proven, not assumed: a mismatch that was already
// there at deploy time would otherwise be credited to the override below.
await expect
.poll(
async () => {
const detail = expectOk(
await jsonRequest<BlueprintDetail>(page, `/api/blueprints/${blueprintId}`),
'reading the blueprint',
);
const approved =
detail.gitopsRevision?.targets?.[0]?.artifact?.expected?.services?.[0]?.platformDigest ?? null;
return approved === pinned && detail.deployments.some((row) => row.status === 'active');
},
{ timeout: 150_000, intervals: [1_000] },
)
.toBe(true);
expect(
containerImageReference(blueprintName, 'web'),
'the deploy must run the approved reference, or the override proves nothing',
).toContain(`nginx@${pinned}`);
// Replace the running workload under the same project. The compose on disk
// still pins the approved digest, so the only thing that changed is what is
// actually running.
writeFileSync(overrideFile, `services:\n web:\n image: ${movedImage}\n`);
execFileSync(
'docker',
[
'compose', '-p', blueprintName,
'-f', `${stackDir}/compose.yaml`,
'-f', overrideFile,
'up', '-d', '--force-recreate', '--pull', 'never',
],
{ timeout: DOCKER_TIMEOUT_MS, stdio: 'pipe' },
);
// Observe records the drift and leaves it, which is what makes the row
// drifted rather than repaired on the pass that finds it.
await expect
.poll(
async () => {
const detail = expectOk(
await jsonRequest<BlueprintDetail>(page, `/api/blueprints/${blueprintId}`),
'reading the blueprint',
);
return detail.deployments.some((row) => row.status === 'drifted');
},
{ timeout: 150_000, intervals: [2_000] },
)
.toBe(true);
const drifted = expectOk(
await jsonRequest<BlueprintDetail>(page, `/api/blueprints/${blueprintId}`),
'reading the drifted row',
).deployments.find((row) => row.status === 'drifted');
expect(drifted?.drift_summary, 'a drifted row carries why it drifted').toBeTruthy();
expect(
containerImageReference(blueprintName, 'web'),
'the moved image is what is running until the repair lands',
).not.toContain(`nginx@${pinned}`);
// Switch the policy on. The edit clears the approval, because the plan the
// operator confirmed described a different policy, so the plan is confirmed
// again against the same intent.
const switched = await jsonRequest<{ error?: string }>(page, `/api/blueprints/${blueprintId}`, {
method: 'PUT',
body: { drift_mode: 'enforce' },
});
expect(switched.status, `switching to enforce: ${JSON.stringify(switched.body)}`).toBe(200);
await confirmPlan(page, blueprintId);
// The row was already drifted when Enforce started, so the repair runs on a
// later pass than the one that recorded the drift. Either the confirm
// executes it or the next reconciler tick does; the poll covers both.
//
// What this covers is the cycle and the reconvergence, not the settle of a
// repair that could not complete: here the repair succeeds, so the row is
// never stranded in `correcting` across ticks. That path needs a refusal
// arranged on purpose, and it is pinned deterministically in
// `backend/src/__tests__/blueprints-inline-drift-repair-retry.test.ts`.
await expect
.poll(
async () => {
const detail = expectOk(
await jsonRequest<BlueprintDetail>(page, `/api/blueprints/${blueprintId}`),
'reading the blueprint',
);
const row = detail.deployments[0];
return row?.status === 'active' && row.drift_summary === null;
},
{ timeout: 180_000, intervals: [2_000] },
)
.toBe(true);
// The repair restored the approved identity rather than adopting whatever
// the tag points at now, which is the whole point of a digest-pinned repair.
const repaired = containerImageReference(blueprintName, 'web');
expect(
repaired,
'the repaired workload must run the approved image identity',
).toContain(`nginx@${pinned}`);
const after = expectOk(
await jsonRequest<DriftPayload>(page, `/api/stacks/${blueprintName}/drift`),
'reading drift after the repair',
);
expect(
after.gitopsRevision?.targets?.[0]?.artifact?.expected?.services?.[0]?.platformDigest,
'the expectation never moved to the running image',
).toBe(pinned);
});
test.afterEach(async ({ page }) => {
if (blueprintId === null) return;
const id = blueprintId;
// Asserted rather than swallowed, and the id is only cleared once the delete
// is confirmed, so a failed teardown cannot quietly leave a live stack and a
// reconciler row behind for every spec that runs after this one.
const deleted = await jsonRequest<unknown>(page, `/api/blueprints/${id}`, { method: 'DELETE' });
expect([200, 204], `deleting the blueprint: ${JSON.stringify(deleted.body)}`).toContain(
deleted.status,
);
blueprintId = null;
// The API reports success even when Compose teardown did not, so the
// containers are checked directly rather than trusting the status alone.
const remaining = execFileSync(
'docker',
[
'ps', '-a',
'--filter', `label=com.docker.compose.project=${blueprintName.toLowerCase()}`,
'--format', '{{.Names}}',
],
{ timeout: DOCKER_TIMEOUT_MS, stdio: 'pipe' },
)
.toString()
.trim();
expect(remaining, 'the deleted blueprint left containers behind').toBe('');
});
});