From a5faf15ed7fec47da8d12da9d2eaeae9669877c5 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Fri, 16 Oct 2026 10:58:03 -0700 Subject: [PATCH 01/16] test(e2e): run the Playwright suite against a local production build Prototype. EMDASH_E2E_BUILD=production makes global setup run `astro build` on the Node fixture and serve dist/server/entry.mjs instead of `astro dev`. The page and admin warm-ups are skipped, since a build has nothing to compile on demand. Production builds need an opt-in for the dev-only test hooks. EMDASH_INSECURE_TEST_BYPASS, set to a fixed public value, enables dev-bypass, dev-reset, captured dev emails, the console email provider and the localhost registry allowances in a built site. It is a switch, not a secret, and must not be set on a real deployment. Fixes for differences that only show up in production builds: - Set EMDASH_SITE_URL for the build and the server; production setup won't infer it, and image remotePatterns are derived from it at build time. - Add sharp to the Node fixture so the built server can optimize images. - Use manual i18n routing in the fixture so Astro's generated locale fallback routes don't shadow its [locale] pages. - Install the registry fixture at server start, not only in astro.config. Test fixes for problems a single-server run exposes: - Look up sandbox hook events by key instead of scanning the first 100. - Accept additional users in the sandbox boundaries users check. - Keep media drags away from the viewport edge and re-measure the drop target, since dnd-kit auto-scroll moves it during the drag. - Use an https image URL in the editor image panel test (production CSP). Co-Authored-By: Claude Opus 5.5 --- e2e/fixture/astro.config.mjs | 3 + e2e/fixture/package.json | 3 +- e2e/fixture/src/middleware.ts | 5 + e2e/fixture/src/outer-middleware.ts | 10 ++ e2e/global-setup.ts | 113 +++++++++++++----- e2e/tests/editor-image-panel.spec.ts | 2 +- e2e/tests/media-library.spec.ts | 31 +++-- .../sandbox-plugin-admin-surfaces.spec.ts | 8 +- e2e/tests/sandbox-plugin-boundaries.spec.ts | 4 +- packages/core/src/api/dev-only.ts | 21 ++++ packages/core/src/api/handlers/registry.ts | 3 +- packages/core/src/astro/integration/index.ts | 3 +- .../api/admin/plugins/registry/artifact.ts | 3 +- .../src/astro/routes/api/auth/dev-bypass.ts | 5 +- .../core/src/astro/routes/api/dev/emails.ts | 7 +- .../src/astro/routes/api/setup/dev-bypass.ts | 5 +- .../src/astro/routes/api/setup/dev-reset.ts | 5 +- packages/core/src/emdash-runtime.ts | 5 +- packages/core/src/registry/artifact-fetch.ts | 3 +- packages/core/src/registry/config.ts | 3 +- .../plugins/marketplace-test/src/plugin.ts | 20 +++- pnpm-lock.yaml | 4 +- 22 files changed, 194 insertions(+), 72 deletions(-) create mode 100644 e2e/fixture/src/middleware.ts create mode 100644 packages/core/src/api/dev-only.ts 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/global-setup.ts b/e2e/global-setup.ts index 0d3c1bd415..d439b645be 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -7,7 +7,7 @@ */ import { execFile, spawn } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -46,6 +46,12 @@ const TARGETS: Record = { }; 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; @@ -336,7 +342,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 +370,47 @@ 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: "localhost", + PORT: String(PORT), + }, + stdio: "pipe", + }); + } 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 +454,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/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/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/core/src/api/dev-only.ts b/packages/core/src/api/dev-only.ts new file mode 100644 index 0000000000..ccac9a5bfd --- /dev/null +++ b/packages/core/src/api/dev-only.ts @@ -0,0 +1,21 @@ +/** + * Gate for development-only endpoints and behavior (dev-bypass, dev-reset, + * captured dev emails). + * + * Always enabled in `astro dev`. A production build enables it only when the + * server process is started with `EMDASH_INSECURE_TEST_BYPASS` set to the + * exact value below, so the Playwright suite can run against a production + * build. The value is deliberately public: it is a switch, not a secret, and + * must never be set on a real deployment. + */ +export const INSECURE_TEST_BYPASS_VALUE = "i-understand-this-site-has-no-authentication"; + +/** Safe outside Vite (e.g. integration hooks), where `import.meta.env` is undefined. */ +export function isInsecureTestBypassEnabled(): boolean { + const env = typeof process !== "undefined" && process.env ? process.env : {}; + return env.EMDASH_INSECURE_TEST_BYPASS === INSECURE_TEST_BYPASS_VALUE; +} + +export function isDevOnlyEnabled(): boolean { + return import.meta.env.DEV || isInsecureTestBypassEnabled(); +} diff --git a/packages/core/src/api/handlers/registry.ts b/packages/core/src/api/handlers/registry.ts index 440d746888..6df2b4f5a9 100644 --- a/packages/core/src/api/handlers/registry.ts +++ b/packages/core/src/api/handlers/registry.ts @@ -64,6 +64,7 @@ import type { RegistryConfig, RegistryConfigInput } from "../../registry/types.j import { resolveAndValidateExternalUrlTarget } from "../../security/ssrf.js"; import { EmDashStorageError } from "../../storage/types.js"; import type { Storage } from "../../storage/types.js"; +import { isDevOnlyEnabled } from "../dev-only.js"; import type { ApiResult } from "../types.js"; import { deleteBundleFromR2, @@ -374,7 +375,7 @@ async function fetchArtifact( }, resolveHostname: async (hostname) => (await resolveAndValidateExternalUrlTarget(`https://${hostname}`)).addresses, - allowHttpLocalhost: import.meta.env.DEV, + allowHttpLocalhost: isDevOnlyEnabled(), headerTimeoutMs: ARTIFACT_FETCH_TIMEOUT_MS, totalTimeoutMs: ARTIFACT_TOTAL_BUDGET_MS, maxBytes: MAX_ARTIFACT_BYTES, diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index e735a23790..c8cc33d874 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -17,6 +17,7 @@ import { fileURLToPath } from "node:url"; import type { AstroIntegration, AstroIntegrationLogger, AstroIntegrationMiddleware } from "astro"; +import { isInsecureTestBypassEnabled } from "../../api/dev-only.js"; import { getEnvSiteUrl } from "../../api/public-url.js"; import { validateAllowedOrigins, validateOriginShape } from "../../auth/allowed-origins.js"; import { normalizeMigrationConfig } from "../../database/migrations/policy.js"; @@ -625,7 +626,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { astroCommand = command; usesCloudflareAdapter = astroConfig.adapter?.name === "@astrojs/cloudflare"; normalizeRegistryConfig(registry, { - allowLocalhost: command === "dev" || command === "sync", + allowLocalhost: command === "dev" || command === "sync" || isInsecureTestBypassEnabled(), }); printBanner(logger); if (command === "dev") { diff --git a/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts b/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts index bb118de6d3..23077f6d5c 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts @@ -34,6 +34,7 @@ import { fetchVerifiedResource } from "@emdash-cms/registry-verification/fetch"; import type { APIRoute } from "astro"; import { requirePerm } from "#api/authorize.js"; +import { isDevOnlyEnabled } from "#api/dev-only.js"; import { apiError } from "#api/error.js"; import { verifyRegistryArtifactChecksum } from "../../../../../../registry/artifact-checksum.js"; @@ -467,7 +468,7 @@ function proxyFetchOptions(signal: AbortSignal) { }), resolveHostname: async (hostname: string) => (await resolveAndValidateExternalUrlTarget(`https://${hostname}/`)).addresses, - allowHttpLocalhost: import.meta.env.DEV, + allowHttpLocalhost: isDevOnlyEnabled(), maxBytes: MAX_IMAGE_BYTES, maxRedirects: MAX_REDIRECTS, totalTimeoutMs: FETCH_TIMEOUT_MS, diff --git a/packages/core/src/astro/routes/api/auth/dev-bypass.ts b/packages/core/src/astro/routes/api/auth/dev-bypass.ts index 9f5f48497d..29ecd21358 100644 --- a/packages/core/src/astro/routes/api/auth/dev-bypass.ts +++ b/packages/core/src/astro/routes/api/auth/dev-bypass.ts @@ -5,7 +5,7 @@ * Development-only endpoint to bypass passkey authentication. * Creates or uses a test admin user and establishes a session. * - * ONLY available when import.meta.env.DEV is true. + * ONLY available when isDevOnlyEnabled() is true. * * Usage: * - GET with redirect: /_emdash/api/auth/dev-bypass?redirect=/_emdash/admin @@ -21,6 +21,7 @@ export const prerender = false; import { ulid } from "ulidx"; +import { isDevOnlyEnabled } from "#api/dev-only.js"; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { escapeHtml } from "#api/escape.js"; import { isSafeRedirect } from "#api/redirect.js"; @@ -33,7 +34,7 @@ const ROLE_ADMIN = 50; async function handleDevBypass(context: Parameters[0]): Promise { // CRITICAL: Only allow in development mode - if (!import.meta.env.DEV) { + if (!isDevOnlyEnabled()) { return apiError("FORBIDDEN", "Dev bypass is only available in development mode", 403); } diff --git a/packages/core/src/astro/routes/api/dev/emails.ts b/packages/core/src/astro/routes/api/dev/emails.ts index e277b5577e..9fede2a051 100644 --- a/packages/core/src/astro/routes/api/dev/emails.ts +++ b/packages/core/src/astro/routes/api/dev/emails.ts @@ -5,7 +5,7 @@ * Development-only endpoint to view and clear emails captured by * the dev console email provider. * - * ONLY available when import.meta.env.DEV is true. + * ONLY available when isDevOnlyEnabled() is true. * */ @@ -13,12 +13,13 @@ import type { APIRoute } from "astro"; export const prerender = false; +import { isDevOnlyEnabled } from "#api/dev-only.js"; import { apiError, apiSuccess } from "#api/error.js"; import { clearDevEmails, getDevEmails } from "../../../../plugins/email-console.js"; export const GET: APIRoute = async () => { - if (!import.meta.env.DEV) { + if (!isDevOnlyEnabled()) { return apiError("FORBIDDEN", "Dev emails endpoint is only available in development mode", 403); } @@ -27,7 +28,7 @@ export const GET: APIRoute = async () => { }; export const DELETE: APIRoute = async () => { - if (!import.meta.env.DEV) { + if (!isDevOnlyEnabled()) { return apiError("FORBIDDEN", "Dev emails endpoint is only available in development mode", 403); } diff --git a/packages/core/src/astro/routes/api/setup/dev-bypass.ts b/packages/core/src/astro/routes/api/setup/dev-bypass.ts index 2cc26ebc59..209b9d3b7f 100644 --- a/packages/core/src/astro/routes/api/setup/dev-bypass.ts +++ b/packages/core/src/astro/routes/api/setup/dev-bypass.ts @@ -6,7 +6,7 @@ * Creates a dev admin user and marks setup complete after runtime initialization. * Newly applied sample content has its Media Usage index prepared before redirecting. * - * ONLY available when import.meta.env.DEV is true. + * ONLY available when isDevOnlyEnabled() is true. * * Usage: * - GET with redirect: /_emdash/api/setup/dev-bypass?redirect=/_emdash/admin @@ -24,6 +24,7 @@ export const prerender = false; import { ulid } from "ulidx"; +import { isDevOnlyEnabled } from "#api/dev-only.js"; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { escapeHtml } from "#api/escape.js"; import { deleteApiTokensByName, handleApiTokenCreate } from "#api/handlers/api-tokens.js"; @@ -44,7 +45,7 @@ const DEV_SITE_TITLE = "EmDash Dev Site"; async function handleDevBypass(context: Parameters[0]): Promise { // CRITICAL: Only allow in development mode - if (!import.meta.env.DEV) { + if (!isDevOnlyEnabled()) { return apiError("FORBIDDEN", "Dev bypass is only available in development mode", 403); } diff --git a/packages/core/src/astro/routes/api/setup/dev-reset.ts b/packages/core/src/astro/routes/api/setup/dev-reset.ts index 04d0a484b3..af2b094029 100644 --- a/packages/core/src/astro/routes/api/setup/dev-reset.ts +++ b/packages/core/src/astro/routes/api/setup/dev-reset.ts @@ -5,18 +5,19 @@ * Clears the setup_complete flag and deletes all users, * returning the site to the pre-setup state. * - * ONLY available when import.meta.env.DEV is true. + * ONLY available when isDevOnlyEnabled() is true. */ import type { APIRoute } from "astro"; export const prerender = false; +import { isDevOnlyEnabled } from "#api/dev-only.js"; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { OptionsRepository } from "#db/repositories/options.js"; export const POST: APIRoute = async ({ locals }) => { - if (!import.meta.env.DEV) { + if (!isDevOnlyEnabled()) { return apiError("FORBIDDEN", "Dev reset is only available in development mode", 403); } diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 4567e81357..26b1eca51b 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -22,6 +22,7 @@ import { Kysely, type Dialect } from "kysely"; import virtualConfig from "virtual:emdash/config"; import { z } from "zod"; +import { isDevOnlyEnabled } from "./api/dev-only.js"; import { ErrorCode } from "./api/errors.js"; import { buildManifestCollections } from "./api/handlers/manifest.js"; import { @@ -1923,8 +1924,8 @@ export class EmDashRuntime { // In dev mode, register a built-in console email provider. // It participates in exclusive hook resolution like any other plugin — // auto-selected when it's the sole provider, overridden when a real one is configured. - // Gated by import.meta.env.DEV to prevent silent email loss in production. - if (import.meta.env.DEV) { + // Gated by isDevOnlyEnabled() to prevent silent email loss in production. + if (isDevOnlyEnabled()) { try { const devConsolePlugin = definePlugin({ id: DEV_CONSOLE_EMAIL_PLUGIN_ID, diff --git a/packages/core/src/registry/artifact-fetch.ts b/packages/core/src/registry/artifact-fetch.ts index f2f7a692ff..990e05c72c 100644 --- a/packages/core/src/registry/artifact-fetch.ts +++ b/packages/core/src/registry/artifact-fetch.ts @@ -1,6 +1,7 @@ import type { ClientRequest, IncomingMessage } from "node:http"; import type { RequestOptions } from "node:https"; +import { isDevOnlyEnabled } from "../api/dev-only.js"; import { resolveAndValidateExternalUrlTarget, SsrfError } from "../security/ssrf.js"; const TRAILING_DOT = /\.+$/; @@ -75,7 +76,7 @@ async function resolveSafeArtifactTarget(urlString: string): Promise<{ const hostname = stripIpv6Brackets(rawHostname); const localhost = isLocalhostHostname(hostname); - if (!import.meta.env.DEV) { + if (!isDevOnlyEnabled()) { if (url.protocol === "http:") { throw new Error("Artifact URL must use https"); } diff --git a/packages/core/src/registry/config.ts b/packages/core/src/registry/config.ts index 86f7f18a80..a8e0a2e1e3 100644 --- a/packages/core/src/registry/config.ts +++ b/packages/core/src/registry/config.ts @@ -9,6 +9,7 @@ import { isDid } from "@atcute/lexicons/syntax"; +import { isDevOnlyEnabled } from "../api/dev-only.js"; import type { RegistryConfig, RegistryConfigInput, RegistryConfigOption } from "./types.js"; export const DEFAULT_REGISTRY_AGGREGATOR_URL = "https://registry.emdashcms.com"; @@ -280,7 +281,7 @@ export function validateAggregatorUrl( hostname.startsWith("::ffff:127.") || hostname.startsWith("::ffff:7f00:"); - const allowLocalhost = options.allowLocalhost ?? import.meta.env.DEV; + const allowLocalhost = options.allowLocalhost ?? isDevOnlyEnabled(); if (!allowLocalhost) { if (parsed.protocol === "http:") { throw new RegistryConfigurationError( 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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ff7490c4a..c04a45f39b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1145,6 +1145,9 @@ importers: react-dom: specifier: 'catalog:' version: 19.2.4(react@19.2.4) + sharp: + specifier: ^0.35.4 + version: 0.35.4(@types/node@26.1.1) e2e/fixture-cloudflare: dependencies: @@ -23238,7 +23241,6 @@ snapshots: '@img/sharp-win32-ia32': 0.35.4 '@img/sharp-win32-x64': 0.35.4 '@types/node': 26.1.1 - optional: true shebang-command@2.0.0: dependencies: From 2155adbf9922903a8030540f536195cccbea3f8d Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Fri, 16 Oct 2026 11:34:00 -0700 Subject: [PATCH 02/16] test(e2e): run the Node suite on Endform against the production build Under Endform (ENDFORM=true), Playwright starts e2e/endform-server.ts as a webServer on the CLI machine. It runs the normal global setup in production-build mode and serves the server info on 127.0.0.1:4446. Endform proxies loopback traffic from its remote runners back to the CLI, and an auto fixture copies the server info into each runner's temp file, so the tests themselves don't change. Every test runs on its own runner. Specs that reset or wipe site-wide state (setup wizard, passkey setup, redirect loop detection) move to an `exclusive` project that runs as a second suite run, one test at a time. EMDASH_ENDFORM_CONCURRENCY caps concurrent tests, since every admin load pulls the uncompressed bundle through the CLI machine's network. Test assets now resolve relative to the spec file instead of process.cwd(), which is not the repository root on a runner. The new Endform E2E workflow runs both suite runs in one job, without sharding. Native Playwright runs are unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/endform-e2e.yml | 48 +++++++++++++ e2e/endform-server.ts | 66 +++++++++++++++++ e2e/fixtures/index.ts | 18 ++++- e2e/fixtures/refresh-server-pat.ts | 10 +++ e2e/tests/admin-fixes.spec.ts | 3 +- e2e/tests/bylines.spec.ts | 6 +- e2e/tests/media-library.spec.ts | 3 +- .../sandbox-plugin-admin-surfaces.spec.ts | 4 +- endform.config.ts | 14 ++++ package.json | 2 + playwright.config.ts | 63 +++++++++++++---- pnpm-lock.yaml | 70 ++++++++++++++++++- 12 files changed, 283 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/endform-e2e.yml create mode 100644 e2e/endform-server.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..9726cc2a90 --- /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: "32" + +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 || '32' }} + 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/e2e/endform-server.ts b/e2e/endform-server.ts new file mode 100644 index 0000000000..a9e1dd4197 --- /dev/null +++ b/e2e/endform-server.ts @@ -0,0 +1,66 @@ +/** + * 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(); + +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/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 { name: filename, mimeType: "image/png", buffer: Buffer.concat([ - readFileSync(join(process.cwd(), "e2e/fixtures/assets/test-image.png")), + readFileSync(fileURLToPath(new URL("../fixtures/assets/test-image.png", import.meta.url))), Buffer.from(filename), ]), }); diff --git a/endform.config.ts b/endform.config.ts new file mode 100644 index 0000000000..1dc852ca6c --- /dev/null +++ b/endform.config.ts @@ -0,0 +1,14 @@ +import { defineEndformConfig } from "endform"; + +// Every test loads the admin bundle from the CLI machine through the proxy, +// so the CLI machine's network decides how many tests can run at once. +const concurrency = Number(process.env.EMDASH_ENDFORM_CONCURRENCY || 8); + +export default defineEndformConfig({ + additionalFiles: ["e2e/fixtures/assets/**"], + proxyNetworkHosts: [""], + concurrentTestLimits: [ + { scope: "within-suite-run", limit: concurrency }, + { scope: "within-suite-run", label: "project:exclusive", limit: 1 }, + ], +}); diff --git a/package.json b/package.json index d44c2e9d82..f2322bed44 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@types/node": "catalog:", "@typescript/native-preview": "7.0.0-dev.20260421.2", "emdash": "workspace:*", + "endform": "0.81.5", "knip": "^5.84.1", "oxfmt": "^0.59.0", "oxlint": "^1.74.0", @@ -57,6 +58,7 @@ "pkg-pr-new": "^0.0.78", "prettier": "^3.9.5", "prettier-plugin-astro": "^0.14.1", + "tsx": "4.21.0", "typescript": "6.0.0-beta" }, "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", diff --git a/playwright.config.ts b/playwright.config.ts index 62ba752804..81203c7e01 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,16 @@ import { defineConfig, devices } from "@playwright/test"; +const isEndform = process.env.ENDFORM === "true"; + +// Specs that reset or wipe state the whole site shares. On Endform they run in +// their own suite run, one test at a time, after everything else. +const EXCLUSIVE_SPECS = [ + "passkey-full-setup-virtual-auth.spec.ts", + "redirect-loop-detection.spec.ts", + "setup-wizard.spec.ts", +]; +const IGNORED_SPECS = ["portable-text-table.spec.ts"]; + /** * Playwright E2E test configuration for EmDash CMS * @@ -10,9 +21,10 @@ import { defineConfig, devices } from "@playwright/test"; */ export default defineConfig({ testDir: "./e2e/tests", - testIgnore: "portable-text-table.spec.ts", - // Disable parallel to avoid shared database state issues - fullyParallel: false, + testIgnore: IGNORED_SPECS, + // Local runs share one database and run in order. On Endform every test + // runs on its own machine against the production build. + fullyParallel: isEndform, workers: 1, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, @@ -23,21 +35,44 @@ export default defineConfig({ // first hit; give it headroom so cold compilation doesn't time out specs. timeout: process.env.EMDASH_E2E_TARGET === "cloudflare" ? 90_000 : 30000, - globalSetup: "./e2e/global-setup.ts", - globalTeardown: "./e2e/global-teardown.ts", + globalSetup: isEndform ? undefined : "./e2e/global-setup.ts", + globalTeardown: isEndform ? undefined : "./e2e/global-teardown.ts", + // Endform runs the webServer on the CLI machine and proxies loopback + // traffic to it from the remote runners. + webServer: isEndform + ? { + command: "pnpm exec tsx e2e/endform-server.ts", + url: "http://127.0.0.1:4446/ready", + env: { EMDASH_E2E_BUILD: "production" }, + reuseExistingServer: false, + timeout: 240_000, + } + : undefined, use: { baseURL: "http://localhost:4444", - trace: "on-first-retry", + trace: isEndform ? "retain-on-failure" : "on-first-retry", screenshot: "only-on-failure", }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], - - // No webServer — global-setup.ts handles server lifecycle + projects: isEndform + ? [ + { + name: "chromium", + testIgnore: [...IGNORED_SPECS, ...EXCLUSIVE_SPECS], + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "exclusive", + testMatch: EXCLUSIVE_SPECS, + fullyParallel: false, + use: { ...devices["Desktop Chrome"] }, + }, + ] + : [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c04a45f39b..806f1fac85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -374,6 +374,9 @@ importers: emdash: specifier: workspace:* version: link:packages/core + endform: + specifier: 0.81.5 + version: 0.81.5 knip: specifier: ^5.84.1 version: 5.84.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.10.13)(typescript@6.0.0-beta) @@ -395,6 +398,9 @@ importers: prettier-plugin-astro: specifier: ^0.14.1 version: 0.14.1 + tsx: + specifier: 4.21.0 + version: 4.21.0 typescript: specifier: 6.0.0-beta version: 6.0.0-beta @@ -9784,6 +9790,40 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + endform-darwin-arm64@0.81.5: + resolution: {integrity: sha512-+lODLjRXR2mgh9djn4hGlRrOAs6FZWl6Uy5VvnqmqhWvj7EZZ0TFDaO4PDavqkTu4azexn8t+wwFQmbprbYVxw==} + cpu: [arm64] + os: [darwin] + + endform-darwin-x86@0.81.5: + resolution: {integrity: sha512-bIdlYSQ6owQhb093fH9aadTPwZl/Jt2NtRFUgNVwNtnNmnCJAhoMMtdw2h+Xqfp+vvQ2unIyNMDO9rbTwdIqjA==} + cpu: [x64] + os: [darwin] + + endform-linux-arm64@0.81.5: + resolution: {integrity: sha512-r3aqkLWohnT01Cmc1zjmgSI80rTRjWJzxmJGaKBQAL45h08VluKvV/LGBpc6FkIeGqhpTECu7MjUv9/Km24fAg==} + cpu: [arm64] + os: [linux] + + endform-linux-x86@0.81.5: + resolution: {integrity: sha512-l2ehMvjLIE/m0skqvRV0pXNZW0Vqhs4vswOxVbIt5W6KsX7+uoqd3/D6c49CIjMQeadOHIFihAWu35vgsRykXg==} + cpu: [x64] + os: [linux] + + endform-win32-arm64@0.81.5: + resolution: {integrity: sha512-HI7WqWGFhEIJbOKYS17ISxKjb6PjosiOKcxxGXFt2yY8B4dUjSvYdKNQKaHU0ZmvB26z0kQ6eWsrhyPrO+H0DA==} + cpu: [arm64] + os: [win32] + + endform-win32-x86@0.81.5: + resolution: {integrity: sha512-CSsiG4m7C62qCVw/UoizPC0mbPZmN0qydKW/6auW66gglB9VO51+daBPbSsR8g4d3CrTFwFv6saQ51AVsj02Tg==} + cpu: [x64] + os: [win32] + + endform@0.81.5: + resolution: {integrity: sha512-8oOSOy8AzXnAO6Wwt1qMLdTbbCU/lWgorAVkCBKf2o5zbdVMJmkkkhv+5Ri99jhfojulu2lpjD04sWw/Pm5Lzg==} + hasBin: true + enhanced-resolve@5.24.2: resolution: {integrity: sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==} engines: {node: '>=10.13.0'} @@ -20416,6 +20456,33 @@ snapshots: once: 1.4.0 optional: true + endform-darwin-arm64@0.81.5: + optional: true + + endform-darwin-x86@0.81.5: + optional: true + + endform-linux-arm64@0.81.5: + optional: true + + endform-linux-x86@0.81.5: + optional: true + + endform-win32-arm64@0.81.5: + optional: true + + endform-win32-x86@0.81.5: + optional: true + + endform@0.81.5: + optionalDependencies: + endform-darwin-arm64: 0.81.5 + endform-darwin-x86: 0.81.5 + endform-linux-arm64: 0.81.5 + endform-linux-x86: 0.81.5 + endform-win32-arm64: 0.81.5 + endform-win32-x86: 0.81.5 + enhanced-resolve@5.24.2: dependencies: graceful-fs: 4.2.11 @@ -20529,7 +20596,6 @@ snapshots: '@esbuild/win32-arm64': 0.27.7 '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 - optional: true esbuild@0.28.1: optionalDependencies: @@ -20870,7 +20936,6 @@ snapshots: get-tsconfig@4.14.3: dependencies: resolve-pkg-maps: 1.0.0 - optional: true get-tsconfig@5.0.0-beta.4: dependencies: @@ -23615,7 +23680,6 @@ snapshots: get-tsconfig: 4.14.3 optionalDependencies: fsevents: 2.3.3 - optional: true tunnel-agent@0.6.0: dependencies: From 970209854aca6aa02dc39a06c4ddebb102e0f880 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Fri, 16 Oct 2026 11:53:11 -0700 Subject: [PATCH 03/16] test(e2e): make the suite pass in parallel on Endform - Send test assets to Endform runners: `e2e/fixtures/assets/**` matched no files, `e2e/fixtures/assets/*` does. - Put a gzip proxy in front of the production-build server. Astro's standalone server doesn't compress, and every Endform test downloads the ~9 MB admin bundle through the CLI machine; gzip cuts that about 4x. - Give tests their own data instead of sharing it with tests that now run at the same time: - content types: each lifecycle test creates its own collection through the API instead of relying on the previous test's fixed slug. - i18n: the Translate buttons test opens a post it created, not whichever post is first in the list. - visual editing: the two tests that save real edits work on a copy of the seeded image post, which other tests expect unchanged. - media library: crop uploads get unique bytes, since uploads are deduplicated by content hash; the featured image picker accepts any copy of test-image.png. Co-Authored-By: Claude Opus 5.5 --- e2e/fixture/.emdash/migrations.json | 119 ++++++++++++++++++++++++++++ e2e/fixtures/compression-proxy.ts | 57 +++++++++++++ e2e/global-setup.ts | 8 +- e2e/tests/content-types.spec.ts | 68 ++++++++++++---- e2e/tests/i18n.spec.ts | 11 +-- e2e/tests/media-library.spec.ts | 7 +- e2e/tests/visual-editing.spec.ts | 48 +++++++++-- endform.config.ts | 2 +- 8 files changed, 288 insertions(+), 32 deletions(-) create mode 100644 e2e/fixture/.emdash/migrations.json create mode 100644 e2e/fixtures/compression-proxy.ts diff --git a/e2e/fixture/.emdash/migrations.json b/e2e/fixture/.emdash/migrations.json new file mode 100644 index 0000000000..514cb356ab --- /dev/null +++ b/e2e/fixture/.emdash/migrations.json @@ -0,0 +1,119 @@ +{ + "schemaVersion": 1, + "emdashVersion": "1.3.0", + "migrationSet": { + "names": [ + "001_initial", + "002_media_status", + "003_schema_registry", + "004_plugins", + "005_menus", + "006_taxonomy_defs", + "007_widgets", + "008_auth", + "009_user_disabled", + "011_sections", + "012_search", + "013_scheduled_publishing", + "014_draft_revisions", + "015_indexes", + "016_api_tokens", + "017_authorization_codes", + "018_seo", + "019_i18n", + "020_collection_url_pattern", + "021_remove_section_categories", + "022_marketplace_plugin_state", + "023_plugin_metadata", + "024_media_placeholders", + "025_oauth_clients", + "026_cron_tasks", + "027_comments", + "028_drop_author_url", + "029_redirects", + "030_widen_scheduled_index", + "031_bylines", + "032_rate_limits", + "033_optimize_content_indexes", + "034_published_at_index", + "035_bounded_404_log", + "036_i18n_menus_and_taxonomies", + "037_credential_algorithm", + "038_registry_plugin_state", + "039_fix_fts5_triggers", + "040_byline_i18n", + "041_content_locale_list_index", + "042_byline_fields", + "043_content_references", + "044_comment_reactions", + "045_taxonomy_parent_group", + "046_media_usage_index", + "047_restore_taxonomy_parent_index", + "048_restore_content_taxonomies_term_index", + "049_taxonomies_name_locale_index", + "050_media_usage_index_status", + "051_content_taxonomies_denorm", + "052_media_usage_read_index", + "053_plugin_mcp_tools", + "054_media_upload_attempts", + "055_content_translation_group_locale_index", + "056_taxonomy_term_sort_order", + "057_collection_hidden", + "058_collection_sort_order", + "059_revision_prune_queue", + "060_collection_admin_config", + "061_media_usage_cleanup", + "062_media_usage_cleanup_fence", + "063_media_usage_incremental_work", + "064_fts_plain_text", + "065_media_usage_collection_deletion", + "066_media_usage_reconciliation", + "067_indexed_content_fields", + "068_content_taxonomy_entry_groups", + "069_collection_title_date_fields", + "070_collection_routable", + "071_restore_content_bylines_table", + "072_media_folders", + "073_media_focal_point", + "074_content_deleted_scheduled_index", + "075_entry_edit_locks", + "076_collection_nav_group", + "077_plugin_storage_revisions", + "078_menu_item_translation_groups", + "079_datetime_normalization", + "080_content_translation_locale_unique", + "081_redirect_write_guards", + "082_taxonomy_translation_locale_unique", + "083_block_types", + "084_site_transfer", + "085_taxonomy_def_groups", + "086_relations_structural", + "087_reference_field_relations", + "088_cron_oneshot_utc", + "089_auto_seed_completion", + "090_redirect_enable_loop_guard", + "091_redirect_artifacts" + ], + "fingerprint": "88b366448fc5cdd19bb5cc825e0174f710cda2f20e76681455343484d64d1ee8" + }, + "i18n": { + "defaultLocale": "en", + "locales": [ + "en", + "fr", + "es" + ], + "fallback": { + "fr": "en", + "es": "en" + }, + "prefixDefaultLocale": false + }, + "database": { + "type": "sqlite", + "executorEntrypoint": "emdash/internal/db/sqlite-migrations", + "executorConfig": { + "url": "file:/var/folders/6l/nfz7krzx6rndp8vqfbh_zp2m0000gn/T/emdash-pw-production/test.db" + } + } +} diff --git a/e2e/fixtures/compression-proxy.ts b/e2e/fixtures/compression-proxy.ts new file mode 100644 index 0000000000..4ce2b2d56a --- /dev/null +++ b/e2e/fixtures/compression-proxy.ts @@ -0,0 +1,57 @@ +/** + * 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. + */ +import { createServer, request, type Server } from "node:http"; +import { createGzip } from "node:zlib"; + +const COMPRESSIBLE_TYPE = /^(text\/|application\/(javascript|json|xml)|image\/svg\+xml)/; +const ACCEPTS_GZIP = /\bgzip\b/; + +export function startCompressionProxy(listenPort: number, targetPort: number): Promise { + const server = createServer((clientRequest, clientResponse) => { + const upstream = request( + { + host: "127.0.0.1", + port: targetPort, + method: clientRequest.method, + path: clientRequest.url, + headers: clientRequest.headers, + }, + (upstreamResponse) => { + const headers = { ...upstreamResponse.headers }; + const acceptsGzip = ACCEPTS_GZIP.test( + String(clientRequest.headers["accept-encoding"] ?? ""), + ); + const compress = + acceptsGzip && + clientRequest.method !== "HEAD" && + !headers["content-encoding"] && + COMPRESSIBLE_TYPE.test(String(headers["content-type"] ?? "")); + if (!compress) { + clientResponse.writeHead(upstreamResponse.statusCode ?? 502, 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(upstreamResponse.statusCode ?? 502, headers); + upstreamResponse.pipe(createGzip()).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/global-setup.ts b/e2e/global-setup.ts index d439b645be..d2c918cf98 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -56,6 +56,8 @@ 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 @@ -398,11 +400,13 @@ export default async function globalSetup(): 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/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 0eec6e3d4d..797885db9c 100644 --- a/e2e/tests/media-library.spec.ts +++ b/e2e/tests/media-library.spec.ts @@ -82,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(); @@ -499,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"); diff --git a/e2e/tests/visual-editing.spec.ts b/e2e/tests/visual-editing.spec.ts index c00b95472b..6009d5823d 100644 --- a/e2e/tests/visual-editing.spec.ts +++ b/e2e/tests/visual-editing.spec.ts @@ -10,6 +10,7 @@ */ import { test, expect } from "../fixtures"; +import type { ServerInfo } from "../fixtures"; const MEDIA_FILE_PATTERN = /\/_emdash\/api\/media\/file\/.+\.\w+/; const IMAGE_BUTTON_PATTERN = /Image/; @@ -17,6 +18,40 @@ const IMAGE_BUTTON_PATTERN = /Image/; // The seeded post with an image block has slug "post-with-image" const POST_WITH_IMAGE_PATH = "/posts/post-with-image"; +/** + * Publish a copy of the seeded image post and return its path. Tests that save + * real edits use a copy, so other tests can rely on the seeded post's blocks. + */ +async function copyImagePost(serverInfo: ServerInfo): Promise { + const headers = { + Authorization: `Bearer ${serverInfo.token}`, + "Content-Type": "application/json", + Origin: serverInfo.baseUrl, + "X-EmDash-Request": "1", + }; + const api = `${serverInfo.baseUrl}/_emdash/api/content/posts`; + const source = await fetch(`${api}/post-with-image`, { headers }); + if (!source.ok) throw new Error(`Loading the seeded post failed: ${source.status}`); + const { data: sourceData } = (await source.json()) as { + data: { item: { data: Record } }; + }; + const slug = `post-with-image-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + const created = await fetch(api, { + method: "POST", + headers, + body: JSON.stringify({ data: sourceData.item.data, slug }), + }); + if (!created.ok) throw new Error(`Copying the seeded post failed: ${created.status}`); + const { data: createdData } = (await created.json()) as { data: { item: { id: string } } }; + const published = await fetch(`${api}/${createdData.item.id}/publish`, { + method: "POST", + headers, + body: "{}", + }); + if (!published.ok) throw new Error(`Publishing the copied post failed: ${published.status}`); + return `/posts/${slug}`; +} + /** * Navigate to a page, retrying if Astro's dev server shows a compilation error. * This handles the transient "No cached compile metadata" race condition. @@ -679,8 +714,10 @@ test.describe("Inline Editor", () => { test("refuses file drop and paste without losing edits or breaking image insertion", async ({ page, + serverInfo, }) => { - await gotoWithRetry(page, POST_WITH_IMAGE_PATH); + const postPath = await copyImagePost(serverInfo); + await gotoWithRetry(page, postPath); const editor = page.locator(".emdash-inline-editor"); await expect(editor).toBeVisible({ timeout: 15000 }); @@ -866,8 +903,9 @@ test.describe("Inline Editor", () => { expect(body.data.body.map((block) => block.asset?._ref)).toContain("e2e-1"); }); - test("flushes unsaved edits when navigating away (#1582)", async ({ page }) => { - await gotoWithRetry(page, POST_WITH_IMAGE_PATH); + test("flushes unsaved edits when navigating away (#1582)", async ({ page, serverInfo }) => { + const postPath = await copyImagePost(serverInfo); + await gotoWithRetry(page, postPath); const editor = page.locator(".emdash-inline-editor"); await expect(editor).toBeVisible({ timeout: 15000 }); @@ -897,13 +935,13 @@ test.describe("Inline Editor", () => { // reloading immediately: on slow runners the PUT may still be in // flight when the request event fires. await expect - .poll(async () => (await page.request.get(POST_WITH_IMAGE_PATH)).text(), { + .poll(async () => (await page.request.get(postPath)).text(), { timeout: 10000, }) .toContain("Repro1582"); // The edit must survive a fresh load of the post. - await gotoWithRetry(page, POST_WITH_IMAGE_PATH); + await gotoWithRetry(page, postPath); const reloadedEditor = page.locator(".emdash-inline-editor"); await expect(reloadedEditor).toBeVisible({ timeout: 15000 }); await expect(reloadedEditor.locator("text=Repro1582")).toBeVisible(); diff --git a/endform.config.ts b/endform.config.ts index 1dc852ca6c..451ce51625 100644 --- a/endform.config.ts +++ b/endform.config.ts @@ -5,7 +5,7 @@ import { defineEndformConfig } from "endform"; const concurrency = Number(process.env.EMDASH_ENDFORM_CONCURRENCY || 8); export default defineEndformConfig({ - additionalFiles: ["e2e/fixtures/assets/**"], + additionalFiles: ["e2e/fixtures/assets/*"], proxyNetworkHosts: [""], concurrentTestLimits: [ { scope: "within-suite-run", limit: concurrency }, From ab71ec6d1c3d657d41d1698ab79b8a853ace7053 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Fri, 16 Oct 2026 11:53:27 -0700 Subject: [PATCH 04/16] test(e2e): ignore generated fixture migrations journal Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + e2e/fixture/.emdash/migrations.json | 119 ---------------------------- 2 files changed, 1 insertion(+), 119 deletions(-) delete mode 100644 e2e/fixture/.emdash/migrations.json 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/fixture/.emdash/migrations.json b/e2e/fixture/.emdash/migrations.json deleted file mode 100644 index 514cb356ab..0000000000 --- a/e2e/fixture/.emdash/migrations.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "schemaVersion": 1, - "emdashVersion": "1.3.0", - "migrationSet": { - "names": [ - "001_initial", - "002_media_status", - "003_schema_registry", - "004_plugins", - "005_menus", - "006_taxonomy_defs", - "007_widgets", - "008_auth", - "009_user_disabled", - "011_sections", - "012_search", - "013_scheduled_publishing", - "014_draft_revisions", - "015_indexes", - "016_api_tokens", - "017_authorization_codes", - "018_seo", - "019_i18n", - "020_collection_url_pattern", - "021_remove_section_categories", - "022_marketplace_plugin_state", - "023_plugin_metadata", - "024_media_placeholders", - "025_oauth_clients", - "026_cron_tasks", - "027_comments", - "028_drop_author_url", - "029_redirects", - "030_widen_scheduled_index", - "031_bylines", - "032_rate_limits", - "033_optimize_content_indexes", - "034_published_at_index", - "035_bounded_404_log", - "036_i18n_menus_and_taxonomies", - "037_credential_algorithm", - "038_registry_plugin_state", - "039_fix_fts5_triggers", - "040_byline_i18n", - "041_content_locale_list_index", - "042_byline_fields", - "043_content_references", - "044_comment_reactions", - "045_taxonomy_parent_group", - "046_media_usage_index", - "047_restore_taxonomy_parent_index", - "048_restore_content_taxonomies_term_index", - "049_taxonomies_name_locale_index", - "050_media_usage_index_status", - "051_content_taxonomies_denorm", - "052_media_usage_read_index", - "053_plugin_mcp_tools", - "054_media_upload_attempts", - "055_content_translation_group_locale_index", - "056_taxonomy_term_sort_order", - "057_collection_hidden", - "058_collection_sort_order", - "059_revision_prune_queue", - "060_collection_admin_config", - "061_media_usage_cleanup", - "062_media_usage_cleanup_fence", - "063_media_usage_incremental_work", - "064_fts_plain_text", - "065_media_usage_collection_deletion", - "066_media_usage_reconciliation", - "067_indexed_content_fields", - "068_content_taxonomy_entry_groups", - "069_collection_title_date_fields", - "070_collection_routable", - "071_restore_content_bylines_table", - "072_media_folders", - "073_media_focal_point", - "074_content_deleted_scheduled_index", - "075_entry_edit_locks", - "076_collection_nav_group", - "077_plugin_storage_revisions", - "078_menu_item_translation_groups", - "079_datetime_normalization", - "080_content_translation_locale_unique", - "081_redirect_write_guards", - "082_taxonomy_translation_locale_unique", - "083_block_types", - "084_site_transfer", - "085_taxonomy_def_groups", - "086_relations_structural", - "087_reference_field_relations", - "088_cron_oneshot_utc", - "089_auto_seed_completion", - "090_redirect_enable_loop_guard", - "091_redirect_artifacts" - ], - "fingerprint": "88b366448fc5cdd19bb5cc825e0174f710cda2f20e76681455343484d64d1ee8" - }, - "i18n": { - "defaultLocale": "en", - "locales": [ - "en", - "fr", - "es" - ], - "fallback": { - "fr": "en", - "es": "en" - }, - "prefixDefaultLocale": false - }, - "database": { - "type": "sqlite", - "executorEntrypoint": "emdash/internal/db/sqlite-migrations", - "executorConfig": { - "url": "file:/var/folders/6l/nfz7krzx6rndp8vqfbh_zp2m0000gn/T/emdash-pw-production/test.db" - } - } -} From 9162927bb70460a57733d87ba88fa05573f28b2f Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Fri, 16 Oct 2026 11:54:25 -0700 Subject: [PATCH 05/16] test(e2e): log the Endform fixture's server mode to the CI output Co-Authored-By: Claude Opus 5.5 --- e2e/endform-server.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/e2e/endform-server.ts b/e2e/endform-server.ts index a9e1dd4197..d55501cc63 100644 --- a/e2e/endform-server.ts +++ b/e2e/endform-server.ts @@ -19,6 +19,10 @@ 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") { From 7656f8b5c8f6288a099053549f8a573fce9aa98e Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Fri, 16 Oct 2026 12:07:05 -0700 Subject: [PATCH 06/16] test(e2e): fix the remaining parallel failures on Endform - Compress only built assets in the gzip proxy, so pages and API responses keep the headers the server set (the outer middleware test checks Content-Length). - Allowed domains: clean up only the domains each test created, instead of every e2e-test domain, which deleted other tests' domains. - Menus: wait for the add-link form's own dialog to close. Toasts also have the dialog role, so waiting for any dialog waited for each toast to time out. Serve an empty menu list to the empty-state test. - Visual editing: the image insert test works on its own copy of the seeded post, and the slash menu test no longer saves its typing to it. - Run CI at concurrency 64. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/endform-e2e.yml | 4 ++-- e2e/fixtures/admin.ts | 12 ++++-------- e2e/fixtures/compression-proxy.ts | 6 +++++- e2e/tests/allowed-domains.spec.ts | 28 +++++++++++++++------------- e2e/tests/menus.spec.ts | 10 +++++++++- e2e/tests/visual-editing.spec.ts | 5 +++-- 6 files changed, 38 insertions(+), 27 deletions(-) diff --git a/.github/workflows/endform-e2e.yml b/.github/workflows/endform-e2e.yml index 9726cc2a90..df32e2e514 100644 --- a/.github/workflows/endform-e2e.yml +++ b/.github/workflows/endform-e2e.yml @@ -6,7 +6,7 @@ on: inputs: concurrency: description: Maximum tests running at once - default: "32" + default: "64" permissions: contents: read @@ -24,7 +24,7 @@ jobs: contents: read id-token: write env: - EMDASH_ENDFORM_CONCURRENCY: ${{ inputs.concurrency || '32' }} + 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: 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 index 4ce2b2d56a..8f778e9b0e 100644 --- a/e2e/fixtures/compression-proxy.ts +++ b/e2e/fixtures/compression-proxy.ts @@ -4,11 +4,14 @@ * 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. + * 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 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/; @@ -30,6 +33,7 @@ export function startCompressionProxy(listenPort: number, targetPort: number): P const compress = acceptsGzip && clientRequest.method !== "HEAD" && + BUILT_ASSET_PATH.test(clientRequest.url ?? "") && !headers["content-encoding"] && COMPRESSIBLE_TYPE.test(String(headers["content-type"] ?? "")); if (!compress) { 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/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/visual-editing.spec.ts b/e2e/tests/visual-editing.spec.ts index 6009d5823d..563910d7fa 100644 --- a/e2e/tests/visual-editing.spec.ts +++ b/e2e/tests/visual-editing.spec.ts @@ -363,6 +363,7 @@ test.describe("Inline Editor", () => { }); test("shows slash menu on / keystroke", async ({ page }) => { + await stubSaves(page); await gotoWithRetry(page, POST_WITH_IMAGE_PATH); const editor = page.locator(".emdash-inline-editor"); @@ -856,9 +857,9 @@ test.describe("Inline Editor", () => { expect(await guidance.evaluate((element) => getComputedStyle(element).direction)).toBe("rtl"); }); - test("inserts the chosen image, selects it, and saves it", async ({ page }) => { + test("inserts the chosen image, selects it, and saves it", async ({ page, serverInfo }) => { await stubSaves(page); - await gotoWithRetry(page, POST_WITH_IMAGE_PATH); + await gotoWithRetry(page, await copyImagePost(serverInfo)); const editor = page.locator(".emdash-inline-editor"); await expect(editor).toBeVisible({ timeout: 15000 }); const picker = await openImagePicker(page); From e1c5cc8d1004d72c62f2ceb11241b10eca428041 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Fri, 16 Oct 2026 12:14:00 -0700 Subject: [PATCH 07/16] test(e2e): compress each built asset once, stub the redirects empty state The gzip proxy re-compressed the 5.7 MB admin chunk on every request, which saturated the CI machine at concurrency 64. Built assets are content-hashed, so cache each compressed body for the life of the server. The redirects empty-state test now serves an empty list, since other tests create redirects at the same time. Co-Authored-By: Claude Opus 5.5 --- e2e/fixtures/compression-proxy.ts | 47 +++++++++++++++++++++++-------- e2e/tests/redirects.spec.ts | 8 ++++++ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/e2e/fixtures/compression-proxy.ts b/e2e/fixtures/compression-proxy.ts index 8f778e9b0e..99ea5e0552 100644 --- a/e2e/fixtures/compression-proxy.ts +++ b/e2e/fixtures/compression-proxy.ts @@ -8,44 +8,67 @@ * compressed, so pages and API responses reach tests exactly as the server * sent them. */ -import { createServer, request, type Server } from "node:http"; +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: clientRequest.url, + path: url, headers: clientRequest.headers, }, (upstreamResponse) => { - const headers = { ...upstreamResponse.headers }; - const acceptsGzip = ACCEPTS_GZIP.test( - String(clientRequest.headers["accept-encoding"] ?? ""), - ); + const status = upstreamResponse.statusCode ?? 502; + const headers: OutgoingHttpHeaders = { ...upstreamResponse.headers }; const compress = - acceptsGzip && - clientRequest.method !== "HEAD" && - BUILT_ASSET_PATH.test(clientRequest.url ?? "") && + wantsGzip && !headers["content-encoding"] && COMPRESSIBLE_TYPE.test(String(headers["content-type"] ?? "")); if (!compress) { - clientResponse.writeHead(upstreamResponse.statusCode ?? 502, headers); + 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(upstreamResponse.statusCode ?? 502, headers); - upstreamResponse.pipe(createGzip()).pipe(clientResponse); + 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", () => { diff --git a/e2e/tests/redirects.spec.ts b/e2e/tests/redirects.spec.ts index b6d758b835..17f9fd05c4 100644 --- a/e2e/tests/redirects.spec.ts +++ b/e2e/tests/redirects.spec.ts @@ -7,6 +7,8 @@ import { test, expect } from "../fixtures"; +const REDIRECTS_LIST_URL = /\/_emdash\/api\/redirects(\?.*)?$/; + test.describe("Redirects", () => { test.beforeEach(async ({ admin }) => { await admin.devBypassAuth(); @@ -14,6 +16,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(); From af325fdd081d9b7d201ee4445434ed5b1693550b Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Fri, 16 Oct 2026 12:22:25 -0700 Subject: [PATCH 08/16] test(e2e): isolate redirects, content edits, and comments from parallel tests - Redirects CRUD: unique paths per test, act on the test's own row by its button label, and wait on the redirect form's dialog rather than any dialog (toasts have the dialog role too). - Content CRUD: the update test edits a post it created instead of renaming the seeded "First Post" that other tests open. - Comments: the spec deletes every comment before each test, so it runs in the exclusive suite run. Co-Authored-By: Claude Opus 5.5 --- e2e/tests/content-crud.spec.ts | 28 ++++++--- e2e/tests/redirects.spec.ts | 104 ++++++++++++++++----------------- playwright.config.ts | 1 + 3 files changed, 71 insertions(+), 62 deletions(-) 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/redirects.spec.ts b/e2e/tests/redirects.spec.ts index 17f9fd05c4..50b58bf95e 100644 --- a/e2e/tests/redirects.spec.ts +++ b/e2e/tests/redirects.spec.ts @@ -5,10 +5,31 @@ * plus the 404 tracking tab. */ +import type { Page } from "@playwright/test"; + import { test, expect } from "../fixtures"; const REDIRECTS_LIST_URL = /\/_emdash\/api\/redirects(\?.*)?$/; +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(); @@ -39,100 +60,73 @@ 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