fix(deploy): verify live D1 readiness
This commit is contained in:
1 parent
77ea605b4d
commit
8bfe0cf0c4
14 files changed
+367
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@ target/
|
||||
.wrangler/
|
||||
.wrangler.generated-migrations/
|
||||
.wrangler.generated*.toml
|
||||
.wrangler.deployment-targets.json
|
||||
.dev.vars
|
||||
wrangler.local.toml
|
||||
|
||||
|
||||
@@ -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<unknown>();
|
||||
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)
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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:验证部署与登录
|
||||
|
||||
@@ -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://<your-worker-domain>` 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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user