devflare 1.0.0-next.39 → 1.0.0-next.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/account-BtWcv3X9.js +462 -0
- package/dist/_chunks/account-resources-CsmVowJp.js +159 -0
- package/dist/_chunks/ai-CBEuLdoP.js +322 -0
- package/dist/_chunks/api-TzdliH-6.js +568 -0
- package/dist/_chunks/build-3T6O4uIg.js +24 -0
- package/dist/_chunks/build-artifacts-8TqBSQkG.js +483 -0
- package/dist/_chunks/cli-BTF1X4SG.js +1128 -0
- package/dist/_chunks/cloudflare-BaloqI3H.js +157 -0
- package/dist/_chunks/colors-DiK6wPJ5.js +13 -0
- package/dist/_chunks/command-utils-BsfkmTqh.js +45 -0
- package/dist/_chunks/compiler-uoyAQ8zW.js +424 -0
- package/dist/_chunks/config-D5u-bEkn.js +78 -0
- package/dist/_chunks/config-path-DCPto4XB.js +42 -0
- package/dist/_chunks/context-CX50Y2Kb.js +2250 -0
- package/dist/_chunks/decorators-QmV57ixr.js +19 -0
- package/dist/_chunks/dependencies-C2oxFjU_.js +57 -0
- package/dist/_chunks/deploy-DMTr6JeR.js +915 -0
- package/dist/_chunks/dev-CvRHmRFZ.js +3456 -0
- package/dist/_chunks/doctor-DIG8DoFU.js +219 -0
- package/dist/_chunks/durable-object-DMtH0XYX.js +234 -0
- package/dist/_chunks/env-S0_nMVz1.js +125 -0
- package/dist/_chunks/family-DXC5SdFK.js +143 -0
- package/dist/_chunks/generated-artifacts-dMJY4KAt.js +32 -0
- package/dist/_chunks/glob-CmQOvunB.js +56 -0
- package/dist/_chunks/init-3moXTgpb.js +165 -0
- package/dist/_chunks/loader-DLXO60RO.js +1153 -0
- package/dist/_chunks/local-dev-vars-CTSa-wvF.js +81 -0
- package/dist/_chunks/local-hyperdrive-CJ90j46O.js +119 -0
- package/dist/_chunks/local-media-bindings-DsAW4AMF.js +335 -0
- package/dist/_chunks/local-secrets-DMmEOygL.js +123 -0
- package/dist/_chunks/local-workflow-entrypoints-Wip3o1u8.js +90 -0
- package/dist/_chunks/login-BmnDzDlH.js +51 -0
- package/dist/_chunks/package-metadata-DAuCkyvK.js +39 -0
- package/dist/_chunks/preferences-BKp_7XJx.js +241 -0
- package/dist/_chunks/preview-BhqDmq8I.js +456 -0
- package/dist/_chunks/preview-registry-ClAbLTHH.js +1186 -0
- package/dist/_chunks/preview-resources-DdXj8LMM.js +358 -0
- package/dist/_chunks/previews-Dlr13x5M.js +1134 -0
- package/dist/_chunks/productions-D3AnOHac.js +411 -0
- package/dist/_chunks/ref-CYJNAAe_.js +178 -0
- package/dist/_chunks/remote-CZnP-HLJ.js +79 -0
- package/dist/_chunks/remote-config-D39BY9ji.js +132 -0
- package/dist/_chunks/resolve-package-Y84HB3Nt.js +133 -0
- package/dist/_chunks/rolldown-runtime-CvQ6eiug.js +14 -0
- package/dist/_chunks/routes-C3FqES2W.js +796 -0
- package/dist/_chunks/runtime-FvXvfuZ2.js +550 -0
- package/dist/_chunks/schema-normalization-DWVleVxo.js +477 -0
- package/dist/_chunks/secrets-70eN0Q6C.js +95 -0
- package/dist/_chunks/token-kguIDmej.js +333 -0
- package/dist/_chunks/tokens-iVcgNXi-.js +221 -0
- package/dist/_chunks/types-OrLbzv4I.js +476 -0
- package/dist/_chunks/ui-BUoZApvE.js +103 -0
- package/dist/_chunks/usage-BBTGzjon.js +272 -0
- package/dist/_chunks/vite-CsI0E-uV.js +1235 -0
- package/dist/_chunks/vite-utils-OVLMV605.js +183 -0
- package/dist/_chunks/worker-CS1jgNFE.js +410 -0
- package/dist/_chunks/worker-bundler-dTTnYU3n.js +458 -0
- package/dist/_chunks/worker-entrypoint-CQW77lG8.js +247 -0
- package/dist/_chunks/workerName-CFJsLZA-.js +23 -0
- package/dist/bridge/index.d.ts +6 -6
- package/dist/bridge/miniflare.d.ts +2 -2
- package/dist/bridge/proxy.d.ts +1 -1
- package/dist/bridge/v2/body-streams.d.ts +1 -1
- package/dist/bridge/v2/codec.d.ts +4 -4
- package/dist/bridge/v2/index.d.ts +10 -10
- package/dist/bridge/v2/serialization.d.ts +1 -1
- package/dist/bridge/v2/value-codec.d.ts +1 -1
- package/dist/bridge/v2/value-serialization.d.ts.map +1 -1
- package/dist/bridge/v2/ws-relay.d.ts +3 -3
- package/dist/browser-shim/index.d.ts +2 -2
- package/dist/browser.d.ts +70 -70
- package/dist/browser.js +74 -2462
- package/dist/bundler/do-bundler.d.ts +1 -1
- package/dist/bundler/index.d.ts +3 -3
- package/dist/bundler/rolldown-shared.d.ts +1 -1
- package/dist/bundler/worker-bundler.d.ts +1 -1
- package/dist/cli/build-manifest.d.ts +1 -1
- package/dist/cli/command-utils.d.ts +1 -1
- package/dist/cli/commands/account.d.ts +1 -1
- package/dist/cli/commands/ai.d.ts +1 -1
- package/dist/cli/commands/build-artifacts.d.ts +5 -5
- package/dist/cli/commands/build.d.ts +1 -1
- package/dist/cli/commands/config.d.ts +1 -1
- package/dist/cli/commands/deploy/prepare.d.ts +2 -2
- package/dist/cli/commands/deploy/runtime.d.ts +1 -1
- package/dist/cli/commands/deploy/verification.d.ts +1 -1
- package/dist/cli/commands/deploy.d.ts +1 -1
- package/dist/cli/commands/dev.d.ts +1 -1
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/login.d.ts +1 -1
- package/dist/cli/commands/previews-support/cleanup.d.ts +1 -1
- package/dist/cli/commands/previews-support/family.d.ts +3 -3
- package/dist/cli/commands/previews-support/render.d.ts +3 -3
- package/dist/cli/commands/previews-support/theme.d.ts +2 -2
- package/dist/cli/commands/previews-support/types.d.ts +2 -2
- package/dist/cli/commands/previews.d.ts +1 -1
- package/dist/cli/commands/productions.d.ts +1 -1
- package/dist/cli/commands/remote.d.ts +1 -1
- package/dist/cli/commands/secrets.d.ts +1 -1
- package/dist/cli/commands/token.d.ts +1 -1
- package/dist/cli/commands/type-generation/discovery.d.ts +2 -2
- package/dist/cli/commands/type-generation/generator.d.ts +3 -3
- package/dist/cli/commands/type-generation/models.d.ts +1 -1
- package/dist/cli/commands/types.d.ts +1 -1
- package/dist/cli/commands/worker.d.ts +1 -1
- package/dist/cli/deploy-strategy.d.ts +1 -1
- package/dist/cli/deploy-target.d.ts +1 -1
- package/dist/cli/help-pages/pages/account.d.ts +1 -1
- package/dist/cli/help-pages/pages/core.d.ts +1 -1
- package/dist/cli/help-pages/pages/index.d.ts +1 -1
- package/dist/cli/help-pages/pages/misc.d.ts +1 -1
- package/dist/cli/help-pages/pages/previews.d.ts +1 -1
- package/dist/cli/help-pages/pages/productions.d.ts +1 -1
- package/dist/cli/help-pages/render.d.ts +2 -2
- package/dist/cli/help-pages/shared.d.ts +1 -1
- package/dist/cli/help.d.ts +2 -2
- package/dist/cli/index.js +2 -1383
- package/dist/cli/preview-bindings.d.ts +3 -3
- package/dist/cli/preview.d.ts +1 -1
- package/dist/cli/wrangler-auth.d.ts +1 -1
- package/dist/cloudflare/account-core.d.ts +2 -2
- package/dist/cloudflare/account-resources.d.ts +2 -2
- package/dist/cloudflare/account-status.d.ts +1 -1
- package/dist/cloudflare/account-workers.d.ts +2 -2
- package/dist/cloudflare/account.d.ts +7 -7
- package/dist/cloudflare/auth.d.ts +1 -1
- package/dist/cloudflare/index.d.ts +13 -13
- package/dist/cloudflare/index.js +4 -59
- package/dist/cloudflare/kv-namespace.d.ts +1 -1
- package/dist/cloudflare/preview-registry-cache.d.ts +1 -1
- package/dist/cloudflare/preview-registry-inference.d.ts +2 -2
- package/dist/cloudflare/preview-registry-records.d.ts +3 -3
- package/dist/cloudflare/preview-registry-shape.d.ts +2 -2
- package/dist/cloudflare/preview-registry-store.d.ts +3 -3
- package/dist/cloudflare/preview-registry-transport.d.ts +2 -2
- package/dist/cloudflare/preview-registry-types.d.ts +2 -2
- package/dist/cloudflare/preview-registry.d.ts +5 -5
- package/dist/cloudflare/tokens.d.ts +2 -2
- package/dist/cloudflare/usage.d.ts +2 -2
- package/dist/config/binding-resolution-helpers.d.ts +1 -1
- package/dist/config/compiler/bindings.d.ts +2 -2
- package/dist/config/compiler/core-helpers.d.ts +2 -2
- package/dist/config/compiler/do-workers.d.ts +2 -2
- package/dist/config/compiler/paths.d.ts +1 -1
- package/dist/config/compiler.d.ts +6 -6
- package/dist/config/define.d.ts +2 -2
- package/dist/config/deploy-resources.d.ts +5 -5
- package/dist/config/env-vars.d.ts +1 -1
- package/dist/config/framework-providers.d.ts +1 -1
- package/dist/config/index.d.ts +12 -12
- package/dist/config/loader.d.ts +2 -2
- package/dist/config/local-dev-vars.d.ts +1 -1
- package/dist/config/preview-resources.d.ts +4 -4
- package/dist/config/preview.d.ts +1 -1
- package/dist/config/ref.d.ts +2 -2
- package/dist/config/resolve-phased.d.ts +4 -4
- package/dist/config/resolve.d.ts +1 -1
- package/dist/config/resource-resolution.d.ts +6 -6
- package/dist/config/schema-env.d.ts +20 -20
- package/dist/config/schema-normalization.d.ts +1 -1
- package/dist/config/schema-types-bindings.d.ts +4 -4
- package/dist/config/schema-types-build.d.ts +1 -1
- package/dist/config/schema-types.d.ts +7 -7
- package/dist/config/schema.d.ts +46 -46
- package/dist/config/service-bindings-validation.d.ts +1 -1
- package/dist/config-entry.d.ts +6 -6
- package/dist/config-entry.js +3 -8
- package/dist/decorators/index.d.ts +2 -2
- package/dist/decorators/index.js +2 -9
- package/dist/dev-server/d1-migrations.d.ts +1 -1
- package/dist/dev-server/dev-server-state.d.ts +6 -6
- package/dist/dev-server/gateway-script.d.ts +1 -1
- package/dist/dev-server/index.d.ts +1 -1
- package/dist/dev-server/miniflare-bindings.d.ts +1 -1
- package/dist/dev-server/miniflare-dev-config.d.ts +5 -5
- package/dist/dev-server/miniflare-worker-config.d.ts +3 -3
- package/dist/dev-server/server-startup-helpers.d.ts +6 -6
- package/dist/dev-server/worker-surface-paths.d.ts +2 -2
- package/dist/index.d.ts +8 -8
- package/dist/index.js +9 -22
- package/dist/runtime/context-events.d.ts +1 -1
- package/dist/runtime/context.d.ts +3 -3
- package/dist/runtime/exports.d.ts +2 -2
- package/dist/runtime/index.d.ts +10 -10
- package/dist/runtime/index.js +5 -57
- package/dist/runtime/middleware.d.ts +1 -1
- package/dist/runtime/router/index.d.ts +3 -3
- package/dist/runtime/validation.d.ts +1 -1
- package/dist/secrets/local-secrets.d.ts +1 -1
- package/dist/shims/local-media-bindings.d.ts +1 -1
- package/dist/sveltekit/index.d.ts +1 -1
- package/dist/sveltekit/index.js +327 -9
- package/dist/sveltekit/local-bindings.d.ts +1 -1
- package/dist/sveltekit/platform.d.ts +1 -1
- package/dist/test/binding-hints.d.ts +2 -2
- package/dist/test/cf.d.ts +19 -19
- package/dist/test/index.d.ts +18 -18
- package/dist/test/index.js +4495 -46
- package/dist/test/offline-bindings.d.ts +3 -3
- package/dist/test/resolve-service-bindings.d.ts +1 -1
- package/dist/test/simple-context-bindings.d.ts +1 -1
- package/dist/test/simple-context-durable-objects.d.ts +1 -1
- package/dist/test/simple-context-env.d.ts +2 -2
- package/dist/test/simple-context-handlers.d.ts +2 -2
- package/dist/test/simple-context-lifecycle.d.ts +2 -2
- package/dist/test/simple-context-mfconfig.d.ts +1 -1
- package/dist/test/simple-context-multi-worker.d.ts +2 -2
- package/dist/test/simple-context-runtime.d.ts +1 -1
- package/dist/test/simple-context-startup.d.ts +1 -1
- package/dist/test/simple-context.d.ts +1 -1
- package/dist/test/utilities/env.d.ts +3 -3
- package/dist/test/utilities.d.ts +10 -10
- package/dist/test/worker.d.ts +1 -1
- package/dist/transform/index.d.ts +2 -2
- package/dist/utils/send-email.js +163 -19
- package/dist/vite/config-file.d.ts +2 -2
- package/dist/vite/index.d.ts +3 -3
- package/dist/vite/index.js +2 -31
- package/dist/vite/plugin-config-hook.d.ts +1 -1
- package/dist/vite/plugin-context.d.ts +3 -3
- package/dist/vite/plugin-durable-objects.d.ts +2 -2
- package/dist/vite/plugin-programmatic.d.ts +1 -1
- package/dist/vite/plugin-service-bindings.d.ts +2 -2
- package/dist/vite/plugin.d.ts +3 -3
- package/dist/worker-entry/composed-worker.d.ts +1 -1
- package/dist/worker-entry/routes.d.ts +2 -2
- package/dist/worker-entry/surface-paths.d.ts +1 -1
- package/dist/workflows/local-workflow-entrypoints.d.ts +1 -1
- package/package.json +5 -3
- package/dist/account-4tgh03a8.js +0 -484
- package/dist/ai-r3zqmxan.js +0 -261
- package/dist/api-6z01z734.js +0 -33
- package/dist/build-kw625v84.js +0 -51
- package/dist/config-ghk2bxj3.js +0 -102
- package/dist/deploy-9kee65n9.js +0 -1062
- package/dist/dev-x8zr249a.js +0 -3569
- package/dist/doctor-0ng5qrr1.js +0 -258
- package/dist/durable-object-0ybmg08a.js +0 -13
- package/dist/index-04wh9bxx.js +0 -514
- package/dist/index-0cc05hzv.js +0 -74
- package/dist/index-2yv7w548.js +0 -185
- package/dist/index-37x76zdn.js +0 -4
- package/dist/index-3cpzsr47.js +0 -125
- package/dist/index-3t6rypgc.js +0 -13
- package/dist/index-4mmpccrb.js +0 -309
- package/dist/index-5dkjffqz.js +0 -25
- package/dist/index-5hy7jmv3.js +0 -890
- package/dist/index-627srx16.js +0 -45
- package/dist/index-7m557715.js +0 -283
- package/dist/index-a8pnvg3r.js +0 -52
- package/dist/index-aabgympv.js +0 -39
- package/dist/index-atwc3csp.js +0 -256
- package/dist/index-cv4cvrxe.js +0 -2046
- package/dist/index-h7r11y4a.js +0 -539
- package/dist/index-hvtd936d.js +0 -1229
- package/dist/index-kcp7y951.js +0 -136
- package/dist/index-maw0jjnn.js +0 -147
- package/dist/index-mek9msfv.js +0 -705
- package/dist/index-n4xkaymm.js +0 -236
- package/dist/index-nkk59p3y.js +0 -579
- package/dist/index-phcrvs6d.js +0 -342
- package/dist/index-se4kw8tm.js +0 -460
- package/dist/index-x8s9rwfh.js +0 -2020
- package/dist/index-y7w3x9p1.js +0 -18
- package/dist/index-z9gy8w6b.js +0 -57
- package/dist/index-zdd0d9qa.js +0 -199
- package/dist/init-tb8jqgvr.js +0 -180
- package/dist/login-4h1sfsed.js +0 -75
- package/dist/previews-qr7c5j8h.js +0 -1336
- package/dist/productions-xpmd6bw8.js +0 -505
- package/dist/remote-kqcvhnfx.js +0 -192
- package/dist/secrets-6wbdsdek.js +0 -89
- package/dist/token-yp1qa70y.js +0 -419
- package/dist/types-15yv4cj3.js +0 -701
- package/dist/worker-1yg72jxg.js +0 -510
- package/dist/worker-entrypoint-y8s9kdcx.js +0 -15
package/dist/test/index.js
CHANGED
|
@@ -1,47 +1,4496 @@
|
|
|
1
|
-
import"../
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
1
|
+
import { d as resolveConfigEnvVars } from "../_chunks/preview-BhqDmq8I.js";
|
|
2
|
+
import { i as resolveConfigPath, r as loadConfig } from "../_chunks/loader-DLXO60RO.js";
|
|
3
|
+
import { c as normalizeHyperdriveBinding, d as normalizeMediaBinding, f as normalizeMtlsCertificateBinding, h as normalizeWorkflowBinding, i as normalizeArtifactsBinding, l as normalizeImagesBinding, m as normalizeSecretsStoreBinding, o as normalizeDOBinding, p as normalizePipelineBinding, s as normalizeDispatchNamespaceBinding, t as getLocalD1DatabaseIdentifier } from "../_chunks/schema-normalization-DWVleVxo.js";
|
|
4
|
+
import { t as applyLocalDevVarsToConfig } from "../_chunks/local-dev-vars-CTSa-wvF.js";
|
|
5
|
+
import { g as isAuthenticated, p as getApiToken } from "../_chunks/api-TzdliH-6.js";
|
|
6
|
+
import { d as getPrimaryAccount, n as getEffectiveAccountId } from "../_chunks/preferences-BKp_7XJx.js";
|
|
7
|
+
import { A as createTailEvent, D as createFetchEvent, E as createEmailEvent, F as BridgeClient, M as createEnvProxy, O as createQueueEvent, P as setBindingHints, _ as runWithContext, k as createScheduledEvent, v as runWithEventContext } from "../_chunks/context-CX50Y2Kb.js";
|
|
8
|
+
import { createLocalSendEmailBinding, wrapEnvSendEmailBindings } from "../utils/send-email.js";
|
|
9
|
+
import { n as __setTestContext, r as env, t as __clearTestContext } from "../_chunks/env-S0_nMVz1.js";
|
|
10
|
+
import { d as invokeFetchModule, m as resolveFetchHandler, r as matchFetchRoute, t as createRouteResolve } from "../_chunks/runtime-FvXvfuZ2.js";
|
|
11
|
+
import { i as extractBindingHints, n as createLocalWorkerLoaderBinding, r as disposeLocalWorkerLoaderBindings, t as createLocalHyperdrive } from "../_chunks/local-hyperdrive-CJ90j46O.js";
|
|
12
|
+
import { a as hasServiceBindings, f as buildHyperdrivesConfig, i as hasCrossWorkerDOs, o as resolveDOBindings, r as clearBundleCache, s as resolveServiceBindings, t as discoverRoutes } from "../_chunks/routes-C3FqES2W.js";
|
|
13
|
+
import { i as findFiles, t as DEFAULT_DO_PATTERN } from "../_chunks/glob-CmQOvunB.js";
|
|
14
|
+
import { a as isRemoteModeActive, i as getRemoteModeStatus } from "../_chunks/remote-config-D39BY9ji.js";
|
|
15
|
+
import { t as bundleWorkflowEntrypointScript } from "../_chunks/local-workflow-entrypoints-Wip3o1u8.js";
|
|
16
|
+
import { a as resolveLocalSecretValuesForBindings, n as buildLocalSecretWrappedBindingConfig } from "../_chunks/local-secrets-DMmEOygL.js";
|
|
17
|
+
import { t as buildLocalBindingShimServiceConfig } from "../_chunks/local-media-bindings-DsAW4AMF.js";
|
|
18
|
+
import { t as canProceedWithTest } from "../_chunks/usage-BBTGzjon.js";
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { stat } from "node:fs/promises";
|
|
21
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
22
|
+
import { existsSync as existsSync$1, mkdirSync as mkdirSync$1, writeFileSync as writeFileSync$1 } from "fs";
|
|
23
|
+
import { dirname as dirname$1, join as join$1, resolve as resolve$1 } from "path";
|
|
24
|
+
import { readFile as readFile$1 } from "fs/promises";
|
|
25
|
+
import { createServer } from "net";
|
|
26
|
+
import { fileURLToPath } from "url";
|
|
27
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
28
|
+
import { createConnection } from "node:net";
|
|
29
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
30
|
+
import { execa } from "execa";
|
|
31
|
+
//#region src/test/remote-cloudflare.ts
|
|
32
|
+
function createRemoteCloudflareClient(accountId) {
|
|
33
|
+
let resolvedAccountId = null;
|
|
34
|
+
async function getAccountId() {
|
|
35
|
+
if (accountId) return accountId;
|
|
36
|
+
if (resolvedAccountId) return resolvedAccountId;
|
|
37
|
+
const primary = await getPrimaryAccount();
|
|
38
|
+
if (!primary) throw new Error("No Cloudflare account found. Run: bunx wrangler login");
|
|
39
|
+
const { accountId: effectiveId } = await getEffectiveAccountId(primary.id);
|
|
40
|
+
resolvedAccountId = effectiveId;
|
|
41
|
+
return effectiveId;
|
|
42
|
+
}
|
|
43
|
+
async function getToken() {
|
|
44
|
+
const token = await getApiToken();
|
|
45
|
+
if (!token) throw new Error("Not authenticated. Run: bunx wrangler login");
|
|
46
|
+
return token;
|
|
47
|
+
}
|
|
48
|
+
async function jsonRequest(options) {
|
|
49
|
+
const [acctId, token] = await Promise.all([getAccountId(), getToken()]);
|
|
50
|
+
const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${acctId}${options.path}`, {
|
|
51
|
+
method: options.method,
|
|
52
|
+
headers: {
|
|
53
|
+
Authorization: `Bearer ${token}`,
|
|
54
|
+
"Content-Type": options.contentType ?? "application/json"
|
|
55
|
+
},
|
|
56
|
+
body: options.body
|
|
57
|
+
});
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const errorText = await response.text();
|
|
60
|
+
throw new Error(`${options.serviceLabel} API error (${response.status}): ${errorText}`);
|
|
61
|
+
}
|
|
62
|
+
const result = await response.json();
|
|
63
|
+
if (!result.success) {
|
|
64
|
+
const message = result.errors?.[0]?.message || `Unknown ${options.serviceLabel} error`;
|
|
65
|
+
throw new Error(`${options.serviceLabel} API error: ${message}`);
|
|
66
|
+
}
|
|
67
|
+
return result.result;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
getAccountId,
|
|
71
|
+
getToken,
|
|
72
|
+
jsonRequest
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/test/remote-ai.ts
|
|
77
|
+
function encodePathSegment(value) {
|
|
78
|
+
return encodeURIComponent(value);
|
|
79
|
+
}
|
|
80
|
+
function applyExtraHeaders(headers, extraHeaders) {
|
|
81
|
+
if (!extraHeaders) return;
|
|
82
|
+
for (const [key, value] of Object.entries(extraHeaders)) headers.set(key, String(value));
|
|
83
|
+
}
|
|
84
|
+
function createRemoteAIGateway(cloudflare, gatewayId, owner) {
|
|
85
|
+
const encodedGatewayId = encodePathSegment(gatewayId);
|
|
86
|
+
const gateway = {
|
|
87
|
+
async patchLog(logId, data) {
|
|
88
|
+
await cloudflare.jsonRequest({
|
|
89
|
+
method: "PATCH",
|
|
90
|
+
path: `/ai-gateway/gateways/${encodedGatewayId}/logs/${encodePathSegment(logId)}`,
|
|
91
|
+
serviceLabel: "AI Gateway",
|
|
92
|
+
body: JSON.stringify(data)
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
async getLog(logId) {
|
|
96
|
+
return cloudflare.jsonRequest({
|
|
97
|
+
method: "GET",
|
|
98
|
+
path: `/ai-gateway/gateways/${encodedGatewayId}/logs/${encodePathSegment(logId)}`,
|
|
99
|
+
serviceLabel: "AI Gateway"
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
async getUrl(provider) {
|
|
103
|
+
const baseUrl = `https://gateway.ai.cloudflare.com/v1/${encodePathSegment(await cloudflare.getAccountId())}/${encodedGatewayId}`;
|
|
104
|
+
return provider ? `${baseUrl}/${encodePathSegment(provider)}` : `${baseUrl}/`;
|
|
105
|
+
},
|
|
106
|
+
async run(data, options) {
|
|
107
|
+
const [url, token] = await Promise.all([gateway.getUrl(), cloudflare.getToken()]);
|
|
108
|
+
const headers = new Headers({
|
|
109
|
+
Authorization: `Bearer ${token}`,
|
|
110
|
+
"Content-Type": "application/json"
|
|
111
|
+
});
|
|
112
|
+
applyExtraHeaders(headers, options?.extraHeaders);
|
|
113
|
+
const response = await fetch(url, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers,
|
|
116
|
+
body: JSON.stringify(data),
|
|
117
|
+
signal: options?.signal
|
|
118
|
+
});
|
|
119
|
+
const logId = response.headers.get("cf-aig-log-id") ?? response.headers.get("cf-ai-gateway-log-id");
|
|
120
|
+
if (logId) owner.aiGatewayLogId = logId;
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
const errorText = await response.text();
|
|
123
|
+
throw new Error(`AI Gateway API error (${response.status}): ${errorText}`);
|
|
124
|
+
}
|
|
125
|
+
return response;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
return gateway;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Creates a remote AI binding that calls Cloudflare's REST API.
|
|
132
|
+
* Matches the Workers AI binding interface.
|
|
133
|
+
*/
|
|
134
|
+
function createRemoteAI(accountId) {
|
|
135
|
+
const cloudflare = createRemoteCloudflareClient(accountId);
|
|
136
|
+
const ai = {
|
|
137
|
+
aiGatewayLogId: null,
|
|
138
|
+
async run(model, inputs) {
|
|
139
|
+
return cloudflare.jsonRequest({
|
|
140
|
+
method: "POST",
|
|
141
|
+
path: `/ai/run/${model}`,
|
|
142
|
+
serviceLabel: "AI",
|
|
143
|
+
body: JSON.stringify(inputs)
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
gateway(gatewayId) {
|
|
147
|
+
return createRemoteAIGateway(cloudflare, gatewayId, ai);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
return ai;
|
|
151
|
+
}
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/test/remote-vectorize.ts
|
|
154
|
+
/**
|
|
155
|
+
* Creates a remote Vectorize binding that calls Cloudflare's REST API.
|
|
156
|
+
* Matches the Workers Vectorize binding interface.
|
|
157
|
+
*/
|
|
158
|
+
function createRemoteVectorize(indexName, accountId) {
|
|
159
|
+
const cloudflare = createRemoteCloudflareClient(accountId);
|
|
160
|
+
async function apiRequest(method, endpoint, body) {
|
|
161
|
+
return cloudflare.jsonRequest({
|
|
162
|
+
method,
|
|
163
|
+
path: `/vectorize/v2/indexes/${indexName}${endpoint}`,
|
|
164
|
+
serviceLabel: "Vectorize",
|
|
165
|
+
body: body ? JSON.stringify(body) : void 0
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
async function ndjsonRequest(endpoint, vectors) {
|
|
169
|
+
const ndjson = vectors.map((v) => JSON.stringify(v)).join("\n");
|
|
170
|
+
return cloudflare.jsonRequest({
|
|
171
|
+
method: "POST",
|
|
172
|
+
path: `/vectorize/v2/indexes/${indexName}${endpoint}`,
|
|
173
|
+
serviceLabel: "Vectorize",
|
|
174
|
+
contentType: "application/x-ndjson",
|
|
175
|
+
body: ndjson
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
async describe() {
|
|
180
|
+
return apiRequest("GET", "");
|
|
181
|
+
},
|
|
182
|
+
async query(vector, options) {
|
|
183
|
+
return apiRequest("POST", "/query", {
|
|
184
|
+
vector: Array.isArray(vector) ? vector : Array.from(vector),
|
|
185
|
+
topK: options?.topK ?? 10,
|
|
186
|
+
returnValues: options?.returnValues ?? false,
|
|
187
|
+
returnMetadata: options?.returnMetadata ?? "none",
|
|
188
|
+
namespace: options?.namespace,
|
|
189
|
+
filter: options?.filter
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
async insert(vectors) {
|
|
193
|
+
const result = await ndjsonRequest("/insert", vectors);
|
|
194
|
+
return {
|
|
195
|
+
count: result.count,
|
|
196
|
+
ids: result.ids || vectors.map((v) => v.id)
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
async upsert(vectors) {
|
|
200
|
+
const result = await ndjsonRequest("/upsert", vectors);
|
|
201
|
+
return {
|
|
202
|
+
count: result.count,
|
|
203
|
+
ids: result.ids || vectors.map((v) => v.id)
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
async deleteByIds(ids) {
|
|
207
|
+
return {
|
|
208
|
+
count: (await apiRequest("POST", "/delete-by-ids", { ids })).count,
|
|
209
|
+
ids
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
async getByIds(ids) {
|
|
213
|
+
return apiRequest("POST", "/get-by-ids", { ids });
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/test/utilities/context.ts
|
|
219
|
+
/**
|
|
220
|
+
* Creates a test context with mock ExecutionContext
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* ```ts
|
|
224
|
+
* const ctx = createTestContext({
|
|
225
|
+
* env: { API_KEY: 'test' },
|
|
226
|
+
* request: new Request('https://test.com')
|
|
227
|
+
* })
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
function createMockTestContext(options = {}) {
|
|
231
|
+
const waitUntilPromises = [];
|
|
232
|
+
return {
|
|
233
|
+
env: options.env ?? {},
|
|
234
|
+
ctx: {
|
|
235
|
+
waitUntil(promise) {
|
|
236
|
+
waitUntilPromises.push(promise);
|
|
237
|
+
},
|
|
238
|
+
passThroughOnException() {},
|
|
239
|
+
props: {}
|
|
240
|
+
},
|
|
241
|
+
request: options.request ?? null,
|
|
242
|
+
waitUntilPromises
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Runs a function within a test context
|
|
247
|
+
*
|
|
248
|
+
* @example
|
|
249
|
+
* ```ts
|
|
250
|
+
* const response = await withTestContext(
|
|
251
|
+
* { env: { DB: mockD1 } },
|
|
252
|
+
* async () => {
|
|
253
|
+
* // env, ctx, locals all work here
|
|
254
|
+
* return handler.fetch(new Request('https://test.com'))
|
|
255
|
+
* }
|
|
256
|
+
* )
|
|
257
|
+
* ```
|
|
258
|
+
*/
|
|
259
|
+
async function withTestContext(options, handler) {
|
|
260
|
+
const testCtx = createMockTestContext(options);
|
|
261
|
+
return runWithContext(testCtx.env, testCtx.ctx, options.request ?? null, handler, options.type ?? "fetch");
|
|
262
|
+
}
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region src/test/utilities/kv.ts
|
|
265
|
+
/**
|
|
266
|
+
* Creates a mock KVNamespace for testing
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* ```ts
|
|
270
|
+
* const kv = createMockKV({ 'key': 'value' })
|
|
271
|
+
* await kv.get('key') // 'value'
|
|
272
|
+
* ```
|
|
273
|
+
*/
|
|
274
|
+
function createMockKV(initialData = {}) {
|
|
275
|
+
const store = /* @__PURE__ */ new Map();
|
|
276
|
+
const metadata = /* @__PURE__ */ new Map();
|
|
277
|
+
const encoder = new TextEncoder();
|
|
278
|
+
const decoder = new TextDecoder();
|
|
279
|
+
for (const [key, value] of Object.entries(initialData)) store.set(key, encoder.encode(value));
|
|
280
|
+
const toBytes = async (value) => {
|
|
281
|
+
if (typeof value === "string") return encoder.encode(value);
|
|
282
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0));
|
|
283
|
+
if (ArrayBuffer.isView(value)) {
|
|
284
|
+
const view = value;
|
|
285
|
+
const copy = new Uint8Array(view.byteLength);
|
|
286
|
+
copy.set(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
|
287
|
+
return copy;
|
|
288
|
+
}
|
|
289
|
+
const reader = value.getReader();
|
|
290
|
+
const chunks = [];
|
|
291
|
+
let total = 0;
|
|
292
|
+
while (true) {
|
|
293
|
+
const result = await reader.read();
|
|
294
|
+
if (result.done) break;
|
|
295
|
+
if (result.value) {
|
|
296
|
+
const chunk = result.value instanceof Uint8Array ? result.value : new Uint8Array(result.value);
|
|
297
|
+
chunks.push(chunk);
|
|
298
|
+
total += chunk.length;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const combined = new Uint8Array(total);
|
|
302
|
+
let offset = 0;
|
|
303
|
+
for (const chunk of chunks) {
|
|
304
|
+
combined.set(chunk, offset);
|
|
305
|
+
offset += chunk.length;
|
|
306
|
+
}
|
|
307
|
+
return combined;
|
|
308
|
+
};
|
|
309
|
+
const decodeBytes = (bytes, type) => {
|
|
310
|
+
switch (type) {
|
|
311
|
+
case "json": return JSON.parse(decoder.decode(bytes));
|
|
312
|
+
case "arrayBuffer": {
|
|
313
|
+
const copy = new Uint8Array(bytes.length);
|
|
314
|
+
copy.set(bytes);
|
|
315
|
+
return copy.buffer;
|
|
316
|
+
}
|
|
317
|
+
case "stream": {
|
|
318
|
+
const copy = new Uint8Array(bytes.length);
|
|
319
|
+
copy.set(bytes);
|
|
320
|
+
return new ReadableStream({ start(controller) {
|
|
321
|
+
controller.enqueue(copy);
|
|
322
|
+
controller.close();
|
|
323
|
+
} });
|
|
324
|
+
}
|
|
325
|
+
default: return decoder.decode(bytes);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
const resolveType = (options) => {
|
|
329
|
+
return typeof options === "string" ? options : options?.type ?? "text";
|
|
330
|
+
};
|
|
331
|
+
return {
|
|
332
|
+
async get(key, options) {
|
|
333
|
+
const bytes = store.get(key);
|
|
334
|
+
if (bytes === void 0) return null;
|
|
335
|
+
return decodeBytes(bytes, resolveType(options));
|
|
336
|
+
},
|
|
337
|
+
async put(key, value, _options) {
|
|
338
|
+
const bytes = await toBytes(value);
|
|
339
|
+
store.set(key, bytes);
|
|
340
|
+
},
|
|
341
|
+
async delete(key) {
|
|
342
|
+
store.delete(key);
|
|
343
|
+
metadata.delete(key);
|
|
344
|
+
},
|
|
345
|
+
async list(options) {
|
|
346
|
+
const prefix = options?.prefix ?? "";
|
|
347
|
+
const limit = options?.limit ?? 1e3;
|
|
348
|
+
const keys = Array.from(store.keys()).filter((key) => key.startsWith(prefix)).slice(0, limit).map((name) => ({ name }));
|
|
349
|
+
return {
|
|
350
|
+
keys,
|
|
351
|
+
list_complete: keys.length < limit,
|
|
352
|
+
cursor: void 0
|
|
353
|
+
};
|
|
354
|
+
},
|
|
355
|
+
async getWithMetadata(key, options) {
|
|
356
|
+
const bytes = store.get(key);
|
|
357
|
+
return {
|
|
358
|
+
value: bytes === void 0 ? null : decodeBytes(bytes, resolveType(options)),
|
|
359
|
+
metadata: metadata.get(key) ?? null
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/test/utilities/d1.ts
|
|
366
|
+
const TABLE_NAME_RE = /(?:from|into|update)\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/i;
|
|
367
|
+
const extractTable = (sql) => {
|
|
368
|
+
const match = TABLE_NAME_RE.exec(sql);
|
|
369
|
+
return match ? match[1] : null;
|
|
47
370
|
};
|
|
371
|
+
const detectOp = (sql) => {
|
|
372
|
+
const trimmed = sql.trimStart().toLowerCase();
|
|
373
|
+
if (trimmed.startsWith("select")) return "select";
|
|
374
|
+
if (trimmed.startsWith("insert")) return "insert";
|
|
375
|
+
if (trimmed.startsWith("update")) return "update";
|
|
376
|
+
if (trimmed.startsWith("delete")) return "delete";
|
|
377
|
+
return "other";
|
|
378
|
+
};
|
|
379
|
+
/**
|
|
380
|
+
* Creates a mock D1Database for testing
|
|
381
|
+
*
|
|
382
|
+
* @example
|
|
383
|
+
* ```ts
|
|
384
|
+
* // Legacy: fixed results for all queries
|
|
385
|
+
* const d1 = createMockD1([{ id: 1, name: 'Alice' }])
|
|
386
|
+
*
|
|
387
|
+
* // Preferred: per-table fixtures
|
|
388
|
+
* const d1 = createMockD1({ fixtures: { users: [{ id: 1, name: 'Alice' }] } })
|
|
389
|
+
* await d1.prepare('SELECT * FROM users').all() // returns users fixture
|
|
390
|
+
* ```
|
|
391
|
+
*/
|
|
392
|
+
function createMockD1(mockResultsOrOptions = []) {
|
|
393
|
+
const options = Array.isArray(mockResultsOrOptions) ? { results: mockResultsOrOptions } : mockResultsOrOptions;
|
|
394
|
+
const tables = /* @__PURE__ */ new Map();
|
|
395
|
+
for (const [name, rows] of Object.entries(options.fixtures ?? {})) tables.set(name, [...rows]);
|
|
396
|
+
const fallback = options.results ?? [];
|
|
397
|
+
const resolveRows = (sql) => {
|
|
398
|
+
const op = detectOp(sql);
|
|
399
|
+
const table = extractTable(sql);
|
|
400
|
+
if (table && tables.has(table)) return {
|
|
401
|
+
rows: tables.get(table) ?? [],
|
|
402
|
+
op,
|
|
403
|
+
table
|
|
404
|
+
};
|
|
405
|
+
return {
|
|
406
|
+
rows: [...fallback],
|
|
407
|
+
op,
|
|
408
|
+
table
|
|
409
|
+
};
|
|
410
|
+
};
|
|
411
|
+
const createStatement = (sql) => {
|
|
412
|
+
let boundValues = [];
|
|
413
|
+
const statement = {
|
|
414
|
+
bind(...values) {
|
|
415
|
+
boundValues = values;
|
|
416
|
+
return statement;
|
|
417
|
+
},
|
|
418
|
+
async first(column) {
|
|
419
|
+
const { rows } = resolveRows(sql);
|
|
420
|
+
const row = rows[0];
|
|
421
|
+
if (!row) return null;
|
|
422
|
+
if (column) return row[column];
|
|
423
|
+
return row;
|
|
424
|
+
},
|
|
425
|
+
async all() {
|
|
426
|
+
const { rows } = resolveRows(sql);
|
|
427
|
+
return {
|
|
428
|
+
results: rows,
|
|
429
|
+
success: true,
|
|
430
|
+
meta: {
|
|
431
|
+
duration: 0,
|
|
432
|
+
changes: 0,
|
|
433
|
+
last_row_id: 0
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
},
|
|
437
|
+
async run() {
|
|
438
|
+
const { op, table } = resolveRows(sql);
|
|
439
|
+
let changes = 0;
|
|
440
|
+
let lastRowId = 0;
|
|
441
|
+
if (op === "insert" && table) {
|
|
442
|
+
const rows = tables.get(table) ?? [];
|
|
443
|
+
const bound = boundValues.length > 0 ? Object.fromEntries(boundValues.map((v, i) => [`col${i}`, v])) : {};
|
|
444
|
+
rows.push(bound);
|
|
445
|
+
tables.set(table, rows);
|
|
446
|
+
changes = 1;
|
|
447
|
+
lastRowId = rows.length;
|
|
448
|
+
} else if (op === "delete" && table) {
|
|
449
|
+
changes = (tables.get(table) ?? []).length;
|
|
450
|
+
tables.set(table, []);
|
|
451
|
+
} else if (op === "update" && table) changes = (tables.get(table) ?? []).length;
|
|
452
|
+
return {
|
|
453
|
+
results: [],
|
|
454
|
+
success: true,
|
|
455
|
+
meta: {
|
|
456
|
+
duration: 0,
|
|
457
|
+
changes,
|
|
458
|
+
last_row_id: lastRowId
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
},
|
|
462
|
+
async raw(_options) {
|
|
463
|
+
const { rows } = resolveRows(sql);
|
|
464
|
+
return rows.map((row) => Object.values(row));
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
return statement;
|
|
468
|
+
};
|
|
469
|
+
return {
|
|
470
|
+
prepare(query) {
|
|
471
|
+
return createStatement(query);
|
|
472
|
+
},
|
|
473
|
+
async exec(_query) {
|
|
474
|
+
return {
|
|
475
|
+
results: [],
|
|
476
|
+
success: true,
|
|
477
|
+
meta: {
|
|
478
|
+
duration: 0,
|
|
479
|
+
changes: 0,
|
|
480
|
+
last_row_id: 0
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
},
|
|
484
|
+
async batch(statements) {
|
|
485
|
+
return statements.map(() => ({
|
|
486
|
+
results: [],
|
|
487
|
+
success: true,
|
|
488
|
+
meta: {
|
|
489
|
+
duration: 0,
|
|
490
|
+
changes: 0,
|
|
491
|
+
last_row_id: 0
|
|
492
|
+
}
|
|
493
|
+
}));
|
|
494
|
+
},
|
|
495
|
+
async dump() {
|
|
496
|
+
return /* @__PURE__ */ new ArrayBuffer(0);
|
|
497
|
+
},
|
|
498
|
+
withSession(_constraintOrBookmark) {
|
|
499
|
+
return this;
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
//#endregion
|
|
504
|
+
//#region src/test/utilities/r2.ts
|
|
505
|
+
/**
|
|
506
|
+
* Creates a mock R2Bucket for testing
|
|
507
|
+
*
|
|
508
|
+
* @example
|
|
509
|
+
* ```ts
|
|
510
|
+
* const r2 = createMockR2()
|
|
511
|
+
* await r2.put('file.txt', 'content')
|
|
512
|
+
* const obj = await r2.get('file.txt')
|
|
513
|
+
* ```
|
|
514
|
+
*/
|
|
515
|
+
async function coerceToString(value) {
|
|
516
|
+
if (typeof value === "string") return value;
|
|
517
|
+
if (value === null) return "";
|
|
518
|
+
if (value instanceof ArrayBuffer) return new TextDecoder().decode(value);
|
|
519
|
+
if (ArrayBuffer.isView(value)) return new TextDecoder().decode(value);
|
|
520
|
+
if (value instanceof Blob) return await value.text();
|
|
521
|
+
const reader = value.getReader();
|
|
522
|
+
const chunks = [];
|
|
523
|
+
let done = false;
|
|
524
|
+
while (!done) {
|
|
525
|
+
const result = await reader.read();
|
|
526
|
+
done = result.done;
|
|
527
|
+
if (result.value) chunks.push(new TextDecoder().decode(result.value));
|
|
528
|
+
}
|
|
529
|
+
return chunks.join("");
|
|
530
|
+
}
|
|
531
|
+
function createMockR2() {
|
|
532
|
+
const store = /* @__PURE__ */ new Map();
|
|
533
|
+
const uploads = /* @__PURE__ */ new Map();
|
|
534
|
+
const createR2Object = (key, content, metadata) => {
|
|
535
|
+
const data = new TextEncoder().encode(content);
|
|
536
|
+
return {
|
|
537
|
+
key,
|
|
538
|
+
version: "1",
|
|
539
|
+
size: data.length,
|
|
540
|
+
etag: `"${key}-etag"`,
|
|
541
|
+
httpEtag: `"${key}-etag"`,
|
|
542
|
+
uploaded: /* @__PURE__ */ new Date(),
|
|
543
|
+
httpMetadata: metadata ?? {},
|
|
544
|
+
customMetadata: {},
|
|
545
|
+
checksums: {},
|
|
546
|
+
storageClass: "Standard",
|
|
547
|
+
body: new ReadableStream({ start(controller) {
|
|
548
|
+
controller.enqueue(data);
|
|
549
|
+
controller.close();
|
|
550
|
+
} }),
|
|
551
|
+
bodyUsed: false,
|
|
552
|
+
async arrayBuffer() {
|
|
553
|
+
return new Uint8Array(data).buffer;
|
|
554
|
+
},
|
|
555
|
+
async text() {
|
|
556
|
+
return content;
|
|
557
|
+
},
|
|
558
|
+
async json() {
|
|
559
|
+
return JSON.parse(content);
|
|
560
|
+
},
|
|
561
|
+
async blob() {
|
|
562
|
+
return new Blob([data]);
|
|
563
|
+
},
|
|
564
|
+
writeHttpMetadata(headers) {}
|
|
565
|
+
};
|
|
566
|
+
};
|
|
567
|
+
return {
|
|
568
|
+
async put(key, value, options) {
|
|
569
|
+
const content = await coerceToString(value);
|
|
570
|
+
store.set(key, { content });
|
|
571
|
+
return createR2Object(key, content);
|
|
572
|
+
},
|
|
573
|
+
async get(key, options) {
|
|
574
|
+
const item = store.get(key);
|
|
575
|
+
if (!item) return null;
|
|
576
|
+
return createR2Object(key, item.content, item.metadata);
|
|
577
|
+
},
|
|
578
|
+
async head(key) {
|
|
579
|
+
const item = store.get(key);
|
|
580
|
+
if (!item) return null;
|
|
581
|
+
return {
|
|
582
|
+
key,
|
|
583
|
+
version: "1",
|
|
584
|
+
size: new TextEncoder().encode(item.content).length,
|
|
585
|
+
etag: `"${key}-etag"`,
|
|
586
|
+
httpEtag: `"${key}-etag"`,
|
|
587
|
+
uploaded: /* @__PURE__ */ new Date(),
|
|
588
|
+
httpMetadata: item.metadata ?? {},
|
|
589
|
+
customMetadata: {},
|
|
590
|
+
checksums: {},
|
|
591
|
+
storageClass: "Standard",
|
|
592
|
+
writeHttpMetadata(headers) {}
|
|
593
|
+
};
|
|
594
|
+
},
|
|
595
|
+
async delete(keys) {
|
|
596
|
+
const keyArray = Array.isArray(keys) ? keys : [keys];
|
|
597
|
+
for (const key of keyArray) store.delete(key);
|
|
598
|
+
},
|
|
599
|
+
async list(options) {
|
|
600
|
+
return {
|
|
601
|
+
objects: Array.from(store.entries()).map(([key, { content, metadata }]) => createR2Object(key, content, metadata)),
|
|
602
|
+
truncated: false,
|
|
603
|
+
delimitedPrefixes: []
|
|
604
|
+
};
|
|
605
|
+
},
|
|
606
|
+
async createMultipartUpload(key, options) {
|
|
607
|
+
const uploadId = crypto.randomUUID();
|
|
608
|
+
uploads.set(uploadId, {
|
|
609
|
+
key,
|
|
610
|
+
parts: /* @__PURE__ */ new Map(),
|
|
611
|
+
options
|
|
612
|
+
});
|
|
613
|
+
return createMultipart(key, uploadId);
|
|
614
|
+
},
|
|
615
|
+
resumeMultipartUpload(key, uploadId) {
|
|
616
|
+
if (!uploads.has(uploadId)) uploads.set(uploadId, {
|
|
617
|
+
key,
|
|
618
|
+
parts: /* @__PURE__ */ new Map()
|
|
619
|
+
});
|
|
620
|
+
return createMultipart(key, uploadId);
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
function createMultipart(key, uploadId) {
|
|
624
|
+
const requireEntry = () => {
|
|
625
|
+
const entry = uploads.get(uploadId);
|
|
626
|
+
if (!entry) throw new Error(`Mock R2 multipart upload "${uploadId}" for key "${key}" is not active (it was aborted or completed).`);
|
|
627
|
+
return entry;
|
|
628
|
+
};
|
|
629
|
+
return {
|
|
630
|
+
key,
|
|
631
|
+
uploadId,
|
|
632
|
+
async uploadPart(partNumber, value, _options) {
|
|
633
|
+
const entry = requireEntry();
|
|
634
|
+
const str = await coerceToString(value);
|
|
635
|
+
entry.parts.set(partNumber, str);
|
|
636
|
+
return {
|
|
637
|
+
partNumber,
|
|
638
|
+
etag: `"${key}-part-${partNumber}-etag"`
|
|
639
|
+
};
|
|
640
|
+
},
|
|
641
|
+
async abort() {
|
|
642
|
+
uploads.delete(uploadId);
|
|
643
|
+
},
|
|
644
|
+
async complete(uploadedParts) {
|
|
645
|
+
const entry = requireEntry();
|
|
646
|
+
const content = [...uploadedParts].sort((a, b) => a.partNumber - b.partNumber).map((part) => {
|
|
647
|
+
const chunk = entry.parts.get(part.partNumber);
|
|
648
|
+
if (chunk === void 0) throw new Error(`Mock R2 multipart complete() references part ${part.partNumber} that was never uploaded for upload "${uploadId}".`);
|
|
649
|
+
return chunk;
|
|
650
|
+
}).join("");
|
|
651
|
+
const metadata = entry.options?.customMetadata;
|
|
652
|
+
store.set(key, {
|
|
653
|
+
content,
|
|
654
|
+
metadata
|
|
655
|
+
});
|
|
656
|
+
uploads.delete(uploadId);
|
|
657
|
+
return createR2Object(key, content, metadata);
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
//#endregion
|
|
663
|
+
//#region src/test/utilities/queue.ts
|
|
664
|
+
/**
|
|
665
|
+
* Creates a mock Queue for testing
|
|
666
|
+
*/
|
|
667
|
+
function createMockQueue() {
|
|
668
|
+
const messages = [];
|
|
669
|
+
const metrics = {
|
|
670
|
+
backlogCount: 0,
|
|
671
|
+
backlogBytes: 0
|
|
672
|
+
};
|
|
673
|
+
const response = { metadata: { metrics } };
|
|
674
|
+
return {
|
|
675
|
+
async metrics() {
|
|
676
|
+
return metrics;
|
|
677
|
+
},
|
|
678
|
+
async send(message, options) {
|
|
679
|
+
messages.push({
|
|
680
|
+
body: message,
|
|
681
|
+
options
|
|
682
|
+
});
|
|
683
|
+
return response;
|
|
684
|
+
},
|
|
685
|
+
async sendBatch(batch, options) {
|
|
686
|
+
for (const message of batch) messages.push({
|
|
687
|
+
body: message.body,
|
|
688
|
+
options: {
|
|
689
|
+
contentType: message.contentType,
|
|
690
|
+
delaySeconds: message.delaySeconds ?? options?.delaySeconds
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
return response;
|
|
694
|
+
},
|
|
695
|
+
_getMessages() {
|
|
696
|
+
return messages;
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/test/utilities/platform.ts
|
|
702
|
+
/**
|
|
703
|
+
* Creates a local fixed-window RateLimit binding for testing.
|
|
704
|
+
*/
|
|
705
|
+
function createMockRateLimit(options = {}) {
|
|
706
|
+
const limit = options.limit ?? Number.MAX_SAFE_INTEGER;
|
|
707
|
+
const periodMs = (options.period ?? 60) * 1e3;
|
|
708
|
+
const windows = /* @__PURE__ */ new Map();
|
|
709
|
+
return { async limit({ key }) {
|
|
710
|
+
const now = Date.now();
|
|
711
|
+
const existing = windows.get(key);
|
|
712
|
+
if (!existing || existing.resetAt <= now) {
|
|
713
|
+
windows.set(key, {
|
|
714
|
+
count: 1,
|
|
715
|
+
resetAt: now + periodMs
|
|
716
|
+
});
|
|
717
|
+
return { success: limit >= 1 };
|
|
718
|
+
}
|
|
719
|
+
existing.count += 1;
|
|
720
|
+
return { success: existing.count <= limit };
|
|
721
|
+
} };
|
|
722
|
+
}
|
|
723
|
+
function createMockVersionMetadata(metadata = {}) {
|
|
724
|
+
return {
|
|
725
|
+
id: metadata.id ?? "devflare-local-version",
|
|
726
|
+
tag: metadata.tag ?? "local",
|
|
727
|
+
timestamp: metadata.timestamp ?? "1970-01-01T00:00:00.000Z"
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Creates a Secrets Store binding whose get() method returns a fixed value.
|
|
732
|
+
*/
|
|
733
|
+
function createMockSecretsStoreSecret(value) {
|
|
734
|
+
return { async get() {
|
|
735
|
+
return value;
|
|
736
|
+
} };
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Creates a Hyperdrive binding around a local database connection string.
|
|
740
|
+
*
|
|
741
|
+
* Connection fields are usable with any Node database client; the raw socket
|
|
742
|
+
* `connect()` is a documented limitation that throws a clear, actionable error.
|
|
743
|
+
*/
|
|
744
|
+
function createMockHyperdrive(connectionString) {
|
|
745
|
+
return createLocalHyperdrive(connectionString);
|
|
746
|
+
}
|
|
747
|
+
function createDefaultWorkerStub() {
|
|
748
|
+
return {
|
|
749
|
+
getEntrypoint() {
|
|
750
|
+
throw new Error("Mock WorkerLoader stub has no entrypoint. Pass createMockWorkerLoader({ stub }) for behavior.");
|
|
751
|
+
},
|
|
752
|
+
getDurableObjectClass() {
|
|
753
|
+
throw new Error("Mock WorkerLoader stub cannot materialise a Durable Object class. getDurableObjectClass() returns an opaque facet-spawning reference that workerd only exposes inside the runtime. Pass createMockWorkerLoader({ stub }) to inject a stub, or use createTestContext() (a real Miniflare worker) for Durable Object behavior.");
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Creates a Worker Loader binding for pure unit tests.
|
|
759
|
+
*/
|
|
760
|
+
function createMockWorkerLoader(options = {}) {
|
|
761
|
+
const stub = options.stub ?? createDefaultWorkerStub();
|
|
762
|
+
return {
|
|
763
|
+
get(_name, _getCode) {
|
|
764
|
+
return stub;
|
|
765
|
+
},
|
|
766
|
+
load(_code) {
|
|
767
|
+
return stub;
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
function defaultMTLSCertificateHandler() {
|
|
772
|
+
throw new Error("Mock mTLS Certificate Fetcher has no handler. Pass createMockMTLSCertificate(handler) for behavior.");
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Creates an mTLS certificate binding fetcher for pure unit tests.
|
|
776
|
+
*/
|
|
777
|
+
function createMockMTLSCertificate(handler = defaultMTLSCertificateHandler) {
|
|
778
|
+
return { async fetch(input, init) {
|
|
779
|
+
return handler(input, init);
|
|
780
|
+
} };
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Creates a Dispatch Namespace binding for pure unit tests.
|
|
784
|
+
*/
|
|
785
|
+
function createMockDispatchNamespace(options = {}) {
|
|
786
|
+
return { get(name) {
|
|
787
|
+
const worker = options.workers?.[name];
|
|
788
|
+
if (!worker) throw new Error(`Mock DispatchNamespace has no worker named "${name}".`);
|
|
789
|
+
return typeof worker === "function" ? createMockMTLSCertificate(worker) : worker;
|
|
790
|
+
} };
|
|
791
|
+
}
|
|
792
|
+
//#endregion
|
|
793
|
+
//#region src/test/utilities/workflows.ts
|
|
794
|
+
function createMockWorkflowInstance(id, options = {}) {
|
|
795
|
+
let status = options.status ?? "queued";
|
|
796
|
+
let output = options.output;
|
|
797
|
+
let error = options.error;
|
|
798
|
+
return {
|
|
799
|
+
id,
|
|
800
|
+
async pause() {
|
|
801
|
+
status = "paused";
|
|
802
|
+
},
|
|
803
|
+
async resume() {
|
|
804
|
+
status = "running";
|
|
805
|
+
},
|
|
806
|
+
async terminate() {
|
|
807
|
+
status = "terminated";
|
|
808
|
+
},
|
|
809
|
+
async restart() {
|
|
810
|
+
status = "queued";
|
|
811
|
+
error = void 0;
|
|
812
|
+
output = void 0;
|
|
813
|
+
},
|
|
814
|
+
async status() {
|
|
815
|
+
return {
|
|
816
|
+
status,
|
|
817
|
+
...error && { error },
|
|
818
|
+
...output !== void 0 && { output }
|
|
819
|
+
};
|
|
820
|
+
},
|
|
821
|
+
async sendEvent(_event) {}
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Creates a Workflow binding for pure unit tests.
|
|
826
|
+
*/
|
|
827
|
+
function createMockWorkflow(options = {}) {
|
|
828
|
+
const instances = /* @__PURE__ */ new Map();
|
|
829
|
+
let sequence = 0;
|
|
830
|
+
for (const [id, instanceOptions] of Object.entries(options.instances ?? {})) instances.set(id, createMockWorkflowInstance(id, instanceOptions));
|
|
831
|
+
const createInstance = (id) => {
|
|
832
|
+
if (instances.has(id)) throw new Error(`Mock Workflow already has an instance named "${id}".`);
|
|
833
|
+
const instance = createMockWorkflowInstance(id);
|
|
834
|
+
instances.set(id, instance);
|
|
835
|
+
return instance;
|
|
836
|
+
};
|
|
837
|
+
return {
|
|
838
|
+
async get(id) {
|
|
839
|
+
const instance = instances.get(id);
|
|
840
|
+
if (!instance) throw new Error(`Mock Workflow has no instance named "${id}".`);
|
|
841
|
+
return instance;
|
|
842
|
+
},
|
|
843
|
+
async create(options) {
|
|
844
|
+
return createInstance(options?.id ?? `mock-workflow-${++sequence}`);
|
|
845
|
+
},
|
|
846
|
+
async createBatch(batch) {
|
|
847
|
+
return batch.map((options) => {
|
|
848
|
+
return createInstance(options.id ?? `mock-workflow-${++sequence}`);
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Creates a Pipeline binding for pure unit tests.
|
|
855
|
+
*/
|
|
856
|
+
function createMockPipeline() {
|
|
857
|
+
const records = [];
|
|
858
|
+
return {
|
|
859
|
+
async send(batch) {
|
|
860
|
+
records.push(...batch);
|
|
861
|
+
},
|
|
862
|
+
_getRecords() {
|
|
863
|
+
return [...records];
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
//#endregion
|
|
868
|
+
//#region src/test/utilities/media.ts
|
|
869
|
+
function createEmptyImageStream() {
|
|
870
|
+
return new ReadableStream({ start(controller) {
|
|
871
|
+
controller.close();
|
|
872
|
+
} });
|
|
873
|
+
}
|
|
874
|
+
function createMockImageTransformationResult(response) {
|
|
875
|
+
return {
|
|
876
|
+
response() {
|
|
877
|
+
return response.clone();
|
|
878
|
+
},
|
|
879
|
+
contentType() {
|
|
880
|
+
return response.headers.get("Content-Type") ?? "image/png";
|
|
881
|
+
},
|
|
882
|
+
image() {
|
|
883
|
+
return response.clone().body ?? createEmptyImageStream();
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
function createMockImageTransformer(response) {
|
|
888
|
+
const transformer = {
|
|
889
|
+
transform(_transform) {
|
|
890
|
+
return transformer;
|
|
891
|
+
},
|
|
892
|
+
draw(_image, _options) {
|
|
893
|
+
return transformer;
|
|
894
|
+
},
|
|
895
|
+
async output(_options) {
|
|
896
|
+
return createMockImageTransformationResult(response);
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
return transformer;
|
|
900
|
+
}
|
|
901
|
+
function createMockHostedImagesBinding() {
|
|
902
|
+
const unsupported = () => {
|
|
903
|
+
throw new Error("Mock Images hosted API is not implemented. Pass a custom ImagesBinding through createMockEnv({ images }) if your test needs hosted image behavior.");
|
|
904
|
+
};
|
|
905
|
+
return {
|
|
906
|
+
image(_imageId) {
|
|
907
|
+
return {
|
|
908
|
+
details: unsupported,
|
|
909
|
+
bytes: unsupported,
|
|
910
|
+
update: unsupported,
|
|
911
|
+
delete: unsupported
|
|
912
|
+
};
|
|
913
|
+
},
|
|
914
|
+
upload: unsupported,
|
|
915
|
+
list: unsupported
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Creates an Images binding for pure unit tests.
|
|
920
|
+
*/
|
|
921
|
+
function createMockImagesBinding(options = {}) {
|
|
922
|
+
const response = options.response ?? new Response("", { headers: { "Content-Type": "image/png" } });
|
|
923
|
+
const info = options.info ?? {
|
|
924
|
+
format: response.headers.get("Content-Type") ?? "image/png",
|
|
925
|
+
fileSize: 0,
|
|
926
|
+
width: 0,
|
|
927
|
+
height: 0
|
|
928
|
+
};
|
|
929
|
+
return {
|
|
930
|
+
async info(_stream, _options) {
|
|
931
|
+
return info;
|
|
932
|
+
},
|
|
933
|
+
input(_stream, _options) {
|
|
934
|
+
return createMockImageTransformer(response);
|
|
935
|
+
},
|
|
936
|
+
hosted: createMockHostedImagesBinding()
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
function createEmptyMediaStream() {
|
|
940
|
+
return new ReadableStream({ start(controller) {
|
|
941
|
+
controller.close();
|
|
942
|
+
} });
|
|
943
|
+
}
|
|
944
|
+
function createMockMediaTransformationResult(response) {
|
|
945
|
+
return {
|
|
946
|
+
async media() {
|
|
947
|
+
return response.clone().body ?? createEmptyMediaStream();
|
|
948
|
+
},
|
|
949
|
+
async response() {
|
|
950
|
+
return response.clone();
|
|
951
|
+
},
|
|
952
|
+
async contentType() {
|
|
953
|
+
return response.headers.get("Content-Type") ?? "video/mp4";
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
function createMockMediaTransformer(response) {
|
|
958
|
+
return {
|
|
959
|
+
transform(_transform) {
|
|
960
|
+
return { output(_output) {
|
|
961
|
+
return createMockMediaTransformationResult(response);
|
|
962
|
+
} };
|
|
963
|
+
},
|
|
964
|
+
output(_output) {
|
|
965
|
+
return createMockMediaTransformationResult(response);
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Creates a Media Transformations binding for pure unit tests.
|
|
971
|
+
*/
|
|
972
|
+
function createMockMediaBinding(options = {}) {
|
|
973
|
+
const response = options.response ?? new Response("", { headers: { "Content-Type": "video/mp4" } });
|
|
974
|
+
return { input(_media) {
|
|
975
|
+
return createMockMediaTransformer(response);
|
|
976
|
+
} };
|
|
977
|
+
}
|
|
978
|
+
//#endregion
|
|
979
|
+
//#region src/test/utilities/artifacts.ts
|
|
980
|
+
function createArtifactTimestamp() {
|
|
981
|
+
return (/* @__PURE__ */ new Date("2026-04-26T00:00:00.000Z")).toISOString();
|
|
982
|
+
}
|
|
983
|
+
function createArtifactsRepoInfo(name, options = {}) {
|
|
984
|
+
const now = createArtifactTimestamp();
|
|
985
|
+
return {
|
|
986
|
+
id: `repo-${name}`,
|
|
987
|
+
name,
|
|
988
|
+
description: options.description ?? null,
|
|
989
|
+
defaultBranch: options.defaultBranch ?? "main",
|
|
990
|
+
createdAt: now,
|
|
991
|
+
updatedAt: now,
|
|
992
|
+
lastPushAt: null,
|
|
993
|
+
source: options.source ?? null,
|
|
994
|
+
readOnly: options.readOnly ?? false,
|
|
995
|
+
remote: `https://example.com/artifacts/default/${name}.git`
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
function isArtifactsBinding$1(value) {
|
|
999
|
+
return typeof value.create === "function";
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Creates an in-memory Artifacts binding for pure unit tests.
|
|
1003
|
+
*/
|
|
1004
|
+
function createMockArtifacts(options = {}) {
|
|
1005
|
+
const repos = /* @__PURE__ */ new Map();
|
|
1006
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
1007
|
+
const addRepo = (info) => {
|
|
1008
|
+
repos.set(info.name, info);
|
|
1009
|
+
if (!tokens.has(info.name)) tokens.set(info.name, []);
|
|
1010
|
+
};
|
|
1011
|
+
for (const repo of options.repos ?? []) addRepo({
|
|
1012
|
+
...createArtifactsRepoInfo(repo.name),
|
|
1013
|
+
...repo
|
|
1014
|
+
});
|
|
1015
|
+
const createToken = (repoName, scope = "write", ttl = 86400) => {
|
|
1016
|
+
const existing = tokens.get(repoName) ?? [];
|
|
1017
|
+
const id = `token-${repoName}-${existing.length + 1}`;
|
|
1018
|
+
const expiresAt = new Date(Date.parse(createArtifactTimestamp()) + ttl * 1e3).toISOString();
|
|
1019
|
+
const token = {
|
|
1020
|
+
id,
|
|
1021
|
+
scope,
|
|
1022
|
+
state: "active",
|
|
1023
|
+
createdAt: createArtifactTimestamp(),
|
|
1024
|
+
expiresAt
|
|
1025
|
+
};
|
|
1026
|
+
tokens.set(repoName, [...existing, token]);
|
|
1027
|
+
return {
|
|
1028
|
+
id,
|
|
1029
|
+
plaintext: `${id}-plaintext`,
|
|
1030
|
+
scope,
|
|
1031
|
+
expiresAt
|
|
1032
|
+
};
|
|
1033
|
+
};
|
|
1034
|
+
const createRepoHandle = (info) => ({
|
|
1035
|
+
...info,
|
|
1036
|
+
async createToken(scope, ttl) {
|
|
1037
|
+
return createToken(info.name, scope, ttl);
|
|
1038
|
+
},
|
|
1039
|
+
async listTokens() {
|
|
1040
|
+
const repoTokens = tokens.get(info.name) ?? [];
|
|
1041
|
+
return {
|
|
1042
|
+
tokens: repoTokens,
|
|
1043
|
+
total: repoTokens.length
|
|
1044
|
+
};
|
|
1045
|
+
},
|
|
1046
|
+
async revokeToken(tokenOrId) {
|
|
1047
|
+
const repoTokens = tokens.get(info.name) ?? [];
|
|
1048
|
+
const index = repoTokens.findIndex((token) => token.id === tokenOrId);
|
|
1049
|
+
if (index === -1) return false;
|
|
1050
|
+
repoTokens[index] = {
|
|
1051
|
+
...repoTokens[index],
|
|
1052
|
+
state: "revoked"
|
|
1053
|
+
};
|
|
1054
|
+
tokens.set(info.name, repoTokens);
|
|
1055
|
+
return true;
|
|
1056
|
+
},
|
|
1057
|
+
async fork(name, forkOptions) {
|
|
1058
|
+
return createRepo(name, {
|
|
1059
|
+
description: forkOptions?.description ?? info.description ?? void 0,
|
|
1060
|
+
readOnly: forkOptions?.readOnly ?? info.readOnly,
|
|
1061
|
+
setDefaultBranch: info.defaultBranch,
|
|
1062
|
+
source: `artifacts:default/${info.name}`
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
const createRepo = async (name, createOptions = {}) => {
|
|
1067
|
+
const info = createArtifactsRepoInfo(name, {
|
|
1068
|
+
description: createOptions.description,
|
|
1069
|
+
readOnly: createOptions.readOnly,
|
|
1070
|
+
defaultBranch: createOptions.setDefaultBranch,
|
|
1071
|
+
source: createOptions.source
|
|
1072
|
+
});
|
|
1073
|
+
addRepo(info);
|
|
1074
|
+
const token = createToken(name);
|
|
1075
|
+
return {
|
|
1076
|
+
id: info.id,
|
|
1077
|
+
name: info.name,
|
|
1078
|
+
description: info.description,
|
|
1079
|
+
defaultBranch: info.defaultBranch,
|
|
1080
|
+
remote: info.remote,
|
|
1081
|
+
token: token.plaintext,
|
|
1082
|
+
tokenExpiresAt: token.expiresAt
|
|
1083
|
+
};
|
|
1084
|
+
};
|
|
1085
|
+
return {
|
|
1086
|
+
create: createRepo,
|
|
1087
|
+
async get(name) {
|
|
1088
|
+
const repo = repos.get(name);
|
|
1089
|
+
return repo ? createRepoHandle(repo) : null;
|
|
1090
|
+
},
|
|
1091
|
+
async import(params) {
|
|
1092
|
+
return createRepo(params.target.name, {
|
|
1093
|
+
description: params.target.opts?.description,
|
|
1094
|
+
readOnly: params.target.opts?.readOnly,
|
|
1095
|
+
source: params.source.url
|
|
1096
|
+
});
|
|
1097
|
+
},
|
|
1098
|
+
async list(opts) {
|
|
1099
|
+
const limit = opts?.limit ?? 50;
|
|
1100
|
+
const repoList = Array.from(repos.values()).slice(0, limit).map((repo) => {
|
|
1101
|
+
const { remote: _remote, ...rest } = repo;
|
|
1102
|
+
return rest;
|
|
1103
|
+
});
|
|
1104
|
+
return {
|
|
1105
|
+
repos: repoList,
|
|
1106
|
+
total: repos.size,
|
|
1107
|
+
...repos.size > repoList.length && { cursor: String(repoList.length) }
|
|
1108
|
+
};
|
|
1109
|
+
},
|
|
1110
|
+
async delete(name) {
|
|
1111
|
+
tokens.delete(name);
|
|
1112
|
+
return repos.delete(name);
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
//#endregion
|
|
1117
|
+
//#region src/test/utilities/env.ts
|
|
1118
|
+
/**
|
|
1119
|
+
* Creates a complete mock environment with specified bindings
|
|
1120
|
+
*
|
|
1121
|
+
* @example
|
|
1122
|
+
* ```ts
|
|
1123
|
+
* const env = createMockEnv({
|
|
1124
|
+
* kv: ['CACHE'],
|
|
1125
|
+
* d1: ['DB'],
|
|
1126
|
+
* vars: { API_KEY: 'secret' }
|
|
1127
|
+
* })
|
|
1128
|
+
* ```
|
|
1129
|
+
*/
|
|
1130
|
+
function createMockEnv(options = {}) {
|
|
1131
|
+
const env = {};
|
|
1132
|
+
if (options.kv) for (const name of options.kv) env[name] = createMockKV();
|
|
1133
|
+
if (options.d1) for (const name of options.d1) env[name] = createMockD1();
|
|
1134
|
+
if (options.r2) for (const name of options.r2) env[name] = createMockR2();
|
|
1135
|
+
if (options.queues) for (const name of options.queues) env[name] = createMockQueue();
|
|
1136
|
+
if (options.rateLimits) for (const [name, rateLimitOptions] of Object.entries(options.rateLimits)) env[name] = createMockRateLimit(rateLimitOptions);
|
|
1137
|
+
if (options.versionMetadata) env[options.versionMetadata] = createMockVersionMetadata();
|
|
1138
|
+
if (options.hyperdrive) for (const [name, binding] of Object.entries(options.hyperdrive)) env[name] = typeof binding === "string" ? createMockHyperdrive(binding) : binding;
|
|
1139
|
+
if (Array.isArray(options.workerLoaders)) for (const name of options.workerLoaders) env[name] = createMockWorkerLoader();
|
|
1140
|
+
else if (options.workerLoaders) for (const [name, workerLoaderOptions] of Object.entries(options.workerLoaders)) env[name] = createMockWorkerLoader(workerLoaderOptions);
|
|
1141
|
+
if (Array.isArray(options.mtlsCertificates)) for (const name of options.mtlsCertificates) env[name] = createMockMTLSCertificate();
|
|
1142
|
+
else if (options.mtlsCertificates) for (const [name, handler] of Object.entries(options.mtlsCertificates)) env[name] = createMockMTLSCertificate(handler);
|
|
1143
|
+
if (Array.isArray(options.dispatchNamespaces)) for (const name of options.dispatchNamespaces) env[name] = createMockDispatchNamespace();
|
|
1144
|
+
else if (options.dispatchNamespaces) for (const [name, dispatchNamespaceOptions] of Object.entries(options.dispatchNamespaces)) env[name] = createMockDispatchNamespace(dispatchNamespaceOptions);
|
|
1145
|
+
if (Array.isArray(options.workflows)) for (const name of options.workflows) env[name] = createMockWorkflow();
|
|
1146
|
+
else if (options.workflows) for (const [name, workflowOptions] of Object.entries(options.workflows)) env[name] = "create" in workflowOptions ? workflowOptions : createMockWorkflow(workflowOptions);
|
|
1147
|
+
if (Array.isArray(options.pipelines)) for (const name of options.pipelines) env[name] = createMockPipeline();
|
|
1148
|
+
else if (options.pipelines) for (const [name, pipeline] of Object.entries(options.pipelines)) env[name] = pipeline;
|
|
1149
|
+
if (typeof options.images === "string") env[options.images] = createMockImagesBinding();
|
|
1150
|
+
else if (options.images) env.IMAGES = options.images;
|
|
1151
|
+
if (typeof options.media === "string") env[options.media] = createMockMediaBinding();
|
|
1152
|
+
else if (options.media) env.MEDIA = options.media;
|
|
1153
|
+
if (Array.isArray(options.artifacts)) for (const name of options.artifacts) env[name] = createMockArtifacts();
|
|
1154
|
+
else if (options.artifacts) for (const [name, artifactsOptions] of Object.entries(options.artifacts)) env[name] = isArtifactsBinding$1(artifactsOptions) ? artifactsOptions : createMockArtifacts(artifactsOptions);
|
|
1155
|
+
if (options.secretsStore) for (const [name, value] of Object.entries(options.secretsStore)) env[name] = createMockSecretsStoreSecret(value);
|
|
1156
|
+
if (options.vars) Object.assign(env, options.vars);
|
|
1157
|
+
if (options.secrets) Object.assign(env, options.secrets);
|
|
1158
|
+
if (options.custom) Object.assign(env, options.custom);
|
|
1159
|
+
return env;
|
|
1160
|
+
}
|
|
1161
|
+
//#endregion
|
|
1162
|
+
//#region src/test/simple-context-bindings.ts
|
|
1163
|
+
/**
|
|
1164
|
+
* Build the initial remote/static binding map for a test context.
|
|
1165
|
+
*
|
|
1166
|
+
* - When `isRemoteModeActive()`, registers `RemoteAI` / `RemoteVectorize`
|
|
1167
|
+
* proxies for every `bindings.ai` / `bindings.vectorize` entry. Otherwise
|
|
1168
|
+
* those bindings are left to come from Miniflare (or remain absent).
|
|
1169
|
+
* - `config.vars` are always copied in as-is.
|
|
1170
|
+
* - `config.bindings.sendEmail` is always wired to the local SendEmail
|
|
1171
|
+
* binding (so tests can intercept outgoing mail without remote setup).
|
|
1172
|
+
*/
|
|
1173
|
+
function buildRemoteAndStaticBindings(config) {
|
|
1174
|
+
const remoteBindings = {};
|
|
1175
|
+
if (isRemoteModeActive()) {
|
|
1176
|
+
if (config.bindings?.ai) {
|
|
1177
|
+
const aiBindingName = config.bindings.ai.binding || "AI";
|
|
1178
|
+
remoteBindings[aiBindingName] = createRemoteAI(config.accountId);
|
|
1179
|
+
}
|
|
1180
|
+
if (config.bindings?.vectorize) for (const [name, vectorConfig] of Object.entries(config.bindings.vectorize)) remoteBindings[name] = createRemoteVectorize(vectorConfig.indexName, config.accountId);
|
|
1181
|
+
}
|
|
1182
|
+
if (config.vars) for (const [key, value] of Object.entries(config.vars)) remoteBindings[key] = value;
|
|
1183
|
+
if (config.bindings?.sendEmail) for (const [name, binding] of Object.entries(config.bindings.sendEmail)) remoteBindings[name] = createLocalSendEmailBinding(binding);
|
|
1184
|
+
if (config.bindings?.workerLoaders) for (const name of Object.keys(config.bindings.workerLoaders)) remoteBindings[name] = createLocalWorkerLoaderBinding();
|
|
1185
|
+
if (config.bindings?.versionMetadata) remoteBindings[config.bindings.versionMetadata.binding] = createMockVersionMetadata();
|
|
1186
|
+
return remoteBindings;
|
|
1187
|
+
}
|
|
1188
|
+
//#endregion
|
|
1189
|
+
//#region src/test/simple-context-gateway-script.ts
|
|
1190
|
+
function buildGatewayScript(bundledCode, wrappers, nativeRpcBindingNames = []) {
|
|
1191
|
+
return `
|
|
1192
|
+
// Bundled transport + DO classes
|
|
1193
|
+
${bundledCode}
|
|
1194
|
+
|
|
1195
|
+
// DO Wrappers with RPC
|
|
1196
|
+
${wrappers}
|
|
1197
|
+
|
|
1198
|
+
const __nativeRpcBindings = new Set(${JSON.stringify(nativeRpcBindingNames)})
|
|
1199
|
+
|
|
1200
|
+
// Transport encoding helper
|
|
1201
|
+
const __transportEncoders = typeof transport !== 'undefined' ? transport : {}
|
|
1202
|
+
|
|
1203
|
+
function __encodeTransport(value) {
|
|
1204
|
+
if (value === null || value === undefined) return value
|
|
1205
|
+
|
|
1206
|
+
// Try each encoder
|
|
1207
|
+
for (const [typeName, transporter] of Object.entries(__transportEncoders)) {
|
|
1208
|
+
const encoded = transporter.encode(value)
|
|
1209
|
+
if (encoded !== false && encoded !== undefined) {
|
|
1210
|
+
return { __transport: typeName, value: encoded }
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Recursively encode arrays and objects
|
|
1215
|
+
if (Array.isArray(value)) {
|
|
1216
|
+
return value.map(__encodeTransport)
|
|
1217
|
+
}
|
|
1218
|
+
if (typeof value === 'object') {
|
|
1219
|
+
const result = {}
|
|
1220
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1221
|
+
result[k] = __encodeTransport(v)
|
|
1222
|
+
}
|
|
1223
|
+
return result
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
return value
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// Gateway with WebSocket RPC
|
|
1230
|
+
export default {
|
|
1231
|
+
async fetch(request, env) {
|
|
1232
|
+
if (request.headers.get('Upgrade') === 'websocket') {
|
|
1233
|
+
const { 0: client, 1: server } = new WebSocketPair()
|
|
1234
|
+
server.accept()
|
|
1235
|
+
server.addEventListener('message', async (e) => {
|
|
1236
|
+
try {
|
|
1237
|
+
const m = JSON.parse(e.data)
|
|
1238
|
+
if (m.t === 'rpc.call') {
|
|
1239
|
+
const result = await executeRpc(env, m.method, m.params)
|
|
1240
|
+
server.send(JSON.stringify({ t: 'rpc.ok', id: m.id, result }))
|
|
1241
|
+
}
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
server.send(JSON.stringify({ t: 'rpc.err', id: 'unknown', error: { code: 'RPC_ERROR', message: error.message } }))
|
|
1244
|
+
}
|
|
1245
|
+
})
|
|
1246
|
+
return new Response(null, { status: 101, webSocket: client })
|
|
1247
|
+
}
|
|
1248
|
+
return new Response('Gateway')
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
async function executeRpc(env, method, params) {
|
|
1253
|
+
const [bindingName, ...rest] = method.split('.')
|
|
1254
|
+
let op = rest.join('.')
|
|
1255
|
+
const binding = env[bindingName]
|
|
1256
|
+
const RAW_EMAIL = 'EmailMessage::raw'
|
|
1257
|
+
if (!binding) throw new Error('Binding not found: ' + bindingName)
|
|
1258
|
+
|
|
1259
|
+
// Normalize namespaced op names (kv.*, r2.*, d1.*, do.*, queue.*, ai.*, var.*)
|
|
1260
|
+
// down to the legacy verbs this dispatcher historically used. The bridge
|
|
1261
|
+
// proxy always emits namespaced forms (see src/bridge/proxy.ts and B3 in
|
|
1262
|
+
// REMAINING.md); this keeps the dispatcher backwards-compatible while the
|
|
1263
|
+
// rest of the codebase converges on the namespaced convention.
|
|
1264
|
+
if (op.indexOf('kv.') === 0) op = op.slice(3)
|
|
1265
|
+
else if (op.indexOf('do.') === 0) {
|
|
1266
|
+
const tail = op.slice(3)
|
|
1267
|
+
if (tail === 'fetch') op = 'stub.fetch'
|
|
1268
|
+
else if (tail === 'rpc') op = 'stub.rpc'
|
|
1269
|
+
else op = tail
|
|
1270
|
+
}
|
|
1271
|
+
else if (op.indexOf('queue.') === 0) op = op.slice(6)
|
|
1272
|
+
else if (op.indexOf('ai.') === 0) op = op.slice(3)
|
|
1273
|
+
else if (op.indexOf('var.') === 0) op = op.slice(4)
|
|
1274
|
+
else if (op.indexOf('d1.stmt.') === 0) op = 'prepare.' + op.slice('d1.stmt.'.length)
|
|
1275
|
+
else if (op.indexOf('d1.') === 0) op = op.slice(3)
|
|
1276
|
+
// r2.* and email.* keep their existing prefixes
|
|
1277
|
+
|
|
1278
|
+
// KV operations
|
|
1279
|
+
if (op === 'get') return binding.get(params[0], params[1])
|
|
1280
|
+
if (op === 'put') return binding.put(params[0], params[1], params[2])
|
|
1281
|
+
if (op === 'delete') return binding.delete(params[0])
|
|
1282
|
+
if (op === 'list') return binding.list(params[0])
|
|
1283
|
+
if (op === 'getWithMetadata') return binding.getWithMetadata(params[0], params[1])
|
|
1284
|
+
|
|
1285
|
+
// R2 operations
|
|
1286
|
+
if (op === 'r2.get') return binding.get(params[0], params[1])
|
|
1287
|
+
if (op === 'r2.put') return binding.put(params[0], params[1], params[2])
|
|
1288
|
+
if (op === 'r2.delete') return binding.delete(params[0])
|
|
1289
|
+
if (op === 'r2.list') return binding.list(params[0])
|
|
1290
|
+
if (op === 'r2.head' || op === 'head') return binding.head(params[0])
|
|
1291
|
+
|
|
1292
|
+
// D1 operations
|
|
1293
|
+
if (op === 'exec') return binding.exec(params[0])
|
|
1294
|
+
if (op === 'dump') return binding.dump()
|
|
1295
|
+
if (op === 'batch') {
|
|
1296
|
+
const stmts = params[0].map(s => {
|
|
1297
|
+
const stmt = binding.prepare(s.sql)
|
|
1298
|
+
return s.bindings?.length ? stmt.bind(...s.bindings) : stmt
|
|
1299
|
+
})
|
|
1300
|
+
return binding.batch(stmts)
|
|
1301
|
+
}
|
|
1302
|
+
if (op === 'prepare.run') return binding.prepare(params[0]).bind(...(params[1] || [])).run()
|
|
1303
|
+
if (op === 'prepare.all') return binding.prepare(params[0]).bind(...(params[1] || [])).all()
|
|
1304
|
+
if (op === 'prepare.first') return binding.prepare(params[0]).bind(...(params[1] || [])).first(params[2])
|
|
1305
|
+
if (op === 'prepare.raw') return binding.prepare(params[0]).bind(...(params[1] || [])).raw({ columnNames: params[2] })
|
|
1306
|
+
|
|
1307
|
+
// Send email operations
|
|
1308
|
+
if (op === 'email.send') {
|
|
1309
|
+
return binding.send(__normalizeEmailMessage(params[0]))
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// DO operations
|
|
1313
|
+
if (op === 'idFromName') {
|
|
1314
|
+
return { __type: 'DOId', hex: binding.idFromName(params[0]).toString() }
|
|
1315
|
+
}
|
|
1316
|
+
if (op === 'stub.rpc') {
|
|
1317
|
+
const [, idSerialized, rpcMethod, rpcParams] = params
|
|
1318
|
+
const stub = binding.get(binding.idFromString(idSerialized.hex))
|
|
1319
|
+
|
|
1320
|
+
if (__nativeRpcBindings.has(bindingName) && typeof stub[rpcMethod] === 'function') {
|
|
1321
|
+
let result = await stub[rpcMethod](...(rpcParams || []))
|
|
1322
|
+
result = __encodeTransport(result)
|
|
1323
|
+
return result
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
const response = await stub.fetch(new Request('http://do/_rpc', {
|
|
1327
|
+
method: 'POST',
|
|
1328
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1329
|
+
body: JSON.stringify({ method: rpcMethod, params: rpcParams || [] })
|
|
1330
|
+
}))
|
|
1331
|
+
|
|
1332
|
+
const payload = await response.json()
|
|
1333
|
+
if (!response.ok || !payload?.ok) {
|
|
1334
|
+
throw new Error(payload?.error?.message || ('DO RPC failed with status ' + response.status))
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
return payload.result
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
throw new Error('Unknown operation: ' + method)
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function __createEmailMessageRaw(raw) {
|
|
1344
|
+
if (typeof raw === 'string' || raw instanceof ReadableStream) {
|
|
1345
|
+
return raw
|
|
1346
|
+
}
|
|
1347
|
+
if (raw instanceof Uint8Array || raw instanceof ArrayBuffer) {
|
|
1348
|
+
return new Response(raw).body
|
|
1349
|
+
}
|
|
1350
|
+
throw new Error('Unsupported EmailMessage raw payload')
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function __buildRawEmail(message) {
|
|
1354
|
+
const lines = []
|
|
1355
|
+
const messageId = '<' + Date.now() + '-' + Math.random().toString(36).slice(2) + '@devflare.dev>'
|
|
1356
|
+
|
|
1357
|
+
lines.push('From: ' + message.from)
|
|
1358
|
+
lines.push('To: ' + (Array.isArray(message.to) ? message.to.join(', ') : message.to))
|
|
1359
|
+
lines.push('Date: ' + new Date().toUTCString())
|
|
1360
|
+
lines.push('Message-ID: ' + messageId)
|
|
1361
|
+
|
|
1362
|
+
if (message.subject) lines.push('Subject: ' + message.subject)
|
|
1363
|
+
if (message.replyTo) lines.push('Reply-To: ' + String(message.replyTo))
|
|
1364
|
+
if (message.cc) lines.push('Cc: ' + (Array.isArray(message.cc) ? message.cc.join(', ') : message.cc))
|
|
1365
|
+
if (message.bcc) lines.push('Bcc: ' + (Array.isArray(message.bcc) ? message.bcc.join(', ') : message.bcc))
|
|
1366
|
+
|
|
1367
|
+
for (const [key, value] of Object.entries(message.headers || {})) {
|
|
1368
|
+
lines.push(key + ': ' + value)
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
lines.push('MIME-Version: 1.0')
|
|
1372
|
+
lines.push('Content-Type: ' + (message.html ? 'text/html' : 'text/plain') + '; charset=UTF-8')
|
|
1373
|
+
lines.push('')
|
|
1374
|
+
lines.push(String(message.html ?? message.text ?? '').replace(/\\r?\\n/g, '\\r\\n'))
|
|
1375
|
+
|
|
1376
|
+
return lines.join('\\r\\n')
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function __normalizeEmailMessage(message) {
|
|
1380
|
+
if (!message || typeof message !== 'object' || !('from' in message) || !('to' in message)) {
|
|
1381
|
+
return message
|
|
1382
|
+
}
|
|
1383
|
+
if ('EmailMessage::raw' in message) {
|
|
1384
|
+
return message
|
|
1385
|
+
}
|
|
1386
|
+
if ('raw' in message && message.raw !== undefined) {
|
|
1387
|
+
return {
|
|
1388
|
+
from: message.from,
|
|
1389
|
+
to: message.to,
|
|
1390
|
+
[RAW_EMAIL]: __createEmailMessageRaw(message.raw)
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
return {
|
|
1394
|
+
from: message.from,
|
|
1395
|
+
to: message.to,
|
|
1396
|
+
[RAW_EMAIL]: __createEmailMessageRaw(__buildRawEmail(message))
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
`;
|
|
1400
|
+
}
|
|
1401
|
+
//#endregion
|
|
1402
|
+
//#region src/test/simple-context-paths.ts
|
|
1403
|
+
const DEFAULT_TRANSPORT_ENTRY_FILES = [
|
|
1404
|
+
"src/transport.ts",
|
|
1405
|
+
"src/transport.js",
|
|
1406
|
+
"src/transport.mts",
|
|
1407
|
+
"src/transport.mjs"
|
|
1408
|
+
];
|
|
1409
|
+
const CURRENT_PACKAGE_ROOT = findPackageRoot(dirname$1(fileURLToPath(import.meta.url)));
|
|
1410
|
+
/**
|
|
1411
|
+
* Access Bun global via globalThis to avoid shadowing richer @types/bun
|
|
1412
|
+
* when available. Returns undefined if not running in Bun.
|
|
1413
|
+
*/
|
|
1414
|
+
function getBunRuntime() {
|
|
1415
|
+
const g = globalThis;
|
|
1416
|
+
if (typeof g.Bun === "object" && g.Bun !== null) return g.Bun;
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Get the directory of the test file.
|
|
1420
|
+
* Prefers stack trace parsing so bun test hooks resolve the actual test file,
|
|
1421
|
+
* then falls back to the current working directory.
|
|
1422
|
+
*
|
|
1423
|
+
* We intentionally do not use Bun.main here because workspace consumers often
|
|
1424
|
+
* import the built devflare package from `packages/devflare/dist`, which would
|
|
1425
|
+
* incorrectly anchor autodiscovery inside the devflare package instead of the
|
|
1426
|
+
* calling project under test.
|
|
1427
|
+
*/
|
|
1428
|
+
function getCallerDirectory() {
|
|
1429
|
+
const stackCallerDirectory = getStackCallerDirectory();
|
|
1430
|
+
if (stackCallerDirectory) return stackCallerDirectory;
|
|
1431
|
+
return process.cwd();
|
|
1432
|
+
}
|
|
1433
|
+
function getStackCallerDirectory() {
|
|
1434
|
+
const originalPrepare = Error.prepareStackTrace;
|
|
1435
|
+
Error.prepareStackTrace = (_, stack) => stack;
|
|
1436
|
+
try {
|
|
1437
|
+
const stack = (/* @__PURE__ */ new Error()).stack;
|
|
1438
|
+
for (const site of stack ?? []) {
|
|
1439
|
+
const filename = site.getFileName?.();
|
|
1440
|
+
if (filename && !isInsideCurrentPackage(filename) && !filename.includes("simple-context") && !filename.includes("node_modules") && !filename.includes("[") && existsSync$1(filename)) return dirname$1(filename);
|
|
1441
|
+
}
|
|
1442
|
+
} finally {
|
|
1443
|
+
Error.prepareStackTrace = originalPrepare;
|
|
1444
|
+
}
|
|
1445
|
+
return null;
|
|
1446
|
+
}
|
|
1447
|
+
function findPackageRoot(startDir) {
|
|
1448
|
+
let currentDir = startDir;
|
|
1449
|
+
while (true) {
|
|
1450
|
+
if (existsSync$1(join$1(currentDir, "package.json"))) return currentDir;
|
|
1451
|
+
const parentDir = dirname$1(currentDir);
|
|
1452
|
+
if (parentDir === currentDir) return startDir;
|
|
1453
|
+
currentDir = parentDir;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
function isInsideCurrentPackage(filePath) {
|
|
1457
|
+
const normalizedFilePath = filePath.replace(/\\/g, "/");
|
|
1458
|
+
const normalizedPackageRoot = CURRENT_PACKAGE_ROOT.replace(/\\/g, "/");
|
|
1459
|
+
return normalizedFilePath === normalizedPackageRoot || normalizedFilePath.startsWith(`${normalizedPackageRoot}/`);
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Find the nearest supported devflare config by searching upward from startDir.
|
|
1463
|
+
*/
|
|
1464
|
+
async function findNearestConfig(startDir) {
|
|
1465
|
+
let currentDir = startDir;
|
|
1466
|
+
while (true) {
|
|
1467
|
+
const configPath = await resolveConfigPath(currentDir);
|
|
1468
|
+
if (configPath) return configPath;
|
|
1469
|
+
const parentDir = dirname$1(currentDir);
|
|
1470
|
+
if (parentDir === currentDir) return null;
|
|
1471
|
+
currentDir = parentDir;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
async function getAvailablePort() {
|
|
1475
|
+
return await new Promise((resolvePort, reject) => {
|
|
1476
|
+
const server = createServer();
|
|
1477
|
+
server.once("error", reject);
|
|
1478
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1479
|
+
const address = server.address();
|
|
1480
|
+
if (!address || typeof address === "string") {
|
|
1481
|
+
server.close(() => reject(/* @__PURE__ */ new Error("Could not determine an available port")));
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
const { port } = address;
|
|
1485
|
+
server.close((error) => {
|
|
1486
|
+
if (error) {
|
|
1487
|
+
reject(error);
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
resolvePort(port);
|
|
1491
|
+
});
|
|
1492
|
+
});
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
function resolveTransportFile(configDir, configuredPath) {
|
|
1496
|
+
if (typeof configuredPath === "string") return configuredPath;
|
|
1497
|
+
if (configuredPath === null) return null;
|
|
1498
|
+
for (const defaultEntry of DEFAULT_TRANSPORT_ENTRY_FILES) if (existsSync$1(join$1(configDir, defaultEntry))) return defaultEntry;
|
|
1499
|
+
return null;
|
|
1500
|
+
}
|
|
1501
|
+
//#endregion
|
|
1502
|
+
//#region src/test/simple-context-durable-objects.ts
|
|
1503
|
+
/**
|
|
1504
|
+
* Find all exported class names in a TypeScript/JavaScript file.
|
|
1505
|
+
*/
|
|
1506
|
+
function findExportedClasses(code) {
|
|
1507
|
+
const classes = [];
|
|
1508
|
+
const classPattern = /export\s+class\s+(\w+)/g;
|
|
1509
|
+
let match = classPattern.exec(code);
|
|
1510
|
+
while (match !== null) {
|
|
1511
|
+
classes.push(match[1]);
|
|
1512
|
+
match = classPattern.exec(code);
|
|
1513
|
+
}
|
|
1514
|
+
return classes;
|
|
1515
|
+
}
|
|
1516
|
+
function classSupportsNativeDurableObjectRpc(code, className) {
|
|
1517
|
+
return new RegExp(`export\\s+class\\s+${className}\\s+extends\\s+DurableObject\\b`).test(code);
|
|
1518
|
+
}
|
|
1519
|
+
function toGeneratedIdentifier(value) {
|
|
1520
|
+
const normalized = value.replace(/[^A-Za-z0-9_$]/g, "_");
|
|
1521
|
+
return /^[A-Za-z_$]/.test(normalized) ? normalized : `_${normalized}`;
|
|
1522
|
+
}
|
|
1523
|
+
async function discoverLocalDurableObjectClasses(config, configDir) {
|
|
1524
|
+
const classToFilePath = /* @__PURE__ */ new Map();
|
|
1525
|
+
const doPatternConfig = config.files?.durableObjects;
|
|
1526
|
+
const doPattern = typeof doPatternConfig === "string" ? doPatternConfig : DEFAULT_DO_PATTERN;
|
|
1527
|
+
if (doPatternConfig === false) return classToFilePath;
|
|
1528
|
+
const doFiles = await findFiles(doPattern, { cwd: configDir });
|
|
1529
|
+
for (const filePath of doFiles) try {
|
|
1530
|
+
const code = await readFile$1(filePath, "utf-8");
|
|
1531
|
+
const classNames = findExportedClasses(code);
|
|
1532
|
+
for (const className of classNames) classToFilePath.set(className, {
|
|
1533
|
+
filePath,
|
|
1534
|
+
nativeRpc: classSupportsNativeDurableObjectRpc(code, className)
|
|
1535
|
+
});
|
|
1536
|
+
} catch {}
|
|
1537
|
+
return classToFilePath;
|
|
1538
|
+
}
|
|
1539
|
+
async function resolveLocalDurableObjects(config, configDir) {
|
|
1540
|
+
const doConfig = {};
|
|
1541
|
+
const doInfos = [];
|
|
1542
|
+
const classToFilePath = await discoverLocalDurableObjectClasses(config, configDir);
|
|
1543
|
+
for (const [name, rawDoInfo] of Object.entries(config.bindings?.durableObjects ?? {})) {
|
|
1544
|
+
const doInfo = normalizeDOBinding(rawDoInfo);
|
|
1545
|
+
if (doInfo.__ref) continue;
|
|
1546
|
+
let scriptPath;
|
|
1547
|
+
let nativeRpc = false;
|
|
1548
|
+
if (doInfo.kind === "cross-worker" && doInfo.scriptName) {
|
|
1549
|
+
scriptPath = join$1(configDir, "src", doInfo.scriptName);
|
|
1550
|
+
try {
|
|
1551
|
+
nativeRpc = classSupportsNativeDurableObjectRpc(await readFile$1(scriptPath, "utf-8"), doInfo.className);
|
|
1552
|
+
} catch {
|
|
1553
|
+
nativeRpc = false;
|
|
1554
|
+
}
|
|
1555
|
+
} else {
|
|
1556
|
+
const discoveredClass = classToFilePath.get(doInfo.className);
|
|
1557
|
+
if (!discoveredClass) throw new Error(`Durable object ${name} (className: '${doInfo.className}') not found.\nEither:\n 1. Set files.durableObjects pattern in config (e.g., 'src/do.*.ts')\n 2. Use explicit scriptName: { className: '${doInfo.className}', scriptName: 'do.file.ts' }`);
|
|
1558
|
+
scriptPath = discoveredClass.filePath;
|
|
1559
|
+
nativeRpc = discoveredClass.nativeRpc;
|
|
1560
|
+
}
|
|
1561
|
+
const runtimeClassName = nativeRpc ? doInfo.className : `__Devflare${toGeneratedIdentifier(name)}RpcWrapper`;
|
|
1562
|
+
doConfig[name] = runtimeClassName;
|
|
1563
|
+
doInfos.push({
|
|
1564
|
+
name,
|
|
1565
|
+
className: doInfo.className,
|
|
1566
|
+
scriptPath,
|
|
1567
|
+
nativeRpc,
|
|
1568
|
+
runtimeClassName
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
return {
|
|
1572
|
+
doConfig,
|
|
1573
|
+
doInfos
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
function buildWrapperCode(doInfos) {
|
|
1577
|
+
return doInfos.filter((info) => !info.nativeRpc).map((info) => `
|
|
1578
|
+
export class ${info.runtimeClassName} {
|
|
1579
|
+
constructor(state, env) {
|
|
1580
|
+
this.__instance = new ${info.className}(state, env)
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
async fetch(request) {
|
|
1584
|
+
const url = new URL(request.url)
|
|
1585
|
+
if (request.method !== 'POST' || url.pathname !== '/_rpc') {
|
|
1586
|
+
return new Response('Not found', { status: 404 })
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
try {
|
|
1590
|
+
const payload = await request.json()
|
|
1591
|
+
const method = payload?.method
|
|
1592
|
+
const params = Array.isArray(payload?.params) ? payload.params : []
|
|
1593
|
+
const target = this.__instance?.[method]
|
|
1594
|
+
|
|
1595
|
+
if (typeof target !== 'function') {
|
|
1596
|
+
return new Response(JSON.stringify({
|
|
1597
|
+
ok: false,
|
|
1598
|
+
error: { message: 'Method not found: ' + String(method) }
|
|
1599
|
+
}), {
|
|
1600
|
+
status: 404,
|
|
1601
|
+
headers: { 'Content-Type': 'application/json' }
|
|
1602
|
+
})
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
let result = await target.apply(this.__instance, params)
|
|
1606
|
+
result = __encodeTransport(result)
|
|
1607
|
+
|
|
1608
|
+
return new Response(JSON.stringify({ ok: true, result }), {
|
|
1609
|
+
headers: { 'Content-Type': 'application/json' }
|
|
1610
|
+
})
|
|
1611
|
+
} catch (error) {
|
|
1612
|
+
return new Response(JSON.stringify({
|
|
1613
|
+
ok: false,
|
|
1614
|
+
error: { message: error instanceof Error ? error.message : String(error) }
|
|
1615
|
+
}), {
|
|
1616
|
+
status: 500,
|
|
1617
|
+
headers: { 'Content-Type': 'application/json' }
|
|
1618
|
+
})
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
}`.trim()).join("\n\n");
|
|
1622
|
+
}
|
|
1623
|
+
async function bundleDurableObjectModules(configDir, doInfos, transportFile) {
|
|
1624
|
+
const virtualImports = [];
|
|
1625
|
+
const virtualExports = [];
|
|
1626
|
+
if (transportFile) {
|
|
1627
|
+
const transportPath = join$1(configDir, transportFile);
|
|
1628
|
+
virtualImports.push(`import { transport } from '${transportPath.replace(/\\/g, "/")}'`);
|
|
1629
|
+
virtualExports.push("export { transport }");
|
|
1630
|
+
}
|
|
1631
|
+
for (const info of doInfos) {
|
|
1632
|
+
virtualImports.push(`import { ${info.className} } from '${info.scriptPath.replace(/\\/g, "/")}'`);
|
|
1633
|
+
virtualExports.push(`export { ${info.className} }`);
|
|
1634
|
+
}
|
|
1635
|
+
if (virtualImports.length === 0) return "";
|
|
1636
|
+
const virtualEntry = [
|
|
1637
|
+
...virtualImports,
|
|
1638
|
+
"",
|
|
1639
|
+
...virtualExports
|
|
1640
|
+
].join("\n");
|
|
1641
|
+
const virtualPath = join$1(configDir, ".devflare", "__test_entry.ts");
|
|
1642
|
+
mkdirSync$1(dirname$1(virtualPath), { recursive: true });
|
|
1643
|
+
writeFileSync$1(virtualPath, virtualEntry);
|
|
1644
|
+
const bun = getBunRuntime();
|
|
1645
|
+
if (!bun) throw new Error("Bun runtime is required for createTestContext with Durable Objects");
|
|
1646
|
+
const result = await bun.build({
|
|
1647
|
+
entrypoints: [virtualPath],
|
|
1648
|
+
target: "browser",
|
|
1649
|
+
format: "esm",
|
|
1650
|
+
minify: false,
|
|
1651
|
+
external: ["cloudflare:workers", "cloudflare:*"]
|
|
1652
|
+
});
|
|
1653
|
+
if (!result.success) throw new Error(`Failed to bundle test entry: ${result.logs.join("\n")}`);
|
|
1654
|
+
return await result.outputs[0].text();
|
|
1655
|
+
}
|
|
1656
|
+
async function buildDurableObjectGateway(config, configDir, transportFile) {
|
|
1657
|
+
const workflowEntrypointScript = await bundleWorkflowEntrypointScript(config, configDir);
|
|
1658
|
+
if (!config.bindings?.durableObjects) return { script: buildGatewayScript(workflowEntrypointScript, "") };
|
|
1659
|
+
const { doConfig, doInfos } = await resolveLocalDurableObjects(config, configDir);
|
|
1660
|
+
const bundledCode = await bundleDurableObjectModules(configDir, doInfos, transportFile);
|
|
1661
|
+
const wrapperCode = buildWrapperCode(doInfos);
|
|
1662
|
+
return {
|
|
1663
|
+
durableObjects: doConfig,
|
|
1664
|
+
script: buildGatewayScript([workflowEntrypointScript, bundledCode].filter(Boolean).join("\n\n"), wrapperCode, doInfos.filter((info) => info.nativeRpc).map((info) => info.name))
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
//#endregion
|
|
1668
|
+
//#region src/test/email.ts
|
|
1669
|
+
let miniflarePort = 8787;
|
|
1670
|
+
let emailListeners = [];
|
|
1671
|
+
let sentEmails = [];
|
|
1672
|
+
let emailHandlerPath = null;
|
|
1673
|
+
let configDir$4 = null;
|
|
1674
|
+
let testEnvGetter$4 = null;
|
|
1675
|
+
/**
|
|
1676
|
+
* Configure the email test helper
|
|
1677
|
+
* @internal
|
|
1678
|
+
*/
|
|
1679
|
+
function configureEmail(options = {}) {
|
|
1680
|
+
if (options.port) miniflarePort = options.port;
|
|
1681
|
+
emailHandlerPath = options.handlerPath ?? emailHandlerPath;
|
|
1682
|
+
configDir$4 = options.configDir ?? configDir$4;
|
|
1683
|
+
testEnvGetter$4 = options.getEnv ?? testEnvGetter$4;
|
|
1684
|
+
}
|
|
1685
|
+
/**
|
|
1686
|
+
* Build a raw email string from options
|
|
1687
|
+
*/
|
|
1688
|
+
function buildRawEmail(options) {
|
|
1689
|
+
if (options.raw) return options.raw;
|
|
1690
|
+
const lines = [];
|
|
1691
|
+
const messageId = `<${Date.now()}-${Math.random().toString(36).slice(2)}@devflare.dev>`;
|
|
1692
|
+
const date = (/* @__PURE__ */ new Date()).toUTCString();
|
|
1693
|
+
lines.push(`From: ${options.from}`);
|
|
1694
|
+
lines.push(`To: ${options.to}`);
|
|
1695
|
+
lines.push(`Date: ${date}`);
|
|
1696
|
+
lines.push(`Message-ID: ${messageId}`);
|
|
1697
|
+
if (options.subject) lines.push(`Subject: ${options.subject}`);
|
|
1698
|
+
if (options.headers) for (const [key, value] of Object.entries(options.headers)) lines.push(`${key}: ${value}`);
|
|
1699
|
+
lines.push("MIME-Version: 1.0");
|
|
1700
|
+
lines.push("Content-Type: text/plain; charset=UTF-8");
|
|
1701
|
+
lines.push("");
|
|
1702
|
+
lines.push(options.body ?? "");
|
|
1703
|
+
return lines.join("\r\n");
|
|
1704
|
+
}
|
|
1705
|
+
function createEmailHeaders(rawEmail) {
|
|
1706
|
+
const headers = new Headers();
|
|
1707
|
+
const lines = rawEmail.split(/\r?\n/);
|
|
1708
|
+
for (const line of lines) {
|
|
1709
|
+
if (!line.trim()) break;
|
|
1710
|
+
const colonIndex = line.indexOf(":");
|
|
1711
|
+
if (colonIndex <= 0) continue;
|
|
1712
|
+
headers.append(line.slice(0, colonIndex).trim(), line.slice(colonIndex + 1).trim());
|
|
1713
|
+
}
|
|
1714
|
+
return headers;
|
|
1715
|
+
}
|
|
1716
|
+
function createRawEmailStream(rawEmail) {
|
|
1717
|
+
return new ReadableStream({ start(controller) {
|
|
1718
|
+
controller.enqueue(new TextEncoder().encode(rawEmail));
|
|
1719
|
+
controller.close();
|
|
1720
|
+
} });
|
|
1721
|
+
}
|
|
1722
|
+
function resolveEmailHandler(module) {
|
|
1723
|
+
if (typeof module.default === "function") return module.default;
|
|
1724
|
+
if (module.default && typeof module.default.email === "function") return module.default.email.bind(module.default);
|
|
1725
|
+
if (typeof module.email === "function") return module.email;
|
|
1726
|
+
return null;
|
|
1727
|
+
}
|
|
1728
|
+
function getRecordedRawContent(raw) {
|
|
1729
|
+
if (typeof raw === "string") return raw;
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Send an incoming email through the email test helper.
|
|
1733
|
+
*
|
|
1734
|
+
* When `createTestContext()` has configured an email handler, this imports and
|
|
1735
|
+
* invokes that handler directly and waits for queued `waitUntil()` work.
|
|
1736
|
+
* Otherwise it attempts the local `/cdn-cgi/handler/email` endpoint exposed by
|
|
1737
|
+
* compatible local runtimes.
|
|
1738
|
+
*/
|
|
1739
|
+
async function send$1(options) {
|
|
1740
|
+
const raw = buildRawEmail(options);
|
|
1741
|
+
if (emailHandlerPath && configDir$4 && testEnvGetter$4) {
|
|
1742
|
+
const emailHandler = resolveEmailHandler(await import(join$1(configDir$4, emailHandlerPath)));
|
|
1743
|
+
if (!emailHandler) throw new Error(`Email handler at "${emailHandlerPath}" must export a default function or named "email" export.\nExpected: export async function email(message) { ... }`);
|
|
1744
|
+
const waitUntilPromises = [];
|
|
1745
|
+
const ctx = {
|
|
1746
|
+
waitUntil(promise) {
|
|
1747
|
+
waitUntilPromises.push(promise);
|
|
1748
|
+
},
|
|
1749
|
+
passThroughOnException() {},
|
|
1750
|
+
props: {}
|
|
1751
|
+
};
|
|
1752
|
+
const runtimeEnv = testEnvGetter$4();
|
|
1753
|
+
const timestamp = /* @__PURE__ */ new Date();
|
|
1754
|
+
const emailEvent = createEmailEvent({
|
|
1755
|
+
from: options.from,
|
|
1756
|
+
to: options.to,
|
|
1757
|
+
headers: createEmailHeaders(raw),
|
|
1758
|
+
raw: createRawEmailStream(raw),
|
|
1759
|
+
rawSize: raw.length,
|
|
1760
|
+
setReject(reason) {
|
|
1761
|
+
throw new Error(`Email rejected: ${reason}`);
|
|
1762
|
+
},
|
|
1763
|
+
async forward(rcptTo) {
|
|
1764
|
+
recordSentEmail({
|
|
1765
|
+
type: "forward",
|
|
1766
|
+
from: options.from,
|
|
1767
|
+
to: rcptTo,
|
|
1768
|
+
raw,
|
|
1769
|
+
timestamp
|
|
1770
|
+
});
|
|
1771
|
+
},
|
|
1772
|
+
async reply(message) {
|
|
1773
|
+
recordSentEmail({
|
|
1774
|
+
type: "reply",
|
|
1775
|
+
from: message.from ?? options.to,
|
|
1776
|
+
to: message.to ?? options.from,
|
|
1777
|
+
raw: getRecordedRawContent(message.raw),
|
|
1778
|
+
timestamp
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
}, runtimeEnv, ctx);
|
|
1782
|
+
await runWithEventContext(emailEvent, () => emailHandler(emailEvent));
|
|
1783
|
+
await Promise.all(waitUntilPromises);
|
|
1784
|
+
return new Response(JSON.stringify({
|
|
1785
|
+
ok: true,
|
|
1786
|
+
from: options.from,
|
|
1787
|
+
to: options.to
|
|
1788
|
+
}), { headers: { "Content-Type": "application/json" } });
|
|
1789
|
+
}
|
|
1790
|
+
const url = new URL(`http://localhost:${miniflarePort}/cdn-cgi/handler/email`);
|
|
1791
|
+
url.searchParams.set("from", options.from);
|
|
1792
|
+
url.searchParams.set("to", options.to);
|
|
1793
|
+
return await fetch(url.toString(), {
|
|
1794
|
+
method: "POST",
|
|
1795
|
+
headers: { "Content-Type": "text/plain" },
|
|
1796
|
+
body: raw
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Register a callback for outgoing emails.
|
|
1801
|
+
*
|
|
1802
|
+
* This only fires when the surrounding runtime wiring records outgoing emails.
|
|
1803
|
+
* @returns Unsubscribe function
|
|
1804
|
+
*/
|
|
1805
|
+
function onReceive(callback) {
|
|
1806
|
+
emailListeners.push(callback);
|
|
1807
|
+
return () => {
|
|
1808
|
+
emailListeners = emailListeners.filter((cb) => cb !== callback);
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Get all recorded outgoing emails since test context was created
|
|
1813
|
+
*/
|
|
1814
|
+
function getSentEmails() {
|
|
1815
|
+
return [...sentEmails];
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Clear recorded outgoing email history
|
|
1819
|
+
*/
|
|
1820
|
+
function clearSentEmails() {
|
|
1821
|
+
sentEmails = [];
|
|
1822
|
+
}
|
|
1823
|
+
/**
|
|
1824
|
+
* Add a sent email to the history
|
|
1825
|
+
* @internal Called by helper paths that explicitly record outgoing email
|
|
1826
|
+
* (currently direct `forward()`/`reply()` test flows).
|
|
1827
|
+
*/
|
|
1828
|
+
function recordSentEmail(email) {
|
|
1829
|
+
sentEmails.push(email);
|
|
1830
|
+
for (const listener of emailListeners) try {
|
|
1831
|
+
listener(email);
|
|
1832
|
+
} catch (error) {
|
|
1833
|
+
console.error("[devflare/test] Email listener error:", error);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* Reset email state
|
|
1838
|
+
* @internal Called when test context is disposed
|
|
1839
|
+
*/
|
|
1840
|
+
function resetEmailState() {
|
|
1841
|
+
miniflarePort = 8787;
|
|
1842
|
+
emailHandlerPath = null;
|
|
1843
|
+
configDir$4 = null;
|
|
1844
|
+
testEnvGetter$4 = null;
|
|
1845
|
+
emailListeners = [];
|
|
1846
|
+
sentEmails = [];
|
|
1847
|
+
}
|
|
1848
|
+
const email = {
|
|
1849
|
+
send: send$1,
|
|
1850
|
+
onReceive,
|
|
1851
|
+
getSentEmails,
|
|
1852
|
+
clearSentEmails
|
|
1853
|
+
};
|
|
1854
|
+
//#endregion
|
|
1855
|
+
//#region src/test/queue.ts
|
|
1856
|
+
let queueHandlerPath = null;
|
|
1857
|
+
let configDir$3 = null;
|
|
1858
|
+
let testEnvGetter$3 = null;
|
|
1859
|
+
/**
|
|
1860
|
+
* Configure the queue test helper
|
|
1861
|
+
* @internal Called by createTestContext to set up handler path and env
|
|
1862
|
+
*/
|
|
1863
|
+
function configureQueue(options) {
|
|
1864
|
+
queueHandlerPath = options.handlerPath;
|
|
1865
|
+
configDir$3 = options.configDir;
|
|
1866
|
+
testEnvGetter$3 = options.getEnv;
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* Reset queue helper state
|
|
1870
|
+
* @internal Called when test context is disposed
|
|
1871
|
+
*/
|
|
1872
|
+
function resetQueueState() {
|
|
1873
|
+
queueHandlerPath = null;
|
|
1874
|
+
configDir$3 = null;
|
|
1875
|
+
testEnvGetter$3 = null;
|
|
1876
|
+
}
|
|
1877
|
+
const EMPTY_QUEUE_METADATA = { metrics: {
|
|
1878
|
+
backlogCount: 0,
|
|
1879
|
+
backlogBytes: 0
|
|
1880
|
+
} };
|
|
1881
|
+
/**
|
|
1882
|
+
* Create a mock Message object that tracks ack/retry/noRetry calls
|
|
1883
|
+
*/
|
|
1884
|
+
function createMessage(options) {
|
|
1885
|
+
const id = options.id ?? crypto.randomUUID();
|
|
1886
|
+
const timestamp = options.timestamp ?? /* @__PURE__ */ new Date();
|
|
1887
|
+
const attempts = options.attempts ?? 1;
|
|
1888
|
+
let state = "pending";
|
|
1889
|
+
return {
|
|
1890
|
+
id,
|
|
1891
|
+
timestamp,
|
|
1892
|
+
body: options.body,
|
|
1893
|
+
attempts,
|
|
1894
|
+
ack() {
|
|
1895
|
+
state = "acked";
|
|
1896
|
+
},
|
|
1897
|
+
retry(opts) {
|
|
1898
|
+
state = "retried";
|
|
1899
|
+
},
|
|
1900
|
+
retryAll() {
|
|
1901
|
+
state = "retried";
|
|
1902
|
+
},
|
|
1903
|
+
get _state() {
|
|
1904
|
+
return state;
|
|
1905
|
+
}
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* Create a mock MessageBatch from an array of messages
|
|
1910
|
+
*/
|
|
1911
|
+
function createMessageBatch(messages) {
|
|
1912
|
+
return {
|
|
1913
|
+
queue: "test-queue",
|
|
1914
|
+
metadata: EMPTY_QUEUE_METADATA,
|
|
1915
|
+
messages,
|
|
1916
|
+
ackAll() {
|
|
1917
|
+
for (const msg of messages) msg.ack();
|
|
1918
|
+
},
|
|
1919
|
+
retryAll() {
|
|
1920
|
+
for (const msg of messages) msg.retry();
|
|
1921
|
+
}
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* Trigger the queue handler with a batch of messages.
|
|
1926
|
+
* This directly invokes the queue handler function from your config.
|
|
1927
|
+
*
|
|
1928
|
+
* @param messages - Array of message options or bodies
|
|
1929
|
+
* @returns Result object with acked/retried/failed message IDs
|
|
1930
|
+
*
|
|
1931
|
+
* @example
|
|
1932
|
+
* ```ts
|
|
1933
|
+
* // Trigger with message bodies (IDs auto-generated)
|
|
1934
|
+
* const result = await cf.queue.trigger([
|
|
1935
|
+
* { type: 'process', data: { x: 1 } },
|
|
1936
|
+
* { type: 'cleanup', data: {} }
|
|
1937
|
+
* ])
|
|
1938
|
+
*
|
|
1939
|
+
* // Trigger with full message options
|
|
1940
|
+
* const result = await cf.queue.trigger([
|
|
1941
|
+
* { id: 'msg-1', body: { type: 'process' }, attempts: 2 }
|
|
1942
|
+
* ])
|
|
1943
|
+
* ```
|
|
1944
|
+
*/
|
|
1945
|
+
async function trigger$2(messages) {
|
|
1946
|
+
if (!queueHandlerPath) throw new Error("Queue handler not configured. Make sure your devflare.config.ts has files.queue set, and the file exists at the specified path (default: src/queue.ts)");
|
|
1947
|
+
if (!configDir$3 || !testEnvGetter$3) throw new Error("Queue helper not initialized. Call createTestContext() before using cf.queue.trigger()");
|
|
1948
|
+
const handlerModule = await import(join$1(configDir$3, queueHandlerPath));
|
|
1949
|
+
const queueHandler = handlerModule.default ?? handlerModule.queue;
|
|
1950
|
+
if (typeof queueHandler !== "function") throw new Error(`Queue handler at "${queueHandlerPath}" must export a default function or named "queue" export.\nExpected: export async function queue(event) { ... }`);
|
|
1951
|
+
const mockMessages = messages.map((msg) => {
|
|
1952
|
+
if (typeof msg === "object" && msg !== null && "body" in msg) return msg;
|
|
1953
|
+
return { body: msg };
|
|
1954
|
+
}).map((opts) => createMessage(opts));
|
|
1955
|
+
const batch = createMessageBatch(mockMessages);
|
|
1956
|
+
const waitUntilPromises = [];
|
|
1957
|
+
const queueEvent = createQueueEvent(batch, testEnvGetter$3(), {
|
|
1958
|
+
waitUntil(promise) {
|
|
1959
|
+
waitUntilPromises.push(promise);
|
|
1960
|
+
},
|
|
1961
|
+
passThroughOnException() {},
|
|
1962
|
+
props: {}
|
|
1963
|
+
});
|
|
1964
|
+
await runWithEventContext(queueEvent, () => queueHandler(queueEvent));
|
|
1965
|
+
await Promise.all(waitUntilPromises);
|
|
1966
|
+
const acked = [];
|
|
1967
|
+
const retried = [];
|
|
1968
|
+
const failed = [];
|
|
1969
|
+
for (const msg of mockMessages) switch (msg._state) {
|
|
1970
|
+
case "acked":
|
|
1971
|
+
acked.push(msg.id);
|
|
1972
|
+
break;
|
|
1973
|
+
case "retried":
|
|
1974
|
+
retried.push(msg.id);
|
|
1975
|
+
break;
|
|
1976
|
+
case "failed":
|
|
1977
|
+
failed.push(msg.id);
|
|
1978
|
+
break;
|
|
1979
|
+
}
|
|
1980
|
+
return {
|
|
1981
|
+
acked,
|
|
1982
|
+
retried,
|
|
1983
|
+
failed,
|
|
1984
|
+
total: mockMessages.length
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
/**
|
|
1988
|
+
* Convenience method to trigger the queue handler with a single message.
|
|
1989
|
+
*
|
|
1990
|
+
* @param message - Message body or options
|
|
1991
|
+
* @returns Result object
|
|
1992
|
+
*
|
|
1993
|
+
* @example
|
|
1994
|
+
* ```ts
|
|
1995
|
+
* await cf.queue.send({ type: 'process', data: { x: 1 } })
|
|
1996
|
+
* ```
|
|
1997
|
+
*/
|
|
1998
|
+
async function send(message) {
|
|
1999
|
+
return trigger$2([message]);
|
|
2000
|
+
}
|
|
2001
|
+
const queue = {
|
|
2002
|
+
trigger: trigger$2,
|
|
2003
|
+
send
|
|
2004
|
+
};
|
|
2005
|
+
//#endregion
|
|
2006
|
+
//#region src/test/scheduled.ts
|
|
2007
|
+
let scheduledHandlerPath = null;
|
|
2008
|
+
let configDir$2 = null;
|
|
2009
|
+
let testEnvGetter$2 = null;
|
|
2010
|
+
/**
|
|
2011
|
+
* Configure the scheduled test helper
|
|
2012
|
+
* @internal Called by createTestContext to set up handler path and env
|
|
2013
|
+
*/
|
|
2014
|
+
function configureScheduled(options) {
|
|
2015
|
+
scheduledHandlerPath = options.handlerPath;
|
|
2016
|
+
configDir$2 = options.configDir;
|
|
2017
|
+
testEnvGetter$2 = options.getEnv;
|
|
2018
|
+
}
|
|
2019
|
+
/**
|
|
2020
|
+
* Reset scheduled helper state
|
|
2021
|
+
* @internal Called when test context is disposed
|
|
2022
|
+
*/
|
|
2023
|
+
function resetScheduledState() {
|
|
2024
|
+
scheduledHandlerPath = null;
|
|
2025
|
+
configDir$2 = null;
|
|
2026
|
+
testEnvGetter$2 = null;
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Trigger the scheduled handler.
|
|
2030
|
+
* This directly invokes the scheduled handler function from your config.
|
|
2031
|
+
*
|
|
2032
|
+
* @param cronOrOptions - Cron expression string or options object
|
|
2033
|
+
* @returns Result object with success status
|
|
2034
|
+
*
|
|
2035
|
+
* @example
|
|
2036
|
+
* // Trigger with specific cron expression (every 6 hours)
|
|
2037
|
+
* await cf.scheduled.trigger('0 0,6,12,18 * * *')
|
|
2038
|
+
*
|
|
2039
|
+
* @example
|
|
2040
|
+
* // Trigger with options (Weekly Monday at midnight)
|
|
2041
|
+
* await cf.scheduled.trigger({
|
|
2042
|
+
* cron: '0 0 * * 1',
|
|
2043
|
+
* scheduledTime: new Date('2026-01-13T00:00:00Z')
|
|
2044
|
+
* })
|
|
2045
|
+
*
|
|
2046
|
+
* @example
|
|
2047
|
+
* // Trigger without cron (just scheduled time)
|
|
2048
|
+
* await cf.scheduled.trigger()
|
|
2049
|
+
*/
|
|
2050
|
+
async function trigger$1(cronOrOptions) {
|
|
2051
|
+
if (!scheduledHandlerPath) throw new Error("Scheduled handler not configured. Make sure your devflare.config.ts has files.scheduled set, and the file exists at the specified path (default: src/scheduled.ts)");
|
|
2052
|
+
if (!configDir$2 || !testEnvGetter$2) throw new Error("Scheduled helper not initialized. Call createTestContext() before using cf.scheduled.trigger()");
|
|
2053
|
+
const options = typeof cronOrOptions === "string" ? { cron: cronOrOptions } : cronOrOptions ?? {};
|
|
2054
|
+
const cron = options.cron ?? "* * * * *";
|
|
2055
|
+
const scheduledTime = options.scheduledTime instanceof Date ? options.scheduledTime.getTime() : options.scheduledTime ?? Date.now();
|
|
2056
|
+
const handlerModule = await import(join$1(configDir$2, scheduledHandlerPath));
|
|
2057
|
+
const scheduledHandler = handlerModule.default ?? handlerModule.scheduled;
|
|
2058
|
+
if (typeof scheduledHandler !== "function") throw new Error(`Scheduled handler at "${scheduledHandlerPath}" must export a default function or named "scheduled" export.\nExpected: export async function scheduled(event) { ... }`);
|
|
2059
|
+
const controller = {
|
|
2060
|
+
scheduledTime,
|
|
2061
|
+
cron,
|
|
2062
|
+
noRetry() {}
|
|
2063
|
+
};
|
|
2064
|
+
const waitUntilPromises = [];
|
|
2065
|
+
const scheduledEvent = createScheduledEvent(controller, testEnvGetter$2(), {
|
|
2066
|
+
waitUntil(promise) {
|
|
2067
|
+
waitUntilPromises.push(promise);
|
|
2068
|
+
},
|
|
2069
|
+
passThroughOnException() {},
|
|
2070
|
+
props: {}
|
|
2071
|
+
});
|
|
2072
|
+
try {
|
|
2073
|
+
await runWithEventContext(scheduledEvent, () => scheduledHandler(scheduledEvent));
|
|
2074
|
+
await Promise.all(waitUntilPromises);
|
|
2075
|
+
return {
|
|
2076
|
+
success: true,
|
|
2077
|
+
cron,
|
|
2078
|
+
scheduledTime
|
|
2079
|
+
};
|
|
2080
|
+
} catch (error) {
|
|
2081
|
+
return {
|
|
2082
|
+
success: false,
|
|
2083
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2084
|
+
cron,
|
|
2085
|
+
scheduledTime
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
const scheduled = { trigger: trigger$1 };
|
|
2090
|
+
//#endregion
|
|
2091
|
+
//#region src/test/tail.ts
|
|
2092
|
+
let tailHandlerPath = null;
|
|
2093
|
+
let configDir$1 = null;
|
|
2094
|
+
let testEnvGetter$1 = null;
|
|
2095
|
+
/**
|
|
2096
|
+
* Configure the tail test helper
|
|
2097
|
+
* @internal Called by createTestContext to set up handler path and env
|
|
2098
|
+
*/
|
|
2099
|
+
function configureTail(options) {
|
|
2100
|
+
tailHandlerPath = options.handlerPath;
|
|
2101
|
+
configDir$1 = options.configDir;
|
|
2102
|
+
testEnvGetter$1 = options.getEnv;
|
|
2103
|
+
}
|
|
2104
|
+
/**
|
|
2105
|
+
* Reset tail helper state
|
|
2106
|
+
* @internal Called when test context is disposed
|
|
2107
|
+
*/
|
|
2108
|
+
function resetTailState() {
|
|
2109
|
+
tailHandlerPath = null;
|
|
2110
|
+
configDir$1 = null;
|
|
2111
|
+
testEnvGetter$1 = null;
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* Create a complete TraceItem from options
|
|
2115
|
+
*/
|
|
2116
|
+
function createTraceItem(options) {
|
|
2117
|
+
return {
|
|
2118
|
+
scriptName: options.scriptName ?? "test-worker",
|
|
2119
|
+
outcome: options.outcome ?? "ok",
|
|
2120
|
+
eventTimestamp: options.eventTimestamp ?? Date.now(),
|
|
2121
|
+
event: options.event ?? { request: {
|
|
2122
|
+
url: "https://example.com/",
|
|
2123
|
+
method: "GET"
|
|
2124
|
+
} },
|
|
2125
|
+
logs: options.logs ?? [],
|
|
2126
|
+
exceptions: options.exceptions ?? [],
|
|
2127
|
+
diagnosticsChannelEvents: options.diagnosticsChannelEvents ?? [],
|
|
2128
|
+
scriptVersion: options.scriptVersion ?? { id: "test-version" },
|
|
2129
|
+
dispatchNamespace: options.dispatchNamespace,
|
|
2130
|
+
scriptTags: options.scriptTags ?? []
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* Trigger the tail handler with trace items.
|
|
2135
|
+
* This directly invokes the auto-detected `src/tail.ts` handler, or another
|
|
2136
|
+
* tail handler that has already been configured internally.
|
|
2137
|
+
*
|
|
2138
|
+
* @param items - Array of trace items or trace item options
|
|
2139
|
+
* @returns Result object with success status
|
|
2140
|
+
*
|
|
2141
|
+
* @example
|
|
2142
|
+
* ```ts
|
|
2143
|
+
* // Trigger with full trace items
|
|
2144
|
+
* await cf.tail.trigger([
|
|
2145
|
+
* {
|
|
2146
|
+
* scriptName: 'my-worker',
|
|
2147
|
+
* outcome: 'ok',
|
|
2148
|
+
* logs: [{ level: 'log', message: ['Request processed'], timestamp: Date.now() }]
|
|
2149
|
+
* }
|
|
2150
|
+
* ])
|
|
2151
|
+
*
|
|
2152
|
+
* // Trigger with minimal options (defaults applied)
|
|
2153
|
+
* await cf.tail.trigger([
|
|
2154
|
+
* { logs: [{ level: 'error', message: ['Something failed'], timestamp: Date.now() }] }
|
|
2155
|
+
* ])
|
|
2156
|
+
* ```
|
|
2157
|
+
*/
|
|
2158
|
+
async function trigger(items) {
|
|
2159
|
+
if (!tailHandlerPath) throw new Error("Tail handler not configured. Add a src/tail.ts file exporting tail(), or configure a tail handler before calling cf.tail.trigger().");
|
|
2160
|
+
if (!configDir$1 || !testEnvGetter$1) throw new Error("Tail helper not initialized. Call createTestContext() before using cf.tail.trigger()");
|
|
2161
|
+
const traceItems = items.map((item) => {
|
|
2162
|
+
if ("eventTimestamp" in item && "outcome" in item && "scriptName" in item) return item;
|
|
2163
|
+
return createTraceItem(item);
|
|
2164
|
+
});
|
|
2165
|
+
const handlerModule = await import(join$1(configDir$1, tailHandlerPath));
|
|
2166
|
+
const defaultExport = handlerModule.default;
|
|
2167
|
+
const tailHandler = typeof defaultExport === "function" ? defaultExport : defaultExport && typeof defaultExport.tail === "function" ? defaultExport.tail.bind(defaultExport) : handlerModule.tail;
|
|
2168
|
+
if (typeof tailHandler !== "function") throw new Error(`Tail handler at "${tailHandlerPath}" must export a default function or named "tail" export.\nExpected: export async function tail(event) { ... } or export default { tail(events, env, ctx) { ... } }`);
|
|
2169
|
+
const waitUntilPromises = [];
|
|
2170
|
+
const ctx = {
|
|
2171
|
+
waitUntil(promise) {
|
|
2172
|
+
waitUntilPromises.push(promise);
|
|
2173
|
+
},
|
|
2174
|
+
passThroughOnException() {},
|
|
2175
|
+
props: {}
|
|
2176
|
+
};
|
|
2177
|
+
const env = testEnvGetter$1();
|
|
2178
|
+
const tailEvent = createTailEvent(traceItems, env, ctx);
|
|
2179
|
+
try {
|
|
2180
|
+
await runWithEventContext(tailEvent, () => tailHandler.length >= 2 ? tailHandler(traceItems, env, ctx) : tailHandler(tailEvent, env, ctx));
|
|
2181
|
+
await Promise.all(waitUntilPromises);
|
|
2182
|
+
return {
|
|
2183
|
+
success: true,
|
|
2184
|
+
itemCount: traceItems.length
|
|
2185
|
+
};
|
|
2186
|
+
} catch (error) {
|
|
2187
|
+
return {
|
|
2188
|
+
success: false,
|
|
2189
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2190
|
+
itemCount: traceItems.length
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* Create a TraceItem with sensible defaults.
|
|
2196
|
+
* Useful for building test data.
|
|
2197
|
+
*
|
|
2198
|
+
* @param options - Partial trace item options
|
|
2199
|
+
* @returns Complete TraceItem
|
|
2200
|
+
*/
|
|
2201
|
+
function create(options = {}) {
|
|
2202
|
+
return createTraceItem(options);
|
|
2203
|
+
}
|
|
2204
|
+
const tail = {
|
|
2205
|
+
trigger,
|
|
2206
|
+
create
|
|
2207
|
+
};
|
|
2208
|
+
//#endregion
|
|
2209
|
+
//#region src/test/worker.ts
|
|
2210
|
+
let fetchHandlerPath = null;
|
|
2211
|
+
let configDir = null;
|
|
2212
|
+
let testEnvGetter = null;
|
|
2213
|
+
let fileRoutes = [];
|
|
2214
|
+
/**
|
|
2215
|
+
* Configure the worker test helper
|
|
2216
|
+
* @internal Called by createTestContext to set up handler path and env
|
|
2217
|
+
*/
|
|
2218
|
+
function configureWorker(options) {
|
|
2219
|
+
fetchHandlerPath = options.handlerPath;
|
|
2220
|
+
fileRoutes = options.routes ?? [];
|
|
2221
|
+
configDir = options.configDir;
|
|
2222
|
+
testEnvGetter = options.getEnv;
|
|
2223
|
+
}
|
|
2224
|
+
/**
|
|
2225
|
+
* Reset worker helper state
|
|
2226
|
+
* @internal Called when test context is disposed
|
|
2227
|
+
*/
|
|
2228
|
+
function resetWorkerState() {
|
|
2229
|
+
fetchHandlerPath = null;
|
|
2230
|
+
fileRoutes = [];
|
|
2231
|
+
configDir = null;
|
|
2232
|
+
testEnvGetter = null;
|
|
2233
|
+
}
|
|
2234
|
+
/**
|
|
2235
|
+
* Trigger the fetch handler with a request.
|
|
2236
|
+
* This directly invokes the fetch handler function from your config.
|
|
2237
|
+
*
|
|
2238
|
+
* @param request - Request object or URL string
|
|
2239
|
+
* @param options - Optional fetch options (method, headers, body)
|
|
2240
|
+
* @returns Response from the handler
|
|
2241
|
+
*
|
|
2242
|
+
* @example
|
|
2243
|
+
* ```ts
|
|
2244
|
+
* // With Request object
|
|
2245
|
+
* const response = await cf.worker.fetch(new Request('http://localhost/api'))
|
|
2246
|
+
*
|
|
2247
|
+
* // With URL string
|
|
2248
|
+
* const response = await cf.worker.fetch('http://localhost/api')
|
|
2249
|
+
*
|
|
2250
|
+
* // With options
|
|
2251
|
+
* const response = await cf.worker.fetch('/api/users', {
|
|
2252
|
+
* method: 'POST',
|
|
2253
|
+
* body: { name: 'Alice' }
|
|
2254
|
+
* })
|
|
2255
|
+
* ```
|
|
2256
|
+
*/
|
|
2257
|
+
async function fetch$1(request, options) {
|
|
2258
|
+
if (!fetchHandlerPath && fileRoutes.length === 0) throw new Error("Fetch handler not configured. Make sure your devflare.config.ts has files.fetch set or a routes directory is available, and that the corresponding files exist (defaults: src/fetch.ts and src/routes/**).");
|
|
2259
|
+
if (!configDir || !testEnvGetter) throw new Error("Worker helper not initialized. Call createTestContext() before using cf.worker.fetch()");
|
|
2260
|
+
const workerConfigDir = configDir;
|
|
2261
|
+
const getEnv = testEnvGetter;
|
|
2262
|
+
let req;
|
|
2263
|
+
if (typeof request === "string") {
|
|
2264
|
+
const url = request.startsWith("http") ? request : `http://localhost${request.startsWith("/") ? "" : "/"}${request}`;
|
|
2265
|
+
const headers = new Headers(options?.headers);
|
|
2266
|
+
let body;
|
|
2267
|
+
if (options?.body !== void 0) if (typeof options.body === "string") body = options.body;
|
|
2268
|
+
else {
|
|
2269
|
+
body = JSON.stringify(options.body);
|
|
2270
|
+
if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
|
2271
|
+
}
|
|
2272
|
+
req = new Request(url, {
|
|
2273
|
+
method: options?.method ?? "GET",
|
|
2274
|
+
headers,
|
|
2275
|
+
body
|
|
2276
|
+
});
|
|
2277
|
+
} else req = request;
|
|
2278
|
+
const handlerModule = fetchHandlerPath ? await import(join$1(workerConfigDir, fetchHandlerPath)) : {};
|
|
2279
|
+
const routeModules = await Promise.all(fileRoutes.map(async (route) => {
|
|
2280
|
+
return {
|
|
2281
|
+
...route,
|
|
2282
|
+
module: await import(join$1(workerConfigDir, route.filePath))
|
|
2283
|
+
};
|
|
2284
|
+
}));
|
|
2285
|
+
const hasMethodHandler = [
|
|
2286
|
+
"GET",
|
|
2287
|
+
"HEAD",
|
|
2288
|
+
"POST",
|
|
2289
|
+
"PUT",
|
|
2290
|
+
"PATCH",
|
|
2291
|
+
"DELETE",
|
|
2292
|
+
"OPTIONS",
|
|
2293
|
+
"ALL"
|
|
2294
|
+
].some((method) => {
|
|
2295
|
+
return typeof handlerModule[method] === "function" || typeof handlerModule.default?.[method] === "function";
|
|
2296
|
+
});
|
|
2297
|
+
if (!resolveFetchHandler(handlerModule) && !hasMethodHandler && routeModules.length === 0) throw new Error(`Fetch handler at "${fetchHandlerPath}" must export one of:\n- request-wide \"handle\" middleware\n- named \"fetch\"\n- default fetch handler\n- HTTP method exports such as \"GET\" or \"POST\"`);
|
|
2298
|
+
const waitUntilPromises = [];
|
|
2299
|
+
const ctx = {
|
|
2300
|
+
waitUntil(promise) {
|
|
2301
|
+
waitUntilPromises.push(promise);
|
|
2302
|
+
},
|
|
2303
|
+
passThroughOnException() {},
|
|
2304
|
+
props: {}
|
|
2305
|
+
};
|
|
2306
|
+
const env = getEnv();
|
|
2307
|
+
const initialRouteMatch = routeModules.length > 0 ? matchFetchRoute(routeModules, req) : null;
|
|
2308
|
+
const fetchEvent = createFetchEvent(req, env, ctx, { params: initialRouteMatch?.params ?? {} });
|
|
2309
|
+
return await runWithEventContext(fetchEvent, () => invokeFetchModule(handlerModule, fetchEvent, routeModules.length > 0 ? createRouteResolve(routeModules, fetchEvent) : void 0));
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Shorthand for GET requests
|
|
2313
|
+
*/
|
|
2314
|
+
async function get(path, headers) {
|
|
2315
|
+
return fetch$1(path, {
|
|
2316
|
+
method: "GET",
|
|
2317
|
+
headers
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
/**
|
|
2321
|
+
* Shorthand for POST requests with JSON body
|
|
2322
|
+
*/
|
|
2323
|
+
async function post(path, body, headers) {
|
|
2324
|
+
return fetch$1(path, {
|
|
2325
|
+
method: "POST",
|
|
2326
|
+
body,
|
|
2327
|
+
headers
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
/**
|
|
2331
|
+
* Shorthand for PUT requests with JSON body
|
|
2332
|
+
*/
|
|
2333
|
+
async function put(path, body, headers) {
|
|
2334
|
+
return fetch$1(path, {
|
|
2335
|
+
method: "PUT",
|
|
2336
|
+
body,
|
|
2337
|
+
headers
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Shorthand for DELETE requests
|
|
2342
|
+
*/
|
|
2343
|
+
async function del(path, headers) {
|
|
2344
|
+
return fetch$1(path, {
|
|
2345
|
+
method: "DELETE",
|
|
2346
|
+
headers
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
/**
|
|
2350
|
+
* Shorthand for PATCH requests with JSON body
|
|
2351
|
+
*/
|
|
2352
|
+
async function patch(path, body, headers) {
|
|
2353
|
+
return fetch$1(path, {
|
|
2354
|
+
method: "PATCH",
|
|
2355
|
+
body,
|
|
2356
|
+
headers
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
const worker = {
|
|
2360
|
+
fetch: fetch$1,
|
|
2361
|
+
get,
|
|
2362
|
+
post,
|
|
2363
|
+
put,
|
|
2364
|
+
delete: del,
|
|
2365
|
+
patch
|
|
2366
|
+
};
|
|
2367
|
+
//#endregion
|
|
2368
|
+
//#region src/test/simple-context-env.ts
|
|
2369
|
+
/**
|
|
2370
|
+
* Wire up every per-surface handler helper (queue / scheduled / worker /
|
|
2371
|
+
* tail / email) with the same `configDir` + `getEnv` accessor.
|
|
2372
|
+
*/
|
|
2373
|
+
function configureSurfaceHandlers(input) {
|
|
2374
|
+
const { handlerPaths, configDir, activePort, getEnv } = input;
|
|
2375
|
+
configureQueue({
|
|
2376
|
+
handlerPath: handlerPaths.queue,
|
|
2377
|
+
configDir,
|
|
2378
|
+
getEnv
|
|
2379
|
+
});
|
|
2380
|
+
configureScheduled({
|
|
2381
|
+
handlerPath: handlerPaths.scheduled,
|
|
2382
|
+
configDir,
|
|
2383
|
+
getEnv
|
|
2384
|
+
});
|
|
2385
|
+
configureWorker({
|
|
2386
|
+
handlerPath: handlerPaths.fetch,
|
|
2387
|
+
routes: handlerPaths.routes?.routes.map((route) => ({
|
|
2388
|
+
filePath: route.filePath,
|
|
2389
|
+
routePath: route.routePath,
|
|
2390
|
+
segments: route.segments
|
|
2391
|
+
})) ?? [],
|
|
2392
|
+
configDir,
|
|
2393
|
+
getEnv
|
|
2394
|
+
});
|
|
2395
|
+
configureTail({
|
|
2396
|
+
handlerPath: handlerPaths.tail,
|
|
2397
|
+
configDir,
|
|
2398
|
+
getEnv
|
|
2399
|
+
});
|
|
2400
|
+
configureEmail({
|
|
2401
|
+
port: activePort,
|
|
2402
|
+
handlerPath: handlerPaths.email,
|
|
2403
|
+
configDir,
|
|
2404
|
+
getEnv
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
/**
|
|
2408
|
+
* Build the bridge-backed env accessor used by single-worker test contexts.
|
|
2409
|
+
*
|
|
2410
|
+
* Resolution order, given a property access:
|
|
2411
|
+
* 1. Remote bindings (AI/Vectorize/vars/sendEmail) registered up-front.
|
|
2412
|
+
* 2. For non-DO/non-service hints: Miniflare binding (raw KV/D1/R2/etc).
|
|
2413
|
+
* 3. Bridge env proxy (for everything else, including DOs and services).
|
|
2414
|
+
* 4. Final fallback: Miniflare binding (when the hint preferred bridge but
|
|
2415
|
+
* the proxy did not surface it).
|
|
2416
|
+
*/
|
|
2417
|
+
function createBridgeEnvAccessor(state, hints, shouldPreferBridgeBinding) {
|
|
2418
|
+
return new Proxy({}, {
|
|
2419
|
+
get(_, prop) {
|
|
2420
|
+
const hint = hints[prop];
|
|
2421
|
+
const prefersBridgeBinding = shouldPreferBridgeBinding(hint);
|
|
2422
|
+
if (state.remoteBindings && prop in state.remoteBindings) return state.remoteBindings[prop];
|
|
2423
|
+
if (!prefersBridgeBinding && state.miniflareBindings && prop in state.miniflareBindings) return state.miniflareBindings[prop];
|
|
2424
|
+
if (state.envProxy) return state.envProxy[prop];
|
|
2425
|
+
if (prefersBridgeBinding && state.miniflareBindings && prop in state.miniflareBindings) return state.miniflareBindings[prop];
|
|
2426
|
+
},
|
|
2427
|
+
has(_, prop) {
|
|
2428
|
+
return Boolean(state.remoteBindings && prop in state.remoteBindings || state.miniflareBindings && prop in state.miniflareBindings || state.envProxy !== null);
|
|
2429
|
+
}
|
|
2430
|
+
});
|
|
2431
|
+
}
|
|
2432
|
+
/**
|
|
2433
|
+
* Build the simpler env accessor used by multi-worker test contexts (no
|
|
2434
|
+
* bridge-backed proxy: services + DOs go through Miniflare's own bindings).
|
|
2435
|
+
*/
|
|
2436
|
+
function createMultiWorkerEnvAccessor(state) {
|
|
2437
|
+
return new Proxy({}, {
|
|
2438
|
+
get(_, prop) {
|
|
2439
|
+
if (state.remoteBindings && prop in state.remoteBindings) return state.remoteBindings[prop];
|
|
2440
|
+
if (state.miniflareBindings && prop in state.miniflareBindings) return state.miniflareBindings[prop];
|
|
2441
|
+
},
|
|
2442
|
+
has(_, prop) {
|
|
2443
|
+
return Boolean(state.remoteBindings && prop in state.remoteBindings || state.miniflareBindings && prop in state.miniflareBindings);
|
|
2444
|
+
}
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
//#endregion
|
|
2448
|
+
//#region src/test/simple-context-handlers.ts
|
|
2449
|
+
const DEFAULT_FETCH_PATH = "src/fetch.ts";
|
|
2450
|
+
const DEFAULT_QUEUE_PATH = "src/queue.ts";
|
|
2451
|
+
const DEFAULT_SCHEDULED_PATH = "src/scheduled.ts";
|
|
2452
|
+
const DEFAULT_EMAIL_PATH = "src/email.ts";
|
|
2453
|
+
const DEFAULT_TAIL_PATH = "src/tail.ts";
|
|
2454
|
+
async function resolveHandlerPath(configDir, configValue, defaultPath) {
|
|
2455
|
+
if (typeof configValue === "string") return configValue;
|
|
2456
|
+
if (configValue === false) return null;
|
|
2457
|
+
const defaultAbsolute = join$1(configDir, defaultPath);
|
|
2458
|
+
try {
|
|
2459
|
+
await (await import("fs/promises")).access(defaultAbsolute);
|
|
2460
|
+
return defaultPath;
|
|
2461
|
+
} catch {
|
|
2462
|
+
return null;
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
async function resolveHandlerPaths(configDir, config) {
|
|
2466
|
+
const [fetch, queue, scheduled, email, tail, routes] = await Promise.all([
|
|
2467
|
+
resolveHandlerPath(configDir, config.files?.fetch, DEFAULT_FETCH_PATH),
|
|
2468
|
+
resolveHandlerPath(configDir, config.files?.queue, DEFAULT_QUEUE_PATH),
|
|
2469
|
+
resolveHandlerPath(configDir, config.files?.scheduled, DEFAULT_SCHEDULED_PATH),
|
|
2470
|
+
resolveHandlerPath(configDir, config.files?.email, DEFAULT_EMAIL_PATH),
|
|
2471
|
+
resolveHandlerPath(configDir, config.files?.tail, DEFAULT_TAIL_PATH),
|
|
2472
|
+
discoverRoutes(configDir, config)
|
|
2473
|
+
]);
|
|
2474
|
+
return {
|
|
2475
|
+
fetch,
|
|
2476
|
+
queue,
|
|
2477
|
+
scheduled,
|
|
2478
|
+
email,
|
|
2479
|
+
tail,
|
|
2480
|
+
routes
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
//#endregion
|
|
2484
|
+
//#region src/test/containers.ts
|
|
2485
|
+
const realContainerCommandRunner = { async exec(command, args, options = {}) {
|
|
2486
|
+
try {
|
|
2487
|
+
const result = await execa(command, args, {
|
|
2488
|
+
cwd: options.cwd,
|
|
2489
|
+
env: options.env,
|
|
2490
|
+
reject: false,
|
|
2491
|
+
timeout: options.timeoutMs ?? 15e3
|
|
2492
|
+
});
|
|
2493
|
+
return {
|
|
2494
|
+
exitCode: result.exitCode ?? 0,
|
|
2495
|
+
stdout: String(result.stdout ?? ""),
|
|
2496
|
+
stderr: String(result.stderr ?? "")
|
|
2497
|
+
};
|
|
2498
|
+
} catch (error) {
|
|
2499
|
+
const err = error;
|
|
2500
|
+
return {
|
|
2501
|
+
exitCode: err.exitCode ?? 1,
|
|
2502
|
+
stdout: String(err.stdout ?? ""),
|
|
2503
|
+
stderr: String(err.stderr ?? err.message ?? "Container command failed")
|
|
2504
|
+
};
|
|
2505
|
+
}
|
|
2506
|
+
} };
|
|
2507
|
+
async function detectContainerEngine(options = {}) {
|
|
2508
|
+
const runner = options.runner ?? realContainerCommandRunner;
|
|
2509
|
+
const candidates = options.engine && options.engine !== "auto" ? [options.engine] : ["docker", "podman"];
|
|
2510
|
+
const checked = [];
|
|
2511
|
+
for (const engine of candidates) {
|
|
2512
|
+
let result;
|
|
2513
|
+
try {
|
|
2514
|
+
result = await runner.exec(engine, ["info"], { timeoutMs: 1e4 });
|
|
2515
|
+
} catch (error) {
|
|
2516
|
+
result = {
|
|
2517
|
+
exitCode: 1,
|
|
2518
|
+
stdout: "",
|
|
2519
|
+
stderr: error instanceof Error ? error.message : String(error)
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
if (result.exitCode === 0) {
|
|
2523
|
+
checked.push({
|
|
2524
|
+
engine,
|
|
2525
|
+
available: true
|
|
2526
|
+
});
|
|
2527
|
+
return {
|
|
2528
|
+
available: true,
|
|
2529
|
+
engine,
|
|
2530
|
+
checked
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
checked.push({
|
|
2534
|
+
engine,
|
|
2535
|
+
available: false,
|
|
2536
|
+
reason: formatCommandFailure(result)
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
return {
|
|
2540
|
+
available: false,
|
|
2541
|
+
reason: checked.map((check) => `${check.engine}: ${check.reason ?? "not available"}`).join("; "),
|
|
2542
|
+
checked
|
|
2543
|
+
};
|
|
2544
|
+
}
|
|
2545
|
+
async function getContainerSkipReason(options = {}) {
|
|
2546
|
+
if (!isTruthyEnvFlag((options.env ?? process.env).DEVFLARE_CONTAINER_TESTS)) return "Container tests require DEVFLARE_CONTAINER_TESTS=1 because they launch local Docker/Podman containers.";
|
|
2547
|
+
const status = await detectContainerEngine(options);
|
|
2548
|
+
if (!status.available) return `No reachable Docker or Podman engine found: ${status.reason}`;
|
|
2549
|
+
return null;
|
|
2550
|
+
}
|
|
2551
|
+
function createContainerManager(options = {}) {
|
|
2552
|
+
const active = /* @__PURE__ */ new Set();
|
|
2553
|
+
const getRunner = () => options.runner ?? realContainerCommandRunner;
|
|
2554
|
+
const manager = {
|
|
2555
|
+
detectEngine(engineOptions = {}) {
|
|
2556
|
+
return detectContainerEngine({
|
|
2557
|
+
engine: engineOptions.engine ?? options.engine,
|
|
2558
|
+
runner: engineOptions.runner ?? getRunner()
|
|
2559
|
+
});
|
|
2560
|
+
},
|
|
2561
|
+
async start(className, startOptions) {
|
|
2562
|
+
const resolved = await resolveContainerStart(className, startOptions, options.cwd ?? getCallerDirectory());
|
|
2563
|
+
const engine = getAvailableContainerEngine(await manager.detectEngine(), className);
|
|
2564
|
+
const runner = getRunner();
|
|
2565
|
+
const offline = startOptions.offline ?? true;
|
|
2566
|
+
const prepared = await prepareImage({
|
|
2567
|
+
engine,
|
|
2568
|
+
runner,
|
|
2569
|
+
image: resolved.image,
|
|
2570
|
+
className,
|
|
2571
|
+
configDir: resolved.configDir,
|
|
2572
|
+
imageBuildContext: resolved.config?.imageBuildContext,
|
|
2573
|
+
offline
|
|
2574
|
+
});
|
|
2575
|
+
const host = startOptions.host ?? "127.0.0.1";
|
|
2576
|
+
const hostPort = startOptions.hostPort ?? await (options.allocatePort ?? getAvailablePort)();
|
|
2577
|
+
const containerName = makeContainerName(className, startOptions.instance);
|
|
2578
|
+
const runArgs = buildRunArgs({
|
|
2579
|
+
name: containerName,
|
|
2580
|
+
className,
|
|
2581
|
+
image: prepared.image,
|
|
2582
|
+
host,
|
|
2583
|
+
hostPort,
|
|
2584
|
+
containerPort: startOptions.port,
|
|
2585
|
+
envVars: startOptions.envVars,
|
|
2586
|
+
entrypoint: startOptions.entrypoint,
|
|
2587
|
+
command: startOptions.command
|
|
2588
|
+
});
|
|
2589
|
+
const runResult = await runner.exec(engine, runArgs, {
|
|
2590
|
+
cwd: resolved.configDir,
|
|
2591
|
+
env: options.env ?? process.env
|
|
2592
|
+
});
|
|
2593
|
+
if (runResult.exitCode !== 0) throw new Error(`Failed to start Devflare container "${className}": ${formatCommandFailure(runResult)}`);
|
|
2594
|
+
const instance = new LocalDevflareContainer({
|
|
2595
|
+
id: runResult.stdout.trim() || containerName,
|
|
2596
|
+
name: containerName,
|
|
2597
|
+
className,
|
|
2598
|
+
engine,
|
|
2599
|
+
host,
|
|
2600
|
+
hostPort,
|
|
2601
|
+
port: startOptions.port,
|
|
2602
|
+
runner,
|
|
2603
|
+
fetchImpl: options.fetch ?? fetch,
|
|
2604
|
+
onDispose: (container) => active.delete(container)
|
|
2605
|
+
});
|
|
2606
|
+
active.add(instance);
|
|
2607
|
+
await waitForContainerReadiness(instance, startOptions, options.waitForPort ?? waitForTcpPort);
|
|
2608
|
+
return instance;
|
|
2609
|
+
},
|
|
2610
|
+
async stopAll() {
|
|
2611
|
+
await Promise.all([...active].map((container) => container.stop()));
|
|
2612
|
+
}
|
|
2613
|
+
};
|
|
2614
|
+
return manager;
|
|
2615
|
+
}
|
|
2616
|
+
const defaultContainerManager = createContainerManager();
|
|
2617
|
+
const containers = defaultContainerManager;
|
|
2618
|
+
async function stopActiveContainers() {
|
|
2619
|
+
await defaultContainerManager.stopAll();
|
|
2620
|
+
}
|
|
2621
|
+
function getAvailableContainerEngine(status, className) {
|
|
2622
|
+
if (!status.available) throw new Error(`Cannot start Devflare container "${className}": ${status.reason}`);
|
|
2623
|
+
return status.engine;
|
|
2624
|
+
}
|
|
2625
|
+
async function waitForContainerReadiness(instance, options, waitForPort) {
|
|
2626
|
+
if (!(options.waitForReady ?? true)) return;
|
|
2627
|
+
try {
|
|
2628
|
+
await waitForPort(instance.host, instance.hostPort, options.readyTimeoutMs ?? 2e4);
|
|
2629
|
+
} catch (error) {
|
|
2630
|
+
await instance.destroy();
|
|
2631
|
+
throw error;
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
async function resolveContainerStart(className, options, cwd) {
|
|
2635
|
+
if (options.image) return {
|
|
2636
|
+
configDir: cwd,
|
|
2637
|
+
image: options.image
|
|
2638
|
+
};
|
|
2639
|
+
const { config, configDir } = await loadContainerConfig(options.configPath, cwd);
|
|
2640
|
+
const container = config.containers?.find((candidate) => candidate.className === className || candidate.name === className);
|
|
2641
|
+
if (!container) throw new Error(`Container "${className}" was not found in devflare config.`);
|
|
2642
|
+
return {
|
|
2643
|
+
configDir,
|
|
2644
|
+
image: container.image,
|
|
2645
|
+
config: container
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
async function loadContainerConfig(configPath, cwd) {
|
|
2649
|
+
const absolutePath = configPath ? resolve(cwd, configPath) : await findNearestConfig(cwd);
|
|
2650
|
+
if (!absolutePath) throw new Error(`Could not find a devflare config file for container lookup. Searched upward from: ${cwd}`);
|
|
2651
|
+
const configDir = dirname(absolutePath);
|
|
2652
|
+
return {
|
|
2653
|
+
config: await applyLocalDevVarsToConfig(await resolveConfigEnvVars(await loadConfig({
|
|
2654
|
+
cwd: configDir,
|
|
2655
|
+
configFile: basename(absolutePath)
|
|
2656
|
+
}), {
|
|
2657
|
+
cwd: configDir,
|
|
2658
|
+
configPath: absolutePath,
|
|
2659
|
+
mode: "dev"
|
|
2660
|
+
}), {
|
|
2661
|
+
cwd: configDir,
|
|
2662
|
+
configPath: absolutePath
|
|
2663
|
+
}),
|
|
2664
|
+
configDir
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
async function prepareImage(options) {
|
|
2668
|
+
if (looksLikeLocalPath(options.image)) return { image: await buildLocalImage(options) };
|
|
2669
|
+
if ((await options.runner.exec(options.engine, [
|
|
2670
|
+
"image",
|
|
2671
|
+
"inspect",
|
|
2672
|
+
options.image
|
|
2673
|
+
], { cwd: options.configDir })).exitCode === 0) return { image: options.image };
|
|
2674
|
+
if (options.offline) throw new Error(`Container image "${options.image}" is not present locally. Devflare container tests are offline-first; pull/build the image ahead of time or pass offline: false.`);
|
|
2675
|
+
const pull = await options.runner.exec(options.engine, ["pull", options.image], { cwd: options.configDir });
|
|
2676
|
+
if (pull.exitCode !== 0) throw new Error(`Failed to pull container image "${options.image}": ${formatCommandFailure(pull)}`);
|
|
2677
|
+
return { image: options.image };
|
|
2678
|
+
}
|
|
2679
|
+
async function buildLocalImage(options) {
|
|
2680
|
+
const imagePath = resolve(options.configDir, options.image);
|
|
2681
|
+
const imageStat = await stat(imagePath);
|
|
2682
|
+
const dockerfile = imageStat.isDirectory() ? join(imagePath, "Dockerfile") : imagePath;
|
|
2683
|
+
const context = resolve(options.configDir, options.imageBuildContext ?? (imageStat.isDirectory() ? options.image : dirname(options.image)));
|
|
2684
|
+
if (!existsSync(dockerfile)) throw new Error(`Container Dockerfile does not exist: ${dockerfile}`);
|
|
2685
|
+
const tag = makeLocalImageTag(options.className, options.configDir, options.image);
|
|
2686
|
+
const args = [
|
|
2687
|
+
"build",
|
|
2688
|
+
...options.offline ? [getOfflineBuildPullArg(options.engine)] : [],
|
|
2689
|
+
"-t",
|
|
2690
|
+
tag,
|
|
2691
|
+
"-f",
|
|
2692
|
+
dockerfile,
|
|
2693
|
+
context
|
|
2694
|
+
];
|
|
2695
|
+
const result = await options.runner.exec(options.engine, args, { cwd: options.configDir });
|
|
2696
|
+
if (result.exitCode !== 0) throw new Error(`Failed to build local container image "${options.image}": ${formatCommandFailure(result)}`);
|
|
2697
|
+
return tag;
|
|
2698
|
+
}
|
|
2699
|
+
function buildRunArgs(options) {
|
|
2700
|
+
const args = [
|
|
2701
|
+
"run",
|
|
2702
|
+
"-d",
|
|
2703
|
+
"--name",
|
|
2704
|
+
options.name,
|
|
2705
|
+
"--label",
|
|
2706
|
+
"devflare.managed=true",
|
|
2707
|
+
"--label",
|
|
2708
|
+
`devflare.container.class=${options.className}`,
|
|
2709
|
+
"-p",
|
|
2710
|
+
`${options.host}:${options.hostPort}:${options.containerPort}`
|
|
2711
|
+
];
|
|
2712
|
+
for (const [key, value] of Object.entries(options.envVars ?? {})) args.push("-e", `${key}=${value}`);
|
|
2713
|
+
const command = [...options.command ?? []];
|
|
2714
|
+
if (options.entrypoint && options.entrypoint.length > 0) {
|
|
2715
|
+
args.push("--entrypoint", options.entrypoint[0]);
|
|
2716
|
+
command.unshift(...options.entrypoint.slice(1));
|
|
2717
|
+
}
|
|
2718
|
+
args.push(options.image, ...command);
|
|
2719
|
+
return args;
|
|
2720
|
+
}
|
|
2721
|
+
var LocalDevflareContainer = class {
|
|
2722
|
+
id;
|
|
2723
|
+
name;
|
|
2724
|
+
className;
|
|
2725
|
+
engine;
|
|
2726
|
+
host;
|
|
2727
|
+
hostPort;
|
|
2728
|
+
port;
|
|
2729
|
+
runner;
|
|
2730
|
+
fetchImpl;
|
|
2731
|
+
onDispose;
|
|
2732
|
+
disposed = false;
|
|
2733
|
+
constructor(options) {
|
|
2734
|
+
this.id = options.id;
|
|
2735
|
+
this.name = options.name;
|
|
2736
|
+
this.className = options.className;
|
|
2737
|
+
this.engine = options.engine;
|
|
2738
|
+
this.host = options.host;
|
|
2739
|
+
this.hostPort = options.hostPort;
|
|
2740
|
+
this.port = options.port;
|
|
2741
|
+
this.runner = options.runner;
|
|
2742
|
+
this.fetchImpl = options.fetchImpl;
|
|
2743
|
+
this.onDispose = options.onDispose;
|
|
2744
|
+
}
|
|
2745
|
+
fetch(input, init) {
|
|
2746
|
+
return fetchWithStartupRetries(this.fetchImpl, this.toLocalRequest(input, init));
|
|
2747
|
+
}
|
|
2748
|
+
async logs() {
|
|
2749
|
+
const result = await this.runner.exec(this.engine, ["logs", this.name]);
|
|
2750
|
+
if (result.exitCode !== 0) throw new Error(`Failed to read logs for Devflare container "${this.name}": ${formatCommandFailure(result)}`);
|
|
2751
|
+
return result.stdout;
|
|
2752
|
+
}
|
|
2753
|
+
async getState() {
|
|
2754
|
+
const result = await this.runner.exec(this.engine, [
|
|
2755
|
+
"inspect",
|
|
2756
|
+
"--format",
|
|
2757
|
+
"{{json .State}}",
|
|
2758
|
+
this.name
|
|
2759
|
+
]);
|
|
2760
|
+
if (result.exitCode !== 0) throw new Error(`Failed to inspect Devflare container "${this.name}": ${formatCommandFailure(result)}`);
|
|
2761
|
+
const parsed = JSON.parse(result.stdout || "{}");
|
|
2762
|
+
return {
|
|
2763
|
+
status: parsed.Status ?? (parsed.Running ? "running" : "stopped"),
|
|
2764
|
+
running: Boolean(parsed.Running),
|
|
2765
|
+
...typeof parsed.ExitCode === "number" && { exitCode: parsed.ExitCode }
|
|
2766
|
+
};
|
|
2767
|
+
}
|
|
2768
|
+
async stop() {
|
|
2769
|
+
if (this.disposed) return;
|
|
2770
|
+
try {
|
|
2771
|
+
await this.runner.exec(this.engine, ["stop", this.name]);
|
|
2772
|
+
} finally {
|
|
2773
|
+
await this.runner.exec(this.engine, [
|
|
2774
|
+
"rm",
|
|
2775
|
+
"-f",
|
|
2776
|
+
this.name
|
|
2777
|
+
]);
|
|
2778
|
+
this.disposed = true;
|
|
2779
|
+
this.onDispose(this);
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
async destroy() {
|
|
2783
|
+
if (this.disposed) return;
|
|
2784
|
+
await this.runner.exec(this.engine, [
|
|
2785
|
+
"rm",
|
|
2786
|
+
"-f",
|
|
2787
|
+
this.name
|
|
2788
|
+
]);
|
|
2789
|
+
this.disposed = true;
|
|
2790
|
+
this.onDispose(this);
|
|
2791
|
+
}
|
|
2792
|
+
toLocalRequest(input, init) {
|
|
2793
|
+
const base = `http://${this.host}:${this.hostPort}/`;
|
|
2794
|
+
if (input instanceof Request) {
|
|
2795
|
+
const source = init ? new Request(input, init) : input;
|
|
2796
|
+
const sourceUrl = new URL(source.url);
|
|
2797
|
+
const localUrl = new URL(`${sourceUrl.pathname}${sourceUrl.search}`, base);
|
|
2798
|
+
return new Request(localUrl, {
|
|
2799
|
+
method: source.method,
|
|
2800
|
+
headers: source.headers,
|
|
2801
|
+
body: source.body,
|
|
2802
|
+
redirect: source.redirect,
|
|
2803
|
+
signal: source.signal
|
|
2804
|
+
});
|
|
2805
|
+
}
|
|
2806
|
+
const sourceUrl = new URL(String(input), base);
|
|
2807
|
+
const localUrl = new URL(`${sourceUrl.pathname}${sourceUrl.search}`, base);
|
|
2808
|
+
return new Request(localUrl, init);
|
|
2809
|
+
}
|
|
2810
|
+
};
|
|
2811
|
+
async function fetchWithStartupRetries(fetchImpl, request) {
|
|
2812
|
+
if (!canRetryRequest(request)) return fetchImpl(request);
|
|
2813
|
+
const deadline = Date.now() + 5e3;
|
|
2814
|
+
let lastError;
|
|
2815
|
+
while (Date.now() < deadline) try {
|
|
2816
|
+
return await fetchImpl(request.clone());
|
|
2817
|
+
} catch (error) {
|
|
2818
|
+
if (!isTransientContainerFetchError(error)) throw error;
|
|
2819
|
+
lastError = error;
|
|
2820
|
+
await setTimeout$1(100);
|
|
2821
|
+
}
|
|
2822
|
+
throw lastError;
|
|
2823
|
+
}
|
|
2824
|
+
function canRetryRequest(request) {
|
|
2825
|
+
return (request.method === "GET" || request.method === "HEAD") && request.body === null;
|
|
2826
|
+
}
|
|
2827
|
+
function isTransientContainerFetchError(error) {
|
|
2828
|
+
const value = error;
|
|
2829
|
+
const code = value.code ?? value.cause?.code;
|
|
2830
|
+
if (code === "ECONNRESET" || code === "ECONNREFUSED" || code === "EPIPE" || code === "UND_ERR_SOCKET") return true;
|
|
2831
|
+
return typeof value.message === "string" && (value.message.includes("socket connection was closed") || value.message.includes("fetch failed"));
|
|
2832
|
+
}
|
|
2833
|
+
async function waitForTcpPort(host, port, timeoutMs) {
|
|
2834
|
+
const start = Date.now();
|
|
2835
|
+
let lastError;
|
|
2836
|
+
while (Date.now() - start < timeoutMs) try {
|
|
2837
|
+
await connectOnce(host, port);
|
|
2838
|
+
return;
|
|
2839
|
+
} catch (error) {
|
|
2840
|
+
lastError = error;
|
|
2841
|
+
await setTimeout$1(100);
|
|
2842
|
+
}
|
|
2843
|
+
const message = lastError instanceof Error ? lastError.message : "timed out";
|
|
2844
|
+
throw new Error(`Timed out waiting for container port ${host}:${port}: ${message}`);
|
|
2845
|
+
}
|
|
2846
|
+
function connectOnce(host, port) {
|
|
2847
|
+
return new Promise((resolveConnection, rejectConnection) => {
|
|
2848
|
+
const socket = createConnection({
|
|
2849
|
+
host,
|
|
2850
|
+
port
|
|
2851
|
+
});
|
|
2852
|
+
socket.once("connect", () => {
|
|
2853
|
+
socket.destroy();
|
|
2854
|
+
resolveConnection();
|
|
2855
|
+
});
|
|
2856
|
+
socket.once("error", rejectConnection);
|
|
2857
|
+
});
|
|
2858
|
+
}
|
|
2859
|
+
function looksLikeLocalPath(image) {
|
|
2860
|
+
return image === "Dockerfile" || image.startsWith(".") || image.startsWith("/") || image.startsWith("\\") || isAbsolute(image) || image.endsWith("/Dockerfile") || image.endsWith("\\Dockerfile");
|
|
2861
|
+
}
|
|
2862
|
+
function getOfflineBuildPullArg(engine) {
|
|
2863
|
+
return engine === "podman" ? "--pull=never" : "--pull=false";
|
|
2864
|
+
}
|
|
2865
|
+
function makeLocalImageTag(className, configDir, image) {
|
|
2866
|
+
const hash = createHash("sha256").update(`${configDir}:${image}`).digest("hex").slice(0, 12);
|
|
2867
|
+
return `devflare-local-${sanitizeName(className)}:${hash}`;
|
|
2868
|
+
}
|
|
2869
|
+
function makeContainerName(className, instance) {
|
|
2870
|
+
return `devflare-${sanitizeName(className)}-${sanitizeName(instance ?? "default")}-${randomUUID().slice(0, 8)}`;
|
|
2871
|
+
}
|
|
2872
|
+
function sanitizeName(value) {
|
|
2873
|
+
return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "container";
|
|
2874
|
+
}
|
|
2875
|
+
function formatCommandFailure(result) {
|
|
2876
|
+
return (result.stderr || result.stdout || `exit code ${result.exitCode}`).trim();
|
|
2877
|
+
}
|
|
2878
|
+
function isTruthyEnvFlag(value) {
|
|
2879
|
+
return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes";
|
|
2880
|
+
}
|
|
2881
|
+
//#endregion
|
|
2882
|
+
//#region src/test/simple-context-lifecycle.ts
|
|
2883
|
+
/**
|
|
2884
|
+
* Resolve and load the devflare config for the test context.
|
|
2885
|
+
*
|
|
2886
|
+
* If `configPath` is given, it is interpreted relative to the caller's
|
|
2887
|
+
* directory (the file that invoked `createTestContext()`). Otherwise the
|
|
2888
|
+
* resolver walks upward from the caller's directory looking for a supported
|
|
2889
|
+
* `devflare.config.*` file.
|
|
2890
|
+
*/
|
|
2891
|
+
async function resolveTestContextConfig(configPath, callerDir = getCallerDirectory()) {
|
|
2892
|
+
let absolutePath;
|
|
2893
|
+
if (configPath) absolutePath = resolve$1(callerDir, configPath);
|
|
2894
|
+
else {
|
|
2895
|
+
const found = await findNearestConfig(callerDir);
|
|
2896
|
+
if (!found) throw new Error(`Could not find a devflare config file. Searched upward from: ${callerDir}\nExpected one of: devflare.config.ts, devflare.config.mts, devflare.config.js, devflare.config.mjs\nEither create a config file or provide an explicit path: createTestContext('./path/to/config.ts')`);
|
|
2897
|
+
absolutePath = found;
|
|
2898
|
+
}
|
|
2899
|
+
const configDir = dirname$1(absolutePath);
|
|
2900
|
+
const config = await applyLocalDevVarsToConfig(await resolveConfigEnvVars(await loadConfig({
|
|
2901
|
+
cwd: configDir,
|
|
2902
|
+
configFile: absolutePath.split(/[/\\]/).pop()
|
|
2903
|
+
}), {
|
|
2904
|
+
cwd: configDir,
|
|
2905
|
+
configPath: absolutePath,
|
|
2906
|
+
mode: "dev"
|
|
2907
|
+
}), {
|
|
2908
|
+
cwd: configDir,
|
|
2909
|
+
configPath: absolutePath
|
|
2910
|
+
});
|
|
2911
|
+
return {
|
|
2912
|
+
absolutePath,
|
|
2913
|
+
configDir,
|
|
2914
|
+
config
|
|
2915
|
+
};
|
|
2916
|
+
}
|
|
2917
|
+
/**
|
|
2918
|
+
* Build the dispose() function that tears down a test context. Disconnects
|
|
2919
|
+
* the bridge client, disposes Miniflare, clears per-handler global state,
|
|
2920
|
+
* and clears the registered test-context env accessor.
|
|
2921
|
+
*/
|
|
2922
|
+
function createDisposeContext(state) {
|
|
2923
|
+
return async () => {
|
|
2924
|
+
if (state.client) {
|
|
2925
|
+
await state.client.disconnect();
|
|
2926
|
+
state.client = null;
|
|
2927
|
+
}
|
|
2928
|
+
if (state.miniflare) {
|
|
2929
|
+
await state.miniflare.dispose();
|
|
2930
|
+
state.miniflare = null;
|
|
2931
|
+
}
|
|
2932
|
+
await disposeLocalWorkerLoaderBindings();
|
|
2933
|
+
await stopActiveContainers();
|
|
2934
|
+
state.envProxy = null;
|
|
2935
|
+
state.transportDecode = null;
|
|
2936
|
+
state.remoteBindings = null;
|
|
2937
|
+
state.miniflareBindings = null;
|
|
2938
|
+
resetQueueState();
|
|
2939
|
+
resetScheduledState();
|
|
2940
|
+
resetWorkerState();
|
|
2941
|
+
resetTailState();
|
|
2942
|
+
resetEmailState();
|
|
2943
|
+
__clearTestContext();
|
|
2944
|
+
};
|
|
2945
|
+
}
|
|
2946
|
+
//#endregion
|
|
2947
|
+
//#region src/test/simple-context-mfconfig.ts
|
|
2948
|
+
/**
|
|
2949
|
+
* Build the seed Miniflare config for an inline (single-worker) bridge.
|
|
2950
|
+
* Pure: no I/O, no closures.
|
|
2951
|
+
*/
|
|
2952
|
+
function buildInlineBridgeMfConfig(config, options = {}) {
|
|
2953
|
+
const localWorkerBindings = config.vars ?? {};
|
|
2954
|
+
const localSecretWrappedBindingConfig = options.cwd ? buildLocalSecretWrappedBindingConfig(config, options.cwd) : void 0;
|
|
2955
|
+
const localBindingShimServiceConfig = buildLocalBindingShimServiceConfig(config);
|
|
2956
|
+
const localSecretBindingNames = new Set(localSecretWrappedBindingConfig?.localBindingNames ?? []);
|
|
2957
|
+
const mfConfig = {
|
|
2958
|
+
modules: true,
|
|
2959
|
+
compatibilityDate: config.compatibilityDate ?? "2025-01-01",
|
|
2960
|
+
...config.compatibilityFlags && { compatibilityFlags: config.compatibilityFlags }
|
|
2961
|
+
};
|
|
2962
|
+
if (config.bindings?.kv) mfConfig.kvNamespaces = Object.keys(config.bindings.kv);
|
|
2963
|
+
if (config.bindings?.r2) mfConfig.r2Buckets = Object.keys(config.bindings.r2);
|
|
2964
|
+
if (config.bindings?.d1) mfConfig.d1Databases = Object.fromEntries(Object.entries(config.bindings.d1).map(([bindingName, bindingConfig]) => {
|
|
2965
|
+
return [bindingName, getLocalD1DatabaseIdentifier(bindingConfig)];
|
|
2966
|
+
}));
|
|
2967
|
+
const hyperdrivesConfig = buildHyperdrivesConfig(config.bindings ?? {});
|
|
2968
|
+
if (hyperdrivesConfig) mfConfig.hyperdrives = hyperdrivesConfig;
|
|
2969
|
+
if (config.bindings?.queues?.producers) {
|
|
2970
|
+
const queueProducers = {};
|
|
2971
|
+
for (const [bindingName, queueName] of Object.entries(config.bindings.queues.producers)) queueProducers[bindingName] = { queueName };
|
|
2972
|
+
mfConfig.queueProducers = queueProducers;
|
|
2973
|
+
}
|
|
2974
|
+
if (config.bindings?.rateLimits) mfConfig.ratelimits = Object.fromEntries(Object.entries(config.bindings.rateLimits).map(([bindingName, binding]) => [bindingName, { simple: {
|
|
2975
|
+
limit: binding.simple.limit,
|
|
2976
|
+
period: binding.simple.period
|
|
2977
|
+
} }]));
|
|
2978
|
+
if (config.bindings?.versionMetadata) mfConfig.versionMetadata = config.bindings.versionMetadata.binding;
|
|
2979
|
+
if (config.bindings?.workerLoaders) mfConfig.workerLoaders = Object.fromEntries(Object.keys(config.bindings.workerLoaders).map((bindingName) => [bindingName, {}]));
|
|
2980
|
+
if (config.bindings?.mtlsCertificates) mfConfig.mtlsCertificates = Object.fromEntries(Object.entries(config.bindings.mtlsCertificates).map(([bindingName, binding]) => {
|
|
2981
|
+
return [bindingName, { certificate_id: normalizeMtlsCertificateBinding(binding).certificateId }];
|
|
2982
|
+
}));
|
|
2983
|
+
if (config.bindings?.dispatchNamespaces) mfConfig.dispatchNamespaces = Object.fromEntries(Object.entries(config.bindings.dispatchNamespaces).map(([bindingName, binding]) => {
|
|
2984
|
+
return [bindingName, { namespace: normalizeDispatchNamespaceBinding(binding).namespace }];
|
|
2985
|
+
}));
|
|
2986
|
+
if (config.bindings?.workflows) mfConfig.workflows = Object.fromEntries(Object.entries(config.bindings.workflows).map(([bindingName, binding]) => {
|
|
2987
|
+
const normalized = normalizeWorkflowBinding(binding);
|
|
2988
|
+
return [bindingName, {
|
|
2989
|
+
name: normalized.name,
|
|
2990
|
+
className: normalized.className,
|
|
2991
|
+
...normalized.scriptName && { scriptName: normalized.scriptName },
|
|
2992
|
+
...normalized.limits && { stepLimit: normalized.limits.steps }
|
|
2993
|
+
}];
|
|
2994
|
+
}));
|
|
2995
|
+
if (config.bindings?.pipelines) mfConfig.pipelines = Object.fromEntries(Object.entries(config.bindings.pipelines).map(([bindingName, binding]) => {
|
|
2996
|
+
const normalized = normalizePipelineBinding(binding);
|
|
2997
|
+
return [bindingName, typeof binding === "string" ? normalized.pipeline : { pipeline: normalized.pipeline }];
|
|
2998
|
+
}));
|
|
2999
|
+
if (config.bindings?.images && localBindingShimServiceConfig.localBindingNames.length === 0) {
|
|
3000
|
+
const [entry] = Object.entries(config.bindings.images);
|
|
3001
|
+
if (entry) {
|
|
3002
|
+
const [bindingName, binding] = entry;
|
|
3003
|
+
mfConfig.images = { binding: normalizeImagesBinding(bindingName, binding).binding };
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
if (config.bindings?.media && localBindingShimServiceConfig.localBindingNames.length === 0) {
|
|
3007
|
+
const [entry] = Object.entries(config.bindings.media);
|
|
3008
|
+
if (entry) {
|
|
3009
|
+
const [bindingName, binding] = entry;
|
|
3010
|
+
mfConfig.media = { binding: normalizeMediaBinding(bindingName, binding).binding };
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
if (config.bindings?.artifacts) mfConfig.artifacts = Object.fromEntries(Object.entries(config.bindings.artifacts).map(([bindingName, binding]) => {
|
|
3014
|
+
return [bindingName, { namespace: normalizeArtifactsBinding(binding).namespace }];
|
|
3015
|
+
}));
|
|
3016
|
+
if (config.bindings?.aiSearchNamespaces) mfConfig.aiSearchNamespaces = Object.fromEntries(Object.entries(config.bindings.aiSearchNamespaces).map(([bindingName, binding]) => [bindingName, { namespace: binding.namespace }]));
|
|
3017
|
+
if (config.bindings?.aiSearch) mfConfig.aiSearchInstances = Object.fromEntries(Object.entries(config.bindings.aiSearch).map(([bindingName, binding]) => [bindingName, { instance_name: binding.instanceName }]));
|
|
3018
|
+
if (config.bindings?.secretsStore) {
|
|
3019
|
+
const secretsStoreEntries = Object.entries(config.bindings.secretsStore).flatMap(([bindingName, binding]) => {
|
|
3020
|
+
if (localSecretBindingNames.has(bindingName)) return [];
|
|
3021
|
+
const normalized = normalizeSecretsStoreBinding(binding, config.secretsStoreId, bindingName);
|
|
3022
|
+
return [[bindingName, {
|
|
3023
|
+
store_id: normalized.storeId,
|
|
3024
|
+
secret_name: normalized.secretName
|
|
3025
|
+
}]];
|
|
3026
|
+
});
|
|
3027
|
+
if (secretsStoreEntries.length > 0) mfConfig.secretsStoreSecrets = Object.fromEntries(secretsStoreEntries);
|
|
3028
|
+
}
|
|
3029
|
+
const wrappedBindings = { ...localSecretWrappedBindingConfig?.wrappedBindings ?? {} };
|
|
3030
|
+
const localBindingWorkers = [...localSecretWrappedBindingConfig?.workers ?? [], ...localBindingShimServiceConfig.workers];
|
|
3031
|
+
if (Object.keys(wrappedBindings).length > 0) mfConfig.wrappedBindings = wrappedBindings;
|
|
3032
|
+
if (localBindingShimServiceConfig.localBindingNames.length > 0) mfConfig.serviceBindings = {
|
|
3033
|
+
...mfConfig.serviceBindings ?? {},
|
|
3034
|
+
...localBindingShimServiceConfig.serviceBindings
|
|
3035
|
+
};
|
|
3036
|
+
if (localBindingWorkers.length > 0) mfConfig.__devflareLocalBindingWorkers = localBindingWorkers;
|
|
3037
|
+
if (Object.keys(localWorkerBindings).length > 0) mfConfig.bindings = localWorkerBindings;
|
|
3038
|
+
if (config.bindings?.sendEmail) mfConfig.email = { send_email: Object.entries(config.bindings.sendEmail).map(([name, binding]) => ({
|
|
3039
|
+
name,
|
|
3040
|
+
...binding.destinationAddress && { destination_address: binding.destinationAddress },
|
|
3041
|
+
...binding.allowedDestinationAddresses && { allowed_destination_addresses: binding.allowedDestinationAddresses },
|
|
3042
|
+
...binding.allowedSenderAddresses && { allowed_sender_addresses: binding.allowedSenderAddresses }
|
|
3043
|
+
})) };
|
|
3044
|
+
return mfConfig;
|
|
3045
|
+
}
|
|
3046
|
+
//#endregion
|
|
3047
|
+
//#region src/test/simple-context-multi-worker.ts
|
|
3048
|
+
/**
|
|
3049
|
+
* Convert the in-progress single-worker `mfConfig` into a multi-worker
|
|
3050
|
+
* Miniflare config. Mutates `mfConfig` in place.
|
|
3051
|
+
*
|
|
3052
|
+
* The first worker (the "primary") inherits the bridge gateway script and the
|
|
3053
|
+
* KV/R2/D1/email/DO settings that were set on the top-level mfConfig;
|
|
3054
|
+
* additional workers come from `serviceBindingResolution.workers` and
|
|
3055
|
+
* `doBindingResolution.workers`, deduplicated by name.
|
|
3056
|
+
*/
|
|
3057
|
+
function applyMultiWorkerConfig(mfConfig, config, serviceBindingResolution, doBindingResolution) {
|
|
3058
|
+
const primaryDurableObjects = {
|
|
3059
|
+
...mfConfig.durableObjects || {},
|
|
3060
|
+
...doBindingResolution?.crossWorkerDOBindings || {}
|
|
3061
|
+
};
|
|
3062
|
+
const primaryWorker = {
|
|
3063
|
+
name: config.name ?? "primary",
|
|
3064
|
+
modules: true,
|
|
3065
|
+
script: mfConfig.script,
|
|
3066
|
+
compatibilityDate: config.compatibilityDate ?? "2025-01-01",
|
|
3067
|
+
...mfConfig.kvNamespaces && { kvNamespaces: mfConfig.kvNamespaces },
|
|
3068
|
+
...mfConfig.r2Buckets && { r2Buckets: mfConfig.r2Buckets },
|
|
3069
|
+
...mfConfig.d1Databases && { d1Databases: mfConfig.d1Databases },
|
|
3070
|
+
...mfConfig.ratelimits && { ratelimits: mfConfig.ratelimits },
|
|
3071
|
+
...mfConfig.versionMetadata && { versionMetadata: mfConfig.versionMetadata },
|
|
3072
|
+
...mfConfig.workerLoaders && { workerLoaders: mfConfig.workerLoaders },
|
|
3073
|
+
...mfConfig.mtlsCertificates && { mtlsCertificates: mfConfig.mtlsCertificates },
|
|
3074
|
+
...mfConfig.dispatchNamespaces && { dispatchNamespaces: mfConfig.dispatchNamespaces },
|
|
3075
|
+
...mfConfig.workflows && { workflows: mfConfig.workflows },
|
|
3076
|
+
...mfConfig.pipelines && { pipelines: mfConfig.pipelines },
|
|
3077
|
+
...mfConfig.images && { images: mfConfig.images },
|
|
3078
|
+
...mfConfig.media && { media: mfConfig.media },
|
|
3079
|
+
...mfConfig.artifacts && { artifacts: mfConfig.artifacts },
|
|
3080
|
+
...mfConfig.secretsStoreSecrets && { secretsStoreSecrets: mfConfig.secretsStoreSecrets },
|
|
3081
|
+
...mfConfig.wrappedBindings && { wrappedBindings: mfConfig.wrappedBindings },
|
|
3082
|
+
...mfConfig.email && { email: mfConfig.email },
|
|
3083
|
+
...Object.keys(primaryDurableObjects).length > 0 && { durableObjects: primaryDurableObjects },
|
|
3084
|
+
...mfConfig.serviceBindings || serviceBindingResolution?.primaryServiceBindings ? { serviceBindings: {
|
|
3085
|
+
...mfConfig.serviceBindings ?? {},
|
|
3086
|
+
...serviceBindingResolution?.primaryServiceBindings ?? {}
|
|
3087
|
+
} } : {}
|
|
3088
|
+
};
|
|
3089
|
+
const additionalWorkers = [
|
|
3090
|
+
...serviceBindingResolution?.workers || [],
|
|
3091
|
+
...doBindingResolution?.workers || [],
|
|
3092
|
+
...mfConfig.__devflareLocalSecretWorkers || [],
|
|
3093
|
+
...mfConfig.__devflareLocalBindingWorkers || []
|
|
3094
|
+
];
|
|
3095
|
+
const workersByName = /* @__PURE__ */ new Map();
|
|
3096
|
+
for (const worker of additionalWorkers) {
|
|
3097
|
+
if (!workersByName.has(worker.name)) {
|
|
3098
|
+
workersByName.set(worker.name, worker);
|
|
3099
|
+
continue;
|
|
3100
|
+
}
|
|
3101
|
+
const existing = workersByName.get(worker.name);
|
|
3102
|
+
if (worker.durableObjects) existing.durableObjects = {
|
|
3103
|
+
...existing.durableObjects || {},
|
|
3104
|
+
...worker.durableObjects
|
|
3105
|
+
};
|
|
3106
|
+
}
|
|
3107
|
+
const workers = [primaryWorker, ...workersByName.values()];
|
|
3108
|
+
delete mfConfig.script;
|
|
3109
|
+
delete mfConfig.modules;
|
|
3110
|
+
delete mfConfig.kvNamespaces;
|
|
3111
|
+
delete mfConfig.r2Buckets;
|
|
3112
|
+
delete mfConfig.d1Databases;
|
|
3113
|
+
delete mfConfig.ratelimits;
|
|
3114
|
+
delete mfConfig.versionMetadata;
|
|
3115
|
+
delete mfConfig.workerLoaders;
|
|
3116
|
+
delete mfConfig.mtlsCertificates;
|
|
3117
|
+
delete mfConfig.dispatchNamespaces;
|
|
3118
|
+
delete mfConfig.workflows;
|
|
3119
|
+
delete mfConfig.pipelines;
|
|
3120
|
+
delete mfConfig.images;
|
|
3121
|
+
delete mfConfig.media;
|
|
3122
|
+
delete mfConfig.artifacts;
|
|
3123
|
+
delete mfConfig.secretsStoreSecrets;
|
|
3124
|
+
delete mfConfig.wrappedBindings;
|
|
3125
|
+
delete mfConfig.serviceBindings;
|
|
3126
|
+
delete mfConfig.__devflareLocalSecretWorkers;
|
|
3127
|
+
delete mfConfig.__devflareLocalBindingWorkers;
|
|
3128
|
+
delete mfConfig.durableObjects;
|
|
3129
|
+
mfConfig.workers = workers;
|
|
3130
|
+
}
|
|
3131
|
+
//#endregion
|
|
3132
|
+
//#region src/test/simple-context-startup.ts
|
|
3133
|
+
const TEST_CONTEXT_STARTUP_RETRY_ATTEMPTS = 3;
|
|
3134
|
+
const TEST_CONTEXT_STARTUP_RETRY_DELAY_MS = 75;
|
|
3135
|
+
const TEST_CONTEXT_BRIDGE_CONNECT_RETRY_ATTEMPTS = 8;
|
|
3136
|
+
const TEST_CONTEXT_BRIDGE_CONNECT_RETRY_DELAY_MS = 150;
|
|
3137
|
+
function isRetriableTestContextStartupError(error) {
|
|
3138
|
+
if (!(error instanceof Error)) return false;
|
|
3139
|
+
const message = error.message.toLowerCase();
|
|
3140
|
+
return message.includes("websocket connection failed") || message.includes("connection timeout: ws://") || message.includes("econnrefused") || message.includes("eaddrinuse") || message.includes("address already in use");
|
|
3141
|
+
}
|
|
3142
|
+
async function waitForTestContextStartupRetry() {
|
|
3143
|
+
await new Promise((resolve) => setTimeout(resolve, TEST_CONTEXT_STARTUP_RETRY_DELAY_MS));
|
|
3144
|
+
}
|
|
3145
|
+
async function waitForBridgeClientRetry() {
|
|
3146
|
+
await new Promise((resolve) => setTimeout(resolve, TEST_CONTEXT_BRIDGE_CONNECT_RETRY_DELAY_MS));
|
|
3147
|
+
}
|
|
3148
|
+
async function connectBridgeClientWithRetry(url) {
|
|
3149
|
+
let lastError;
|
|
3150
|
+
for (let attempt = 1; attempt <= TEST_CONTEXT_BRIDGE_CONNECT_RETRY_ATTEMPTS; attempt++) {
|
|
3151
|
+
const client = new BridgeClient({ url });
|
|
3152
|
+
try {
|
|
3153
|
+
await client.connect();
|
|
3154
|
+
return client;
|
|
3155
|
+
} catch (error) {
|
|
3156
|
+
lastError = error;
|
|
3157
|
+
client.disconnect();
|
|
3158
|
+
if (attempt >= TEST_CONTEXT_BRIDGE_CONNECT_RETRY_ATTEMPTS || !isRetriableTestContextStartupError(error)) throw error;
|
|
3159
|
+
await waitForBridgeClientRetry();
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error("Bridge-backed test context could not connect to the WebSocket gateway.");
|
|
3163
|
+
}
|
|
3164
|
+
function expandLocalBindingWorkers(mfConfig) {
|
|
3165
|
+
const auxiliaryWorkers = [...mfConfig.__devflareLocalSecretWorkers ?? [], ...mfConfig.__devflareLocalBindingWorkers ?? []];
|
|
3166
|
+
if (!Array.isArray(auxiliaryWorkers) || auxiliaryWorkers.length === 0) return mfConfig;
|
|
3167
|
+
const { __devflareLocalSecretWorkers, __devflareLocalBindingWorkers, port, host, log, kvPersist, r2Persist, d1Persist, durableObjectsPersist, workflowsPersist, imagesPersist, ...primaryWorker } = mfConfig;
|
|
3168
|
+
const primaryWorkerName = typeof primaryWorker.name === "string" ? primaryWorker.name : "primary";
|
|
3169
|
+
return {
|
|
3170
|
+
...port !== void 0 && { port },
|
|
3171
|
+
...host && { host },
|
|
3172
|
+
...log && { log },
|
|
3173
|
+
...kvPersist && { kvPersist },
|
|
3174
|
+
...r2Persist && { r2Persist },
|
|
3175
|
+
...d1Persist && { d1Persist },
|
|
3176
|
+
...durableObjectsPersist && { durableObjectsPersist },
|
|
3177
|
+
...workflowsPersist && { workflowsPersist },
|
|
3178
|
+
...imagesPersist && { imagesPersist },
|
|
3179
|
+
workers: [{
|
|
3180
|
+
...primaryWorker,
|
|
3181
|
+
name: primaryWorkerName
|
|
3182
|
+
}, ...auxiliaryWorkers]
|
|
3183
|
+
};
|
|
3184
|
+
}
|
|
3185
|
+
async function startBridgeBackedTestContext(mfConfig) {
|
|
3186
|
+
const { Miniflare } = await import("miniflare");
|
|
3187
|
+
for (let attempt = 1; attempt <= TEST_CONTEXT_STARTUP_RETRY_ATTEMPTS; attempt++) {
|
|
3188
|
+
const port = await getAvailablePort();
|
|
3189
|
+
let miniflare = null;
|
|
3190
|
+
let client = null;
|
|
3191
|
+
try {
|
|
3192
|
+
miniflare = new Miniflare(expandLocalBindingWorkers({
|
|
3193
|
+
...mfConfig,
|
|
3194
|
+
port
|
|
3195
|
+
}));
|
|
3196
|
+
await miniflare.ready;
|
|
3197
|
+
const miniflareBindings = wrapEnvSendEmailBindings(await miniflare.getBindings());
|
|
3198
|
+
client = await connectBridgeClientWithRetry(`ws://localhost:${port}`);
|
|
3199
|
+
return {
|
|
3200
|
+
port,
|
|
3201
|
+
client,
|
|
3202
|
+
miniflare,
|
|
3203
|
+
miniflareBindings
|
|
3204
|
+
};
|
|
3205
|
+
} catch (error) {
|
|
3206
|
+
client?.disconnect();
|
|
3207
|
+
if (miniflare) try {
|
|
3208
|
+
await miniflare.dispose();
|
|
3209
|
+
} catch {}
|
|
3210
|
+
if (attempt >= TEST_CONTEXT_STARTUP_RETRY_ATTEMPTS || !isRetriableTestContextStartupError(error)) throw error;
|
|
3211
|
+
await waitForTestContextStartupRetry();
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
throw new Error("Bridge-backed test context startup exhausted all retry attempts.");
|
|
3215
|
+
}
|
|
3216
|
+
//#endregion
|
|
3217
|
+
//#region src/test/simple-context-runtime.ts
|
|
3218
|
+
/**
|
|
3219
|
+
* Boot the Miniflare runtime that backs `createTestContext()`.
|
|
3220
|
+
*
|
|
3221
|
+
* Two paths:
|
|
3222
|
+
* - **multi-worker**: when the caller has cross-worker DOs or service
|
|
3223
|
+
* bindings, spin up Miniflare directly on a free port. No bridge client.
|
|
3224
|
+
* - **bridge-backed**: otherwise, defer to `startBridgeBackedTestContext()`,
|
|
3225
|
+
* which also returns a connected `BridgeClient`.
|
|
3226
|
+
*/
|
|
3227
|
+
async function bootTestRuntime(mfConfig, usesMultiWorker) {
|
|
3228
|
+
if (usesMultiWorker) {
|
|
3229
|
+
const { Miniflare } = await import("miniflare");
|
|
3230
|
+
const activePort = await getAvailablePort();
|
|
3231
|
+
const miniflare = new Miniflare({
|
|
3232
|
+
...mfConfig,
|
|
3233
|
+
port: activePort
|
|
3234
|
+
});
|
|
3235
|
+
await miniflare.ready;
|
|
3236
|
+
return {
|
|
3237
|
+
activePort,
|
|
3238
|
+
miniflare,
|
|
3239
|
+
miniflareBindings: wrapEnvSendEmailBindings(await miniflare.getBindings()),
|
|
3240
|
+
client: null
|
|
3241
|
+
};
|
|
3242
|
+
}
|
|
3243
|
+
const started = await startBridgeBackedTestContext(mfConfig);
|
|
3244
|
+
return {
|
|
3245
|
+
activePort: started.port,
|
|
3246
|
+
miniflare: started.miniflare,
|
|
3247
|
+
miniflareBindings: started.miniflareBindings,
|
|
3248
|
+
client: started.client
|
|
3249
|
+
};
|
|
3250
|
+
}
|
|
3251
|
+
//#endregion
|
|
3252
|
+
//#region src/test/simple-context-transport.ts
|
|
3253
|
+
/**
|
|
3254
|
+
* Load the user's transport module from disk and build a name -> decoder map.
|
|
3255
|
+
* Returns `null` if the module exists but does not export a `transport` object
|
|
3256
|
+
* (a warning is logged in that case).
|
|
3257
|
+
*/
|
|
3258
|
+
async function loadTransportDecoders(configDir, transportFile) {
|
|
3259
|
+
const transportModule = await import(join$1(configDir, transportFile));
|
|
3260
|
+
if (!transportModule.transport) {
|
|
3261
|
+
console.warn(`[devflare] Warning: Transport file "${transportFile}" does not export a named "transport" object.\nExpected: export const transport = { ... }\nTransport encoding/decoding will be disabled.`);
|
|
3262
|
+
return null;
|
|
3263
|
+
}
|
|
3264
|
+
const decoders = /* @__PURE__ */ new Map();
|
|
3265
|
+
for (const [typeName, transporter] of Object.entries(transportModule.transport)) {
|
|
3266
|
+
const t = transporter;
|
|
3267
|
+
decoders.set(typeName, t.decode);
|
|
3268
|
+
}
|
|
3269
|
+
return decoders;
|
|
3270
|
+
}
|
|
3271
|
+
/**
|
|
3272
|
+
* Recursively walk `value`, replacing `{ __transport, value }` envelopes with
|
|
3273
|
+
* the result of the registered decoder. Returns `value` unchanged when
|
|
3274
|
+
* `decoders` is `null` or no envelope is present.
|
|
3275
|
+
*/
|
|
3276
|
+
function decodeTransportValue(decoders, value) {
|
|
3277
|
+
if (!decoders || value === null || typeof value !== "object") return value;
|
|
3278
|
+
if ("__transport" in value) {
|
|
3279
|
+
const encoded = value;
|
|
3280
|
+
const decoder = decoders.get(encoded.__transport);
|
|
3281
|
+
if (decoder) return decoder(encoded.value);
|
|
3282
|
+
}
|
|
3283
|
+
if (Array.isArray(value)) return value.map((item) => decodeTransportValue(decoders, item));
|
|
3284
|
+
const result = {};
|
|
3285
|
+
for (const [k, v] of Object.entries(value)) result[k] = decodeTransportValue(decoders, v);
|
|
3286
|
+
return result;
|
|
3287
|
+
}
|
|
3288
|
+
//#endregion
|
|
3289
|
+
//#region src/test/simple-context.ts
|
|
3290
|
+
function createTestContextState() {
|
|
3291
|
+
return {
|
|
3292
|
+
client: null,
|
|
3293
|
+
miniflare: null,
|
|
3294
|
+
envProxy: null,
|
|
3295
|
+
transportDecode: null,
|
|
3296
|
+
remoteBindings: null,
|
|
3297
|
+
miniflareBindings: null
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
function shouldPreferBridgeBinding(hint) {
|
|
3301
|
+
return hint === "do" || hint === "service";
|
|
3302
|
+
}
|
|
3303
|
+
/**
|
|
3304
|
+
* Create a test context from a devflare config file.
|
|
3305
|
+
* This starts Miniflare with the configured bindings and sets up the bridge.
|
|
3306
|
+
*
|
|
3307
|
+
* @param configPath - Optional path to config file. If not provided, searches
|
|
3308
|
+
* upward from the test file for a supported devflare config.
|
|
3309
|
+
*/
|
|
3310
|
+
async function createTestContext(configPath) {
|
|
3311
|
+
const state = createTestContextState();
|
|
3312
|
+
const { configDir, config } = await resolveTestContextConfig(configPath);
|
|
3313
|
+
state.remoteBindings = buildRemoteAndStaticBindings(config);
|
|
3314
|
+
const hints = extractBindingHints(config);
|
|
3315
|
+
const decodeTransport = (value) => decodeTransportValue(state.transportDecode, value);
|
|
3316
|
+
const needsMultiWorkerForServices = hasServiceBindings(config);
|
|
3317
|
+
const needsMultiWorkerForDOs = hasCrossWorkerDOs(config);
|
|
3318
|
+
let serviceBindingResolution = null;
|
|
3319
|
+
let doBindingResolution = null;
|
|
3320
|
+
if (needsMultiWorkerForServices) serviceBindingResolution = await resolveServiceBindings(config, configDir);
|
|
3321
|
+
if (needsMultiWorkerForDOs) doBindingResolution = await resolveDOBindings(config, configDir);
|
|
3322
|
+
const mfConfig = buildInlineBridgeMfConfig(config, { cwd: configDir });
|
|
3323
|
+
const transportFile = resolveTransportFile(configDir, config.files?.transport);
|
|
3324
|
+
if (transportFile) state.transportDecode = await loadTransportDecoders(configDir, transportFile);
|
|
3325
|
+
const gateway = await buildDurableObjectGateway(config, configDir, transportFile);
|
|
3326
|
+
mfConfig.script = gateway.script;
|
|
3327
|
+
if (gateway.durableObjects) mfConfig.durableObjects = gateway.durableObjects;
|
|
3328
|
+
const hasMultiWorkerServices = serviceBindingResolution && serviceBindingResolution.workers.length > 0;
|
|
3329
|
+
const hasMultiWorkerDOs = doBindingResolution && doBindingResolution.workers.length > 0;
|
|
3330
|
+
if (hasMultiWorkerServices || hasMultiWorkerDOs) applyMultiWorkerConfig(mfConfig, config, serviceBindingResolution, doBindingResolution);
|
|
3331
|
+
const usesMultiWorker = Boolean(hasMultiWorkerServices || hasMultiWorkerDOs);
|
|
3332
|
+
const runtime = await bootTestRuntime(mfConfig, usesMultiWorker);
|
|
3333
|
+
const activePort = runtime.activePort;
|
|
3334
|
+
state.miniflare = runtime.miniflare;
|
|
3335
|
+
state.miniflareBindings = runtime.miniflareBindings;
|
|
3336
|
+
state.client = runtime.client;
|
|
3337
|
+
const disposeContext = createDisposeContext(state);
|
|
3338
|
+
const getTestEnv = () => {
|
|
3339
|
+
return new Proxy({}, {
|
|
3340
|
+
get(_, prop) {
|
|
3341
|
+
if (state.remoteBindings && prop in state.remoteBindings) return state.remoteBindings[prop];
|
|
3342
|
+
if (hints[prop] === "sendEmail" && state.envProxy && prop in state.envProxy) return state.envProxy[prop];
|
|
3343
|
+
if (state.miniflareBindings && prop in state.miniflareBindings) return state.miniflareBindings[prop];
|
|
3344
|
+
if (state.envProxy && prop in state.envProxy) return state.envProxy[prop];
|
|
3345
|
+
},
|
|
3346
|
+
has(_, prop) {
|
|
3347
|
+
return Boolean(state.remoteBindings && prop in state.remoteBindings || state.miniflareBindings && prop in state.miniflareBindings || state.envProxy && prop in state.envProxy);
|
|
3348
|
+
}
|
|
3349
|
+
});
|
|
3350
|
+
};
|
|
3351
|
+
configureSurfaceHandlers({
|
|
3352
|
+
handlerPaths: await resolveHandlerPaths(configDir, config),
|
|
3353
|
+
configDir,
|
|
3354
|
+
activePort,
|
|
3355
|
+
getEnv: getTestEnv
|
|
3356
|
+
});
|
|
3357
|
+
if (usesMultiWorker) {
|
|
3358
|
+
setBindingHints(hints);
|
|
3359
|
+
__setTestContext(createMultiWorkerEnvAccessor(state), disposeContext);
|
|
3360
|
+
return;
|
|
3361
|
+
}
|
|
3362
|
+
const bridgeClient = state.client;
|
|
3363
|
+
if (!bridgeClient) throw new Error("Bridge-backed test context did not initialize a client.");
|
|
3364
|
+
setBindingHints(hints);
|
|
3365
|
+
state.envProxy = createEnvProxy({
|
|
3366
|
+
client: bridgeClient,
|
|
3367
|
+
transformResult: (result) => decodeTransport(result)
|
|
3368
|
+
});
|
|
3369
|
+
__setTestContext(createBridgeEnvAccessor(state, hints, shouldPreferBridgeBinding), disposeContext);
|
|
3370
|
+
}
|
|
3371
|
+
//#endregion
|
|
3372
|
+
//#region src/test/cf.ts
|
|
3373
|
+
/**
|
|
3374
|
+
* Unified Cloudflare test helpers.
|
|
3375
|
+
*
|
|
3376
|
+
* Provides a consistent API for triggering the main Cloudflare Worker handler surfaces:
|
|
3377
|
+
* - `cf.email` — Email helper surface with a direct-handler path and a best-effort local endpoint fallback
|
|
3378
|
+
* - `cf.queue` — Queue consumer testing
|
|
3379
|
+
* - `cf.scheduled` — Cron/scheduled handler testing
|
|
3380
|
+
* - `cf.worker` — Fetch handler testing
|
|
3381
|
+
* - `cf.tail` — Tail helper surface (uses `files.tail`, or auto-detects `src/tail.ts` when present)
|
|
3382
|
+
*
|
|
3383
|
+
* The helpers use the real Miniflare-backed bindings created by `createTestContext()`,
|
|
3384
|
+
* but several helper surfaces still synthesize event/controller objects around those
|
|
3385
|
+
* bindings rather than replaying the full Cloudflare runtime dispatch path.
|
|
3386
|
+
* They still install the active Devflare event into AsyncLocalStorage before
|
|
3387
|
+
* invoking user code, so getters such as `getFetchEvent()` and `getQueueEvent()`
|
|
3388
|
+
* work inside the triggered handler.
|
|
3389
|
+
*
|
|
3390
|
+
* @example
|
|
3391
|
+
* ```ts
|
|
3392
|
+
* import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
|
|
3393
|
+
* import { createTestContext, cf } from 'devflare/test'
|
|
3394
|
+
* import { env } from 'devflare'
|
|
3395
|
+
*
|
|
3396
|
+
* beforeAll(() => createTestContext())
|
|
3397
|
+
* afterAll(() => env.dispose())
|
|
3398
|
+
*
|
|
3399
|
+
* describe('My Worker', () => {
|
|
3400
|
+
* test('fetch handler', async () => {
|
|
3401
|
+
* const response = await cf.worker.get('/api/health')
|
|
3402
|
+
* expect(response.status).toBe(200)
|
|
3403
|
+
* })
|
|
3404
|
+
*
|
|
3405
|
+
* test('queue handler', async () => {
|
|
3406
|
+
* // Trigger queue handler with messages
|
|
3407
|
+
* const result = await cf.queue.trigger([
|
|
3408
|
+
* { type: 'process', data: { id: 1 } }
|
|
3409
|
+
* ])
|
|
3410
|
+
* expect(result.acked).toHaveLength(1)
|
|
3411
|
+
*
|
|
3412
|
+
* // Verify side effects in real KV
|
|
3413
|
+
* const stored = await env.RESULTS.get('result:1')
|
|
3414
|
+
* expect(stored).toBeDefined()
|
|
3415
|
+
* })
|
|
3416
|
+
*
|
|
3417
|
+
* test('scheduled handler', async () => {
|
|
3418
|
+
* await cf.scheduled.trigger('0 0 * * 1') // Weekly Monday cron
|
|
3419
|
+
*
|
|
3420
|
+
* // Verify scheduled job ran
|
|
3421
|
+
* const report = await env.RESULTS.get('report:weekly')
|
|
3422
|
+
* expect(report).toBeDefined()
|
|
3423
|
+
* })
|
|
3424
|
+
* })
|
|
3425
|
+
* ```
|
|
3426
|
+
*/
|
|
3427
|
+
const cf = {
|
|
3428
|
+
email,
|
|
3429
|
+
queue,
|
|
3430
|
+
scheduled,
|
|
3431
|
+
worker,
|
|
3432
|
+
tail
|
|
3433
|
+
};
|
|
3434
|
+
//#endregion
|
|
3435
|
+
//#region src/test/should-skip.ts
|
|
3436
|
+
/**
|
|
3437
|
+
* These services cannot be emulated locally — they ALWAYS require
|
|
3438
|
+
* a real connection to Cloudflare's infrastructure.
|
|
3439
|
+
*/
|
|
3440
|
+
const REMOTE_ONLY_SERVICES = new Set([
|
|
3441
|
+
"ai",
|
|
3442
|
+
"ai_search",
|
|
3443
|
+
"ai_gateway",
|
|
3444
|
+
"media",
|
|
3445
|
+
"mtls_certificates",
|
|
3446
|
+
"artifacts",
|
|
3447
|
+
"builds",
|
|
3448
|
+
"vectorize"
|
|
3449
|
+
]);
|
|
3450
|
+
/**
|
|
3451
|
+
* Cached skip results — computed once at module load
|
|
3452
|
+
* Each service gets a Promise<boolean> that resolves to true if should SKIP
|
|
3453
|
+
*/
|
|
3454
|
+
const skipResults = /* @__PURE__ */ new Map();
|
|
3455
|
+
let containerSkipResult = null;
|
|
3456
|
+
/**
|
|
3457
|
+
* Known operational error patterns that should cause skipping rather than failing.
|
|
3458
|
+
* These are expected errors from network issues, API problems, auth failures, etc.
|
|
3459
|
+
*/
|
|
3460
|
+
const EXPECTED_ERROR_PATTERNS = [
|
|
3461
|
+
"ECONNREFUSED",
|
|
3462
|
+
"ETIMEDOUT",
|
|
3463
|
+
"ENOTFOUND",
|
|
3464
|
+
"fetch failed",
|
|
3465
|
+
"network",
|
|
3466
|
+
"401",
|
|
3467
|
+
"403",
|
|
3468
|
+
"429",
|
|
3469
|
+
"500",
|
|
3470
|
+
"502",
|
|
3471
|
+
"503",
|
|
3472
|
+
"504",
|
|
3473
|
+
"rate limit",
|
|
3474
|
+
"unauthorized",
|
|
3475
|
+
"forbidden",
|
|
3476
|
+
"timeout"
|
|
3477
|
+
];
|
|
3478
|
+
/**
|
|
3479
|
+
* Check if an error is an expected operational error that should cause skipping.
|
|
3480
|
+
*/
|
|
3481
|
+
function isExpectedError(error) {
|
|
3482
|
+
if (!(error instanceof Error)) return false;
|
|
3483
|
+
const msg = error.message.toLowerCase();
|
|
3484
|
+
return EXPECTED_ERROR_PATTERNS.some((pattern) => msg.includes(pattern.toLowerCase()));
|
|
3485
|
+
}
|
|
3486
|
+
/**
|
|
3487
|
+
* Compute whether to skip tests for a given service.
|
|
3488
|
+
* Returns true if tests should be SKIPPED.
|
|
3489
|
+
* Logs the reason to console.
|
|
3490
|
+
*
|
|
3491
|
+
* Rethrows unexpected errors (programming bugs) to fail tests loudly.
|
|
3492
|
+
*/
|
|
3493
|
+
async function computeSkip(service) {
|
|
3494
|
+
try {
|
|
3495
|
+
if (REMOTE_ONLY_SERVICES.has(service) && !isRemoteModeActive()) {
|
|
3496
|
+
const status = getRemoteModeStatus();
|
|
3497
|
+
console.log(`⏭️ ${service.toUpperCase()} tests skipped: Remote-only service.\n Enable with: ${status.isEnabled ? "" : "devflare remote enable"}\n Or set: DEVFLARE_REMOTE=1\n See: https://github.com/ArthurvdVenne/devflare#remote-testing`);
|
|
3498
|
+
return true;
|
|
3499
|
+
}
|
|
3500
|
+
if (!await isAuthenticated()) {
|
|
3501
|
+
console.log(`⏭️ ${service.toUpperCase()} tests skipped: Not authenticated. Run: bunx wrangler login\n See: https://github.com/ArthurvdVenne/devflare#authentication`);
|
|
3502
|
+
return true;
|
|
3503
|
+
}
|
|
3504
|
+
const primary = await getPrimaryAccount();
|
|
3505
|
+
if (!primary) {
|
|
3506
|
+
console.log(`⏭️ ${service.toUpperCase()} tests skipped: No Cloudflare account found\n See: https://github.com/ArthurvdVenne/devflare#authentication`);
|
|
3507
|
+
return true;
|
|
3508
|
+
}
|
|
3509
|
+
const { accountId } = await getEffectiveAccountId(primary.id);
|
|
3510
|
+
try {
|
|
3511
|
+
const { allowed, reason } = await canProceedWithTest(accountId, service);
|
|
3512
|
+
if (!allowed) {
|
|
3513
|
+
console.log(`⏭️ ${service.toUpperCase()} tests skipped: ${reason}`);
|
|
3514
|
+
return true;
|
|
3515
|
+
}
|
|
3516
|
+
} catch {}
|
|
3517
|
+
return false;
|
|
3518
|
+
} catch (error) {
|
|
3519
|
+
if (isExpectedError(error)) {
|
|
3520
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
3521
|
+
console.log(`⏭️ ${service.toUpperCase()} tests skipped: ${message}`);
|
|
3522
|
+
return true;
|
|
3523
|
+
}
|
|
3524
|
+
throw error;
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
3527
|
+
/**
|
|
3528
|
+
* Get or compute the skip result for a service
|
|
3529
|
+
*/
|
|
3530
|
+
function getSkipResult(service) {
|
|
3531
|
+
let result = skipResults.get(service);
|
|
3532
|
+
if (!result) {
|
|
3533
|
+
result = computeSkip(service);
|
|
3534
|
+
skipResults.set(service, result);
|
|
3535
|
+
}
|
|
3536
|
+
return result;
|
|
3537
|
+
}
|
|
3538
|
+
function getContainerSkipResult() {
|
|
3539
|
+
if (!containerSkipResult) containerSkipResult = getContainerSkipReason().then((reason) => {
|
|
3540
|
+
if (reason) {
|
|
3541
|
+
console.log(`CONTAINERS tests skipped: ${reason}`);
|
|
3542
|
+
return true;
|
|
3543
|
+
}
|
|
3544
|
+
return false;
|
|
3545
|
+
});
|
|
3546
|
+
return containerSkipResult;
|
|
3547
|
+
}
|
|
3548
|
+
/**
|
|
3549
|
+
* Skip helper with property-based access for each service.
|
|
3550
|
+
* Each property returns a Promise<boolean> where true = SKIP the tests.
|
|
3551
|
+
*
|
|
3552
|
+
* Usage:
|
|
3553
|
+
* ```ts
|
|
3554
|
+
* import { shouldSkip } from 'devflare/test'
|
|
3555
|
+
*
|
|
3556
|
+
* describe.skipIf(shouldSkip.ai)('AI tests', () => {
|
|
3557
|
+
* // These tests only run when authenticated and within limits
|
|
3558
|
+
* })
|
|
3559
|
+
* ```
|
|
3560
|
+
*/
|
|
3561
|
+
const shouldSkip = {
|
|
3562
|
+
get ai() {
|
|
3563
|
+
return getSkipResult("ai");
|
|
3564
|
+
},
|
|
3565
|
+
get aiSearch() {
|
|
3566
|
+
return getSkipResult("ai_search");
|
|
3567
|
+
},
|
|
3568
|
+
get aiGateway() {
|
|
3569
|
+
return getSkipResult("ai_gateway");
|
|
3570
|
+
},
|
|
3571
|
+
get vectorize() {
|
|
3572
|
+
return getSkipResult("vectorize");
|
|
3573
|
+
},
|
|
3574
|
+
get workers() {
|
|
3575
|
+
return getSkipResult("workers");
|
|
3576
|
+
},
|
|
3577
|
+
get kv() {
|
|
3578
|
+
return getSkipResult("kv");
|
|
3579
|
+
},
|
|
3580
|
+
get d1() {
|
|
3581
|
+
return getSkipResult("d1");
|
|
3582
|
+
},
|
|
3583
|
+
get r2() {
|
|
3584
|
+
return getSkipResult("r2");
|
|
3585
|
+
},
|
|
3586
|
+
get queues() {
|
|
3587
|
+
return getSkipResult("queues");
|
|
3588
|
+
},
|
|
3589
|
+
get durableObjects() {
|
|
3590
|
+
return getSkipResult("durable_objects");
|
|
3591
|
+
},
|
|
3592
|
+
get media() {
|
|
3593
|
+
return getSkipResult("media");
|
|
3594
|
+
},
|
|
3595
|
+
get mtlsCertificates() {
|
|
3596
|
+
return getSkipResult("mtls_certificates");
|
|
3597
|
+
},
|
|
3598
|
+
get artifacts() {
|
|
3599
|
+
return getSkipResult("artifacts");
|
|
3600
|
+
},
|
|
3601
|
+
get builds() {
|
|
3602
|
+
return getSkipResult("builds");
|
|
3603
|
+
},
|
|
3604
|
+
get containers() {
|
|
3605
|
+
return getContainerSkipResult();
|
|
3606
|
+
}
|
|
3607
|
+
};
|
|
3608
|
+
//#endregion
|
|
3609
|
+
//#region src/test/ai-search.ts
|
|
3610
|
+
const MOCK_TIMESTAMP = "2026-04-26T00:00:00.000Z";
|
|
3611
|
+
function cloneInfo(value) {
|
|
3612
|
+
return { ...value };
|
|
3613
|
+
}
|
|
3614
|
+
function extractSearchQuery(params) {
|
|
3615
|
+
if ("query" in params && typeof params.query === "string") return params.query;
|
|
3616
|
+
return ("messages" in params ? params.messages : []).filter((message) => message.role === "user" && message.content).map((message) => message.content).join(" ");
|
|
3617
|
+
}
|
|
3618
|
+
function tokenize(value) {
|
|
3619
|
+
return value.toLowerCase().split(/[^a-z0-9_]+/).filter((token) => token.length > 2);
|
|
3620
|
+
}
|
|
3621
|
+
function scoreText(query, text) {
|
|
3622
|
+
const tokens = tokenize(query);
|
|
3623
|
+
if (tokens.length === 0) return 1;
|
|
3624
|
+
const haystack = text.toLowerCase();
|
|
3625
|
+
const matches = tokens.filter((token) => haystack.includes(token)).length;
|
|
3626
|
+
return matches === 0 ? 0 : Math.max(.1, matches / tokens.length);
|
|
3627
|
+
}
|
|
3628
|
+
function streamFromText(text) {
|
|
3629
|
+
const encoded = new TextEncoder().encode(text);
|
|
3630
|
+
return new ReadableStream({ start(controller) {
|
|
3631
|
+
controller.enqueue(encoded);
|
|
3632
|
+
controller.close();
|
|
3633
|
+
} });
|
|
3634
|
+
}
|
|
3635
|
+
async function contentToText(content) {
|
|
3636
|
+
if (typeof content === "string") return content;
|
|
3637
|
+
if (content instanceof Blob) return content.text();
|
|
3638
|
+
return new Response(content).text();
|
|
3639
|
+
}
|
|
3640
|
+
function createItemInfo(id, key, content, options = {}) {
|
|
3641
|
+
return {
|
|
3642
|
+
id,
|
|
3643
|
+
key,
|
|
3644
|
+
status: options.status ?? "completed",
|
|
3645
|
+
next_action: null,
|
|
3646
|
+
namespace: options.namespace,
|
|
3647
|
+
chunks_count: content.length > 0 ? 1 : 0,
|
|
3648
|
+
file_size: new TextEncoder().encode(content).byteLength,
|
|
3649
|
+
source_id: options.sourceId ?? null,
|
|
3650
|
+
last_seen_at: MOCK_TIMESTAMP,
|
|
3651
|
+
created_at: MOCK_TIMESTAMP,
|
|
3652
|
+
metadata: options.metadata
|
|
3653
|
+
};
|
|
3654
|
+
}
|
|
3655
|
+
function createItemChunks(item, chunks) {
|
|
3656
|
+
let offset = 0;
|
|
3657
|
+
return chunks.map((text, index) => {
|
|
3658
|
+
const start = offset;
|
|
3659
|
+
const end = start + new TextEncoder().encode(text).byteLength;
|
|
3660
|
+
offset = end;
|
|
3661
|
+
return {
|
|
3662
|
+
id: `${item.id}:chunk-${index + 1}`,
|
|
3663
|
+
text,
|
|
3664
|
+
start_byte: start,
|
|
3665
|
+
end_byte: end,
|
|
3666
|
+
item: {
|
|
3667
|
+
timestamp: Date.parse(MOCK_TIMESTAMP),
|
|
3668
|
+
key: item.key,
|
|
3669
|
+
metadata: item.metadata
|
|
3670
|
+
}
|
|
3671
|
+
};
|
|
3672
|
+
});
|
|
3673
|
+
}
|
|
3674
|
+
function createStoredItem(sequence, fixture, namespace) {
|
|
3675
|
+
const content = fixture.content ?? fixture.chunks?.join("\n") ?? "";
|
|
3676
|
+
const info = createItemInfo(fixture.id ?? `item-${sequence}`, fixture.key, content, {
|
|
3677
|
+
metadata: fixture.metadata,
|
|
3678
|
+
status: fixture.status,
|
|
3679
|
+
namespace
|
|
3680
|
+
});
|
|
3681
|
+
const chunks = createItemChunks(info, fixture.chunks ?? [content]);
|
|
3682
|
+
return {
|
|
3683
|
+
info,
|
|
3684
|
+
content,
|
|
3685
|
+
contentType: fixture.contentType ?? "text/plain",
|
|
3686
|
+
chunks,
|
|
3687
|
+
logs: [{
|
|
3688
|
+
timestamp: MOCK_TIMESTAMP,
|
|
3689
|
+
action: "index",
|
|
3690
|
+
message: `Indexed ${fixture.key}`,
|
|
3691
|
+
fileKey: fixture.key,
|
|
3692
|
+
chunkCount: chunks.length,
|
|
3693
|
+
processingTimeMs: 0
|
|
3694
|
+
}]
|
|
3695
|
+
};
|
|
3696
|
+
}
|
|
3697
|
+
function isAISearchInstance$1(value) {
|
|
3698
|
+
return typeof value.search === "function";
|
|
3699
|
+
}
|
|
3700
|
+
/**
|
|
3701
|
+
* Creates a deterministic AI Search instance binding for pure unit tests.
|
|
3702
|
+
*/
|
|
3703
|
+
function createMockAISearchInstance(options = {}) {
|
|
3704
|
+
const instanceId = options.id ?? options.info?.id ?? "mock-ai-search";
|
|
3705
|
+
const namespace = options.namespace ?? options.info?.namespace;
|
|
3706
|
+
const items = /* @__PURE__ */ new Map();
|
|
3707
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
3708
|
+
const searches = [];
|
|
3709
|
+
let itemSequence = 0;
|
|
3710
|
+
let jobSequence = 0;
|
|
3711
|
+
let info = {
|
|
3712
|
+
id: instanceId,
|
|
3713
|
+
type: "builtin",
|
|
3714
|
+
namespace,
|
|
3715
|
+
status: "ready",
|
|
3716
|
+
created_at: MOCK_TIMESTAMP,
|
|
3717
|
+
modified_at: MOCK_TIMESTAMP,
|
|
3718
|
+
index_method: {
|
|
3719
|
+
vector: true,
|
|
3720
|
+
keyword: true
|
|
3721
|
+
},
|
|
3722
|
+
fusion_method: "rrf",
|
|
3723
|
+
...options.info
|
|
3724
|
+
};
|
|
3725
|
+
for (const fixture of options.items ?? []) {
|
|
3726
|
+
const stored = createStoredItem(++itemSequence, fixture, namespace);
|
|
3727
|
+
items.set(stored.info.id, stored);
|
|
3728
|
+
}
|
|
3729
|
+
const findMatches = (params) => {
|
|
3730
|
+
searches.push(params);
|
|
3731
|
+
const query = extractSearchQuery(params);
|
|
3732
|
+
const maxResults = params.ai_search_options?.retrieval?.max_num_results ?? 10;
|
|
3733
|
+
const chunks = [];
|
|
3734
|
+
for (const item of items.values()) for (const chunk of item.chunks) {
|
|
3735
|
+
const score = scoreText(query, `${item.info.key} ${chunk.text}`);
|
|
3736
|
+
if (score === 0) continue;
|
|
3737
|
+
chunks.push({
|
|
3738
|
+
id: chunk.id,
|
|
3739
|
+
type: "text",
|
|
3740
|
+
score,
|
|
3741
|
+
text: chunk.text,
|
|
3742
|
+
item: {
|
|
3743
|
+
timestamp: Date.parse(MOCK_TIMESTAMP),
|
|
3744
|
+
key: item.info.key,
|
|
3745
|
+
metadata: item.info.metadata
|
|
3746
|
+
},
|
|
3747
|
+
scoring_details: {
|
|
3748
|
+
keyword_score: score,
|
|
3749
|
+
vector_score: score,
|
|
3750
|
+
keyword_rank: chunks.length + 1,
|
|
3751
|
+
vector_rank: chunks.length + 1,
|
|
3752
|
+
fusion_method: "rrf"
|
|
3753
|
+
}
|
|
3754
|
+
});
|
|
3755
|
+
}
|
|
3756
|
+
return {
|
|
3757
|
+
search_query: query,
|
|
3758
|
+
chunks: chunks.slice(0, maxResults)
|
|
3759
|
+
};
|
|
3760
|
+
};
|
|
3761
|
+
const createItemHandle = (itemId) => ({
|
|
3762
|
+
async info() {
|
|
3763
|
+
const item = items.get(itemId);
|
|
3764
|
+
if (!item) throw new Error(`Mock AI Search item "${itemId}" was not found.`);
|
|
3765
|
+
return cloneInfo(item.info);
|
|
3766
|
+
},
|
|
3767
|
+
async download() {
|
|
3768
|
+
const item = items.get(itemId);
|
|
3769
|
+
if (!item) throw new Error(`Mock AI Search item "${itemId}" was not found.`);
|
|
3770
|
+
return {
|
|
3771
|
+
body: streamFromText(item.content),
|
|
3772
|
+
contentType: item.contentType,
|
|
3773
|
+
filename: item.info.key,
|
|
3774
|
+
size: new TextEncoder().encode(item.content).byteLength
|
|
3775
|
+
};
|
|
3776
|
+
},
|
|
3777
|
+
async sync() {
|
|
3778
|
+
const item = items.get(itemId);
|
|
3779
|
+
if (!item) throw new Error(`Mock AI Search item "${itemId}" was not found.`);
|
|
3780
|
+
item.info.status = "completed";
|
|
3781
|
+
item.info.last_seen_at = MOCK_TIMESTAMP;
|
|
3782
|
+
return cloneInfo(item.info);
|
|
3783
|
+
},
|
|
3784
|
+
async logs(params) {
|
|
3785
|
+
const item = items.get(itemId);
|
|
3786
|
+
if (!item) throw new Error(`Mock AI Search item "${itemId}" was not found.`);
|
|
3787
|
+
const limit = params?.limit ?? 50;
|
|
3788
|
+
const result = item.logs.slice(0, limit);
|
|
3789
|
+
return {
|
|
3790
|
+
result,
|
|
3791
|
+
result_info: {
|
|
3792
|
+
count: result.length,
|
|
3793
|
+
per_page: limit,
|
|
3794
|
+
cursor: null,
|
|
3795
|
+
truncated: item.logs.length > result.length
|
|
3796
|
+
}
|
|
3797
|
+
};
|
|
3798
|
+
},
|
|
3799
|
+
async chunks(params) {
|
|
3800
|
+
const item = items.get(itemId);
|
|
3801
|
+
if (!item) throw new Error(`Mock AI Search item "${itemId}" was not found.`);
|
|
3802
|
+
const offset = params?.offset ?? 0;
|
|
3803
|
+
const limit = params?.limit ?? 20;
|
|
3804
|
+
const result = item.chunks.slice(offset, offset + limit);
|
|
3805
|
+
return {
|
|
3806
|
+
result,
|
|
3807
|
+
result_info: {
|
|
3808
|
+
count: result.length,
|
|
3809
|
+
total: item.chunks.length,
|
|
3810
|
+
limit,
|
|
3811
|
+
offset
|
|
3812
|
+
}
|
|
3813
|
+
};
|
|
3814
|
+
}
|
|
3815
|
+
});
|
|
3816
|
+
const uploadItem = async (name, content, uploadOptions) => {
|
|
3817
|
+
const text = await contentToText(content);
|
|
3818
|
+
const existing = Array.from(items.values()).find((item) => item.info.key === name);
|
|
3819
|
+
const stored = createStoredItem(existing ? Number(existing.info.id.replace(/^item-/, "")) || ++itemSequence : ++itemSequence, {
|
|
3820
|
+
id: existing?.info.id,
|
|
3821
|
+
key: name,
|
|
3822
|
+
content: text,
|
|
3823
|
+
metadata: uploadOptions?.metadata
|
|
3824
|
+
}, namespace);
|
|
3825
|
+
items.set(stored.info.id, stored);
|
|
3826
|
+
return cloneInfo(stored.info);
|
|
3827
|
+
};
|
|
3828
|
+
const itemsApi = {
|
|
3829
|
+
async list(params) {
|
|
3830
|
+
let result = Array.from(items.values()).map((item) => cloneInfo(item.info));
|
|
3831
|
+
if (params?.search) {
|
|
3832
|
+
const search = params.search.toLowerCase();
|
|
3833
|
+
result = result.filter((item) => item.key.toLowerCase().includes(search));
|
|
3834
|
+
}
|
|
3835
|
+
if (params?.status) result = result.filter((item) => item.status === params.status);
|
|
3836
|
+
const page = params?.page ?? 1;
|
|
3837
|
+
const perPage = (params?.per_page ?? result.length) || 50;
|
|
3838
|
+
const start = (page - 1) * perPage;
|
|
3839
|
+
const paged = result.slice(start, start + perPage);
|
|
3840
|
+
return {
|
|
3841
|
+
result: paged,
|
|
3842
|
+
result_info: {
|
|
3843
|
+
count: paged.length,
|
|
3844
|
+
page,
|
|
3845
|
+
per_page: perPage,
|
|
3846
|
+
total_count: result.length
|
|
3847
|
+
}
|
|
3848
|
+
};
|
|
3849
|
+
},
|
|
3850
|
+
upload: uploadItem,
|
|
3851
|
+
async uploadAndPoll(name, content, uploadOptions) {
|
|
3852
|
+
return uploadItem(name, content, uploadOptions);
|
|
3853
|
+
},
|
|
3854
|
+
get(itemId) {
|
|
3855
|
+
return createItemHandle(itemId);
|
|
3856
|
+
},
|
|
3857
|
+
async delete(itemId) {
|
|
3858
|
+
items.delete(itemId);
|
|
3859
|
+
}
|
|
3860
|
+
};
|
|
3861
|
+
const createJobHandle = (jobId) => ({
|
|
3862
|
+
async info() {
|
|
3863
|
+
const job = jobs.get(jobId);
|
|
3864
|
+
if (!job) throw new Error(`Mock AI Search job "${jobId}" was not found.`);
|
|
3865
|
+
return cloneInfo(job);
|
|
3866
|
+
},
|
|
3867
|
+
async logs(params) {
|
|
3868
|
+
const perPage = params?.per_page ?? 50;
|
|
3869
|
+
return {
|
|
3870
|
+
result: [{
|
|
3871
|
+
id: 1,
|
|
3872
|
+
message: `Mock job ${jobId}`,
|
|
3873
|
+
message_type: 0,
|
|
3874
|
+
created_at: Date.parse(MOCK_TIMESTAMP)
|
|
3875
|
+
}].slice(0, perPage),
|
|
3876
|
+
result_info: {
|
|
3877
|
+
count: 1,
|
|
3878
|
+
page: params?.page ?? 1,
|
|
3879
|
+
per_page: perPage,
|
|
3880
|
+
total_count: 1
|
|
3881
|
+
}
|
|
3882
|
+
};
|
|
3883
|
+
},
|
|
3884
|
+
async cancel() {
|
|
3885
|
+
const job = jobs.get(jobId);
|
|
3886
|
+
if (!job) throw new Error(`Mock AI Search job "${jobId}" was not found.`);
|
|
3887
|
+
const updated = {
|
|
3888
|
+
...job,
|
|
3889
|
+
ended_at: MOCK_TIMESTAMP,
|
|
3890
|
+
end_reason: "cancelled"
|
|
3891
|
+
};
|
|
3892
|
+
jobs.set(jobId, updated);
|
|
3893
|
+
return cloneInfo(updated);
|
|
3894
|
+
}
|
|
3895
|
+
});
|
|
3896
|
+
const jobsApi = {
|
|
3897
|
+
async list(params) {
|
|
3898
|
+
const allJobs = Array.from(jobs.values()).map((job) => cloneInfo(job));
|
|
3899
|
+
const page = params?.page ?? 1;
|
|
3900
|
+
const perPage = (params?.per_page ?? allJobs.length) || 50;
|
|
3901
|
+
const start = (page - 1) * perPage;
|
|
3902
|
+
const result = allJobs.slice(start, start + perPage);
|
|
3903
|
+
return {
|
|
3904
|
+
result,
|
|
3905
|
+
result_info: {
|
|
3906
|
+
count: result.length,
|
|
3907
|
+
page,
|
|
3908
|
+
per_page: perPage,
|
|
3909
|
+
total_count: allJobs.length
|
|
3910
|
+
}
|
|
3911
|
+
};
|
|
3912
|
+
},
|
|
3913
|
+
async create(params) {
|
|
3914
|
+
const id = `job-${++jobSequence}`;
|
|
3915
|
+
const job = {
|
|
3916
|
+
id,
|
|
3917
|
+
source: "user",
|
|
3918
|
+
description: params?.description,
|
|
3919
|
+
started_at: MOCK_TIMESTAMP
|
|
3920
|
+
};
|
|
3921
|
+
jobs.set(id, job);
|
|
3922
|
+
return cloneInfo(job);
|
|
3923
|
+
},
|
|
3924
|
+
get(jobId) {
|
|
3925
|
+
return createJobHandle(jobId);
|
|
3926
|
+
}
|
|
3927
|
+
};
|
|
3928
|
+
return {
|
|
3929
|
+
async search(params) {
|
|
3930
|
+
return findMatches(params);
|
|
3931
|
+
},
|
|
3932
|
+
async chatCompletions(params) {
|
|
3933
|
+
const search = findMatches({
|
|
3934
|
+
messages: params.messages,
|
|
3935
|
+
ai_search_options: params.ai_search_options
|
|
3936
|
+
});
|
|
3937
|
+
const content = typeof options.chatMessage === "function" ? options.chatMessage(search.chunks, params) : options.chatMessage ?? (search.chunks.map((chunk) => chunk.text).join("\n") || "No matching offline AI Search chunks.");
|
|
3938
|
+
if (params.stream) return streamFromText(`data: ${JSON.stringify({
|
|
3939
|
+
choices: [{ delta: { content } }],
|
|
3940
|
+
chunks: search.chunks
|
|
3941
|
+
})}\n\n`);
|
|
3942
|
+
return {
|
|
3943
|
+
id: "mock-ai-search-chat",
|
|
3944
|
+
object: "chat.completion",
|
|
3945
|
+
model: params.model ?? "mock-ai-search",
|
|
3946
|
+
choices: [{
|
|
3947
|
+
index: 0,
|
|
3948
|
+
message: {
|
|
3949
|
+
role: "assistant",
|
|
3950
|
+
content
|
|
3951
|
+
}
|
|
3952
|
+
}],
|
|
3953
|
+
chunks: search.chunks
|
|
3954
|
+
};
|
|
3955
|
+
},
|
|
3956
|
+
async update(config) {
|
|
3957
|
+
info = {
|
|
3958
|
+
...info,
|
|
3959
|
+
...config,
|
|
3960
|
+
modified_at: MOCK_TIMESTAMP
|
|
3961
|
+
};
|
|
3962
|
+
return cloneInfo(info);
|
|
3963
|
+
},
|
|
3964
|
+
async info() {
|
|
3965
|
+
return cloneInfo(info);
|
|
3966
|
+
},
|
|
3967
|
+
async stats() {
|
|
3968
|
+
const stats = {
|
|
3969
|
+
queued: 0,
|
|
3970
|
+
running: 0,
|
|
3971
|
+
completed: 0,
|
|
3972
|
+
error: 0,
|
|
3973
|
+
skipped: 0,
|
|
3974
|
+
outdated: 0,
|
|
3975
|
+
last_activity: MOCK_TIMESTAMP,
|
|
3976
|
+
engine: {
|
|
3977
|
+
vectorize: {
|
|
3978
|
+
vectorsCount: items.size,
|
|
3979
|
+
dimensions: 0
|
|
3980
|
+
},
|
|
3981
|
+
r2: {
|
|
3982
|
+
payloadSizeBytes: Array.from(items.values()).reduce((sum, item) => sum + item.info.file_size, 0),
|
|
3983
|
+
metadataSizeBytes: 0,
|
|
3984
|
+
objectCount: items.size
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3987
|
+
};
|
|
3988
|
+
for (const item of items.values()) stats[item.info.status] = (stats[item.info.status] ?? 0) + 1;
|
|
3989
|
+
return stats;
|
|
3990
|
+
},
|
|
3991
|
+
get items() {
|
|
3992
|
+
return itemsApi;
|
|
3993
|
+
},
|
|
3994
|
+
get jobs() {
|
|
3995
|
+
return jobsApi;
|
|
3996
|
+
},
|
|
3997
|
+
_getSearches() {
|
|
3998
|
+
return [...searches];
|
|
3999
|
+
},
|
|
4000
|
+
_getItems() {
|
|
4001
|
+
return Array.from(items.values()).map((item) => cloneInfo(item.info));
|
|
4002
|
+
},
|
|
4003
|
+
_getJobs() {
|
|
4004
|
+
return Array.from(jobs.values()).map((job) => cloneInfo(job));
|
|
4005
|
+
}
|
|
4006
|
+
};
|
|
4007
|
+
}
|
|
4008
|
+
/**
|
|
4009
|
+
* Creates a deterministic AI Search namespace binding for pure unit tests.
|
|
4010
|
+
*/
|
|
4011
|
+
function createMockAISearchNamespace(options = {}) {
|
|
4012
|
+
const namespaceName = options.namespace ?? "default";
|
|
4013
|
+
const instances = /* @__PURE__ */ new Map();
|
|
4014
|
+
for (const [name, instanceOptions] of Object.entries(options.instances ?? {})) instances.set(name, isAISearchInstance$1(instanceOptions) ? instanceOptions : createMockAISearchInstance({
|
|
4015
|
+
id: name,
|
|
4016
|
+
namespace: namespaceName,
|
|
4017
|
+
...instanceOptions
|
|
4018
|
+
}));
|
|
4019
|
+
const getInstance = (name) => {
|
|
4020
|
+
const instance = instances.get(name);
|
|
4021
|
+
if (!instance) throw new Error(`Mock AI Search namespace has no instance named "${name}".`);
|
|
4022
|
+
return instance;
|
|
4023
|
+
};
|
|
4024
|
+
return {
|
|
4025
|
+
get(name) {
|
|
4026
|
+
return getInstance(name);
|
|
4027
|
+
},
|
|
4028
|
+
async list(params) {
|
|
4029
|
+
let result = await Promise.all(Array.from(instances.entries()).map(async ([name, instance]) => ({
|
|
4030
|
+
...await instance.info(),
|
|
4031
|
+
namespace: namespaceName,
|
|
4032
|
+
id: name
|
|
4033
|
+
})));
|
|
4034
|
+
if (params?.search) {
|
|
4035
|
+
const search = params.search.toLowerCase();
|
|
4036
|
+
result = result.filter((instance) => instance.id.toLowerCase().includes(search));
|
|
4037
|
+
}
|
|
4038
|
+
const page = params?.page ?? 1;
|
|
4039
|
+
const perPage = (params?.per_page ?? result.length) || 50;
|
|
4040
|
+
const start = (page - 1) * perPage;
|
|
4041
|
+
const paged = result.slice(start, start + perPage);
|
|
4042
|
+
return {
|
|
4043
|
+
result: paged,
|
|
4044
|
+
result_info: {
|
|
4045
|
+
count: paged.length,
|
|
4046
|
+
page,
|
|
4047
|
+
per_page: perPage,
|
|
4048
|
+
total_count: result.length
|
|
4049
|
+
}
|
|
4050
|
+
};
|
|
4051
|
+
},
|
|
4052
|
+
async create(config) {
|
|
4053
|
+
const instance = createMockAISearchInstance({
|
|
4054
|
+
id: config.id,
|
|
4055
|
+
namespace: namespaceName,
|
|
4056
|
+
info: config
|
|
4057
|
+
});
|
|
4058
|
+
instances.set(config.id, instance);
|
|
4059
|
+
return instance;
|
|
4060
|
+
},
|
|
4061
|
+
async delete(name) {
|
|
4062
|
+
instances.delete(name);
|
|
4063
|
+
},
|
|
4064
|
+
async search(params) {
|
|
4065
|
+
const query = extractSearchQuery(params);
|
|
4066
|
+
const chunks = [];
|
|
4067
|
+
const errors = [];
|
|
4068
|
+
for (const instanceId of params.ai_search_options.instance_ids) {
|
|
4069
|
+
const instance = instances.get(instanceId);
|
|
4070
|
+
if (!instance) {
|
|
4071
|
+
errors.push({
|
|
4072
|
+
instance_id: instanceId,
|
|
4073
|
+
message: `Mock AI Search namespace has no instance named "${instanceId}".`
|
|
4074
|
+
});
|
|
4075
|
+
continue;
|
|
4076
|
+
}
|
|
4077
|
+
const response = "query" in params ? await instance.search({
|
|
4078
|
+
query,
|
|
4079
|
+
ai_search_options: params.ai_search_options
|
|
4080
|
+
}) : await instance.search({
|
|
4081
|
+
messages: params.messages,
|
|
4082
|
+
ai_search_options: params.ai_search_options
|
|
4083
|
+
});
|
|
4084
|
+
chunks.push(...response.chunks.map((chunk) => ({
|
|
4085
|
+
...chunk,
|
|
4086
|
+
instance_id: instanceId
|
|
4087
|
+
})));
|
|
4088
|
+
}
|
|
4089
|
+
return {
|
|
4090
|
+
search_query: query,
|
|
4091
|
+
chunks,
|
|
4092
|
+
...errors.length > 0 && { errors }
|
|
4093
|
+
};
|
|
4094
|
+
},
|
|
4095
|
+
async chatCompletions(params) {
|
|
4096
|
+
const search = await this.search({
|
|
4097
|
+
messages: params.messages,
|
|
4098
|
+
ai_search_options: params.ai_search_options
|
|
4099
|
+
});
|
|
4100
|
+
const content = search.chunks.map((chunk) => chunk.text).join("\n") || "No matching offline AI Search chunks.";
|
|
4101
|
+
if (params.stream) return streamFromText(`data: ${JSON.stringify({ chunks: search.chunks })}\n\n`);
|
|
4102
|
+
return {
|
|
4103
|
+
id: "mock-ai-search-namespace-chat",
|
|
4104
|
+
object: "chat.completion",
|
|
4105
|
+
model: params.model ?? "mock-ai-search",
|
|
4106
|
+
choices: [{
|
|
4107
|
+
index: 0,
|
|
4108
|
+
message: {
|
|
4109
|
+
role: "assistant",
|
|
4110
|
+
content
|
|
4111
|
+
}
|
|
4112
|
+
}],
|
|
4113
|
+
chunks: search.chunks,
|
|
4114
|
+
errors: search.errors
|
|
4115
|
+
};
|
|
4116
|
+
},
|
|
4117
|
+
_getInstances() {
|
|
4118
|
+
return Array.from(instances.keys());
|
|
4119
|
+
}
|
|
4120
|
+
};
|
|
4121
|
+
}
|
|
4122
|
+
//#endregion
|
|
4123
|
+
//#region src/test/offline-bindings.ts
|
|
4124
|
+
const SUPPORT_MATRIX = {
|
|
4125
|
+
rateLimits: {
|
|
4126
|
+
service: "rateLimits",
|
|
4127
|
+
tier: "offline-native",
|
|
4128
|
+
reason: "Miniflare and devflare/test can simulate fixed-window RateLimit bindings locally.",
|
|
4129
|
+
recommendation: "Use createOfflineEnv() or createMockRateLimit() for pure tests; use createTestContext() for Miniflare-backed tests."
|
|
4130
|
+
},
|
|
4131
|
+
versionMetadata: {
|
|
4132
|
+
service: "versionMetadata",
|
|
4133
|
+
tier: "offline-native",
|
|
4134
|
+
reason: "Version metadata can be deterministic in tests without Cloudflare state.",
|
|
4135
|
+
recommendation: "Use createOfflineEnv() or createMockVersionMetadata() when asserting version-aware code."
|
|
4136
|
+
},
|
|
4137
|
+
secretsStore: {
|
|
4138
|
+
service: "secretsStore",
|
|
4139
|
+
tier: "offline-native",
|
|
4140
|
+
reason: "Secrets Store binding shape is local-testable with local secret-store values or fixed fixtures.",
|
|
4141
|
+
recommendation: "Use devflare secrets --local for dev/test values, or pass fixtures.secretsStore values for pure tests."
|
|
4142
|
+
},
|
|
4143
|
+
hyperdrive: {
|
|
4144
|
+
service: "hyperdrive",
|
|
4145
|
+
tier: "offline-native",
|
|
4146
|
+
reason: "Hyperdrive can run locally through Miniflare when Devflare has a local connection string or test fixture for the target database.",
|
|
4147
|
+
recommendation: "Use bindings.hyperdrive.*.localConnectionString, CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_<BINDING>, or fixtures.hyperdrive for offline tests."
|
|
4148
|
+
},
|
|
4149
|
+
workerLoaders: {
|
|
4150
|
+
service: "workerLoaders",
|
|
4151
|
+
tier: "offline-native",
|
|
4152
|
+
reason: "Worker Loader bindings run locally through Miniflare and can use explicit Worker stubs for pure tests.",
|
|
4153
|
+
recommendation: "Use createTestContext() for local WorkerLoader execution; pass fixtures.workerLoaders with a WorkerStub for pure tests."
|
|
4154
|
+
},
|
|
4155
|
+
mtlsCertificates: {
|
|
4156
|
+
service: "mtlsCertificates",
|
|
4157
|
+
tier: "offline-fixture",
|
|
4158
|
+
reason: "Fetcher call paths can be tested locally, but real certificate presentation is Cloudflare/Wrangler remote behavior.",
|
|
4159
|
+
recommendation: "Pass fixtures.mtlsCertificates handlers for unit tests; use remote/deployed tests for certificate presentation."
|
|
4160
|
+
},
|
|
4161
|
+
dispatchNamespaces: {
|
|
4162
|
+
service: "dispatchNamespaces",
|
|
4163
|
+
tier: "offline-fixture",
|
|
4164
|
+
reason: "Tenant dispatch can be backed by explicit test fetchers, but namespace uploads and lifecycle are Cloudflare-managed.",
|
|
4165
|
+
recommendation: "Pass fixtures.dispatchNamespaces workers for deterministic tenant routing tests."
|
|
4166
|
+
},
|
|
4167
|
+
workflows: {
|
|
4168
|
+
service: "workflows",
|
|
4169
|
+
tier: "offline-native",
|
|
4170
|
+
reason: "Workflow binding calls can run through Miniflare or a deterministic local Workflow mock for app-level tests.",
|
|
4171
|
+
recommendation: "Use createOfflineEnv() for pure tests and createTestContext() when Miniflare execution semantics matter."
|
|
4172
|
+
},
|
|
4173
|
+
pipelines: {
|
|
4174
|
+
service: "pipelines",
|
|
4175
|
+
tier: "offline-native",
|
|
4176
|
+
reason: "Pipeline sends can be recorded locally; Cloudflare owns production batching and sinks.",
|
|
4177
|
+
recommendation: "Use createOfflineEnv() or createMockPipeline() to assert records sent by application code."
|
|
4178
|
+
},
|
|
4179
|
+
images: {
|
|
4180
|
+
service: "images",
|
|
4181
|
+
tier: "offline-native",
|
|
4182
|
+
reason: "Images has local development support and Devflare provides a low-fidelity deterministic pure mock.",
|
|
4183
|
+
recommendation: "Use createOfflineEnv() for chain-shape tests; use Cloudflare for hosted image APIs and transform fidelity."
|
|
4184
|
+
},
|
|
4185
|
+
media: {
|
|
4186
|
+
service: "media",
|
|
4187
|
+
tier: "offline-native",
|
|
4188
|
+
reason: "Media Transformations can run through Miniflare wiring locally, and Devflare provides a deterministic pure mock for app-level chain tests.",
|
|
4189
|
+
recommendation: "Use createTestContext() for local Worker binding tests and createMockMediaBinding() for pure tests; use Cloudflare for codec/output fidelity."
|
|
4190
|
+
},
|
|
4191
|
+
artifacts: {
|
|
4192
|
+
service: "artifacts",
|
|
4193
|
+
tier: "offline-fixture",
|
|
4194
|
+
reason: "Artifacts repo metadata and token flows can be modeled in memory; real Git remotes are Cloudflare-managed.",
|
|
4195
|
+
recommendation: "Use createMockArtifacts() for unit tests; use Cloudflare for Git protocol and namespace access."
|
|
4196
|
+
},
|
|
4197
|
+
aiSearch: {
|
|
4198
|
+
service: "aiSearch",
|
|
4199
|
+
tier: "offline-fixture",
|
|
4200
|
+
reason: "AI Search application flows can use deterministic in-memory instances and namespaces, but indexing/ranking/crawling are hosted Cloudflare behavior.",
|
|
4201
|
+
recommendation: "Use createMockAISearchInstance(), createMockAISearchNamespace(), or createOfflineEnv(); use remote tests for real relevance behavior."
|
|
4202
|
+
},
|
|
4203
|
+
aiSearchNamespaces: {
|
|
4204
|
+
service: "aiSearchNamespaces",
|
|
4205
|
+
tier: "offline-fixture",
|
|
4206
|
+
reason: "AI Search namespace management can be backed by an explicit in-memory instance registry for tests.",
|
|
4207
|
+
recommendation: "Use fixtures.aiSearchNamespaces to model tenant instances and multi-instance searches."
|
|
4208
|
+
},
|
|
4209
|
+
containers: {
|
|
4210
|
+
service: "containers",
|
|
4211
|
+
tier: "offline-native",
|
|
4212
|
+
reason: "Devflare can launch local Docker/Podman containers in explicit tests when an engine and cached images are available.",
|
|
4213
|
+
recommendation: "Use devflare/test containers helpers and shouldSkip.containers; keep ordinary unit tests engine-free."
|
|
4214
|
+
},
|
|
4215
|
+
browser: {
|
|
4216
|
+
service: "browser",
|
|
4217
|
+
tier: "offline-native",
|
|
4218
|
+
reason: "Cloudflare lists Browser Run as locally simulatable, while live view, HITL, recordings, and external CDP remain hosted features.",
|
|
4219
|
+
recommendation: "Use Cloudflare/Wrangler Browser local development for browser execution and remote/deployed tests for hosted-only features."
|
|
4220
|
+
},
|
|
4221
|
+
ai: {
|
|
4222
|
+
service: "ai",
|
|
4223
|
+
tier: "remote-boundary",
|
|
4224
|
+
reason: "Workers AI inference has no local simulation in Cloudflare local development.",
|
|
4225
|
+
recommendation: "Use DEVFLARE_REMOTE=1/devflare remote enable for real calls, or inject a custom fake for pure tests."
|
|
4226
|
+
},
|
|
4227
|
+
aiGateway: {
|
|
4228
|
+
service: "aiGateway",
|
|
4229
|
+
tier: "remote-boundary",
|
|
4230
|
+
reason: "AI Gateway routing and logs are Cloudflare account resources reached through the Workers AI binding.",
|
|
4231
|
+
recommendation: "Use remote-mode AI Gateway helpers for integration tests and custom fakes for offline unit tests."
|
|
4232
|
+
},
|
|
4233
|
+
vectorize: {
|
|
4234
|
+
service: "vectorize",
|
|
4235
|
+
tier: "remote-boundary",
|
|
4236
|
+
reason: "Cloudflare lists Vectorize with no local simulation.",
|
|
4237
|
+
recommendation: "Use DEVFLARE_REMOTE=1/devflare remote enable for real indexes, or inject a fake Vectorize binding for pure tests."
|
|
4238
|
+
},
|
|
4239
|
+
builds: {
|
|
4240
|
+
service: "builds",
|
|
4241
|
+
tier: "remote-boundary",
|
|
4242
|
+
reason: "Cloudflare Builds/Git-connected Workers are CI/CD orchestration, not a Worker runtime binding.",
|
|
4243
|
+
recommendation: "Run Devflare commands inside your CI; validate Cloudflare build integration with Cloudflare/Wrangler tests."
|
|
4244
|
+
}
|
|
4245
|
+
};
|
|
4246
|
+
function copySupport(entry) {
|
|
4247
|
+
return { ...entry };
|
|
4248
|
+
}
|
|
4249
|
+
function getOfflineSupportMatrix() {
|
|
4250
|
+
return Object.fromEntries(Object.entries(SUPPORT_MATRIX).map(([service, entry]) => [service, copySupport(entry)]));
|
|
4251
|
+
}
|
|
4252
|
+
function describeOfflineSupport(service) {
|
|
4253
|
+
const entry = SUPPORT_MATRIX[service];
|
|
4254
|
+
if (entry) return copySupport(entry);
|
|
4255
|
+
return {
|
|
4256
|
+
service,
|
|
4257
|
+
tier: "remote-boundary",
|
|
4258
|
+
reason: `No offline support classification exists for "${service}".`,
|
|
4259
|
+
recommendation: "Treat this as a remote Cloudflare boundary until Devflare documents a local simulator or fixture."
|
|
4260
|
+
};
|
|
4261
|
+
}
|
|
4262
|
+
function createMissingSecret(binding) {
|
|
4263
|
+
return { async get() {
|
|
4264
|
+
throw new Error(`Offline Secrets Store binding "${binding}" has no value. Pass fixtures.secretsStore.${binding} or write a local secret with devflare secrets --local.`);
|
|
4265
|
+
} };
|
|
4266
|
+
}
|
|
4267
|
+
function isWorkflowBinding(value) {
|
|
4268
|
+
return typeof value.create === "function";
|
|
4269
|
+
}
|
|
4270
|
+
function isPipelineBinding(value) {
|
|
4271
|
+
return typeof value?.send === "function";
|
|
4272
|
+
}
|
|
4273
|
+
function isImagesBinding(value) {
|
|
4274
|
+
return typeof value?.input === "function";
|
|
4275
|
+
}
|
|
4276
|
+
function isHyperdriveBinding(value) {
|
|
4277
|
+
return typeof value?.connectionString === "string";
|
|
4278
|
+
}
|
|
4279
|
+
function isMediaBinding(value) {
|
|
4280
|
+
return typeof value?.input === "function";
|
|
4281
|
+
}
|
|
4282
|
+
function isArtifactsBinding(value) {
|
|
4283
|
+
return typeof value?.create === "function";
|
|
4284
|
+
}
|
|
4285
|
+
function isAISearchInstance(value) {
|
|
4286
|
+
return typeof value?.search === "function";
|
|
4287
|
+
}
|
|
4288
|
+
function isAISearchNamespace(value) {
|
|
4289
|
+
return typeof value?.get === "function";
|
|
4290
|
+
}
|
|
4291
|
+
function addBoundary(remoteBoundaries, service, binding, reason) {
|
|
4292
|
+
remoteBoundaries.push({
|
|
4293
|
+
service,
|
|
4294
|
+
binding,
|
|
4295
|
+
reason,
|
|
4296
|
+
recommendation: describeOfflineSupport(service).recommendation
|
|
4297
|
+
});
|
|
4298
|
+
}
|
|
4299
|
+
function addStaticBindings(env, config) {
|
|
4300
|
+
if (config.vars) Object.assign(env, config.vars);
|
|
4301
|
+
}
|
|
4302
|
+
function addRateLimitBindings(env, bindings) {
|
|
4303
|
+
for (const [name, binding] of Object.entries(bindings?.rateLimits ?? {})) env[name] = createMockRateLimit(binding.simple);
|
|
4304
|
+
}
|
|
4305
|
+
function addVersionMetadataBinding(env, bindings) {
|
|
4306
|
+
if (bindings?.versionMetadata) env[bindings.versionMetadata.binding] = createMockVersionMetadata();
|
|
4307
|
+
}
|
|
4308
|
+
function getHyperdriveConnectionString(name, binding, fixture) {
|
|
4309
|
+
if (typeof fixture === "string") return fixture;
|
|
4310
|
+
const envValue = process.env[`CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_${name}`] ?? process.env[`WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_${name}`];
|
|
4311
|
+
if (envValue?.trim()) return envValue;
|
|
4312
|
+
return normalizeHyperdriveBinding(binding).localConnectionString;
|
|
4313
|
+
}
|
|
4314
|
+
function addHyperdriveBindings(env, bindings, fixtures, missingFixtures) {
|
|
4315
|
+
for (const [name, binding] of Object.entries(bindings?.hyperdrive ?? {})) {
|
|
4316
|
+
const fixture = fixtures.hyperdrive?.[name];
|
|
4317
|
+
if (isHyperdriveBinding(fixture)) {
|
|
4318
|
+
env[name] = fixture;
|
|
4319
|
+
continue;
|
|
4320
|
+
}
|
|
4321
|
+
const connectionString = getHyperdriveConnectionString(name, binding, fixture);
|
|
4322
|
+
if (connectionString) {
|
|
4323
|
+
env[name] = createMockHyperdrive(connectionString);
|
|
4324
|
+
continue;
|
|
4325
|
+
}
|
|
4326
|
+
missingFixtures.push({
|
|
4327
|
+
service: "hyperdrive",
|
|
4328
|
+
binding: name,
|
|
4329
|
+
reason: `Hyperdrive binding "${name}" has no local connection string. Configure bindings.hyperdrive.${name}.localConnectionString or pass fixtures.hyperdrive.${name}.`
|
|
4330
|
+
});
|
|
4331
|
+
}
|
|
4332
|
+
}
|
|
4333
|
+
function addWorkerLoaderBindings(env, bindings, fixtures) {
|
|
4334
|
+
for (const name of Object.keys(bindings?.workerLoaders ?? {})) env[name] = createMockWorkerLoader(fixtures.workerLoaders?.[name]);
|
|
4335
|
+
}
|
|
4336
|
+
function addMTLSCertificateBindings(env, bindings, fixtures) {
|
|
4337
|
+
for (const name of Object.keys(bindings?.mtlsCertificates ?? {})) env[name] = createMockMTLSCertificate(fixtures.mtlsCertificates?.[name]);
|
|
4338
|
+
}
|
|
4339
|
+
function addDispatchNamespaceBindings(env, bindings, fixtures) {
|
|
4340
|
+
for (const name of Object.keys(bindings?.dispatchNamespaces ?? {})) env[name] = createMockDispatchNamespace(fixtures.dispatchNamespaces?.[name]);
|
|
4341
|
+
}
|
|
4342
|
+
function addWorkflowBindings(env, bindings, fixtures) {
|
|
4343
|
+
for (const name of Object.keys(bindings?.workflows ?? {})) {
|
|
4344
|
+
const fixture = fixtures.workflows?.[name];
|
|
4345
|
+
env[name] = fixture && isWorkflowBinding(fixture) ? fixture : createMockWorkflow(fixture);
|
|
4346
|
+
}
|
|
4347
|
+
}
|
|
4348
|
+
function addPipelineBindings(env, bindings, fixtures) {
|
|
4349
|
+
for (const name of Object.keys(bindings?.pipelines ?? {})) {
|
|
4350
|
+
const fixture = fixtures.pipelines?.[name];
|
|
4351
|
+
env[name] = isPipelineBinding(fixture) ? fixture : createMockPipeline();
|
|
4352
|
+
}
|
|
4353
|
+
}
|
|
4354
|
+
function addImagesBindings(env, bindings, fixtures) {
|
|
4355
|
+
for (const name of Object.keys(bindings?.images ?? {})) {
|
|
4356
|
+
const fixture = fixtures.images?.[name];
|
|
4357
|
+
env[name] = isImagesBinding(fixture) ? fixture : createMockImagesBinding(fixture);
|
|
4358
|
+
}
|
|
4359
|
+
}
|
|
4360
|
+
function addMediaBindings(env, bindings, fixtures) {
|
|
4361
|
+
for (const name of Object.keys(bindings?.media ?? {})) {
|
|
4362
|
+
const fixture = fixtures.media?.[name];
|
|
4363
|
+
env[name] = isMediaBinding(fixture) ? fixture : createMockMediaBinding(fixture);
|
|
4364
|
+
}
|
|
4365
|
+
}
|
|
4366
|
+
function addArtifactsBindings(env, bindings, fixtures) {
|
|
4367
|
+
for (const name of Object.keys(bindings?.artifacts ?? {})) {
|
|
4368
|
+
const fixture = fixtures.artifacts?.[name];
|
|
4369
|
+
env[name] = isArtifactsBinding(fixture) ? fixture : createMockArtifacts(fixture);
|
|
4370
|
+
}
|
|
4371
|
+
}
|
|
4372
|
+
function addSecretsStoreBindings(env, bindings, fixtures, localSecretValues, missingFixtures) {
|
|
4373
|
+
for (const name of Object.keys(bindings?.secretsStore ?? {})) {
|
|
4374
|
+
const value = fixtures.secretsStore?.[name] ?? localSecretValues[name];
|
|
4375
|
+
if (value === void 0) {
|
|
4376
|
+
missingFixtures.push({
|
|
4377
|
+
service: "secretsStore",
|
|
4378
|
+
binding: name,
|
|
4379
|
+
reason: `Secrets Store values are not present in fixtures or the local secret store; pass fixtures.secretsStore.${name} or run devflare secrets --local.`
|
|
4380
|
+
});
|
|
4381
|
+
env[name] = createMissingSecret(name);
|
|
4382
|
+
} else env[name] = createMockSecretsStoreSecret(value);
|
|
4383
|
+
}
|
|
4384
|
+
}
|
|
4385
|
+
function addAISearchBindings(env, bindings, fixtures) {
|
|
4386
|
+
for (const [name, binding] of Object.entries(bindings?.aiSearch ?? {})) {
|
|
4387
|
+
const fixture = fixtures.aiSearch?.[name];
|
|
4388
|
+
env[name] = isAISearchInstance(fixture) ? fixture : createMockAISearchInstance({
|
|
4389
|
+
id: binding.instanceName,
|
|
4390
|
+
...fixture
|
|
4391
|
+
});
|
|
4392
|
+
}
|
|
4393
|
+
}
|
|
4394
|
+
function addAISearchNamespaceBindings(env, bindings, fixtures) {
|
|
4395
|
+
for (const [name, binding] of Object.entries(bindings?.aiSearchNamespaces ?? {})) {
|
|
4396
|
+
const fixture = fixtures.aiSearchNamespaces?.[name];
|
|
4397
|
+
env[name] = isAISearchNamespace(fixture) ? fixture : createMockAISearchNamespace({
|
|
4398
|
+
namespace: binding.namespace,
|
|
4399
|
+
...fixture
|
|
4400
|
+
});
|
|
4401
|
+
}
|
|
4402
|
+
}
|
|
4403
|
+
/**
|
|
4404
|
+
* Core storage and wiring bindings (kv/d1/r2/queues/durableObjects/services)
|
|
4405
|
+
* have no pure-offline fixture: they need a real Miniflare runtime, which only
|
|
4406
|
+
* `createTestContext()` provides. `createOfflineBindings()` therefore cannot
|
|
4407
|
+
* populate them, and leaving them silently absent from `env` is a surprise.
|
|
4408
|
+
* Surface each one in `missingFixtures` (with `env` left unset rather than a fake)
|
|
4409
|
+
* so callers see exactly which bindings require `createTestContext()` /
|
|
4410
|
+
* `createMock*()` instead of getting an `undefined` lookup at use time.
|
|
4411
|
+
*/
|
|
4412
|
+
const OFFLINE_UNAVAILABLE_STORAGE_BINDINGS = [
|
|
4413
|
+
"kv",
|
|
4414
|
+
"d1",
|
|
4415
|
+
"r2",
|
|
4416
|
+
"durableObjects",
|
|
4417
|
+
"services"
|
|
4418
|
+
];
|
|
4419
|
+
function addUnsupportedStorageBindings(bindings, missingFixtures) {
|
|
4420
|
+
for (const service of OFFLINE_UNAVAILABLE_STORAGE_BINDINGS) {
|
|
4421
|
+
const group = bindings?.[service];
|
|
4422
|
+
for (const name of Object.keys(group ?? {})) missingFixtures.push({
|
|
4423
|
+
service,
|
|
4424
|
+
binding: name,
|
|
4425
|
+
reason: `${service} binding "${name}" needs a real Miniflare runtime and is not created by createOfflineBindings(); it will be undefined. Use createTestContext() (Miniflare-backed) or a createMock* helper for this binding.`
|
|
4426
|
+
});
|
|
4427
|
+
}
|
|
4428
|
+
const queueProducers = (bindings?.queues)?.producers ?? {};
|
|
4429
|
+
for (const name of Object.keys(queueProducers)) missingFixtures.push({
|
|
4430
|
+
service: "queues",
|
|
4431
|
+
binding: name,
|
|
4432
|
+
reason: `queues producer binding "${name}" needs a real Miniflare runtime and is not created by createOfflineBindings(); it will be undefined. Use createTestContext() or createMockQueue() for this binding.`
|
|
4433
|
+
});
|
|
4434
|
+
}
|
|
4435
|
+
function addRemoteBoundaries(remoteBoundaries, bindings) {
|
|
4436
|
+
if (bindings?.ai) addBoundary(remoteBoundaries, "ai", bindings.ai.binding || "AI", "Workers AI inference is not available in offline local simulations.");
|
|
4437
|
+
for (const name of Object.keys(bindings?.vectorize ?? {})) addBoundary(remoteBoundaries, "vectorize", name, "Vectorize has no offline local simulation in Cloudflare local development.");
|
|
4438
|
+
}
|
|
4439
|
+
/**
|
|
4440
|
+
* Builds a deterministic, pure-test env object from Devflare config.
|
|
4441
|
+
*
|
|
4442
|
+
* Covers the bindings that have a pure-offline simulator or fixture (rate
|
|
4443
|
+
* limits, version metadata, hyperdrive, worker loaders, mTLS, dispatch
|
|
4444
|
+
* namespaces, workflows, pipelines, images, media, artifacts, secrets store,
|
|
4445
|
+
* AI Search). It does **not** create the core storage/wiring bindings
|
|
4446
|
+
* (`kv`, `d1`, `r2`, `queues`, `durableObjects`, `services`) — those require a
|
|
4447
|
+
* real Miniflare runtime, which only `createTestContext()` provides. When such a
|
|
4448
|
+
* binding is present in config it is reported in the returned `missingFixtures`
|
|
4449
|
+
* (its `env` entry is left unset rather than silently faked); use
|
|
4450
|
+
* `createTestContext()` or a `createMock*()` helper for those bindings.
|
|
4451
|
+
*/
|
|
4452
|
+
function createOfflineBindings(config, fixtures = {}, options = {}) {
|
|
4453
|
+
const env = {};
|
|
4454
|
+
const remoteBoundaries = [];
|
|
4455
|
+
const missingFixtures = [];
|
|
4456
|
+
const bindings = config.bindings;
|
|
4457
|
+
const localSecretValues = options.cwd && options.useLocalSecrets !== false ? resolveLocalSecretValuesForBindings(config, options.cwd) : {};
|
|
4458
|
+
addStaticBindings(env, config);
|
|
4459
|
+
addRateLimitBindings(env, bindings);
|
|
4460
|
+
addVersionMetadataBinding(env, bindings);
|
|
4461
|
+
addHyperdriveBindings(env, bindings, fixtures, missingFixtures);
|
|
4462
|
+
addWorkerLoaderBindings(env, bindings, fixtures);
|
|
4463
|
+
addMTLSCertificateBindings(env, bindings, fixtures);
|
|
4464
|
+
addDispatchNamespaceBindings(env, bindings, fixtures);
|
|
4465
|
+
addWorkflowBindings(env, bindings, fixtures);
|
|
4466
|
+
addPipelineBindings(env, bindings, fixtures);
|
|
4467
|
+
addImagesBindings(env, bindings, fixtures);
|
|
4468
|
+
addMediaBindings(env, bindings, fixtures);
|
|
4469
|
+
addArtifactsBindings(env, bindings, fixtures);
|
|
4470
|
+
addSecretsStoreBindings(env, bindings, fixtures, localSecretValues, missingFixtures);
|
|
4471
|
+
addAISearchBindings(env, bindings, fixtures);
|
|
4472
|
+
addAISearchNamespaceBindings(env, bindings, fixtures);
|
|
4473
|
+
addUnsupportedStorageBindings(bindings, missingFixtures);
|
|
4474
|
+
addRemoteBoundaries(remoteBoundaries, bindings);
|
|
4475
|
+
if (fixtures.custom) Object.assign(env, fixtures.custom);
|
|
4476
|
+
return {
|
|
4477
|
+
env,
|
|
4478
|
+
support: getOfflineSupportMatrix(),
|
|
4479
|
+
remoteBoundaries,
|
|
4480
|
+
missingFixtures
|
|
4481
|
+
};
|
|
4482
|
+
}
|
|
4483
|
+
/**
|
|
4484
|
+
* Convenience wrapper for callers that only need the derived env object.
|
|
4485
|
+
*
|
|
4486
|
+
* Note: the core storage/wiring bindings (`kv`, `d1`, `r2`, `queues`,
|
|
4487
|
+
* `durableObjects`, `services`) are **not** created here and will be
|
|
4488
|
+
* `undefined` in the returned env — they need a real Miniflare runtime. Use
|
|
4489
|
+
* `createTestContext()` or a `createMock*()` helper for them, or call
|
|
4490
|
+
* `createOfflineBindings()` to inspect `missingFixtures` for the exact list.
|
|
4491
|
+
*/
|
|
4492
|
+
function createOfflineEnv(config, fixtures = {}, options = {}) {
|
|
4493
|
+
return createOfflineBindings(config, fixtures, options).env;
|
|
4494
|
+
}
|
|
4495
|
+
//#endregion
|
|
4496
|
+
export { cf, clearBundleCache, containers, createContainerManager, createMockAISearchInstance, createMockAISearchNamespace, createMockArtifacts, createMockD1, createMockDispatchNamespace, createMockEnv, createMockHyperdrive, createMockImagesBinding, createMockKV, createMockMTLSCertificate, createMockMediaBinding, createMockPipeline, createMockQueue, createMockR2, createMockRateLimit, createMockSecretsStoreSecret, createMockTestContext, createMockVersionMetadata, createMockWorkerLoader, createMockWorkflow, createOfflineBindings, createOfflineEnv, createTestContext, describeOfflineSupport, detectContainerEngine, email, env, getContainerSkipReason, getOfflineSupportMatrix, hasCrossWorkerDOs, hasServiceBindings, queue, resolveDOBindings, resolveServiceBindings, scheduled, shouldSkip, stopActiveContainers, tail, withTestContext, worker };
|