diff --git a/backend/scripts/test.sh b/backend/scripts/test.sh index b080e38..b349f76 100755 --- a/backend/scripts/test.sh +++ b/backend/scripts/test.sh @@ -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 diff --git a/frontend/tests/sweep.setup.ts b/frontend/tests/sweep.setup.ts new file mode 100644 index 0000000..d142452 --- /dev/null +++ b/frontend/tests/sweep.setup.ts @@ -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__`, plus an optional `_` 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()})`, + ) + } + } +})