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/.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: 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/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/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: