From 8bfe0cf0c484c0d773edaafd44d74b43bc10730d Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 13 Aug 2026 13:18:15 +0800 Subject: [PATCH] fix(deploy): verify live D1 readiness --- .env.local.example | 2 + .gitignore | 1 + apps/api/src/auth-state.ts | 20 +++- docs/deploy-cloudflare-button.md | 2 + docs/deploy-cloudflare-button.zh-CN.md | 2 + docs/manual-deploy.md | 2 + docs/manual-deploy.zh-CN.md | 2 + scripts/run-wrangler.mjs | 24 ++++- scripts/verify-deployment.mjs | 124 ++++++++++++++++++++++++- scripts/wrangler-runner.mjs | 38 ++++++++ tests/auth-fail-closed.test.ts | 11 +++ tests/auth-state.test.ts | 11 ++- tests/deployment-entrypoints.test.ts | 65 +++++++++++++ tests/deployment-verify.test.ts | 75 +++++++++++++++ 14 files changed, 367 insertions(+), 12 deletions(-) diff --git a/.env.local.example b/.env.local.example index 330a31a6..9d1f5c68 100644 --- a/.env.local.example +++ b/.env.local.example @@ -12,6 +12,8 @@ EDGE_EVER_BUILDS_API_TOKEN= EDGE_EVER_WORKER_NAME=edgeever EDGE_EVER_WORKERS_DEV=true +# Optional for local CLI deployments. CI deployments discover the URL from Wrangler output. +EDGE_EVER_DEPLOYMENT_URL= # Storage EDGE_EVER_D1_DATABASE_NAME=edgeever diff --git a/.gitignore b/.gitignore index d484643e..a2c14b16 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ target/ .wrangler/ .wrangler.generated-migrations/ .wrangler.generated*.toml +.wrangler.deployment-targets.json .dev.vars wrangler.local.toml diff --git a/apps/api/src/auth-state.ts b/apps/api/src/auth-state.ts index b7f89ef9..108725bb 100644 --- a/apps/api/src/auth-state.ts +++ b/apps/api/src/auth-state.ts @@ -24,8 +24,22 @@ export const resolveInstanceAuthMode = ({ }; export const isDatabaseNotReadyError = (error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - return /D1_ERROR|no such table|no such column|DB\.prepare is not a function|Cannot read properties of undefined.*prepare/i.test( - message, + const messages: string[] = []; + const visited = new Set(); + let current: unknown = error; + + while (current !== undefined && current !== null && !visited.has(current)) { + visited.add(current); + if (current instanceof Error) { + messages.push(current.message); + current = current.cause; + continue; + } + messages.push(String(current)); + break; + } + + return messages.some((message) => + /\b(?:no such table|no such column|D1_COLUMN_NOTFOUND)\b/i.test(message) ); }; diff --git a/docs/deploy-cloudflare-button.md b/docs/deploy-cloudflare-button.md index f9d40179..adb60019 100644 --- a/docs/deploy-cloudflare-button.md +++ b/docs/deploy-cloudflare-button.md @@ -69,6 +69,8 @@ Click **Save and Deploy** to trigger the initial build. The deploy command automatically looks up the D1 UUID by the `edgeever` database name. Do not edit `wrangler.toml` or manually copy the D1 ID. The Workers Builds API token must have D1 read/edit permission. +After publishing, the CI deployment records the actual public target reported by Wrangler and requests its `/api/health` endpoint. The build fails if the live Worker is missing its `DB` binding, uses an unprepared D1 database, or does not return a healthy response. + --- ### Step 5: Verify Deployment & Login diff --git a/docs/deploy-cloudflare-button.zh-CN.md b/docs/deploy-cloudflare-button.zh-CN.md index fbbc7d6b..3320dbd3 100644 --- a/docs/deploy-cloudflare-button.zh-CN.md +++ b/docs/deploy-cloudflare-button.zh-CN.md @@ -69,6 +69,8 @@ Deploy command: bun run deploy:cloudflare-builds 部署命令会根据 `edgeever` 数据库名称自动查询 D1 UUID。无需修改 `wrangler.toml`,也无需手工复制 D1 ID。Workers Builds API Token 必须具有 D1 读取和编辑权限。 +发布完成后,CI 部署会记录 Wrangler 返回的实际公网入口,并请求该入口的 `/api/health`。如果线上 Worker 缺少 `DB` binding、绑定了未初始化的 D1,或没有返回健康状态,构建会直接失败。 + --- ### 步骤 5:验证部署与登录 diff --git a/docs/manual-deploy.md b/docs/manual-deploy.md index ef8c9a4b..9d4d171e 100644 --- a/docs/manual-deploy.md +++ b/docs/manual-deploy.md @@ -18,6 +18,8 @@ Use this page for advanced configuration, troubleshooting, and emergency recover `deploy:setup` creates or reuses D1 and R2 and writes configuration to the git-ignored `.env.local`. Without `EDGE_EVER_PASSWORD`, the default login is `admin` / `admin123`. +For local CLI deployment, set `EDGE_EVER_DEPLOYMENT_URL=https://` in `.env.local` to include the live `/api/health` request in deployment verification. CI deployments discover the public URL automatically from Wrangler output. Without an explicit URL, local verification still checks the remote D1 schema and Worker Secret, then reports that the live health check was skipped. + After deployment, confirm: - `/api/health` returns `200` with `"ok": true` diff --git a/docs/manual-deploy.zh-CN.md b/docs/manual-deploy.zh-CN.md index d9625929..0392b5ea 100644 --- a/docs/manual-deploy.zh-CN.md +++ b/docs/manual-deploy.zh-CN.md @@ -18,6 +18,8 @@ `deploy:setup` 会创建或复用 D1、R2,并将配置写入被 Git 忽略的 `.env.local`。不设置 `EDGE_EVER_PASSWORD` 时,默认登录为 `admin` / `admin123`。 +使用本地 CLI 部署时,可在 `.env.local` 中设置 `EDGE_EVER_DEPLOYMENT_URL=https://<你的 Worker 域名>`,让部署验证同时请求线上的 `/api/health`;CI 部署会自动从 Wrangler 输出中识别公网地址。未显式配置地址时,本地验证仍会检查远端 D1 schema 和 Worker Secret,并明确提示已跳过线上健康检查。 + 部署完成后,确认: - `/api/health` 返回 `200` 和 `"ok": true` diff --git a/scripts/run-wrangler.mjs b/scripts/run-wrangler.mjs index 49210822..f73b5a60 100644 --- a/scripts/run-wrangler.mjs +++ b/scripts/run-wrangler.mjs @@ -9,9 +9,12 @@ import { import { dirname, resolve } from "node:path"; import { buildLocalDevEnvironmentFile, + DEPLOYMENT_TARGETS_PATH, findD1DatabaseIdByName, normalizeD1MigrationSql, + parseWranglerDeploymentUrls, runWranglerSync, + shouldCaptureDeploymentTargets, } from "./wrangler-runner.mjs"; import { writeWranglerNotice } from "./wrangler-output.mjs"; @@ -324,6 +327,8 @@ if (changed) { } const isDeployCommand = wranglerArgs.includes("deploy"); +const captureDeploymentTargets = isDeployCommand && shouldCaptureDeploymentTargets(); +const deploymentTargetsPath = resolve(DEPLOYMENT_TARGETS_PATH); const hasSecretsFileArg = wranglerArgs.some((arg) => arg === "--secrets-file" || arg.startsWith("--secrets-file=")); const hasEnvFileArg = wranglerArgs.some((arg) => arg === "--env-file" || arg.startsWith("--env-file=")); const authPassword = envValue("AUTH_PASSWORD"); @@ -356,12 +361,26 @@ if (isDeployCommand && Object.keys(authSecrets).length > 0 && !hasSecretsFileArg finalWranglerArgs.push("--secrets-file", generatedSecretsPath); } +if (captureDeploymentTargets) { + rmSync(deploymentTargetsPath, { force: true }); +} + const result = runWranglerSync(["--config", configPath, ...finalWranglerArgs], { cwd: resolve("."), + encoding: captureDeploymentTargets ? "utf8" : undefined, env: process.env, - stdio: "inherit", + stdio: captureDeploymentTargets ? undefined : "inherit", }); +if (captureDeploymentTargets) { + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + if (result.status === 0) { + const urls = parseWranglerDeploymentUrls(`${result.stdout ?? ""}\n${result.stderr ?? ""}`); + writeFileSync(deploymentTargetsPath, `${JSON.stringify({ urls }, null, 2)}\n`); + } +} + if (result.status === 0 && isDeployCommand) { for (const [secretName, secretValue] of Object.entries(authSecrets)) { const secretResult = runWranglerSync(["--config", configPath, "secret", "put", secretName], { @@ -386,4 +405,5 @@ if (result.error) { process.exit(1); } -process.exit(result.status ?? 1); +// Let piped stdout/stderr flush before the process exits in CI. +process.exitCode = result.status ?? 1; diff --git a/scripts/verify-deployment.mjs b/scripts/verify-deployment.mjs index 1451edb5..b7002c4f 100644 --- a/scripts/verify-deployment.mjs +++ b/scripts/verify-deployment.mjs @@ -1,5 +1,7 @@ import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { DEPLOYMENT_TARGETS_PATH } from "./wrangler-runner.mjs"; export const REQUIRED_TABLES = [ "users", @@ -51,6 +53,105 @@ export const parseSecretNames = (output) => { return new Set(secrets.map((secret) => secret?.name).filter(Boolean)); }; +const instanceEnvironmentValue = (env, name) => { + const instanceKey = env.EDGE_EVER_INSTANCE?.trim().replace(/[^a-zA-Z0-9]/g, "_").toUpperCase(); + return (instanceKey ? env[`EDGE_EVER_${instanceKey}_${name}`]?.trim() : "") + || env[`EDGE_EVER_${name}`]?.trim() + || ""; +}; + +export const normalizeDeploymentUrl = (value) => { + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed) && !/^https?:\/\//i.test(trimmed)) { + throw new Error(`Deployment URL must use HTTP(S) without embedded credentials: ${value}`); + } + const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + let url; + try { + url = new URL(withProtocol); + } catch { + throw new Error(`Invalid deployment URL: ${value}`); + } + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) { + throw new Error(`Deployment URL must use HTTP(S) without embedded credentials: ${value}`); + } + return url.origin; +}; + +export const parseCapturedDeploymentUrls = (content) => { + const parsed = parseJsonOutput(content, "the deployed Worker targets"); + if (!Array.isArray(parsed?.urls) || parsed.urls.some((url) => typeof url !== "string")) { + throw new Error("Wrangler returned an invalid deployed Worker targets file."); + } + return parsed.urls.map((url) => normalizeDeploymentUrl(url)).filter(Boolean); +}; + +export const readCapturedDeploymentUrls = (path = resolve(DEPLOYMENT_TARGETS_PATH)) => + existsSync(path) ? parseCapturedDeploymentUrls(readFileSync(path, "utf8")) : []; + +export const resolveDeploymentUrl = ({ env = process.env, capturedUrls = [] } = {}) => { + const configuredUrl = instanceEnvironmentValue(env, "DEPLOYMENT_URL"); + if (configuredUrl) return normalizeDeploymentUrl(configuredUrl); + + const customDomain = instanceEnvironmentValue(env, "CUSTOM_DOMAIN"); + if (customDomain) return normalizeDeploymentUrl(customDomain); + + return capturedUrls[0]; +}; + +const wait = (durationMs) => new Promise((resolveWait) => setTimeout(resolveWait, durationMs)); + +export const verifyOnlineHealth = async ({ + deploymentUrl, + fetchImpl = fetch, + attempts = 4, + retryDelayMs = 1_000, +} = {}) => { + const normalizedDeploymentUrl = normalizeDeploymentUrl(deploymentUrl ?? ""); + if (!normalizedDeploymentUrl) { + throw new Error("A deployment URL is required for the live Worker health check."); + } + const healthUrl = new URL("/api/health", `${normalizedDeploymentUrl}/`).toString(); + let lastFailure; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const response = await fetchImpl(healthUrl, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }); + const body = await response.text(); + let payload; + try { + payload = JSON.parse(body); + } catch { + payload = undefined; + } + + if (response.ok && payload?.ok === true) { + return { healthUrl, payload }; + } + + if (payload?.error?.code === "database_not_ready") { + lastFailure = new Error( + `Deployed Worker reports database_not_ready at ${healthUrl}. The D1 database verified by Wrangler may differ from the DB binding used by the live Worker.`, + ); + } else { + const diagnostic = payload?.error?.code || `${response.status} ${response.statusText}`.trim(); + lastFailure = new Error(`Deployed Worker health check failed at ${healthUrl}: ${diagnostic}.`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + lastFailure = new Error(`Deployed Worker health check request failed at ${healthUrl}: ${message}.`); + } + + if (attempt < attempts) await wait(retryDelayMs); + } + + throw lastFailure ?? new Error(`Deployed Worker health check failed at ${healthUrl}.`); +}; + const runWrangler = (args, options = {}) => { const result = spawnSync( process.execPath, @@ -74,7 +175,7 @@ const runWrangler = (args, options = {}) => { return result.stdout; }; -const main = () => { +const main = async () => { const schemaOutput = runWrangler([ "d1", "execute", @@ -98,13 +199,26 @@ const main = () => { throw new Error("The deployed Worker has no EdgeEver authentication Secret."); } console.log("[ok] Worker authentication Secret is deployed"); + + const capturedUrls = readCapturedDeploymentUrls(); + const deploymentUrl = resolveDeploymentUrl({ capturedUrls }); + if (!deploymentUrl) { + if (process.env.CI?.trim().toLowerCase() === "true" || process.env.WORKERS_CI === "1") { + throw new Error( + "Could not determine the deployed Worker URL for the live health check. Set EDGE_EVER_DEPLOYMENT_URL and retry deployment.", + ); + } + console.log("[skip] deployed Worker health: set EDGE_EVER_DEPLOYMENT_URL to enable the live check"); + return; + } + + const health = await verifyOnlineHealth({ deploymentUrl }); + console.log(`[ok] deployed Worker health: ${health.healthUrl}`); }; if (import.meta.main) { - try { - main(); - } catch (error) { + main().catch((error) => { console.error(`[fail] ${error instanceof Error ? error.message : String(error)}`); process.exit(1); - } + }); } diff --git a/scripts/wrangler-runner.mjs b/scripts/wrangler-runner.mjs index fa304a90..6d3b4b06 100644 --- a/scripts/wrangler-runner.mjs +++ b/scripts/wrangler-runner.mjs @@ -25,6 +25,44 @@ export const buildWranglerEnvironment = (args, env = process.env) => ({ export const normalizeD1MigrationSql = (sql) => sql.replace(/\r\n?/g, "\n"); +export const DEPLOYMENT_TARGETS_PATH = ".wrangler.deployment-targets.json"; + +const stripAnsi = (value) => value.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ""); + +const normalizeDeploymentUrl = (value) => { + if (/^[a-z][a-z0-9+.-]*:/i.test(value) && !/^https?:\/\//i.test(value)) return undefined; + const withProtocol = /^https?:\/\//i.test(value) ? value : `https://${value}`; + try { + const url = new URL(withProtocol); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) return undefined; + return url.origin; + } catch { + return undefined; + } +}; + +export const parseWranglerDeploymentUrls = (output) => { + const lines = stripAnsi(output).split(/\r?\n/); + const triggerIndex = lines.findIndex((line) => /^Deployed .+ triggers\b/.test(line.trim())); + if (triggerIndex === -1) return []; + + const urls = []; + for (const line of lines.slice(triggerIndex + 1)) { + const target = line.trim(); + if (!target) continue; + if (/^Current Version ID:/i.test(target) || /^No targets deployed for\b/i.test(target)) break; + + const directUrl = /^(https?:\/\/[^\s]+)(?:\s|$)/i.exec(target)?.[1]; + const customDomain = /^([^\s/]+)\s+\(custom domain\b/i.exec(target)?.[1]; + const normalized = normalizeDeploymentUrl(directUrl ?? customDomain ?? ""); + if (normalized && !urls.includes(normalized)) urls.push(normalized); + } + return urls; +}; + +export const shouldCaptureDeploymentTargets = (env = process.env) => + env.WORKERS_CI === "1" || env.CI?.trim().toLowerCase() === "true"; + export const LOCAL_DEV_CREDENTIALS_ENCRYPTION_KEY = "edgeever-local-development-credentials-key-v1"; diff --git a/tests/auth-fail-closed.test.ts b/tests/auth-fail-closed.test.ts index a8b30987..845277ce 100644 --- a/tests/auth-fail-closed.test.ts +++ b/tests/auth-fail-closed.test.ts @@ -50,6 +50,17 @@ describe("production authentication guard", () => { }); }); + test("does not misreport transient D1 failures as unapplied migrations", async () => { + const response = await fetchApi("/api/health", { + DB: createDatabase({ error: new Error("D1_ERROR: Network connection lost.") }), + }); + + expect(response.status).toBe(500); + expect((await response.json()) as { error: { code: string } }).toMatchObject({ + error: { code: "internal_error" }, + }); + }); + test("keeps passwordless access behind the explicit local-only flag", async () => { const response = await fetchApi("/api/v1/auth/session", { DB: createDatabase(), diff --git a/tests/auth-state.test.ts b/tests/auth-state.test.ts index 2ac621c4..e4bfe1b1 100644 --- a/tests/auth-state.test.ts +++ b/tests/auth-state.test.ts @@ -46,9 +46,16 @@ describe("instance authentication state", () => { ).toBe("disabled"); }); - test("recognizes missing D1 bindings and unapplied migrations", () => { + test("recognizes only schema errors as unapplied migrations", () => { expect(isDatabaseNotReadyError(new Error("D1_ERROR: no such table: users"))).toBe(true); - expect(isDatabaseNotReadyError(new TypeError("Cannot read properties of undefined (reading 'prepare')"))).toBe(true); + expect(isDatabaseNotReadyError(new Error("D1_COLUMN_NOTFOUND: users.is_disabled"))).toBe(true); + expect(isDatabaseNotReadyError(new Error("query failed", { + cause: new Error("D1_ERROR: no such column: is_disabled"), + }))).toBe(true); + expect(isDatabaseNotReadyError(new Error("D1_ERROR: Network connection lost."))).toBe(false); + expect(isDatabaseNotReadyError(new Error("D1_ERROR: Exceeded maximum DB size."))).toBe(false); + expect(isDatabaseNotReadyError(new Error("D1_EXEC_ERROR: near SELECTZ: syntax error"))).toBe(false); + expect(isDatabaseNotReadyError(new TypeError("Cannot read properties of undefined (reading 'prepare')"))).toBe(false); expect(isDatabaseNotReadyError(new Error("network timeout"))).toBe(false); }); }); diff --git a/tests/deployment-entrypoints.test.ts b/tests/deployment-entrypoints.test.ts index 2185467f..896b65db 100644 --- a/tests/deployment-entrypoints.test.ts +++ b/tests/deployment-entrypoints.test.ts @@ -176,6 +176,71 @@ describe("Cloudflare deployment entrypoints", () => { } }); + test("records the public Worker target reported by a CI deployment", () => { + const workingDirectory = mkdtempSync(resolve(tmpdir(), "edgeever-deployment-target-")); + const wranglerBinDirectory = resolve( + workingDirectory, + "node_modules", + "wrangler", + "bin", + ); + const inheritedEnvironment = Object.fromEntries( + ["PATH", "Path", "PATHEXT", "SystemRoot", "ComSpec", "TEMP", "TMP"] + .map((name) => [name, process.env[name]]) + .filter((entry): entry is [string, string] => Boolean(entry[1])), + ); + + try { + mkdirSync(wranglerBinDirectory, { recursive: true }); + writeFileSync( + resolve(workingDirectory, "wrangler.toml"), + [ + 'name = "edgeever"', + "workers_dev = true", + 'database_name = "edgeever"', + 'database_id = "11111111-1111-1111-1111-111111111111"', + 'bucket_name = "edgeever-resources"', + 'preview_bucket_name = "edgeever-resources-preview"', + 'migrations_dir = "migrations"', + "", + ].join("\n"), + ); + writeFileSync( + resolve(wranglerBinDirectory, "wrangler.js"), + [ + 'if (process.argv.includes("deploy")) {', + ' process.stdout.write("Uploaded edgeever\\nDeployed edgeever triggers (0.4 sec)\\n https://edgeever.example.workers.dev\\nCurrent Version ID: version-1\\n");', + "}", + "", + ].join("\n"), + ); + + const result = spawnSync( + process.execPath, + [resolve(repositoryRoot, "scripts", "run-wrangler.mjs"), "deploy"], + { + cwd: workingDirectory, + encoding: "utf8", + env: { + ...inheritedEnvironment, + CI: "true", + EDGE_EVER_AUTH_PASSWORD: "test-password", + EDGE_EVER_INSTANCE: "", + WRANGLER_CONFIG: resolve(workingDirectory, "wrangler.toml"), + }, + }, + ); + + expect(result.status).toBe(0); + expect(JSON.parse(readFileSync( + resolve(workingDirectory, ".wrangler.deployment-targets.json"), + "utf8", + ))).toEqual({ urls: ["https://edgeever.example.workers.dev"] }); + } finally { + rmSync(workingDirectory, { force: true, recursive: true }); + } + }); + test("deployed repositories receive guarded daily upstream updates", () => { const workflow = readRepositoryFile(".github/workflows/sync-edgeever-upstream.yml"); const bunConfig = readRepositoryFile("bunfig.toml"); diff --git a/tests/deployment-verify.test.ts b/tests/deployment-verify.test.ts index 972e8f1e..c1db2427 100644 --- a/tests/deployment-verify.test.ts +++ b/tests/deployment-verify.test.ts @@ -1,11 +1,19 @@ import { describe, expect, test } from "bun:test"; import { buildSchemaVerificationSql, + normalizeDeploymentUrl, + parseCapturedDeploymentUrls, parseD1Rows, parseJsonOutput, parseSecretNames, REQUIRED_TABLES, + resolveDeploymentUrl, + verifyOnlineHealth, } from "../scripts/verify-deployment.mjs"; +import { + parseWranglerDeploymentUrls, + shouldCaptureDeploymentTargets, +} from "../scripts/wrangler-runner.mjs"; describe("deployment verification", () => { test("checks the remote schema tables required by current application code", () => { @@ -37,4 +45,71 @@ describe("deployment verification", () => { "EDGE_EVER_AUTH_PASSWORD_HASH", ); }); + + test("captures public Workers and custom-domain URLs from Wrangler deploy output", () => { + expect(parseWranglerDeploymentUrls([ + "Uploaded edgeever (2.1 sec)", + "Deployed edgeever triggers (0.4 sec)", + " https://edgeever.example.workers.dev", + " notes.example.com (custom domain)", + "Current Version ID: version-1", + ].join("\n"))).toEqual([ + "https://edgeever.example.workers.dev", + "https://notes.example.com", + ]); + expect(parseWranglerDeploymentUrls("No targets deployed for edgeever")).toEqual([]); + }); + + test("captures deployment targets only in CI environments", () => { + expect(shouldCaptureDeploymentTargets({ WORKERS_CI: "1" })).toBe(true); + expect(shouldCaptureDeploymentTargets({ CI: " TRUE " })).toBe(true); + expect(shouldCaptureDeploymentTargets({})).toBe(false); + }); + + test("resolves an explicit or captured deployment URL without credentials or paths", () => { + expect(normalizeDeploymentUrl("notes.example.com/path")).toBe("https://notes.example.com"); + expect(parseCapturedDeploymentUrls(JSON.stringify({ + urls: ["https://edgeever.example.workers.dev"], + }))).toEqual(["https://edgeever.example.workers.dev"]); + expect(resolveDeploymentUrl({ + env: { EDGE_EVER_DEPLOYMENT_URL: "https://notes.example.com/app" }, + capturedUrls: ["https://edgeever.example.workers.dev"], + })).toBe("https://notes.example.com"); + expect(resolveDeploymentUrl({ + env: { EDGE_EVER_CUSTOM_DOMAIN: "notes.example.com" }, + capturedUrls: [], + })).toBe("https://notes.example.com"); + expect(() => normalizeDeploymentUrl("file:///tmp/edgeever")).toThrow("must use HTTP(S)"); + }); + + test("verifies the live Worker health response", async () => { + let requestedUrl = ""; + const result = await verifyOnlineHealth({ + deploymentUrl: "https://notes.example.com", + attempts: 1, + fetchImpl: async (url) => { + requestedUrl = String(url); + return Response.json({ ok: true, authMode: "required" }); + }, + }); + + expect(requestedUrl).toBe("https://notes.example.com/api/health"); + expect(result.payload).toMatchObject({ ok: true }); + }); + + test("requires a URL before requesting the live Worker", async () => { + expect(verifyOnlineHealth({ deploymentUrl: "" })).rejects.toThrow( + "A deployment URL is required", + ); + }); + + test("diagnoses a live Worker bound to a different unprepared D1 database", async () => { + expect(verifyOnlineHealth({ + deploymentUrl: "https://notes.example.com", + attempts: 1, + fetchImpl: async () => Response.json({ + error: { code: "database_not_ready" }, + }, { status: 503 }), + })).rejects.toThrow("may differ from the DB binding used by the live Worker"); + }); });