diff --git a/frontend/tests/admin.spec.ts b/frontend/tests/admin.spec.ts index 490810e..192841e 100644 --- a/frontend/tests/admin.spec.ts +++ b/frontend/tests/admin.spec.ts @@ -1,9 +1,24 @@ import { expect, test } from "@playwright/test" import { firstSuperuser, firstSuperuserPassword } from "./config.ts" +import { api, apiPage } from "./utils/api" import { createUser } from "./utils/privateApi" import { randomEmail, randomPassword } from "./utils/random" import { logInUser } from "./utils/user" +test.afterAll(async ({ browser }) => { + // Every user below is created under a randomEmail(), so the superuser can + // sweep them all up by that shape — including what a run that failed halfway + // left in the shared development database. + const page = await apiPage(browser) + const { data } = await (await api(page, "/users/?limit=1000")).json() + for (const user of data as { id: string; email: string }[]) { + if (/^test_.*@example\.com$/.test(user.email)) { + await api(page, `/users/${user.id}`, { method: "DELETE" }) + } + } + await page.close() +}) + test("Admin page is accessible and shows correct title", async ({ page }) => { await page.goto("/admin") await expect(page.getByRole("heading", { name: "Users" })).toBeVisible() diff --git a/frontend/tests/drafts.spec.ts b/frontend/tests/drafts.spec.ts index 8c64565..a9e7bea 100644 --- a/frontend/tests/drafts.spec.ts +++ b/frontend/tests/drafts.spec.ts @@ -1,4 +1,5 @@ -import { expect, type Page, test } from "@playwright/test" +import { expect, test } from "@playwright/test" +import { api, deleteAll } from "./utils/api" /** * Editing writes a draft; only publishing hands it to the engine. The two @@ -12,19 +13,9 @@ test.use({ storageState: "playwright/.auth/user.json" }) test.describe.configure({ mode: "serial" }) -const apiUrl = process.env.VITE_API_URL || "http://api.localhost" - -async function api( - page: Page, - path: string, - init: Record = {}, -) { - const token = await page.evaluate(() => localStorage.getItem("access_token")) - return page.request.fetch(`${apiUrl}/api/v1${path}`, { - ...init, - headers: { Authorization: `Bearer ${token}` }, - }) -} +test.afterAll(async ({ browser }) => { + await deleteAll(browser, [`/flows/${flowName}`]) +}) test("a draft stays off the engine until it is published", async ({ page }) => { await page.goto("/flows") @@ -93,6 +84,4 @@ test("discarding a draft goes back to the published flow", async ({ page }) => { const detail = await (await api(page, `/flows/${flowName}`)).json() expect(detail.has_draft).toBe(false) expect(detail.definition.title).not.toBe("Theirs") - - await api(page, `/flows/${flowName}`, { method: "DELETE" }) }) diff --git a/frontend/tests/endpoints.spec.ts b/frontend/tests/endpoints.spec.ts index db7250d..32b2de8 100644 --- a/frontend/tests/endpoints.spec.ts +++ b/frontend/tests/endpoints.spec.ts @@ -1,4 +1,5 @@ -import { expect, type Page, test } from "@playwright/test" +import { expect, test } from "@playwright/test" +import { api, apiPage, deleteAll } from "./utils/api" /** * Dashboards and other flows appear on the canvas but are not part of the flow. @@ -16,25 +17,8 @@ test.use({ storageState: "playwright/.auth/user.json" }) test.describe.configure({ mode: "serial" }) -const apiUrl = process.env.VITE_API_URL || "http://api.localhost" - -async function api( - page: Page, - path: string, - init: Record = {}, -) { - const token = await page.evaluate(() => localStorage.getItem("access_token")) - return page.request.fetch(`${apiUrl}/api/v1${path}`, { - ...init, - headers: { Authorization: `Bearer ${token}` }, - }) -} - test.beforeAll(async ({ browser }) => { - const page = await browser.newPage({ - storageState: "playwright/.auth/user.json", - }) - await page.goto("/") + const page = await apiPage(browser) // A flow reading a message, and a dashboard control that sets it. await api(page, `/flows/${flowName}`, { @@ -82,13 +66,10 @@ test.beforeAll(async ({ browser }) => { }) test.afterAll(async ({ browser }) => { - const page = await browser.newPage({ - storageState: "playwright/.auth/user.json", - }) - await page.goto("/") - await api(page, `/dashboards/${dashboardName}`, { method: "DELETE" }) - await api(page, `/flows/${flowName}`, { method: "DELETE" }) - await page.close() + await deleteAll(browser, [ + `/dashboards/${dashboardName}`, + `/flows/${flowName}`, + ]) }) test("a dashboard control is drawn on the flow it feeds", async ({ page }) => { diff --git a/frontend/tests/flows.spec.ts b/frontend/tests/flows.spec.ts index 333e080..e6a5a68 100644 --- a/frontend/tests/flows.spec.ts +++ b/frontend/tests/flows.spec.ts @@ -1,4 +1,5 @@ import { expect, type Page, test } from "@playwright/test" +import { deleteAll } from "./utils/api" /** * The flow editor's load-bearing behaviour: nodes are placed, connections come @@ -12,6 +13,10 @@ test.use({ storageState: "playwright/.auth/user.json" }) test.describe.configure({ mode: "serial" }) +test.afterAll(async ({ browser }) => { + await deleteAll(browser, [`/flows/${flowName}`]) +}) + const apiUrl = process.env.VITE_API_URL || "http://api.localhost" /** Write a node's source through the API; typing code is not what we test. */ diff --git a/frontend/tests/runtime.spec.ts b/frontend/tests/runtime.spec.ts index ae12b5c..25b5006 100644 --- a/frontend/tests/runtime.spec.ts +++ b/frontend/tests/runtime.spec.ts @@ -1,4 +1,5 @@ -import { expect, type Page, test } from "@playwright/test" +import { expect, test } from "@playwright/test" +import { api, apiPage, deleteAll } from "./utils/api" /** * Flows can be taken off the engine and put back, and what a node prints — or @@ -11,8 +12,6 @@ test.use({ storageState: "playwright/.auth/user.json" }) test.describe.configure({ mode: "serial" }) -const apiUrl = process.env.VITE_API_URL || "http://api.localhost" - const PRINTING_NODE = `def process(params): print("sensor read 21.5 degrees") return {"reading": 21.5} @@ -21,23 +20,12 @@ const BROKEN_NODE = `def process(reading, params): raise RuntimeError("downstream blew up") ` -async function api( - page: Page, - path: string, - init: Record = {}, -) { - const token = await page.evaluate(() => localStorage.getItem("access_token")) - return page.request.fetch(`${apiUrl}/api/v1${path}`, { - ...init, - headers: { Authorization: `Bearer ${token}` }, - }) -} +test.afterAll(async ({ browser }) => { + await deleteAll(browser, [`/flows/${flowName}`]) +}) test.beforeAll(async ({ browser }) => { - const page = await browser.newPage({ - storageState: "playwright/.auth/user.json", - }) - await page.goto("/") + const page = await apiPage(browser) await api(page, `/flows/${flowName}`, { method: "PUT", data: { @@ -123,6 +111,4 @@ test("a flow can be paused and let go again", async ({ page }) => { await page.getByTestId("resume-flow").click() await expect(page.getByTestId("pause-flow")).toBeVisible() - - await api(page, `/flows/${flowName}`, { method: "DELETE" }) }) diff --git a/frontend/tests/utils/api.ts b/frontend/tests/utils/api.ts new file mode 100644 index 0000000..deca937 --- /dev/null +++ b/frontend/tests/utils/api.ts @@ -0,0 +1,41 @@ +import type { Browser, Page } from "@playwright/test" + +/** + * Talking to the API directly, for the setup and teardown around a spec. + * + * The specs run against a development stack, so whatever they create has to go + * again — the same bargain the backend suite strikes with its throwaway + * database. What a spec leaves behind is in someone's flow list tomorrow. + */ + +const authFile = "playwright/.auth/user.json" +const apiUrl = process.env.VITE_API_URL || "http://api.localhost" + +/** Call the API as the logged-in user of *page*. */ +export async function api( + page: Page, + path: string, + init: Record = {}, +) { + const token = await page.evaluate(() => localStorage.getItem("access_token")) + return page.request.fetch(`${apiUrl}/api/v1${path}`, { + ...init, + headers: { Authorization: `Bearer ${token}` }, + }) +} + +/** An authenticated page for a `beforeAll`/`afterAll`, which get no `page`. */ +export async function apiPage(browser: Browser) { + const page = await browser.newPage({ storageState: authFile }) + await page.goto("/") + return page +} + +/** Delete the given API paths, whether or not they are still there. */ +export async function deleteAll(browser: Browser, paths: string[]) { + const page = await apiPage(browser) + for (const path of paths) { + await api(page, path, { method: "DELETE" }) + } + await page.close() +}