From a0f4d335df030c2fada43e5f1a9da5cb48e85aa2 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Tue, 27 Oct 2026 13:45:11 -0700 Subject: [PATCH 1/3] test(e2e): fix tests that fail when the whole suite runs on one server CI splits the Node suite over 8 shards, each with its own server, so these only fail in a full run against a single server: - The sandbox hook-event test listed the test plugin's events with limit 100; a full run records more. The plugin's events-list route now accepts `ids` for a direct lookup. - The sandbox user-list check required exactly one user, but the passkey setup test adds a second. - In a crowded root folder the dragged media row sat at the viewport's bottom edge, where dnd-kit auto-scrolls and the drop target moves. pointerDrag now scrolls the source to the centre and re-measures the target during the drag. Co-Authored-By: Claude Opus 5.5 --- e2e/tests/media-library.spec.ts | 31 ++++++++++++------- .../sandbox-plugin-admin-surfaces.spec.ts | 8 ++--- e2e/tests/sandbox-plugin-boundaries.spec.ts | 4 ++- .../plugins/marketplace-test/src/plugin.ts | 20 +++++++++--- 4 files changed, 43 insertions(+), 20 deletions(-) diff --git a/e2e/tests/media-library.spec.ts b/e2e/tests/media-library.spec.ts index efac36b7ef..5512d85b42 100644 --- a/e2e/tests/media-library.spec.ts +++ b/e2e/tests/media-library.spec.ts @@ -146,24 +146,33 @@ async function createFolder(page: Page, name: string) { await expect(dialog).not.toBeVisible(); } +async function boxCenter(locator: Locator) { + const box = await locator.boundingBox(); + if (!box) throw new Error("Drag source or target is not visible"); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} + async function pointerDrag(page: Page, source: Locator, target: Locator) { - const sourceBox = await source.boundingBox(); - const targetBox = await target.boundingBox(); - if (!sourceBox || !targetBox) throw new Error("Drag source or target is not visible"); - const sourcePoint = { - x: sourceBox.x + sourceBox.width / 2, - y: sourceBox.y + sourceBox.height / 2, - }; - const targetPoint = { - x: targetBox.x + targetBox.width / 2, - y: targetBox.y + targetBox.height / 2, - }; + // Starting a drag near the viewport edge triggers the drag library's + // auto-scroll, which moves the target away from coordinates measured earlier. + await source.evaluate((element) => element.scrollIntoView({ block: "center" })); + const sourcePoint = await boxCenter(source); + let targetPoint = await boxCenter(target); await page.mouse.move(sourcePoint.x, sourcePoint.y); await page.mouse.down(); await page.mouse.move(sourcePoint.x + 12, sourcePoint.y, { steps: 2 }); await page.mouse.move(targetPoint.x, targetPoint.y, { steps: 8 }); const overlayCard = page.locator("[data-media-drag-overlay] > *"); await overlayCard.waitFor(); + await expect + .poll(async () => { + const current = await boxCenter(target); + const moved = Math.hypot(current.x - targetPoint.x, current.y - targetPoint.y) > 1; + targetPoint = current; + if (moved) await page.mouse.move(current.x, current.y, { steps: 2 }); + return moved; + }) + .toBe(false); const overlayBox = await overlayCard.boundingBox(); if (!overlayBox) throw new Error("Drag overlay is not visible"); expect(Math.abs(overlayBox.x + overlayBox.width / 2 - targetPoint.x)).toBeLessThanOrEqual(4); diff --git a/e2e/tests/sandbox-plugin-admin-surfaces.spec.ts b/e2e/tests/sandbox-plugin-admin-surfaces.spec.ts index 2030cbcba6..b1bca86a89 100644 --- a/e2e/tests/sandbox-plugin-admin-surfaces.spec.ts +++ b/e2e/tests/sandbox-plugin-admin-surfaces.spec.ts @@ -106,18 +106,18 @@ test.describe("Sandboxed plugin admin surfaces", () => { const response = await page.request.post("/_emdash/api/plugins/marketplace-test/events-list", { headers: { "X-EmDash-Request": "1" }, - data: {}, + data: { ids: [contentId, `action:publish:${contentId}`] }, }); const body = (await response.json()) as { data: { events: { items: Array<{ id: string; data: Record }> } }; }; expect(body.data.events.items).toEqual( expect.arrayContaining([ - expect.objectContaining({ id: contentId, data: { type: "saved", collection: "posts" } }), - expect.objectContaining({ + { id: contentId, data: { type: "saved", collection: "posts" } }, + { id: `action:publish:${contentId}`, data: { type: "content-action", action: "publish", contentId }, - }), + }, ]), ); }); diff --git a/e2e/tests/sandbox-plugin-boundaries.spec.ts b/e2e/tests/sandbox-plugin-boundaries.spec.ts index 5291b2c095..d289d1ffa4 100644 --- a/e2e/tests/sandbox-plugin-boundaries.spec.ts +++ b/e2e/tests/sandbox-plugin-boundaries.spec.ts @@ -59,7 +59,9 @@ test.describe("Sandboxed plugin production boundaries", () => { posts: { slug: "posts" }, }); await expect(invoke("users-exercise")).resolves.toMatchObject({ - page: { items: [expect.objectContaining({ email: "dev@emdash.local" })] }, + page: { + items: expect.arrayContaining([expect.objectContaining({ email: "dev@emdash.local" })]), + }, }); await expect(invoke("cron-exercise")).resolves.toMatchObject({ scheduled: [expect.objectContaining({ name: "diagnostic" })], diff --git a/packages/plugins/marketplace-test/src/plugin.ts b/packages/plugins/marketplace-test/src/plugin.ts index d5d60cca74..9023cb6b05 100644 --- a/packages/plugins/marketplace-test/src/plugin.ts +++ b/packages/plugins/marketplace-test/src/plugin.ts @@ -1207,10 +1207,22 @@ const plugin: SandboxedPlugin = { }, }, "events-list": { - handler: async (_route, ctx) => ({ - events: await ctx.storage.events.query({ limit: 100 }), - lifecycle: await ctx.storage.lifecycle.query({ limit: 100 }), - }), + handler: async (route, ctx) => { + const ids = + typeof route.input === "object" && route.input !== null && "ids" in route.input + ? route.input.ids + : undefined; + if (Array.isArray(ids) && ids.every((id) => typeof id === "string")) { + const found = await ctx.storage.events.getMany(ids); + return { + events: { items: Array.from(found, ([id, data]) => ({ id, data })) }, + }; + } + return { + events: await ctx.storage.events.query({ limit: 100 }), + lifecycle: await ctx.storage.lifecycle.query({ limit: 100 }), + }; + }, }, "private-user": { permission: "content:edit_any", From 5bed71772890f0bbe5205ed7672e743922d3875f Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Mon, 26 Oct 2026 21:46:26 -0700 Subject: [PATCH 2/3] test(e2e): run the Node suite on Endform against the production build Under Endform (ENDFORM=true) Playwright starts e2e/endform-server.ts as its webServer on the CLI machine. It runs the normal global setup with EMDASH_E2E_BUILD=production and serves the setup state on 127.0.0.1:4446, which an auto fixture fetches on each runner. Endform proxies loopback traffic from its runners back to the CLI machine, so every test runs on its own runner against the one built server. A gzip proxy in front of the built server compresses /_astro/ assets once, since each runner starts with an empty cache and downloads the admin bundle through the tunnel. About 25 tests that assumed they ran alone against one database now create their own data. Specs that reset the whole site run in a separate exclusive project, and tests that wipe every comment or toggle search for a collection are limited by tag. CI runs this as one job in .github/workflows/endform-e2e.yml. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/endform-e2e.yml | 48 +++++++ e2e/endform-server.ts | 70 +++++++++++ e2e/fixtures/admin.ts | 12 +- e2e/fixtures/compression-proxy.ts | 84 +++++++++++++ e2e/fixtures/content.ts | 36 ++++++ e2e/fixtures/index.ts | 18 ++- e2e/fixtures/refresh-server-pat.ts | 10 ++ e2e/global-setup.ts | 8 +- e2e/tests/accessibility.spec.ts | 4 + e2e/tests/admin-fixes.spec.ts | 3 +- e2e/tests/allowed-domains.spec.ts | 28 +++-- e2e/tests/bylines.spec.ts | 6 +- e2e/tests/comments.spec.ts | 2 +- e2e/tests/content-actions.spec.ts | 17 +-- e2e/tests/content-crud.spec.ts | 28 +++-- e2e/tests/content-types.spec.ts | 68 +++++++--- e2e/tests/i18n.spec.ts | 11 +- e2e/tests/media-library.spec.ts | 23 ++-- e2e/tests/menus.spec.ts | 10 +- e2e/tests/plugin-editor-extensions.spec.ts | 9 +- e2e/tests/redirect-loop-detection.spec.ts | 41 +++--- e2e/tests/redirects.spec.ts | 117 +++++++++-------- e2e/tests/registry-fixture-block-kit.spec.ts | 3 +- .../sandbox-plugin-admin-surfaces.spec.ts | 119 +++++++++--------- e2e/tests/search.spec.ts | 41 +++--- e2e/tests/visual-editing.spec.ts | 74 +++++++++-- e2e/tests/widgets.spec.ts | 21 ++-- endform.config.ts | 18 +++ package.json | 2 + .../plugins/marketplace-test/src/plugin.ts | 14 +++ playwright.config.ts | 60 ++++++--- pnpm-lock.yaml | 70 ++++++++++- 32 files changed, 814 insertions(+), 261 deletions(-) create mode 100644 .github/workflows/endform-e2e.yml create mode 100644 e2e/endform-server.ts create mode 100644 e2e/fixtures/compression-proxy.ts create mode 100644 e2e/fixtures/content.ts create mode 100644 endform.config.ts diff --git a/.github/workflows/endform-e2e.yml b/.github/workflows/endform-e2e.yml new file mode 100644 index 0000000000..e0de880b8e --- /dev/null +++ b/.github/workflows/endform-e2e.yml @@ -0,0 +1,48 @@ +name: Endform E2E + +on: + pull_request: + workflow_dispatch: + inputs: + concurrency: + description: Maximum tests running at once + default: "64" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + node: + name: Node E2E on Endform (production build) + runs-on: ubuntu-latest + timeout-minutes: 40 + permissions: + contents: read + id-token: write + env: + EMDASH_ENDFORM_CONCURRENCY: ${{ inputs.concurrency || '64' }} + ENDFORM_GIT_BRANCH: ${{ github.head_ref || github.ref_name }} + ENDFORM_GIT_COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Build fixture dependencies + run: pnpm run --filter "emdash-e2e-fixture..." build + - name: Run parallel E2E on Endform + id: parallel + run: pnpm exec endform test --organization-id jOXdcdjD --project chromium + - name: Run exclusive E2E on Endform + id: exclusive + if: ${{ !cancelled() }} + run: pnpm exec endform test --organization-id jOXdcdjD --project exclusive diff --git a/e2e/endform-server.ts b/e2e/endform-server.ts new file mode 100644 index 0000000000..d55501cc63 --- /dev/null +++ b/e2e/endform-server.ts @@ -0,0 +1,70 @@ +/** + * Long-running fixture server for Endform runs. + * + * Endform runs each test on a remote machine, so Playwright's globalSetup + * can't hand server info to tests through a local temp file. This process + * runs the normal global setup, then serves the server info on port 4446. + * Endform proxies loopback traffic from the runners back to this machine, + * where the `serverInfo` fixture fetches it. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import globalSetup from "./global-setup.js"; +import globalTeardown from "./global-teardown.js"; + +const SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json"); +const PORT = 4446; + +await globalSetup(); +// Endform only echoes a webServer's stderr. +console.error( + `[pw] Fixture ready (${process.env.EMDASH_E2E_BUILD === "production" ? "production build" : "astro dev"})`, +); + +const server = createServer(async (request, response) => { + if (request.url === "/ready" && request.method === "GET") { + response.end("ready"); + return; + } + if (request.url !== "/server-info") { + response.writeHead(404).end(); + return; + } + if (request.method === "GET") { + response.setHeader("Content-Type", "application/json"); + response.end(readFileSync(SERVER_INFO_PATH)); + return; + } + if (request.method === "POST") { + let body = ""; + for await (const chunk of request) body += chunk; + const update: { token?: unknown } = JSON.parse(body); + if (typeof update.token !== "string") { + response.writeHead(400).end(); + return; + } + const info = JSON.parse(readFileSync(SERVER_INFO_PATH, "utf8")); + info.token = update.token; + writeFileSync(SERVER_INFO_PATH, JSON.stringify(info, null, 2)); + response.end(); + return; + } + response.writeHead(405).end(); +}); + +server.listen(PORT, "127.0.0.1"); + +let stopping = false; +async function stop() { + if (stopping) return; + stopping = true; + server.close(); + await globalTeardown(); + process.exit(0); +} + +process.on("SIGINT", stop); +process.on("SIGTERM", stop); diff --git a/e2e/fixtures/admin.ts b/e2e/fixtures/admin.ts index 32f7bc2475..edaa4abaa2 100644 --- a/e2e/fixtures/admin.ts +++ b/e2e/fixtures/admin.ts @@ -441,19 +441,15 @@ export class AdminPage { // Click add link button await this.page.getByRole("button", { name: "Add Custom Link" }).first().click(); - // Wait for dialog to appear - await this.page.waitForSelector('[role="dialog"]', { state: "visible", timeout: 5000 }); - - // Fill form (scope to dialog to avoid ambiguity) - const dialog = this.page.locator('[role="dialog"]'); + // Toasts also have the dialog role, so pick the form's dialog by its field. + const dialog = this.page.getByRole("dialog").filter({ has: this.page.getByLabel("URL") }); + await expect(dialog).toBeVisible({ timeout: 5000 }); await dialog.getByLabel("Label").fill(label); await dialog.getByLabel("URL").fill(url); // Submit await dialog.getByRole("button", { name: "Add" }).click(); - - // Wait for dialog to close - await this.page.waitForSelector('[role="dialog"]', { state: "hidden" }); + await expect(dialog).toBeHidden(); } /** diff --git a/e2e/fixtures/compression-proxy.ts b/e2e/fixtures/compression-proxy.ts new file mode 100644 index 0000000000..99ea5e0552 --- /dev/null +++ b/e2e/fixtures/compression-proxy.ts @@ -0,0 +1,84 @@ +/** + * Gzip reverse proxy for the production-build fixture. + * + * Astro's standalone Node server sends responses uncompressed, while real + * deployments sit behind a CDN or proxy that compresses them. On Endform every + * test downloads the admin bundle through the CLI machine, so compressing it + * cuts the bytes on that path by about four times. Only built assets are + * compressed, so pages and API responses reach tests exactly as the server + * sent them. + */ +import { createServer, request, type OutgoingHttpHeaders, type Server } from "node:http"; +import { createGzip } from "node:zlib"; + +const BUILT_ASSET_PATH = /^\/_astro\//; +const COMPRESSIBLE_TYPE = /^(text\/|application\/(javascript|json|xml)|image\/svg\+xml)/; +const ACCEPTS_GZIP = /\bgzip\b/; + +interface CompressedAsset { + headers: OutgoingHttpHeaders; + body: Buffer; +} + +export function startCompressionProxy(listenPort: number, targetPort: number): Promise { + // Built assets are content-hashed and never change while the server runs, + // so each one is compressed once instead of on every request. + const compressedAssets = new Map(); + + const server = createServer((clientRequest, clientResponse) => { + const url = clientRequest.url ?? ""; + const wantsGzip = + clientRequest.method === "GET" && + BUILT_ASSET_PATH.test(url) && + ACCEPTS_GZIP.test(String(clientRequest.headers["accept-encoding"] ?? "")); + const cached = wantsGzip ? compressedAssets.get(url) : undefined; + if (cached) { + clientResponse.writeHead(200, cached.headers); + clientResponse.end(cached.body); + return; + } + + const upstream = request( + { + host: "127.0.0.1", + port: targetPort, + method: clientRequest.method, + path: url, + headers: clientRequest.headers, + }, + (upstreamResponse) => { + const status = upstreamResponse.statusCode ?? 502; + const headers: OutgoingHttpHeaders = { ...upstreamResponse.headers }; + const compress = + wantsGzip && + !headers["content-encoding"] && + COMPRESSIBLE_TYPE.test(String(headers["content-type"] ?? "")); + if (!compress) { + clientResponse.writeHead(status, headers); + upstreamResponse.pipe(clientResponse); + return; + } + delete headers["content-length"]; + headers["content-encoding"] = "gzip"; + headers.vary = headers.vary ? `${headers.vary}, Accept-Encoding` : "Accept-Encoding"; + clientResponse.writeHead(status, headers); + const gzip = createGzip(); + const chunks: Buffer[] = []; + gzip.on("data", (chunk: Buffer) => chunks.push(chunk)); + gzip.on("end", () => { + if (status === 200) compressedAssets.set(url, { headers, body: Buffer.concat(chunks) }); + }); + upstreamResponse.pipe(gzip).pipe(clientResponse); + }, + ); + upstream.on("error", () => { + if (!clientResponse.headersSent) clientResponse.writeHead(502); + clientResponse.end(); + }); + clientRequest.pipe(upstream); + }); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(listenPort, "localhost", () => resolve(server)); + }); +} diff --git a/e2e/fixtures/content.ts b/e2e/fixtures/content.ts new file mode 100644 index 0000000000..19e64220e5 --- /dev/null +++ b/e2e/fixtures/content.ts @@ -0,0 +1,36 @@ +import type { ServerInfo } from "./index"; + +/** + * Create a draft post through the API and return its id. Tests that edit a + * post use one of their own, since other tests run against the same site. + */ +export async function createPost(serverInfo: ServerInfo, title: string): Promise { + const response = await fetch(`${serverInfo.baseUrl}/_emdash/api/content/posts`, { + method: "POST", + headers: { + Authorization: `Bearer ${serverInfo.token}`, + "Content-Type": "application/json", + Origin: serverInfo.baseUrl, + "X-EmDash-Request": "1", + }, + body: JSON.stringify({ + data: { + title, + body: [ + { + _type: "block", + _key: "intro", + style: "normal", + children: [{ _type: "span", _key: "intro-text", text: "Post body." }], + markDefs: [], + }, + ], + }, + }), + }); + if (!response.ok) throw new Error(`Creating post failed: ${response.status}`); + const body: { data?: { item?: { id?: unknown } } } = await response.json(); + const id = body.data?.item?.id; + if (typeof id !== "string") throw new Error("Creating post returned no id"); + return id; +} diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts index a17c004a49..d461384f4d 100644 --- a/e2e/fixtures/index.ts +++ b/e2e/fixtures/index.ts @@ -6,7 +6,7 @@ * the AdminPage helper and server context to each test. */ -import { readFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -17,6 +17,7 @@ import { AdminPage } from "./admin"; export { AdminPage } from "./admin"; const SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json"); +export const ENDFORM_SERVER_INFO_URL = "http://127.0.0.1:4446/server-info"; export interface ServerInfo { pid: number; @@ -40,7 +41,22 @@ function getServerInfo(): ServerInfo { export const test = base.extend<{ admin: AdminPage; serverInfo: ServerInfo; + endformServerInfo: void; }>({ + // On Endform the server runs on the CLI machine (see e2e/endform-server.ts); + // copy its server info into this runner's temp file before each test. + endformServerInfo: [ + // oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture dependencies + async ({}, use) => { + if (process.env.ENDFORM === "true") { + const response = await fetch(ENDFORM_SERVER_INFO_URL); + if (!response.ok) throw new Error(`Server info unavailable (${response.status})`); + writeFileSync(SERVER_INFO_PATH, await response.text()); + } + await use(); + }, + { auto: true }, + ], // eslint-disable-next-line no-empty-pattern serverInfo: async ({}, use) => { await use(getServerInfo()); diff --git a/e2e/fixtures/refresh-server-pat.ts b/e2e/fixtures/refresh-server-pat.ts index 8fb56d026d..b179726268 100644 --- a/e2e/fixtures/refresh-server-pat.ts +++ b/e2e/fixtures/refresh-server-pat.ts @@ -6,6 +6,8 @@ import { readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { ENDFORM_SERVER_INFO_URL } from "./index"; + const SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json"); export async function refreshServerPatAfterDevBypass(baseUrl: string): Promise { @@ -21,4 +23,12 @@ export async function refreshServerPatAfterDevBypass(baseUrl: string): Promise { cwd: workDir, env: { ...fixtureEnv, - HOST: "localhost", - PORT: String(PORT), + HOST: "127.0.0.1", + PORT: String(PRODUCTION_SERVER_PORT), }, stdio: "pipe", }); + const { startCompressionProxy } = await import("./fixtures/compression-proxy.js"); + await startCompressionProxy(PORT, PRODUCTION_SERVER_PORT); } else { server = spawn(astroBin, ["dev", "--port", String(PORT)], { cwd: workDir, diff --git a/e2e/tests/accessibility.spec.ts b/e2e/tests/accessibility.spec.ts index 97654b1601..9f5386d3ca 100644 --- a/e2e/tests/accessibility.spec.ts +++ b/e2e/tests/accessibility.spec.ts @@ -86,6 +86,10 @@ test.describe("Accessibility Audit", () => { const metricCards = admin.page.getByTestId("dashboard-metric"); await expect.poll(() => metricCards.count()).toBeGreaterThanOrEqual(3); + // Metric values load after their cards render. + for (const value of await admin.page.getByTestId("dashboard-metric-value").all()) { + await expect(value).not.toBeEmpty(); + } const layout = await admin.page.locator("main").evaluate((main) => { const textStart = (element: Element) => { diff --git a/e2e/tests/admin-fixes.spec.ts b/e2e/tests/admin-fixes.spec.ts index 8376343b2f..94e11f71b7 100644 --- a/e2e/tests/admin-fixes.spec.ts +++ b/e2e/tests/admin-fixes.spec.ts @@ -12,6 +12,7 @@ import { writeFileSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { Page } from "@playwright/test"; @@ -29,7 +30,7 @@ const UPLOAD_BUTTON_PATTERN = /Upload/; const BROWSE_FILES_LABEL = "Browse files to upload"; // ---------- helpers ---------- -const TEST_ASSETS_DIR = join(process.cwd(), "e2e/fixtures/assets"); +const TEST_ASSETS_DIR = fileURLToPath(new URL("../fixtures/assets", import.meta.url)); function ensureTestImage(): string { if (!existsSync(TEST_ASSETS_DIR)) mkdirSync(TEST_ASSETS_DIR, { recursive: true }); diff --git a/e2e/tests/allowed-domains.spec.ts b/e2e/tests/allowed-domains.spec.ts index 57ec496101..98841b4449 100644 --- a/e2e/tests/allowed-domains.spec.ts +++ b/e2e/tests/allowed-domains.spec.ts @@ -22,25 +22,23 @@ function apiHeaders(token: string, baseUrl: string) { test.describe("Allowed Domains Settings", () => { let headers: Record; let baseUrl: string; + // Domains this test created. Other tests run at the same time, so cleanup + // removes only these. + let createdDomains: string[]; test.beforeEach(async ({ admin, serverInfo }) => { await admin.devBypassAuth(); baseUrl = serverInfo.baseUrl; headers = apiHeaders(serverInfo.token, baseUrl); + createdDomains = []; + }); - // Clean up any leftover test domains from previous runs - const res = await fetch(`${baseUrl}/_emdash/api/admin/allowed-domains`, { headers }); - if (res.ok) { - const data: any = await res.json(); - const domains = data.data?.domains ?? []; - for (const d of domains) { - if (d.domain.includes("e2e-test")) { - await fetch( - `${baseUrl}/_emdash/api/admin/allowed-domains/${encodeURIComponent(d.domain)}`, - { method: "DELETE", headers }, - ).catch(() => {}); - } - } + test.afterEach(async () => { + for (const domain of createdDomains) { + await fetch(`${baseUrl}/_emdash/api/admin/allowed-domains/${encodeURIComponent(domain)}`, { + method: "DELETE", + headers, + }).catch(() => {}); } }); @@ -84,6 +82,7 @@ test.describe("Allowed Domains Settings", () => { await admin.waitForLoading(); const testDomain = `e2e-test-${Date.now()}.example.com`; + createdDomains.push(testDomain); // Click "Add Domain" to open the inline form await page.getByRole("button", { name: "Add Domain" }).click(); @@ -118,6 +117,7 @@ test.describe("Allowed Domains Settings", () => { test("removes a domain via the UI", async ({ admin, page }) => { const testDomain = `e2e-test-delete-${Date.now()}.example.com`; + createdDomains.push(testDomain); // Create domain via API first await fetch(`${baseUrl}/_emdash/api/admin/allowed-domains`, { @@ -161,6 +161,7 @@ test.describe("Allowed Domains Settings", () => { test("cancel delete keeps the domain", async ({ admin, page }) => { const testDomain = `e2e-test-keep-${Date.now()}.example.com`; + createdDomains.push(testDomain); // Create domain via API await fetch(`${baseUrl}/_emdash/api/admin/allowed-domains`, { @@ -204,6 +205,7 @@ test.describe("Allowed Domains Settings", () => { test("toggling enabled/disabled updates the domain", async ({ admin, page }) => { const testDomain = `e2e-test-toggle-${Date.now()}.example.com`; + createdDomains.push(testDomain); // Create domain via API await fetch(`${baseUrl}/_emdash/api/admin/allowed-domains`, { diff --git a/e2e/tests/bylines.spec.ts b/e2e/tests/bylines.spec.ts index de95fecc57..6f99d85a10 100644 --- a/e2e/tests/bylines.spec.ts +++ b/e2e/tests/bylines.spec.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { test, expect } from "../fixtures"; @@ -7,7 +7,9 @@ import { test, expect } from "../fixtures"; const CONTENT_EDIT_URL_PATTERN = /\/content\/posts\/[A-Z0-9]+(?:\?.*)?$/; // Shared 1x1 PNG fixture used by the media upload flows. -const TEST_IMAGE_PATH = join(process.cwd(), "e2e/fixtures/assets/test-image.png"); +const TEST_IMAGE_PATH = fileURLToPath( + new URL("../fixtures/assets/test-image.png", import.meta.url), +); function apiHeaders(token: string, baseUrl: string) { return { diff --git a/e2e/tests/comments.spec.ts b/e2e/tests/comments.spec.ts index 6b57f4318c..44cb4006a4 100644 --- a/e2e/tests/comments.spec.ts +++ b/e2e/tests/comments.spec.ts @@ -80,7 +80,7 @@ async function cleanupComments( // Tests // --------------------------------------------------------------------------- -test.describe("Comments Moderation", () => { +test.describe("Comments Moderation", { tag: "@comments" }, () => { test.beforeEach(async ({ admin }) => { await admin.devBypassAuth(); }); diff --git a/e2e/tests/content-actions.spec.ts b/e2e/tests/content-actions.spec.ts index 10e679a1bc..35bf3ca2be 100644 --- a/e2e/tests/content-actions.spec.ts +++ b/e2e/tests/content-actions.spec.ts @@ -578,13 +578,16 @@ test.describe("Trash and restore content", () => { let headers: Record; let baseUrl: string; let postId: string; + let postTitle: string; test.beforeEach(async ({ admin, serverInfo }) => { await admin.devBypassAuth(); baseUrl = serverInfo.baseUrl; headers = apiHeaders(serverInfo.token, baseUrl); - postId = await createPost(baseUrl, headers, "Trash Test Post", `trash-test-${Date.now()}`); + // Both tests find their post by title in a list, so each needs its own. + postTitle = `Trash Test Post ${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + postId = await createPost(baseUrl, headers, postTitle, `trash-test-${Date.now()}`); }); test.afterEach(async () => { @@ -596,14 +599,14 @@ test.describe("Trash and restore content", () => { await admin.waitForLoading(); // Find the row and click the trash button - const row = page.locator("tr", { hasText: "Trash Test Post" }); + const row = page.locator("tr", { hasText: postTitle }); await expect(row).toBeVisible({ timeout: 5000 }); - await row.getByRole("button", { name: "Move Trash Test Post to trash" }).click(); + await row.getByRole("button", { name: `Move ${postTitle} to trash` }).click(); // A confirmation dialog should appear const dialog = page.locator('[role="dialog"]').filter({ hasText: "Move to Trash?" }); await expect(dialog).toBeVisible({ timeout: 5000 }); - await expect(dialog.locator("text=Trash Test Post")).toBeVisible(); + await expect(dialog.getByText(postTitle)).toBeVisible(); // Confirm the deletion const deleteResponse = page.waitForResponse( @@ -621,7 +624,7 @@ test.describe("Trash and restore content", () => { // The post should no longer appear in the "All" tab await admin.waitForLoading(); - await expect(page.locator("tr", { hasText: "Trash Test Post" })).not.toBeVisible({ + await expect(page.locator("tr", { hasText: postTitle })).not.toBeVisible({ timeout: 5000, }); }); @@ -642,7 +645,7 @@ test.describe("Trash and restore content", () => { await admin.waitForLoading(); // The trashed post should appear - const trashedRow = page.locator("tr", { hasText: "Trash Test Post" }); + const trashedRow = page.locator("tr", { hasText: postTitle }); await expect(trashedRow).toBeVisible({ timeout: 10000 }); // Click the restore button @@ -665,7 +668,7 @@ test.describe("Trash and restore content", () => { await admin.waitForLoading(); // The post should be back in the main list - await expect(page.locator("tr", { hasText: "Trash Test Post" })).toBeVisible({ + await expect(page.locator("tr", { hasText: postTitle })).toBeVisible({ timeout: 10000, }); }); diff --git a/e2e/tests/content-crud.spec.ts b/e2e/tests/content-crud.spec.ts index cec6e45120..fda100fd25 100644 --- a/e2e/tests/content-crud.spec.ts +++ b/e2e/tests/content-crud.spec.ts @@ -10,6 +10,7 @@ */ import { test, expect } from "../fixtures"; +import type { ServerInfo } from "../fixtures"; // Regex patterns // Edit-route navigation preserves the entry's locale as a `?locale=` search @@ -18,6 +19,22 @@ const CONTENT_EDIT_URL_PATTERN = /\/content\/posts\/[A-Z0-9]+(?:\?.*)?$/; const CONTENT_ID_PATTERN = /\/content\/posts\/[A-Z0-9]+(?:\?.*)?$/; const NEW_CONTENT_URL_PATTERN = /\/content\/posts\/new(?:[?#].*)?$/; +async function createPost(serverInfo: ServerInfo, title: string): Promise { + const response = await fetch(`${serverInfo.baseUrl}/_emdash/api/content/posts`, { + method: "POST", + headers: { + Authorization: `Bearer ${serverInfo.token}`, + "Content-Type": "application/json", + Origin: serverInfo.baseUrl, + "X-EmDash-Request": "1", + }, + body: JSON.stringify({ data: { title } }), + }); + if (!response.ok) throw new Error(`Creating post failed: ${response.status}`); + const { data } = (await response.json()) as { data: { item: { id: string } } }; + return data.item.id; +} + test.describe("Content CRUD", () => { test.beforeEach(async ({ admin }) => { await admin.devBypassAuth(); @@ -155,13 +172,10 @@ test.describe("Content CRUD", () => { await expect(admin.page.locator("#field-title")).toHaveValue("First Post"); }); - test("saves updated content", async ({ admin }) => { - // Navigate to existing content - await admin.goToContent("posts"); - await admin.waitForLoading(); - - // Click first item to edit - await admin.page.getByRole("link", { name: "First Post", exact: true }).click(); + test("saves updated content", async ({ admin, serverInfo }) => { + // Edit a post of its own: other tests expect the seeded posts unchanged. + const id = await createPost(serverInfo, `Editable Post ${Date.now()}`); + await admin.goToEditContent("posts", id); await admin.waitForLoading(); // Update title diff --git a/e2e/tests/content-types.spec.ts b/e2e/tests/content-types.spec.ts index 836c3569e2..6243df5945 100644 --- a/e2e/tests/content-types.spec.ts +++ b/e2e/tests/content-types.spec.ts @@ -10,16 +10,34 @@ */ import { test, expect } from "../fixtures"; +import type { ServerInfo } from "../fixtures"; // Regex patterns (module scope per lint rules) const CONTENT_TYPES_SLUG_PATTERN = /\/content-types\/posts$/; -// Fixed test slug -- sequential tests (workers: 1) share state. -// Use a fixed value so cross-test dependencies work reliably. -const TEST_SLUG = "e2e_test_articles"; const TEST_LABEL_SINGULAR = "Article"; const TEST_LABEL_PLURAL = `${TEST_LABEL_SINGULAR}s`; +function uniqueSuffix(): string { + return `${Date.now().toString(36)}_${Math.floor(Math.random() * 1e9).toString(36)}`; +} + +async function createCollection(serverInfo: ServerInfo, slug: string, label: string) { + const response = await fetch(`${serverInfo.baseUrl}/_emdash/api/schema/collections`, { + method: "POST", + headers: { + Authorization: `Bearer ${serverInfo.token}`, + "Content-Type": "application/json", + Origin: serverInfo.baseUrl, + "X-EmDash-Request": "1", + }, + body: JSON.stringify({ slug, label }), + }); + if (!response.ok) { + throw new Error(`Creating collection ${slug} failed: ${response.status}`); + } +} + test.describe("Content Types", () => { test.beforeEach(async ({ admin }) => { await admin.devBypassAuth(); @@ -166,6 +184,7 @@ test.describe("Content Types", () => { test.describe("Create Content Type", () => { test("creates a new content type and redirects to editor", async ({ admin }) => { + const slug = `e2e_articles_${uniqueSuffix()}`; await admin.goto("/content-types/new"); await admin.waitForShell(); await admin.waitForLoading(); @@ -183,13 +202,13 @@ test.describe("Content Types", () => { // Override slug with our unique test slug const slugInput = admin.page.getByLabel("Slug", { exact: true }); - await slugInput.fill(TEST_SLUG); + await slugInput.fill(slug); // Submit await admin.page.getByRole("button", { name: "Create Content Type" }).click(); // Should redirect to the new collection's editor page - await expect(admin.page).toHaveURL(new RegExp(`/content-types/${TEST_SLUG}$`), { + await expect(admin.page).toHaveURL(new RegExp(`/content-types/${slug}$`), { timeout: 15000, }); @@ -197,27 +216,37 @@ test.describe("Content Types", () => { await admin.expectPageTitle(TEST_LABEL_PLURAL); }); - test("new collection appears in the content types list", async ({ admin }) => { + test("new collection appears in the content types list", async ({ admin, serverInfo }) => { + const suffix = uniqueSuffix(); + const slug = `e2e_listed_${suffix}`; + const label = `Listed ${suffix}`; + await createCollection(serverInfo, slug, label); + await admin.goto("/content-types"); await admin.waitForLoading(); - // The collection we created in the previous test should appear (scope to table) + // Scope to the table to avoid matching the sidebar link await expect( - admin.page.locator("table").getByRole("link", { name: TEST_LABEL_PLURAL, exact: true }), + admin.page.locator("table").getByRole("link", { name: label, exact: true }), ).toBeVisible({ timeout: 10000 }); - await expect(admin.page.locator("table code", { hasText: TEST_SLUG })).toBeVisible(); + await expect(admin.page.locator("table code", { hasText: slug })).toBeVisible(); }); }); test.describe("Add Field to Content Type", () => { - test("adds a text field to the test collection", async ({ admin }) => { - await admin.goto(`/content-types/${TEST_SLUG}`); + test("adds a text field to the test collection", async ({ admin, serverInfo }) => { + const suffix = uniqueSuffix(); + const slug = `e2e_fields_${suffix}`; + const label = `Fields ${suffix}`; + await createCollection(serverInfo, slug, label); + + await admin.goto(`/content-types/${slug}`); await admin.waitForShell(); await admin.waitForLoading(); // Wait for the collection editor to fully load - await admin.expectPageTitle(TEST_LABEL_PLURAL); + await admin.expectPageTitle(label); // Click "Add Field" button await admin.page.getByRole("button", { name: "Add Field" }).first().click(); @@ -251,18 +280,23 @@ test.describe("Content Types", () => { }); test.describe("Delete Content Type", () => { - test("deletes the test-created collection", async ({ admin }) => { + test("deletes the test-created collection", async ({ admin, serverInfo }) => { + const suffix = uniqueSuffix(); + const slug = `e2e_deleted_${suffix}`; + const label = `Deleted ${suffix}`; + await createCollection(serverInfo, slug, label); + await admin.goto("/content-types"); await admin.waitForLoading(); // Verify the test collection exists before deletion - await expect(admin.page.locator("table code", { hasText: TEST_SLUG })).toBeVisible({ + await expect(admin.page.locator("table code", { hasText: slug })).toBeVisible({ timeout: 10000, }); // Find the row for the test collection and click its delete button - const row = admin.page.locator("tr").filter({ hasText: TEST_LABEL_PLURAL }); - await row.getByRole("button", { name: `Delete ${TEST_LABEL_PLURAL}` }).click(); + const row = admin.page.locator("tr").filter({ hasText: slug }); + await row.getByRole("button", { name: `Delete ${label}` }).click(); // Confirm deletion in the dialog const dialog = admin.page.locator('[role="dialog"]'); @@ -271,7 +305,7 @@ test.describe("Content Types", () => { // Collection should disappear from the list await admin.waitForLoading(); - await expect(admin.page.locator("table code", { hasText: TEST_SLUG })).not.toBeVisible({ + await expect(admin.page.locator("table code", { hasText: slug })).not.toBeVisible({ timeout: 10000, }); }); diff --git a/e2e/tests/i18n.spec.ts b/e2e/tests/i18n.spec.ts index f2fcf302f2..4a12488af4 100644 --- a/e2e/tests/i18n.spec.ts +++ b/e2e/tests/i18n.spec.ts @@ -242,11 +242,12 @@ test.describe("i18n", () => { await expect(currentMarker).toBeVisible(); }); - test("shows Translate buttons for missing locales", async ({ admin }) => { - await admin.goToContent("posts"); - await admin.waitForLoading(); - - await admin.page.locator("table tbody tr a").first().click(); + test("shows Translate buttons for missing locales", async ({ admin, serverInfo }) => { + const post = await createPublishedPost(serverInfo.baseUrl, serverInfo.token, { + title: "Untranslated post", + slug: `untranslated-${Date.now()}`, + }); + await admin.goToEditContent("posts", post.id); await admin.waitForLoading(); // FR and ES should have "Translate" buttons since no translations exist yet diff --git a/e2e/tests/media-library.spec.ts b/e2e/tests/media-library.spec.ts index 5512d85b42..c8f89fdf87 100644 --- a/e2e/tests/media-library.spec.ts +++ b/e2e/tests/media-library.spec.ts @@ -7,13 +7,14 @@ import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { Locator, Page } from "@playwright/test"; import { test, expect, type ServerInfo } from "../fixtures"; // Create a test image for uploads -const TEST_ASSETS_DIR = join(process.cwd(), "e2e/fixtures/assets"); +const TEST_ASSETS_DIR = fileURLToPath(new URL("../fixtures/assets", import.meta.url)); // Regex patterns const MEDIA_API_RESPONSE_PATTERN = /\/api\/media/; @@ -81,7 +82,9 @@ async function uploadCropTestImage(page: Page, filename: string) { await dialog.getByLabel(BROWSE_FILES_LABEL).setInputFiles({ name: filename, mimeType: "image/png", - buffer: CROP_TEST_PNG, + // Uploads are deduplicated by content hash, so unique bytes keep each + // upload its own media item. + buffer: Buffer.concat([CROP_TEST_PNG, Buffer.from(filename)]), }); await expect(dialog.getByText("Complete", { exact: true })).toBeVisible(); await dialog.getByRole("button", { name: "Done" }).click(); @@ -498,7 +501,8 @@ test.describe("Media Library", () => { await page.setViewportSize({ width: 320, height: 800 }); await page.getByRole("button", { name: /browse for Featured Image/i }).click(); const picker = page.getByRole("dialog", { name: "Select Featured Image" }); - await picker.getByRole("button", { name: "test-image.png", exact: true }).click(); + // Other tests upload their own copies of test-image.png. + await picker.getByRole("button", { name: "test-image.png", exact: true }).first().click(); await picker.getByRole("button", { name: "Select", exact: true }).click(); const featuredImageField = page.locator("#field-featured_image"); @@ -904,6 +908,7 @@ test.describe("Media Library", () => { await expect(page.locator("[data-media-library]")).not.toHaveAttribute("aria-busy", "true"); const rtlSearch = page.locator('input[type="search"]'); await rtlSearch.fill(longFolderName); + await expect(page.locator("[data-media-library]")).not.toHaveAttribute("aria-busy", "true"); const longFolderText = page .locator('[data-media-folder-card] [dir="auto"]') .filter({ hasText: longFolderName }); @@ -920,6 +925,7 @@ test.describe("Media Library", () => { await longFolderText.locator("xpath=ancestor::a[1]").click(); await expect(page).toHaveURL(/\/media\?folder=/); + await expect(page.locator("[data-media-library]")).not.toHaveAttribute("aria-busy", "true"); const currentBidiName = page.locator('[aria-current="page"] [dir="auto"]').first(); await expect(currentBidiName).toBeVisible(); const currentBidiMetrics = await currentBidiName.evaluate((element) => ({ @@ -953,10 +959,11 @@ test.describe("Media Library", () => { await createFolder(page, `Press ${Date.now()}`); const mediaGrid = page.locator("[data-media-grid]"); - const originalImage = mediaGrid.locator("img").first(); + const uploaded = mediaGrid.getByRole("button", { name: uniqueFilename, exact: true }); + const originalImage = uploaded.locator("img"); await expect(originalImage).toBeVisible(); const originalSrc = await originalImage.getAttribute("src"); - await mediaGrid.locator("button").first().click(); + await uploaded.click(); const details = page.getByRole("dialog", { name: "Media details" }); await details.getByRole("combobox", { name: "Location" }).click(); @@ -969,7 +976,7 @@ test.describe("Media Library", () => { await page.getByRole("link", { name: `Open folder ${folderName}` }).click(); await expect(page).toHaveURL(/\/media\?folder=/); - await expect(mediaGrid.locator("img").first()).toHaveAttribute("src", originalSrc!); + await expect(originalImage).toHaveAttribute("src", originalSrc!); const mediaLibrary = page.locator("[data-media-library]"); const pageSize = page.getByRole("combobox", { name: "Page size" }); await expect(mediaLibrary).not.toHaveAttribute("aria-busy", "true"); @@ -1024,7 +1031,7 @@ test.describe("Media Library", () => { await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), ).toBe(true); await page.reload(); - await expect(mediaGrid.locator("img").first()).toHaveAttribute("src", originalSrc!); + await expect(originalImage).toHaveAttribute("src", originalSrc!); await page.route("**/_emdash/api/media/folders?**", async (route) => { await new Promise((resolve) => setTimeout(resolve, 1200)); await route.continue(); @@ -1090,6 +1097,6 @@ test.describe("Media Library", () => { await expect(page).toHaveURL(/\/media\/?$/); await page.getByRole("button", { name: "Clear search" }).click(); - await expect(mediaGrid.locator("img").first()).toHaveAttribute("src", originalSrc!); + await expect(originalImage).toHaveAttribute("src", originalSrc!); }); }); diff --git a/e2e/tests/menus.spec.ts b/e2e/tests/menus.spec.ts index 9073a7a03d..1c40097f7d 100644 --- a/e2e/tests/menus.spec.ts +++ b/e2e/tests/menus.spec.ts @@ -7,6 +7,8 @@ import { test, expect } from "../fixtures"; +const MENUS_LIST_URL = /\/_emdash\/api\/menus(\?.*)?$/; + test.describe("Menus", () => { test.beforeEach(async ({ admin }) => { await admin.devBypassAuth(); @@ -24,7 +26,13 @@ test.describe("Menus", () => { await expect(admin.page.getByRole("button", { name: "Create Menu" }).first()).toBeVisible(); }); - test("shows empty state when no menus", async ({ admin }) => { + test("shows empty state when no menus", async ({ admin, page }) => { + // Other tests create menus at the same time, so serve an empty list. + await page.route(MENUS_LIST_URL, (route) => + route.request().method() === "GET" + ? route.fulfill({ json: { data: [] } }) + : route.continue(), + ); await admin.goToMenus(); await admin.waitForLoading(); diff --git a/e2e/tests/plugin-editor-extensions.spec.ts b/e2e/tests/plugin-editor-extensions.spec.ts index 49837d99c6..e30406c29f 100644 --- a/e2e/tests/plugin-editor-extensions.spec.ts +++ b/e2e/tests/plugin-editor-extensions.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "../fixtures"; +import { createPost } from "../fixtures/content"; test.describe("Sandboxed plugin editor extensions", () => { test.skip( @@ -14,7 +15,7 @@ test.describe("Sandboxed plugin editor extensions", () => { admin, serverInfo, }) => { - const entryId = serverInfo.contentIds.posts[0]!; + const entryId = await createPost(serverInfo, `Plugin editor post ${Date.now()}`); await admin.page .context() .addCookies([{ name: "emdash-locale", value: "ar", domain: "localhost", path: "/" }]); @@ -70,7 +71,7 @@ test.describe("Sandboxed plugin editor extensions", () => { admin, serverInfo, }, testInfo) => { - const entryId = serverInfo.contentIds.posts[0]!; + const entryId = await createPost(serverInfo, `Plugin editor post ${Date.now()}`); await admin.page .context() .addCookies([{ name: "emdash-locale", value: "ar", domain: "localhost", path: "/" }]); @@ -128,7 +129,7 @@ test.describe("Sandboxed plugin editor extensions", () => { admin, serverInfo, }, testInfo) => { - const entryId = serverInfo.contentIds.posts[0]!; + const entryId = await createPost(serverInfo, `Plugin editor post ${Date.now()}`); await admin.page .context() .addCookies([{ name: "emdash-locale", value: "ar", domain: "localhost", path: "/" }]); @@ -169,7 +170,7 @@ test.describe("Sandboxed plugin editor extensions", () => { admin, serverInfo, }) => { - const entryId = serverInfo.contentIds.posts[0]!; + const entryId = await createPost(serverInfo, `Plugin editor post ${Date.now()}`); if (adminLocale === "ar") { await admin.page .context() diff --git a/e2e/tests/redirect-loop-detection.spec.ts b/e2e/tests/redirect-loop-detection.spec.ts index c2c6ff048c..1fef21313e 100644 --- a/e2e/tests/redirect-loop-detection.spec.ts +++ b/e2e/tests/redirect-loop-detection.spec.ts @@ -7,6 +7,16 @@ import { test, expect } from "../fixtures"; +// Tests run in parallel against one site. Each test's paths live under its own +// prefix, so its redirects can't form loops with another test's, and cleanup +// removes only the redirects it created. +let prefix = ""; +let createdIds: string[] = []; + +function p(path: string): string { + return `${prefix}${path}`; +} + function apiHeaders(token: string, baseUrl: string) { return { "Content-Type": "application/json", @@ -27,12 +37,13 @@ async function create( ): Promise { const res = await page.request.post(`${baseUrl}/_emdash/api/redirects`, { headers: apiHeaders(token, baseUrl), - data: { source, destination, ...options }, + data: { source: p(source), destination: p(destination), ...options }, }); const body = await res.json(); if (!res.ok()) { return body.error?.message ?? "unknown error"; } + createdIds.push(body.data.id); return body.data.id; } @@ -46,7 +57,7 @@ async function createExpectError( ): Promise { const res = await page.request.post(`${baseUrl}/_emdash/api/redirects`, { headers: apiHeaders(token, baseUrl), - data: { source, destination }, + data: { source: p(source), destination: p(destination) }, }); expect(res.ok(), `Expected rejection for ${source} → ${destination}`).toBe(false); const body = await res.json(); @@ -63,33 +74,33 @@ async function createExpectSuccess( ): Promise { const res = await page.request.post(`${baseUrl}/_emdash/api/redirects`, { headers: apiHeaders(token, baseUrl), - data: { source, destination }, + data: { source: p(source), destination: p(destination) }, }); const body = await res.json(); expect(res.ok(), `Expected success for ${source} → ${destination}: ${JSON.stringify(body)}`).toBe( true, ); + createdIds.push(body.data.id); } -/** Delete all redirects */ +/** Delete the redirects this test created */ async function cleanup( page: import("@playwright/test").Page, baseUrl: string, token: string, ): Promise { const headers = apiHeaders(token, baseUrl); - const res = await page.request.get(`${baseUrl}/_emdash/api/redirects`, { headers }); - if (!res.ok()) return; - const data = await res.json(); - for (const item of data.data?.items ?? []) { - await page.request.delete(`${baseUrl}/_emdash/api/redirects/${item.id}`, { headers }); + for (const id of createdIds) { + await page.request.delete(`${baseUrl}/_emdash/api/redirects/${id}`, { headers }); } + createdIds = []; } test.describe("redirect loop detection", () => { - test.beforeEach(async ({ admin, page, serverInfo }) => { + test.beforeEach(async ({ admin }, testInfo) => { + prefix = `/t-${testInfo.testId}-${testInfo.retry}`; + createdIds = []; await admin.devBypassAuth(); - await cleanup(page, serverInfo.baseUrl, serverInfo.token); }); test.afterEach(async ({ page, serverInfo }) => { @@ -152,7 +163,7 @@ test.describe("redirect loop detection", () => { const res = await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, { headers, - data: { destination: "/a" }, + data: { destination: p("/a") }, }); expect(res.ok()).toBe(false); const body = await res.json(); @@ -167,7 +178,7 @@ test.describe("redirect loop detection", () => { const res = await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, { headers, - data: { destination: "/d" }, + data: { destination: p("/d") }, }); expect(res.ok()).toBe(true); }); @@ -183,7 +194,7 @@ test.describe("redirect loop detection", () => { const res = await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, { headers, - data: { source: "/b", destination: "/a" }, + data: { source: p("/b"), destination: p("/a") }, }); expect(res.ok()).toBe(false); const body = await res.json(); @@ -201,7 +212,7 @@ test.describe("redirect loop detection", () => { const res = await page.request.put(`${baseUrl}/_emdash/api/redirects/${id}`, { headers, - data: { destination: "/a", enabled: true }, + data: { destination: p("/a"), enabled: true }, }); expect(res.ok()).toBe(false); const body = await res.json(); diff --git a/e2e/tests/redirects.spec.ts b/e2e/tests/redirects.spec.ts index b6d758b835..65d9cecd22 100644 --- a/e2e/tests/redirects.spec.ts +++ b/e2e/tests/redirects.spec.ts @@ -5,8 +5,32 @@ * plus the 404 tracking tab. */ +import type { Page } from "@playwright/test"; + import { test, expect } from "../fixtures"; +const REDIRECTS_LIST_URL = /\/_emdash\/api\/redirects(\?.*)?$/; +const NOT_FOUND_SUMMARY_URL = /\/_emdash\/api\/redirects\/404s\/summary(\?.*)?$/; + +function uniqueSuffix(): string { + return `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`; +} + +/** The create/edit form. Toasts also have the dialog role, so pick it by its field. */ +function redirectForm(page: Page) { + return page.getByRole("dialog").filter({ has: page.locator('input[placeholder*="old-page"]') }); +} + +async function createRedirect(page: Page, source: string, destination: string) { + await page.getByRole("button", { name: "New Redirect" }).click(); + const dialog = redirectForm(page); + await expect(dialog).toBeVisible(); + await dialog.locator('input[placeholder*="old-page"]').fill(source); + await dialog.locator('input[placeholder*="new-page"]').fill(destination); + await dialog.getByRole("button", { name: "Create" }).click(); + await expect(dialog).toBeHidden({ timeout: 10000 }); +} + test.describe("Redirects", () => { test.beforeEach(async ({ admin }) => { await admin.devBypassAuth(); @@ -14,6 +38,12 @@ test.describe("Redirects", () => { test.describe("Empty state", () => { test("displays redirects page with empty state", async ({ admin, page }) => { + // Other tests create redirects at the same time, so serve an empty list. + await page.route(REDIRECTS_LIST_URL, (route) => + route.request().method() === "GET" + ? route.fulfill({ json: { data: { items: [] } } }) + : route.continue(), + ); await admin.goto("/redirects"); await admin.waitForShell(); await admin.waitForLoading(); @@ -31,105 +61,82 @@ test.describe("Redirects", () => { test.describe("CRUD", () => { test("creates a redirect", async ({ admin, page }) => { + const source = `/old-page-${uniqueSuffix()}`; + const destination = `/new-page-${uniqueSuffix()}`; await admin.goto("/redirects"); await admin.waitForShell(); await admin.waitForLoading(); - // Open create dialog - await page.getByRole("button", { name: "New Redirect" }).click(); - await expect(page.locator('[role="dialog"]')).toBeVisible(); - - // Fill form - const dialog = page.locator('[role="dialog"]'); - await dialog.locator('input[placeholder*="old-page"]').fill("/old-page"); - await dialog.locator('input[placeholder*="new-page"]').fill("/new-page"); - // Status code defaults to 301 -- leave it - - // Submit - await dialog.getByRole("button", { name: "Create" }).click(); - - // Dialog should close - await expect(page.locator('[role="dialog"]')).not.toBeVisible({ timeout: 10000 }); + await createRedirect(page, source, destination); // Redirect should appear in the list - await expect(page.locator("text=/old-page").first()).toBeVisible(); - await expect(page.locator("text=/new-page").first()).toBeVisible(); + await expect(page.getByText(source, { exact: true })).toBeVisible(); + await expect(page.getByText(destination, { exact: true })).toBeVisible(); }); test("edits a redirect", async ({ admin, page }) => { + const source = `/edit-source-${uniqueSuffix()}`; + const original = `/edit-dest-original-${uniqueSuffix()}`; + const updated = `/edit-dest-updated-${uniqueSuffix()}`; await admin.goto("/redirects"); await admin.waitForShell(); await admin.waitForLoading(); - // Create a redirect first - await page.getByRole("button", { name: "New Redirect" }).click(); - const createDialog = page.locator('[role="dialog"]'); - await createDialog.locator('input[placeholder*="old-page"]').fill("/edit-source"); - await createDialog.locator('input[placeholder*="new-page"]').fill("/edit-dest-original"); - await createDialog.getByRole("button", { name: "Create" }).click(); - await expect(page.locator('[role="dialog"]')).not.toBeVisible({ timeout: 10000 }); - - // Wait for the redirect to appear - await expect(page.locator("text=/edit-source").first()).toBeVisible(); + await createRedirect(page, source, original); + await expect(page.getByText(source, { exact: true })).toBeVisible(); - // Click the edit button on that row (use .first() to avoid ancestor div ambiguity). // Kumo 2.x renders