diff --git a/.github/workflows/endform-e2e.yml b/.github/workflows/endform-e2e.yml new file mode 100644 index 0000000000..df32e2e514 --- /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 2G1ZCj7X --project chromium + - name: Run exclusive E2E on Endform + id: exclusive + if: ${{ !cancelled() }} + run: pnpm exec endform test --organization-id 2G1ZCj7X --project exclusive diff --git a/.gitignore b/.gitignore index 9cd9b8fda3..99578605ec 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ uploads/ !packages/core/tests/integration/fixture/.emdash/ !e2e/fixture/.emdash/ !e2e/fixture-cloudflare/.emdash/ +e2e/fixture*/.emdash/migrations.json !templates/*/.emdash/seed.json # macOS-specific files 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/fixture/astro.config.mjs b/e2e/fixture/astro.config.mjs index 047ae3d8b6..7a2bbbd3b7 100644 --- a/e2e/fixture/astro.config.mjs +++ b/e2e/fixture/astro.config.mjs @@ -121,6 +121,9 @@ export default defineConfig({ defaultLocale: "en", locales: ["en", "fr", "es"], fallback: { fr: "en", es: "en" }, + // The fixture serves locales from its own `[locale]` routes. Automatic + // routing makes production builds add fallback routes that shadow them. + routing: "manual", }, devToolbar: { enabled: false }, vite: { diff --git a/e2e/fixture/package.json b/e2e/fixture/package.json index 66e9718242..72567bfcf0 100644 --- a/e2e/fixture/package.json +++ b/e2e/fixture/package.json @@ -12,6 +12,7 @@ "astro": "catalog:", "emdash": "workspace:*", "react": "catalog:", - "react-dom": "catalog:" + "react-dom": "catalog:", + "sharp": "^0.35.4" } } diff --git a/e2e/fixture/src/middleware.ts b/e2e/fixture/src/middleware.ts new file mode 100644 index 0000000000..91f72f1e3d --- /dev/null +++ b/e2e/fixture/src/middleware.ts @@ -0,0 +1,5 @@ +import { defineMiddleware } from "astro:middleware"; + +// Manual i18n routing requires a middleware file; the fixture's `[locale]` +// routes handle locale prefixes themselves. +export const onRequest = defineMiddleware((_context, next) => next()); diff --git a/e2e/fixture/src/outer-middleware.ts b/e2e/fixture/src/outer-middleware.ts index 327310a38c..8f7b75d9a0 100644 --- a/e2e/fixture/src/outer-middleware.ts +++ b/e2e/fixture/src/outer-middleware.ts @@ -1,6 +1,16 @@ +import { readFileSync } from "node:fs"; + import { defineMiddleware } from "astro:middleware"; +import { installRegistryAuthoritativeFixture } from "emdash/internal/testing/registry"; import { getRequestContext } from "emdash/request-context"; +// astro.config.mjs installs the fixture for `astro dev`; a production server +// never runs the config, so install it here at startup. +const registryFixturePath = process.env.EMDASH_REGISTRY_FIXTURE; +if (!import.meta.env.DEV && registryFixturePath) { + installRegistryAuthoritativeFixture(JSON.parse(readFileSync(registryFixturePath, "utf8"))); +} + const FINALIZER_NONCE = "outer-finalizer"; function preNextState(locals: App.Locals): string { 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 = { }; const TARGET = TARGETS[process.env.EMDASH_E2E_TARGET ?? "node"] ?? TARGETS.node!; +/** + * `EMDASH_E2E_BUILD=production` builds the fixture and serves the built output + * instead of running `astro dev`. Node target only. + */ +const PRODUCTION_BUILD = process.env.EMDASH_E2E_BUILD === "production"; +const INSECURE_TEST_BYPASS_VALUE = "i-understand-this-site-has-no-authentication"; const FIXTURE_DIR = TARGET.fixtureDir; const CLI_BINARY = resolve(ROOT, "packages/core/dist/cli/index.mjs"); const PORT = 4444; const MARKETPLACE_PORT = 4445; +/** The production server listens here, behind the gzip proxy on PORT. */ +const PRODUCTION_SERVER_PORT = 4447; const SERVER_INFO_PATH = join(tmpdir(), "emdash-pw-server.json"); // Regex patterns @@ -336,7 +344,19 @@ export default async function globalSetup(): Promise { // the database — source files stay at their real paths so Astro's virtual // module resolver can find the compile metadata. const workDir = FIXTURE_DIR; - const tempDataDir = mkdtempSync(join(tmpdir(), "emdash-pw-")); + // A production build bakes the database path into its output, so it needs a + // path that stays the same between runs. + let tempDataDir: string; + if (PRODUCTION_BUILD) { + if (TARGET !== TARGETS.node) { + throw new Error("EMDASH_E2E_BUILD=production only supports the node target"); + } + tempDataDir = join(tmpdir(), "emdash-pw-production"); + rmSync(tempDataDir, { recursive: true, force: true }); + mkdirSync(tempDataDir, { recursive: true }); + } else { + tempDataDir = mkdtempSync(join(tmpdir(), "emdash-pw-")); + } const dbPath = join(tempDataDir, "test.db"); const registryFixturePath = join(tempDataDir, "registry-fixture.json"); writeFileSync(registryFixturePath, JSON.stringify(registryFixture)); @@ -352,22 +372,49 @@ export default async function globalSetup(): Promise { rmSync(join(FIXTURE_DIR, ".wrangler"), { recursive: true, force: true }); } - // 2. Start dev server (with marketplace URL injected via env) + // 2. Start the server (with marketplace URL injected via env) const astroBin = join(fixtureNodeModules, ".bin", "astro"); - const server = spawn(astroBin, ["dev", "--port", String(PORT)], { - cwd: workDir, - env: { - ...process.env, + const fixtureEnv = { + ...process.env, + EMDASH_TEST_DB: `file:${dbPath}`, + EMDASH_MARKETPLACE_URL: marketplaceUrl, + EMDASH_REGISTRY_URL: marketplaceUrl, + EMDASH_REGISTRY_FIXTURE: registryFixturePath, + EMDASH_ENCRYPTION_KEY: "emdash_enc_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }; + let server; + if (PRODUCTION_BUILD) { + Object.assign(fixtureEnv, { + EMDASH_INSECURE_TEST_BYPASS: INSECURE_TEST_BYPASS_VALUE, + // Production setup won't infer the site URL from the request, and the + // build derives `image.remotePatterns` for EmDash media from it. + EMDASH_SITE_URL: baseUrl, + }); + console.log("[pw] Building fixture for production..."); + const buildStart = Date.now(); + // The bypass is also needed at build time: registry config validation + // rejects the plain-HTTP localhost mock marketplace otherwise. + await execAsync(astroBin, ["build"], { cwd: workDir, env: fixtureEnv, timeout: 300_000 }); + console.log(`[pw] Fixture built in ${((Date.now() - buildStart) / 1000).toFixed(1)}s.`); + server = spawn(process.execPath, [join(workDir, "dist/server/entry.mjs")], { + cwd: workDir, + env: { + ...fixtureEnv, + 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, // Keep Astro's agent-mode server in this process so teardown owns it. - ASTRO_DEV_BACKGROUND: "1", - EMDASH_TEST_DB: `file:${dbPath}`, - EMDASH_MARKETPLACE_URL: marketplaceUrl, - EMDASH_REGISTRY_URL: marketplaceUrl, - EMDASH_REGISTRY_FIXTURE: registryFixturePath, - EMDASH_ENCRYPTION_KEY: "emdash_enc_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - }, - stdio: "pipe", - }); + env: { ...fixtureEnv, ASTRO_DEV_BACKGROUND: "1" }, + stdio: "pipe", + }); + } let serverOutput = ""; const appendServerOutput = (data: Buffer) => { @@ -411,25 +458,29 @@ export default async function globalSetup(): Promise { console.log("[pw] Seeding test data..."); const seed = await seedTestData(baseUrl, token); - // 5b. Warm up pages that use emdash/ui (triggers Astro compilation of all - // component virtual modules, avoiding race conditions in tests) - console.log("[pw] Warming up pages..."); - for (let attempt = 0; attempt < 3; attempt++) { - try { - const resp = await fetch(`${baseUrl}/posts/post-with-image`); - if (resp.ok) break; - // Retry on compilation errors — Astro may need multiple passes - await new Promise((r) => setTimeout(r, 1000)); - } catch { - await new Promise((r) => setTimeout(r, 1000)); + // A production build compiles everything ahead of time, so only the dev + // server needs warming up. + if (!PRODUCTION_BUILD) { + // 5b. Warm up pages that use emdash/ui (triggers Astro compilation of all + // component virtual modules, avoiding race conditions in tests) + console.log("[pw] Warming up pages..."); + for (let attempt = 0; attempt < 3; attempt++) { + try { + const resp = await fetch(`${baseUrl}/posts/post-with-image`); + if (resp.ok) break; + // Retry on compilation errors — Astro may need multiple passes + await new Promise((r) => setTimeout(r, 1000)); + } catch { + await new Promise((r) => setTimeout(r, 1000)); + } } - } - // 5c. Load the admin in a browser. Its client dependencies are only - // discovered when a browser requests them, and the optimizer then forces - // a full reload that would otherwise land inside the first admin test. - console.log("[pw] Warming up admin..."); - await warmUpAdmin(baseUrl); + // 5c. Load the admin in a browser. Its client dependencies are only + // discovered when a browser requests them, and the optimizer then forces + // a full reload that would otherwise land inside the first admin test. + console.log("[pw] Warming up admin..."); + await warmUpAdmin(baseUrl); + } // 6. Write server info const info = { 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 5206388b98..83c70ea917 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/editor-image-panel.spec.ts b/e2e/tests/editor-image-panel.spec.ts index 5f8a509500..a4d7b35394 100644 --- a/e2e/tests/editor-image-panel.spec.ts +++ b/e2e/tests/editor-image-panel.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "../fixtures"; -const EXTERNAL_IMAGE_URL = "http://media.example.test/editor-panel.png"; +const EXTERNAL_IMAGE_URL = "https://media.example.test/editor-panel.png"; test.describe("Editor image panel", () => { test.beforeEach(async ({ admin }) => { 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 efac36b7ef..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(); @@ -146,24 +149,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); @@ -489,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"); @@ -895,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 }); @@ -911,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) => ({ @@ -944,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(); @@ -960,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"); @@ -1015,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(); @@ -1081,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