From b656c46734adc104ad769808083e777680a4df2e Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Tue, 27 Oct 2026 13:45:44 -0700 Subject: [PATCH 1/4] test(e2e): run the Node suite against a dev-output build EMDASH_E2E_BUILD=production now builds the Node fixture with `astro build --devOutput` and serves dist/server/entry.mjs instead of running `astro dev`. The build keeps `import.meta.env.DEV` true, so the dev-bypass, dev-reset and captured-email endpoints the suite depends on work without any test switch in EmDash. 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 | 3 + e2e/fixture/package.json | 3 +- e2e/fixture/src/middleware.ts | 5 ++ e2e/fixture/src/outer-middleware.ts | 10 +++ e2e/global-setup.ts | 114 +++++++++++++++++++-------- e2e/tests/editor-image-panel.spec.ts | 2 +- pnpm-lock.yaml | 4 +- 8 files changed, 106 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..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..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..1c7fdd5a94 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 with `astro build --devOutput` + * 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,49 @@ export default async function globalSetup(): Promise { rmSync(join(FIXTURE_DIR, ".wrangler"), { recursive: true, force: true }); } - // 2. Start dev server (with marketplace URL injected via env) + // 2. Start the server (with marketplace URL injected via env) const astroBin = join(fixtureNodeModules, ".bin", "astro"); - const server = spawn(astroBin, ["dev", "--port", String(PORT)], { - cwd: workDir, - env: { - ...process.env, + const fixtureEnv = { + ...process.env, + EMDASH_TEST_DB: `file:${dbPath}`, + EMDASH_MARKETPLACE_URL: marketplaceUrl, + EMDASH_REGISTRY_URL: marketplaceUrl, + EMDASH_REGISTRY_FIXTURE: registryFixturePath, + EMDASH_ENCRYPTION_KEY: "emdash_enc_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }; + let server; + if (PRODUCTION_BUILD) { + Object.assign(fixtureEnv, { + // 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(); + // `--devOutput` keeps `import.meta.env.DEV` true, which the dev-bypass + // endpoints the suite logs in through require. + await execAsync(astroBin, ["build", "--devOutput"], { + 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 +455,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 f4c0ccf228e3c14732cbaff1837684330805f1c3 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Mon, 26 Oct 2026 21:41:01 -0700 Subject: [PATCH 2/4] fix(core): accept localhost registries in astro build --devOutput The build-time registry check only allowed localhost for `astro dev` and `astro sync`, while the runtime check follows import.meta.env.DEV. A --devOutput build therefore failed at build time although the built site would accept the URL. Astro sets NODE_ENV=development for --devOutput before loading the config, so the build check uses that. Co-Authored-By: Claude Opus 5.5 --- .changeset/registry-dev-output-build.md | 5 +++ packages/core/src/astro/integration/index.ts | 5 ++- .../registry-localhost-build.test.ts | 44 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .changeset/registry-dev-output-build.md create mode 100644 packages/core/tests/unit/astro/integration/registry-localhost-build.test.ts diff --git a/.changeset/registry-dev-output-build.md b/.changeset/registry-dev-output-build.md new file mode 100644 index 0000000000..0d62d87b8a --- /dev/null +++ b/.changeset/registry-dev-output-build.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes `astro build --devOutput` for sites whose `registry` points at a plain-HTTP localhost aggregator. The build no longer fails with "must use HTTPS outside development"; a development-output build now accepts the same localhost registry URLs as `astro dev`. Production builds still reject them. diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index 6c0a865628..36d01735d1 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -632,7 +632,10 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { astroCommand = command; usesCloudflareAdapter = astroConfig.adapter?.name === "@astrojs/cloudflare"; normalizeRegistryConfig(registry, { - allowLocalhost: command === "dev" || command === "sync", + // `astro build --devOutput` sets NODE_ENV before loading the config and + // produces a build whose runtime registry check allows localhost. + allowLocalhost: + command === "dev" || command === "sync" || process.env.NODE_ENV === "development", }); printBanner(logger); if (command === "dev") { diff --git a/packages/core/tests/unit/astro/integration/registry-localhost-build.test.ts b/packages/core/tests/unit/astro/integration/registry-localhost-build.test.ts new file mode 100644 index 0000000000..b8efd31c19 --- /dev/null +++ b/packages/core/tests/unit/astro/integration/registry-localhost-build.test.ts @@ -0,0 +1,44 @@ +import type { AstroIntegration } from "astro"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +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() { + const root = new URL("file:///tmp/emdash-registry-localhost/"); + await configSetup(emdash({ registry: "http://127.0.0.1:4445" }))({ + command: "build", + config: { + root, + srcDir: new URL("src/", root), + security: {}, + trailingSlash: "ignore", + integrations: [{ name: "@astrojs/react", hooks: {} }], + }, + logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + injectRoute: vi.fn(), + addMiddleware: vi.fn(), + updateConfig: vi.fn(), + } as never); +} + +describe("localhost registry during astro build", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("accepts a localhost aggregator in a --devOutput build", async () => { + vi.stubEnv("NODE_ENV", "development"); + await expect(build()).resolves.toBeUndefined(); + }); + + it("rejects a localhost aggregator in a production build", async () => { + vi.stubEnv("NODE_ENV", "production"); + await expect(build()).rejects.toThrow("must use HTTPS outside development"); + }); +}); From 7f36235aea55a48e2022425330b1935c3f482bc1 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Mon, 26 Oct 2026 21:41:01 -0700 Subject: [PATCH 3/4] ci: run the Node E2E shards against a dev-output 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: From 837c1190b04bd845334076724b828c241f395f7a Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Thu, 29 Oct 2026 16:44:20 -0700 Subject: [PATCH 4/4] test(core): drop the registry localhost build unit test Co-Authored-By: Claude Opus 5.5 --- .../registry-localhost-build.test.ts | 44 ------------------- 1 file changed, 44 deletions(-) delete mode 100644 packages/core/tests/unit/astro/integration/registry-localhost-build.test.ts diff --git a/packages/core/tests/unit/astro/integration/registry-localhost-build.test.ts b/packages/core/tests/unit/astro/integration/registry-localhost-build.test.ts deleted file mode 100644 index b8efd31c19..0000000000 --- a/packages/core/tests/unit/astro/integration/registry-localhost-build.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { AstroIntegration } from "astro"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -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() { - const root = new URL("file:///tmp/emdash-registry-localhost/"); - await configSetup(emdash({ registry: "http://127.0.0.1:4445" }))({ - command: "build", - config: { - root, - srcDir: new URL("src/", root), - security: {}, - trailingSlash: "ignore", - integrations: [{ name: "@astrojs/react", hooks: {} }], - }, - logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, - injectRoute: vi.fn(), - addMiddleware: vi.fn(), - updateConfig: vi.fn(), - } as never); -} - -describe("localhost registry during astro build", () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("accepts a localhost aggregator in a --devOutput build", async () => { - vi.stubEnv("NODE_ENV", "development"); - await expect(build()).resolves.toBeUndefined(); - }); - - it("rejects a localhost aggregator in a production build", async () => { - vi.stubEnv("NODE_ENV", "production"); - await expect(build()).rejects.toThrow("must use HTTPS outside development"); - }); -});