Sweep stale e2e fixtures before a run, not after one

An aborted Playwright run never reaches `afterAll`, so its flows and
dashboards stayed in the store until somebody deleted them by hand. A
setup-project test now removes them before the next run, which is the
only point that is reached even when the run before it died.

The suite runs against whatever stack is up — somebody's own instance
with real flows in it — so the predicate matches only the shape every
fixture has, `test_<spec>_<base36 Date.now()>` with an optional suffix,
and only once that timestamp is an hour old.

Also correct the coverage-html fallback hint: `make clean` cannot clear
a root-owned htmlcov either, for the same reason `coverage html` cannot
write it.
This commit is contained in:
2026-09-14 16:27:36 +02:00
parent a1141fbd6a
commit d4f0345e97
2 changed files with 65 additions and 2 deletions
+4 -2
View File
@@ -6,6 +6,8 @@ set -x
coverage run -m pytest tests/
coverage report
# The HTML report is a convenience: a container run can leave htmlcov/ owned by
# root, and failing here after a green suite reads like a test failure.
# root, and failing here after a green suite reads like a test failure. `make
# clean` is no way out of that one — removing the files inside a directory
# needs write permission on it, which is the permission that is missing.
coverage html --title "${@-coverage}" \
|| echo " ! coverage html failed (htmlcov/ not writable? try 'make clean') — tests passed" >&2
|| echo " ! coverage html failed — tests passed (htmlcov/ root-owned? sudo rm -rf htmlcov)" >&2
+61
View File
@@ -0,0 +1,61 @@
import { expect, test as setup } from "@playwright/test"
import { apiUrl, firstSuperuser, firstSuperuserPassword } from "./config.ts"
/**
* Remove what an aborted run left behind.
*
* Every spec deletes its own flows and dashboards in `afterAll`, which never
* fires when a run is interrupted, and nothing else looks for them — so they
* sit in the store until somebody removes them by hand. Sweeping before a run
* rather than after one is what makes this work at all: it happens even when
* the run that made the mess died.
*
* The suite runs against whatever stack is up, which is somebody's own
* instance with real flows in it, so the predicate has to be incapable of
* matching one. A name qualifies only if it has the shape every fixture has —
* `test_<spec>_<base36 Date.now()>`, plus an optional `_<suffix>` for a second
* artefact of the same spec — and only if the timestamp it carries is an hour
* old, which nothing the run now starting is about to create can be.
*/
const FIXTURE = /^test_[a-z]+_([0-9a-z]{8})(?:_[a-z]+)?$/
const MIN_AGE_MS = 60 * 60 * 1000
function isStaleFixture(name: string): boolean {
const match = FIXTURE.exec(name)
return (
match !== null && Number.parseInt(match[1], 36) < Date.now() - MIN_AGE_MS
)
}
setup("sweep stale fixtures", async ({ request }) => {
const login = await request.post(`${apiUrl}/api/v1/login/access-token`, {
form: { username: firstSuperuser, password: firstSuperuserPassword },
})
expect(login.ok(), `sweep could not log in: ${login.status()}`).toBeTruthy()
const { access_token } = await login.json()
const headers = { Authorization: `Bearer ${access_token}` }
for (const kind of ["dashboards", "flows"]) {
const listed = await request.get(`${apiUrl}/api/v1/${kind}/`, { headers })
expect(
listed.ok(),
`sweep could not list ${kind}: ${listed.status()}`,
).toBeTruthy()
const { data } = await listed.json()
for (const { name } of data) {
if (!isStaleFixture(name)) continue
const gone = await request.delete(`${apiUrl}/api/v1/${kind}/${name}`, {
headers,
})
// Best effort. One that will not go — a flow with a run still on it,
// say — is left for the next sweep rather than failing a suite it has
// nothing to do with.
console.log(
gone.ok()
? `swept ${kind}/${name}`
: `left ${kind}/${name} (${gone.status()})`,
)
}
}
})