From bbce9bbed7a3702310f7b854defd9599234fcd02 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Mon, 26 Oct 2026 21:45:24 -0700 Subject: [PATCH 1/3] feat(core): add dangerouslyEnableTestHooks for testing production builds The dev-bypass, dev-reset and captured-email endpoints, the console email provider and localhost registry URLs are gated on import.meta.env.DEV, so an end-to-end suite that signs in through them can only run against `astro dev`. The new integration option sets a build-time constant that the shared isDevOnlyEnabled() gate reads alongside import.meta.env.DEV. A build without the option gets `false`, so nothing at runtime can switch the endpoints on, and the build-time registry check accepts localhost only when the option is set. `astro build` warns while it is on. Co-Authored-By: Claude Opus 5.5 --- .changeset/dangerously-enable-test-hooks.md | 14 ++++++ packages/core/src/api/dev-only.ts | 15 ++++++ packages/core/src/api/handlers/registry.ts | 3 +- packages/core/src/astro/integration/index.ts | 10 +++- .../core/src/astro/integration/runtime.ts | 14 ++++++ .../core/src/astro/integration/vite-config.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 +- .../integration/test-hooks-option.test.ts | 47 +++++++++++++++++++ 15 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 .changeset/dangerously-enable-test-hooks.md create mode 100644 packages/core/src/api/dev-only.ts create mode 100644 packages/core/tests/unit/astro/integration/test-hooks-option.test.ts diff --git a/.changeset/dangerously-enable-test-hooks.md b/.changeset/dangerously-enable-test-hooks.md new file mode 100644 index 0000000000..afdcd22b2c --- /dev/null +++ b/.changeset/dangerously-enable-test-hooks.md @@ -0,0 +1,14 @@ +--- +"emdash": minor +--- + +Adds `dangerouslyEnableTestHooks` to the `emdash()` integration so end-to-end suites can run against a production build. It turns on the development-only endpoints (`/_emdash/api/setup/dev-bypass`, `/_emdash/api/auth/dev-bypass`, `/_emdash/api/setup/dev-reset`, `/_emdash/api/dev/emails`), the console email provider, and plain-HTTP localhost registry URLs, which otherwise work only under `astro dev`. + +```js +emdash({ + database: sqlite({ url: "file:./test.db" }), + dangerouslyEnableTestHooks: true, +}); +``` + +The option is read at build time and defaults to `false`. Anyone who can reach a site built with it can sign in as an administrator, so use it only for test builds and never deploy one. `astro build` prints a warning when it is on. Builds without it behave as before. diff --git a/packages/core/src/api/dev-only.ts b/packages/core/src/api/dev-only.ts new file mode 100644 index 0000000000..e4fd45ce92 --- /dev/null +++ b/packages/core/src/api/dev-only.ts @@ -0,0 +1,15 @@ +declare const __EMDASH_TEST_HOOKS__: boolean; + +/** + * Gate for development-only endpoints and behavior (dev-bypass, dev-reset, + * captured dev emails, localhost registries). + * + * True under `astro dev`, and in builds made with `dangerouslyEnableTestHooks`. + * Both are build-time constants: in a normal production build this returns + * `false`, and no environment variable or request can change that. + */ +export function isDevOnlyEnabled(): boolean { + return ( + import.meta.env.DEV || (typeof __EMDASH_TEST_HOOKS__ !== "undefined" && __EMDASH_TEST_HOOKS__) + ); +} 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 6c0a865628..45eb3674d2 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -632,9 +632,17 @@ 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" || + resolvedConfig.dangerouslyEnableTestHooks === true, }); printBanner(logger); + if (resolvedConfig.dangerouslyEnableTestHooks && command === "build") { + logger.warn( + "dangerouslyEnableTestHooks is on: anyone who can reach this build can sign in as an administrator. Use it only for end-to-end tests, and never deploy it.", + ); + } if (command === "dev") { // In Node `astro dev`, Vite loads `.env` into `import.meta.env` // but not `process.env`. The plugin-secret encryption key diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index be82cf9218..8d4773d307 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -715,6 +715,20 @@ export interface EmDashConfig { * `env:astro` requirement can be evaluated against the real host version. */ astroVersion?: string; + + /** + * Turns on the development-only endpoints and behavior in a production + * build: dev-bypass login and setup, dev-reset, the console email provider + * and its captured-email endpoint, and plain-HTTP localhost registry URLs. + * + * For end-to-end tests of a built site only. Anyone who can reach a site + * built with this option can sign in as an administrator, so never deploy + * that build. The value is fixed at build time and cannot be switched on + * at runtime. + * + * @default false + */ + dangerouslyEnableTestHooks?: boolean; } const STORED_CONFIG_KEY = Symbol.for("emdash:stored-config"); diff --git a/packages/core/src/astro/integration/vite-config.ts b/packages/core/src/astro/integration/vite-config.ts index 81d8490cce..9590b5c8ac 100644 --- a/packages/core/src/astro/integration/vite-config.ts +++ b/packages/core/src/astro/integration/vite-config.ts @@ -440,6 +440,9 @@ export function createViteConfig( isDev && process.env["EMDASH_PSEUDO_LOCALE"] === "1", ), __EMDASH_ADMIN_LOCALES__: JSON.stringify(adminLocales ?? null), + __EMDASH_TEST_HOOKS__: JSON.stringify( + options.resolvedConfig.dangerouslyEnableTestHooks === true, + ), }, resolve: { dedupe: ["@emdash-cms/admin", "react", "react-dom"], 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 cf17707a38..9fc4235a77 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/core/tests/unit/astro/integration/test-hooks-option.test.ts b/packages/core/tests/unit/astro/integration/test-hooks-option.test.ts new file mode 100644 index 0000000000..7ac5801069 --- /dev/null +++ b/packages/core/tests/unit/astro/integration/test-hooks-option.test.ts @@ -0,0 +1,47 @@ +import type { AstroIntegration } from "astro"; +import { describe, expect, it, vi } from "vitest"; + +import type { EmDashConfig } from "../../../../src/astro/integration/index.js"; +import { emdash } from "../../../../src/astro/integration/index.js"; + +function configSetup(integration: AstroIntegration) { + const handler = integration.hooks["astro:config:setup"]; + if (typeof handler !== "function") throw new Error("Missing astro:config:setup hook"); + return handler; +} + +async function build(config: EmDashConfig) { + const root = new URL("file:///tmp/emdash-test-hooks/"); + const logger = { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }; + await configSetup(emdash(config))({ + command: "build", + config: { + root, + srcDir: new URL("src/", root), + security: {}, + trailingSlash: "ignore", + integrations: [{ name: "@astrojs/react", hooks: {} }], + }, + logger, + injectRoute: vi.fn(), + addMiddleware: vi.fn(), + updateConfig: vi.fn(), + } as never); + return logger.warn.mock.calls.map(([message]) => String(message)); +} + +describe("dangerouslyEnableTestHooks", () => { + it("rejects a localhost registry in a production build without it", async () => { + await expect(build({ registry: "http://127.0.0.1:4445" })).rejects.toThrow( + "must use HTTPS outside development", + ); + }); + + it("accepts a localhost registry in a production build and warns", async () => { + const warnings = await build({ + registry: "http://127.0.0.1:4445", + dangerouslyEnableTestHooks: true, + }); + expect(warnings).toContainEqual(expect.stringContaining("never deploy it")); + }); +}); From 8d301459ffcf566735209e222cd8708ff6be3c57 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Tue, 27 Oct 2026 13:46:00 -0700 Subject: [PATCH 2/3] test(e2e): run the Node suite against a production build EMDASH_E2E_BUILD=production now runs `astro build` on the Node fixture and serves dist/server/entry.mjs instead of running `astro dev`. The fixture sets dangerouslyEnableTestHooks so the suite can still sign in and reset the site, and EMDASH_SITE_URL because production setup and image.remotePatterns need it. Also fixes what a built fixture exposes: manual i18n routing so the fixture's [locale] pages win over Astro's fallback routes, sharp for the image endpoint, the registry fixture installed at server start, an https image URL for the CSP, and an ignore rule for the migrations journal that builds write into the fixture. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + e2e/fixture/astro.config.mjs | 6 ++ e2e/fixture/package.json | 3 +- e2e/fixture/src/middleware.ts | 5 ++ e2e/fixture/src/outer-middleware.ts | 10 +++ e2e/global-setup.ts | 108 +++++++++++++++++++-------- e2e/tests/editor-image-panel.spec.ts | 2 +- pnpm-lock.yaml | 4 +- 8 files changed, 103 insertions(+), 36 deletions(-) create mode 100644 e2e/fixture/src/middleware.ts diff --git a/.gitignore b/.gitignore index 8b2891a360..deb3423b55 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,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/astro.config.mjs b/e2e/fixture/astro.config.mjs index 047ae3d8b6..1b2d20ffb0 100644 --- a/e2e/fixture/astro.config.mjs +++ b/e2e/fixture/astro.config.mjs @@ -115,12 +115,18 @@ export default defineConfig({ marketplace: marketplaceUrl, registry: registryUrl, sandboxRunner: "@emdash-cms/sandbox-workerd", + // The suite signs in and resets the site through the dev-only + // endpoints, which a production build otherwise compiles out. + dangerouslyEnableTestHooks: true, }), ], i18n: { 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..f6f115b9ff 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"; +// A built server never runs astro.config.mjs, which installs the fixture for +// `astro dev`, so install it here at startup too. +const registryFixturePath = process.env.EMDASH_REGISTRY_FIXTURE; +if (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 6057d6c479..98bc3d4c90 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,11 @@ const TARGETS: Record = { }; const TARGET = TARGETS[process.env.EMDASH_E2E_TARGET ?? "node"] ?? TARGETS.node!; +/** + * `EMDASH_E2E_BUILD=production` builds the fixture for production and serves + * the built output instead of running `astro dev`. Node target only. + */ +const PRODUCTION_BUILD = process.env.EMDASH_E2E_BUILD === "production"; const FIXTURE_DIR = TARGET.fixtureDir; const CLI_BINARY = resolve(ROOT, "packages/core/dist/cli/index.mjs"); const PORT = 4444; @@ -336,7 +341,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 +369,43 @@ 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, { + // The build derives `image.remotePatterns` for EmDash media from the site URL. + EMDASH_SITE_URL: baseUrl, + }); + console.log("[pw] Building fixture..."); + const buildStart = Date.now(); + 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 +449,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/pnpm-lock.yaml b/pnpm-lock.yaml index 8a66e1780e..bc4200add9 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: @@ -22366,7 +22369,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 46601034cb758c48503591aaf4b939ab12b67c01 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Mon, 26 Oct 2026 21:45:33 -0700 Subject: [PATCH 3/3] ci: run the Node E2E shards against a production build Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 429affbddf..40a7173473 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -462,6 +462,8 @@ jobs: id: node-e2e if: needs.change-plan.outputs.e2e_node == 'true' run: pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + env: + EMDASH_E2E_BUILD: production - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() && steps.node-e2e.outcome == 'failure' with: