diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6230ee4996..3507479c6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + workflow_dispatch: merge_group: permissions: @@ -225,6 +226,7 @@ jobs: test-backend, test-browser, test-e2e, + test-e2e-endform, test-e2e-table, ] runs-on: ubuntu-latest @@ -246,6 +248,7 @@ jobs: BROWSER_RESULT: ${{ needs.test-browser.result }} E2E_SELECTED: ${{ needs.change-plan.outputs.e2e_node == 'true' || needs.change-plan.outputs.e2e_cloudflare == 'true' }} E2E_RESULT: ${{ needs.test-e2e.result }} + ENDFORM_RESULT: ${{ needs.test-e2e-endform.result }} TABLE_SELECTED: ${{ needs.change-plan.outputs.e2e_table }} TABLE_RESULT: ${{ needs.test-e2e-table.result }} run: | @@ -273,6 +276,7 @@ jobs: check_lane "Backend tests" "$BACKEND_SELECTED" "$BACKEND_RESULT" check_lane "Browser and playground tests" "$BROWSER_SELECTED" "$BROWSER_RESULT" check_lane "Node/Cloudflare E2E" "$E2E_SELECTED" "$E2E_RESULT" + check_lane "Endform E2E tests" "$E2E_SELECTED" "$ENDFORM_RESULT" check_lane "Table E2E" "$TABLE_SELECTED" "$TABLE_RESULT" exit "$failed" @@ -412,13 +416,15 @@ jobs: - run: echo "E2E results are aggregated by the Tests job" test-e2e: - name: E2E shard (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) + name: E2E built native (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) needs: change-plan if: needs.change-plan.outputs.e2e_node == 'true' || needs.change-plan.outputs.e2e_cloudflare == 'true' runs-on: ubuntu-latest - timeout-minutes: 40 + timeout-minutes: 90 + permissions: + contents: read strategy: - fail-fast: true + fail-fast: false matrix: shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] shardTotal: [8] @@ -451,11 +457,11 @@ jobs: - name: Run Node E2E id: node-e2e if: ${{ !cancelled() && steps.node-build.outcome == 'success' }} - run: pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + run: ENDFORM=false pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() && steps.node-e2e.outcome == 'failure' with: - name: playwright-report-node-${{ matrix.shardIndex }} + name: playwright-report-node-native-${{ matrix.shardIndex }} path: | playwright-report/ test-results/ @@ -463,13 +469,74 @@ jobs: - name: Run Cloudflare E2E id: cloudflare-e2e if: ${{ !cancelled() && steps.cloudflare-build.outcome == 'success' }} - run: pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + run: ENDFORM=false pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + env: + EMDASH_E2E_TARGET: cloudflare + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: failure() && steps.cloudflare-e2e.outcome == 'failure' + with: + name: playwright-report-cloudflare-native-${{ matrix.shardIndex }} + path: | + playwright-report/ + test-results/ + retention-days: 7 + + test-e2e-endform: + name: E2E built Endform (full suite) + needs: change-plan + if: needs.change-plan.outputs.e2e_node == 'true' || needs.change-plan.outputs.e2e_cloudflare == 'true' + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: read + id-token: write + env: + 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 Node fixture + id: node-build + if: ${{ !cancelled() && needs.change-plan.outputs.e2e_node == 'true' }} + run: pnpm run --filter emdash... build + - name: Build Cloudflare fixture + id: cloudflare-build + if: ${{ !cancelled() && needs.change-plan.outputs.e2e_cloudflare == 'true' }} + run: pnpm run --filter "emdash-e2e-fixture-cloudflare..." build + - name: Run Node E2E + id: node-e2e + if: ${{ !cancelled() && steps.node-build.outcome == 'success' }} + run: | + pnpm exec endform --version + pnpm exec tsx e2e/run-endform.ts test --organization-id 2G1ZCj7X --timeout 240000 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: failure() && steps.node-e2e.outcome == 'failure' + with: + name: playwright-report-node-endform + path: | + playwright-report/ + test-results/ + retention-days: 7 + - name: Run Cloudflare E2E + id: cloudflare-e2e + if: ${{ !cancelled() && steps.cloudflare-build.outcome == 'success' }} + run: | + pnpm exec endform --version + pnpm exec tsx e2e/run-endform.ts test --organization-id 2G1ZCj7X --timeout 240000 env: EMDASH_E2E_TARGET: cloudflare - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() && steps.cloudflare-e2e.outcome == 'failure' with: - name: playwright-report-cloudflare-${{ matrix.shardIndex }} + name: playwright-report-cloudflare-endform path: | playwright-report/ test-results/ diff --git a/e2e/endform-server.ts b/e2e/endform-server.ts new file mode 100644 index 0000000000..231552e8fe --- /dev/null +++ b/e2e/endform-server.ts @@ -0,0 +1,56 @@ +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 serverInfoPath = join(tmpdir(), "emdash-pw-server.json"); + +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(serverInfoPath)); + 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(serverInfoPath, "utf8")); + info.token = update.token; + writeFileSync(serverInfoPath, JSON.stringify(info)); + response.end(); + return; + } + response.writeHead(405).end(); +}); + +server.listen(4446, "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-cloudflare/astro.config.mjs b/e2e/fixture-cloudflare/astro.config.mjs index 29425265bb..2942a17f22 100644 --- a/e2e/fixture-cloudflare/astro.config.mjs +++ b/e2e/fixture-cloudflare/astro.config.mjs @@ -16,6 +16,8 @@ import registryTestPlugin from "@emdash-cms/plugin-marketplace-test"; import { defineConfig } from "astro/config"; import emdash from "emdash/astro"; +import { builtTestHelpers } from "../fixtures/built-test-helpers.mjs"; + const marketplaceUrl = process.env.EMDASH_MARKETPLACE_URL || undefined; const registryUrl = process.env.EMDASH_REGISTRY_URL || undefined; const registryFixturePath = process.env.EMDASH_REGISTRY_FIXTURE; @@ -80,17 +82,32 @@ export default defineConfig({ integrations: [ react(), emdash({ + siteUrl: "http://localhost:4444", database: d1({ binding: "DB" }), middleware: { outer: "./src/outer-middleware.ts" }, storage: r2({ binding: "MEDIA" }), - plugins: [colorPlugin()], + plugins: [ + colorPlugin(), + ...(process.env.EMDASH_E2E_SERVER === "dev" + ? [] + : [ + { + id: "emdash-console-email", + version: "0.0.0", + entrypoint: fileURLToPath( + new URL("../fixtures/email-capture-plugin.mjs", import.meta.url), + ), + }, + ]), + ], sandboxed: [{ ...registryTestPlugin, hooks: e2eHooks }, deniedPlugin], marketplace: marketplaceUrl, - registry: registryUrl, + registry: process.env.EMDASH_E2E_SERVER === "dev" ? registryUrl : false, sandboxRunner: sandbox(), }), ], i18n: { + routing: "manual", defaultLocale: "en", locales: ["en", "fr", "es"], fallback: { fr: "en", es: "en" }, @@ -100,7 +117,7 @@ export default defineConfig({ define: { __EMDASH_REGISTRY_FIXTURE__: JSON.stringify(registryFixture), }, - plugins: [lateManifestImport], + plugins: [builtTestHelpers(), lateManifestImport], server: { fs: { strict: false }, }, diff --git a/e2e/fixture-cloudflare/src/middleware.ts b/e2e/fixture-cloudflare/src/middleware.ts new file mode 100644 index 0000000000..a8f9e3bee1 --- /dev/null +++ b/e2e/fixture-cloudflare/src/middleware.ts @@ -0,0 +1 @@ +export { onRequest } from "../../fixtures/locale-middleware.js"; diff --git a/e2e/fixture/astro.config.mjs b/e2e/fixture/astro.config.mjs index 047ae3d8b6..5e7fafa5f2 100644 --- a/e2e/fixture/astro.config.mjs +++ b/e2e/fixture/astro.config.mjs @@ -16,13 +16,16 @@ import emdash from "emdash/astro"; import { sqlite } from "emdash/db"; import { installRegistryAuthoritativeFixture } from "emdash/internal/testing/registry"; +import { builtTestHelpers } from "../fixtures/built-test-helpers.mjs"; + const dbUrl = process.env.EMDASH_TEST_DB || "file:./test.db"; const marketplaceUrl = process.env.EMDASH_MARKETPLACE_URL || undefined; const registryUrl = process.env.EMDASH_REGISTRY_URL || undefined; const registryFixturePath = process.env.EMDASH_REGISTRY_FIXTURE; -if (registryFixturePath) { - installRegistryAuthoritativeFixture(JSON.parse(readFileSync(registryFixturePath, "utf8"))); -} +const registryFixture = registryFixturePath + ? JSON.parse(readFileSync(registryFixturePath, "utf8")) + : null; +if (registryFixture) installRegistryAuthoritativeFixture(registryFixture); const e2eHookNames = new Set([ "content:afterSave", "content:beforeDelete", @@ -108,22 +111,39 @@ export default defineConfig({ integrations: [ react(), emdash({ + siteUrl: "http://localhost:4444", database: sqlite({ url: dbUrl }), middleware: { outer: "./src/outer-middleware.ts" }, - plugins: [colorPlugin()], + plugins: [ + colorPlugin(), + ...(process.env.EMDASH_E2E_SERVER === "dev" + ? [] + : [ + { + id: "emdash-console-email", + version: "0.0.0", + entrypoint: fileURLToPath( + new URL("../fixtures/email-capture-plugin.mjs", import.meta.url), + ), + }, + ]), + ], sandboxed: [editorExtensionsPlugin, { ...registryTestPlugin, hooks: e2eHooks }, deniedPlugin], marketplace: marketplaceUrl, - registry: registryUrl, + registry: process.env.EMDASH_E2E_SERVER === "dev" ? registryUrl : false, sandboxRunner: "@emdash-cms/sandbox-workerd", }), ], i18n: { + routing: "manual", defaultLocale: "en", locales: ["en", "fr", "es"], fallback: { fr: "en", es: "en" }, }, devToolbar: { enabled: false }, vite: { + define: { __EMDASH_REGISTRY_FIXTURE__: JSON.stringify(registryFixture) }, + plugins: [builtTestHelpers()], server: { fs: { strict: false }, }, diff --git a/e2e/fixture/package.json b/e2e/fixture/package.json index 66e9718242..4fd3c30f3d 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..a8f9e3bee1 --- /dev/null +++ b/e2e/fixture/src/middleware.ts @@ -0,0 +1 @@ +export { onRequest } from "../../fixtures/locale-middleware.js"; diff --git a/e2e/fixture/src/outer-middleware.ts b/e2e/fixture/src/outer-middleware.ts index 327310a38c..9bfa4f5174 100644 --- a/e2e/fixture/src/outer-middleware.ts +++ b/e2e/fixture/src/outer-middleware.ts @@ -1,6 +1,13 @@ import { defineMiddleware } from "astro:middleware"; +import { installRegistryAuthoritativeFixture } from "emdash/internal/testing/registry"; import { getRequestContext } from "emdash/request-context"; +declare const __EMDASH_REGISTRY_FIXTURE__: unknown; + +if (__EMDASH_REGISTRY_FIXTURE__ !== null) { + installRegistryAuthoritativeFixture(__EMDASH_REGISTRY_FIXTURE__); +} + const FINALIZER_NONCE = "outer-finalizer"; function preNextState(locals: App.Locals): string { diff --git a/e2e/fixtures/built-test-helpers.mjs b/e2e/fixtures/built-test-helpers.mjs new file mode 100644 index 0000000000..cc2f0f270f --- /dev/null +++ b/e2e/fixtures/built-test-helpers.mjs @@ -0,0 +1,60 @@ +const HELPER_ROUTE = + /\/packages\/core\/(?:src|dist)\/astro\/routes\/api\/(?:setup\/dev-(?:bypass|reset)|auth\/dev-bypass|dev\/emails)\.(?:ts|mjs)$/; +const ARTIFACT_ROUTE = + /\/packages\/core\/(?:src|dist)\/astro\/routes\/api\/admin\/plugins\/registry\/artifact\.(?:ts|mjs)$/; +const TRAILING_SEMICOLON = /;\s*$/; + +// Private fixture builds retain setup helpers and the localhost mock registry. +// The admin and application middleware still compile in production mode. +export function builtTestHelpers() { + return { + name: "emdash-e2e-built-helpers", + apply: "build", + enforce: "pre", + transform(code, id) { + if (id === "\0virtual:emdash/config") { + const config = JSON.parse( + code.slice("export default ".length).replace(TRAILING_SEMICOLON, ""), + ); + config.registry = process.env.EMDASH_REGISTRY_URL ?? false; + return { code: `export default ${JSON.stringify(config)};`, map: null }; + } + if (id.includes("/packages/core/") && code.includes("function validateAggregatorUrl(")) { + return { + code: code.replace( + "options.allowLocalhost ?? import.meta.env.DEV", + '(aggregatorUrl === "http://127.0.0.1:4445" || (options.allowLocalhost ?? import.meta.env.DEV))', + ), + map: null, + }; + } + if ( + id.includes("/packages/core/") && + code.includes("async function resolveSafeArtifactTarget(") + ) { + return { + code: code.replace( + "if (!import.meta.env.DEV)", + 'if (!import.meta.env.DEV && url.origin !== "http://127.0.0.1:4445")', + ), + map: null, + }; + } + if ( + ARTIFACT_ROUTE.test(id) || + (id.includes("/packages/core/") && + code.includes("Registry artifact fetch requires an abort signal")) + ) { + return { + code: code.replace("allowHttpLocalhost: import.meta.env.DEV", "allowHttpLocalhost: true"), + map: null, + }; + } + if (!HELPER_ROUTE.test(id.split("?")[0])) return undefined; + if (!code.includes("import.meta.env.DEV")) { + throw new Error(`Missing development guard in fixture helper: ${id}`); + } + return { code: code.replaceAll("import.meta.env.DEV", "true"), map: null }; + }, + }; +} diff --git a/e2e/fixtures/email-capture-plugin.mjs b/e2e/fixtures/email-capture-plugin.mjs new file mode 100644 index 0000000000..d17467993b --- /dev/null +++ b/e2e/fixtures/email-capture-plugin.mjs @@ -0,0 +1,20 @@ +import { definePlugin } from "emdash"; + +export function createPlugin() { + return definePlugin({ + id: "emdash-console-email", + version: "0.0.0", + capabilities: ["hooks.email-transport:register"], + hooks: { + "email:deliver": { + exclusive: true, + handler: async ({ message, source }) => { + const key = Symbol.for("emdash:dev-emails"); + const emails = (globalThis[key] ??= []); + emails.push({ message, source, sentAt: new Date().toISOString() }); + if (emails.length > 100) emails.shift(); + }, + }, + }, + }); +} diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts index a17c004a49..cbe9398d0b 100644 --- a/e2e/fixtures/index.ts +++ b/e2e/fixtures/index.ts @@ -6,13 +6,14 @@ * 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"; import { test as base } from "@playwright/test"; import { AdminPage } from "./admin"; +import { warmUpAdmin } from "./warm-up-admin.js"; export { AdminPage } from "./admin"; @@ -40,7 +41,29 @@ function getServerInfo(): ServerInfo { export const test = base.extend<{ admin: AdminPage; serverInfo: ServerInfo; + _endformServerState: void; }>({ + page: [ + async ({ page }, use) => { + if (process.env.ENDFORM === "true" && process.env.EMDASH_E2E_SERVER === "dev") { + await warmUpAdmin(page, process.env.BASE_URL || "http://localhost:4444"); + } + await use(page); + }, + { timeout: 240_000 }, + ], + _endformServerState: [ + // oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture dependencies + async ({}, use) => { + if (process.env.ENDFORM === "true") { + const response = await fetch("http://localhost:4446/server-info"); + if (!response.ok) throw new Error("Unable to load fixture server state"); + 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/locale-middleware.ts b/e2e/fixtures/locale-middleware.ts new file mode 100644 index 0000000000..bfe76b07b9 --- /dev/null +++ b/e2e/fixtures/locale-middleware.ts @@ -0,0 +1,9 @@ +import { defineMiddleware } from "astro:middleware"; + +const LOCALES = new Set(["en", "fr", "es"]); + +export const onRequest = defineMiddleware((context, next) => { + const locale = context.params.locale; + if (locale && !LOCALES.has(locale)) return new Response("Not found", { status: 404 }); + return next(); +}); diff --git a/e2e/fixtures/refresh-server-pat.ts b/e2e/fixtures/refresh-server-pat.ts index 8fb56d026d..914eb535c8 100644 --- a/e2e/fixtures/refresh-server-pat.ts +++ b/e2e/fixtures/refresh-server-pat.ts @@ -21,4 +21,12 @@ export async function refreshServerPatAfterDevBypass(baseUrl: string): Promise = { const TARGET = TARGETS[process.env.EMDASH_E2E_TARGET ?? "node"] ?? TARGETS.node!; const FIXTURE_DIR = TARGET.fixtureDir; +const BUILT_SERVER = process.env.EMDASH_E2E_SERVER !== "dev"; const CLI_BINARY = resolve(ROOT, "packages/core/dist/cli/index.mjs"); const PORT = 4444; const MARKETPLACE_PORT = 4445; @@ -291,36 +296,16 @@ async function seedTestData( }; } -async function warmUpAdmin(baseUrl: string): Promise { - const { chromium } = await import("@playwright/test"); - const browser = await chromium.launch(); - try { - const page = await browser.newPage(); - // The second load starts after any optimizer reload from the first, so - // its hydration means the dependency set has settled. - for (let load = 0; load < 2; load++) { - try { - await page.goto(`${baseUrl}/_emdash/admin/login`, { - waitUntil: "commit", - timeout: 120_000, - }); - await page.waitForSelector("astro-island:not([ssr])", { timeout: 120_000 }); - await page.waitForLoadState("networkidle", { timeout: 60_000 }); - } catch (error) { - // Non-fatal: a cold first admin test can still pass on retry. - console.warn(`[pw] Admin warm-up load ${load + 1} failed:`, error); - } - } - } finally { - await browser.close(); - } -} - // --------------------------------------------------------------------------- // Global setup // --------------------------------------------------------------------------- export default async function globalSetup(): Promise { + await new Promise((resolvePort, reject) => { + const probe = createServer(); + probe.once("error", reject); + probe.listen(PORT, "127.0.0.1", () => probe.close(() => resolvePort())); + }); await ensureBuilt(); await ensureFixtureDepsBuilt(); @@ -352,22 +337,53 @@ 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. Build and serve the fixture with the same isolated data for both runners. const astroBin = join(fixtureNodeModules, ".bin", "astro"); - const server = spawn(astroBin, ["dev", "--port", String(PORT)], { - cwd: workDir, - env: { - ...process.env, - // 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", + const serverEnv = { + ...process.env, + HOST: "127.0.0.1", + PORT: String(PORT), + ASTRO_DEV_BACKGROUND: "1", + ASTRO_PREVIEW_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", + }; + if (BUILT_SERVER) { + console.log("[pw] Building production fixture..."); + try { + await execAsync(astroBin, ["build"], { + cwd: workDir, + env: serverEnv, + timeout: 240_000, + maxBuffer: 10 * 1024 * 1024, + }); + } catch (error) { + marketplaceServer.close(); + rmSync(tempDataDir, { recursive: true, force: true }); + throw error; + } + } + const standaloneNode = BUILT_SERVER && TARGET.usesTempDb; + const processGroup = true; + const server = spawn( + standaloneNode ? process.execPath : astroBin, + standaloneNode + ? [join(workDir, "dist/server/entry.mjs")] + : [BUILT_SERVER ? "preview" : "dev", "--port", String(PORT)], + { + cwd: workDir, + env: serverEnv, + detached: processGroup, + stdio: "pipe", }, - stdio: "pipe", - }); + ); + writeFileSync( + SERVER_INFO_PATH, + JSON.stringify({ pid: server.pid, processGroup, workDir, tempDataDir }), + ); let serverOutput = ""; const appendServerOutput = (data: Buffer) => { @@ -428,12 +444,21 @@ export default async function globalSetup(): Promise { // 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); + if (!BUILT_SERVER && process.env.ENDFORM !== "true") { + console.log("[pw] Warming up admin..."); + const { chromium } = await import("@playwright/test"); + const browser = await chromium.launch(); + try { + await warmUpAdmin(await browser.newPage(), baseUrl); + } finally { + await browser.close(); + } + } // 6. Write server info const info = { pid: server.pid!, + processGroup, workDir, tempDataDir, baseUrl, @@ -448,7 +473,7 @@ export default async function globalSetup(): Promise { console.log(`[pw] Server ready at ${baseUrl} (pid ${server.pid})`); } catch (error) { - server.kill("SIGTERM"); + await globalTeardown(); marketplaceServer.close(); throw new Error( `${error instanceof Error ? error.message : String(error)}\n\nServer output:\n${serverOutput}`, diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts index 8d865973d9..330f588e37 100644 --- a/e2e/global-teardown.ts +++ b/e2e/global-teardown.ts @@ -23,11 +23,12 @@ export default async function globalTeardown(): Promise { // Kill the server process try { - process.kill(info.pid, "SIGTERM"); + const serverPid = info.processGroup ? -info.pid : info.pid; + process.kill(serverPid, "SIGTERM"); await new Promise((r) => setTimeout(r, 1000)); try { - process.kill(info.pid, 0); // Check if alive - process.kill(info.pid, "SIGKILL"); + process.kill(serverPid, 0); // Check if alive + process.kill(serverPid, "SIGKILL"); } catch { // Already dead } diff --git a/e2e/run-endform.ts b/e2e/run-endform.ts new file mode 100644 index 0000000000..7422c53397 --- /dev/null +++ b/e2e/run-endform.ts @@ -0,0 +1,14 @@ +import { spawn } from "node:child_process"; + +import globalTeardown from "./global-teardown.js"; + +try { + const child = spawn("endform", process.argv.slice(2), { stdio: "inherit" }); + process.exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code) => resolve(code ?? 1)); + }); +} finally { + // Endform can terminate its webServer before the server's signal handler runs. + await globalTeardown(); +} diff --git a/e2e/tests/content-crud.spec.ts b/e2e/tests/content-crud.spec.ts index cec6e45120..7e7fcb598e 100644 --- a/e2e/tests/content-crud.spec.ts +++ b/e2e/tests/content-crud.spec.ts @@ -19,6 +19,7 @@ const CONTENT_ID_PATTERN = /\/content\/posts\/[A-Z0-9]+(?:\?.*)?$/; const NEW_CONTENT_URL_PATTERN = /\/content\/posts\/new(?:[?#].*)?$/; test.describe("Content CRUD", () => { + test.describe.configure({ mode: "serial" }); test.beforeEach(async ({ admin }) => { await admin.devBypassAuth(); }); 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/editor-image-toolbar.spec.ts b/e2e/tests/editor-image-toolbar.spec.ts index 1e5fac5ab6..979b0c43e4 100644 --- a/e2e/tests/editor-image-toolbar.spec.ts +++ b/e2e/tests/editor-image-toolbar.spec.ts @@ -3,7 +3,7 @@ import type { APIRequestContext } from "@playwright/test"; import { test, expect } from "../fixtures"; const EDITOR = "#field-body .ProseMirror"; -const IMAGE_URL = "http://media.example.test/editor-toolbar.png"; +const IMAGE_URL = "https://media.example.test/editor-toolbar.png"; const csrfHeaders = (token: string) => ({ Authorization: `Bearer ${token}`, "X-EmDash-Request": "1", diff --git a/endform.config.ts b/endform.config.ts new file mode 100644 index 0000000000..65e17d0062 --- /dev/null +++ b/endform.config.ts @@ -0,0 +1,7 @@ +import { defineEndformConfig } from "endform"; + +export default defineEndformConfig({ + additionalFiles: ["e2e/fixtures/assets/test-image.png"], + environmentVariables: ["^EMDASH_E2E_TARGET$", "^EMDASH_E2E_SERVER$"], + proxyNetworkHosts: [""], +}); 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..21d9fd7297 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,18 +1,19 @@ import { defineConfig, devices } from "@playwright/test"; +const isEndform = process.env.ENDFORM === "true"; + /** * Playwright E2E test configuration for EmDash CMS * * Tests run against an isolated fixture (not a demo app). - * Global setup creates a temp directory, starts a fresh dev server, - * runs setup via dev-bypass, and seeds collections with test data. + * Global setup builds the fixture, starts its production server, + * and seeds an isolated database through fixture-only setup helpers. * Port 4444 is used to avoid conflicts with development servers. */ export default defineConfig({ testDir: "./e2e/tests", testIgnore: "portable-text-table.spec.ts", - // Disable parallel to avoid shared database state issues - fullyParallel: false, + fullyParallel: isEndform, workers: 1, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, @@ -23,12 +24,20 @@ 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", + webServer: isEndform + ? { + command: "pnpm exec tsx e2e/endform-server.ts", + url: "http://127.0.0.1:4446/ready", + reuseExistingServer: false, + timeout: 240_000, + } + : undefined, use: { - baseURL: "http://localhost:4444", - trace: "on-first-retry", + baseURL: process.env.BASE_URL || "http://localhost:4444", + trace: "retain-on-failure", screenshot: "only-on-failure", }, @@ -38,6 +47,4 @@ export default defineConfig({ use: { ...devices["Desktop Chrome"] }, }, ], - - // No webServer — global-setup.ts handles server lifecycle }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e03384404..11d6dc67c0 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 @@ -1145,6 +1151,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: @@ -9778,6 +9787,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'} @@ -20410,6 +20453,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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1169de4722..3ae80a20f3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,13 @@ minimumReleaseAge: 1440 # Scoped to the binaries/runtime only -- @rolldown/pluginutils etc. stay # under the cooldown. minimumReleaseAgeExclude: + - "endform@0.81.4" + - "endform-darwin-arm64@0.81.4" + - "endform-darwin-x86@0.81.4" + - "endform-linux-arm64@0.81.4" + - "endform-linux-x86@0.81.4" + - "endform-win32-arm64@0.81.4" + - "endform-win32-x86@0.81.4" - "@rolldown/binding-*" - "@emnapi/core" - "@emnapi/runtime"