lopata 0.7.0 → 0.8.2

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.
@@ -0,0 +1,288 @@
1
+ import { randomUUIDv7 } from 'bun'
2
+ import { rmSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import { SqliteCacheStorage } from '../bindings/cache'
5
+ import type { DurableObjectNamespaceImpl } from '../bindings/durable-object'
6
+ import { ForwardableEmailMessage } from '../bindings/email'
7
+ import { createScheduledController } from '../bindings/scheduled'
8
+ import type { SqliteWorkflowBinding } from '../bindings/workflow'
9
+ import { setGlobalEnv } from '../env'
10
+ import { ExecutionContext, runWithExecutionContext } from '../execution-context'
11
+ import { TestClock } from './clock'
12
+ import { TestDurableObjectNamespace } from './durable-object'
13
+ import { buildTestEnv, configToBindings } from './env-builder'
14
+ import { FetchMock, runWithFetchMock } from './fetch-mock'
15
+ import { setupTestEnv, testCachesRef } from './setup'
16
+ import type { TestEnv, TestEnvOptions, WorkerHandlers, WorkerModule } from './types'
17
+ import { TestWorkflowBinding } from './workflow'
18
+
19
+ export { TestClock } from './clock'
20
+ export type { Clock } from './clock'
21
+ export type { TestDurableObjectHandle, TestDurableObjectNamespace, TestDurableObjectStorage, TestWebSocket } from './durable-object'
22
+ export { FetchMock } from './fetch-mock'
23
+ export type { FetchCall } from './fetch-mock'
24
+ export type { BindingSpec, TestEnv, TestEnvOptions, WorkerHandlers, WorkerModule } from './types'
25
+ export type { TestWorkflowBinding, TestWorkflowInstance, TestWorkflowRun } from './workflow'
26
+
27
+ export async function createTestEnv<Env = Record<string, unknown>>(options: TestEnvOptions = {}): Promise<TestEnv<Env>> {
28
+ // Ensure virtual modules + globals are registered (no-op if preload already ran)
29
+ setupTestEnv()
30
+
31
+ // Resolve clock
32
+ let clock: TestClock | null = null
33
+ if (options.clock === true) {
34
+ clock = new TestClock()
35
+ } else if (options.clock instanceof TestClock) {
36
+ clock = options.clock
37
+ }
38
+
39
+ // Create fetch mock (always available, defaults to passthrough)
40
+ const fetchMock = new FetchMock()
41
+
42
+ let mergedBindings = options.bindings
43
+ let mergedVars = options.vars
44
+
45
+ // Load from wrangler config if specified — translate to BindingSpec
46
+ if (options.wrangler) {
47
+ const { loadConfig } = await import('../config')
48
+ const config = await loadConfig(resolve(options.wrangler))
49
+ const { bindings: configBindings, vars: configVars } = configToBindings(config)
50
+ // Merge: explicit options.bindings override wrangler-derived bindings
51
+ mergedBindings = { ...configBindings, ...options.bindings }
52
+ // Merge: explicit options.vars override wrangler vars
53
+ mergedVars = { ...configVars, ...options.vars }
54
+ }
55
+
56
+ const { db, env, registry, tmpDirs } = buildTestEnv(mergedBindings, mergedVars, clock ?? undefined)
57
+
58
+ // Wire in-memory caches for this test env
59
+ testCachesRef.current = new SqliteCacheStorage(db, undefined, clock ?? undefined)
60
+
61
+ // Resolve worker module
62
+ let workerModule: Record<string, unknown>
63
+ let defaultExport: unknown
64
+ let classBasedExport = false
65
+
66
+ if (typeof options.worker === 'string') {
67
+ workerModule = await import(resolve(options.worker))
68
+ defaultExport = workerModule.default
69
+ if (typeof defaultExport === 'function' && defaultExport.prototype) {
70
+ classBasedExport = typeof defaultExport.prototype.fetch === 'function'
71
+ }
72
+ } else if (options.worker && 'default' in options.worker) {
73
+ // WorkerModule — has a `default` export (class or object) + named exports
74
+ const mod = options.worker as WorkerModule
75
+ defaultExport = mod.default
76
+ workerModule = { ...mod }
77
+ if (typeof defaultExport === 'function' && defaultExport.prototype) {
78
+ classBasedExport = typeof defaultExport.prototype.fetch === 'function'
79
+ }
80
+ } else if (options.worker) {
81
+ // Inline handlers object — also expose extra properties (e.g. DO/Workflow classes)
82
+ // as top-level module exports so wireClassRefs can find them
83
+ defaultExport = options.worker
84
+ workerModule = { default: defaultExport, ...options.worker }
85
+ } else {
86
+ defaultExport = {}
87
+ workerModule = { default: defaultExport }
88
+ }
89
+
90
+ // Wire DO/Workflow classes
91
+ for (const entry of registry.durableObjects) {
92
+ const cls = workerModule[entry.className]
93
+ if (!cls) throw new Error(`Durable Object class "${entry.className}" not exported from worker module`)
94
+ entry.namespace._setClass(cls as any, env)
95
+ }
96
+
97
+ for (const entry of registry.workflows) {
98
+ const cls = workerModule[entry.className]
99
+ if (!cls) throw new Error(`Workflow class "${entry.className}" not exported from worker module`)
100
+ entry.binding._setClass(cls as any, env)
101
+ entry.binding.resumeInterrupted()
102
+ }
103
+
104
+ // Wire service bindings — self-referencing
105
+ for (const entry of registry.serviceBindings) {
106
+ const wire = entry.proxy._wire as ((resolver: () => { workerModule: Record<string, unknown>; env: Record<string, unknown> }) => void) | undefined
107
+ if (wire) {
108
+ wire(() => ({ workerModule, env }))
109
+ }
110
+ }
111
+
112
+ // Set globalEnv so `import { env } from 'cloudflare:workers'` works
113
+ setGlobalEnv(env)
114
+
115
+ // --- Handler dispatch helpers ---
116
+
117
+ function getHandler(name: string): ((...args: unknown[]) => Promise<unknown>) | undefined {
118
+ if (classBasedExport) {
119
+ if (typeof (defaultExport as any).prototype[name] === 'function') {
120
+ return (...args: unknown[]) => {
121
+ const ctx = new ExecutionContext()
122
+ const instance = new (defaultExport as new(ctx: ExecutionContext, env: unknown) => Record<string, unknown>)(ctx, env)
123
+ return (instance[name] as (...a: unknown[]) => Promise<unknown>)(...args)
124
+ }
125
+ }
126
+ return undefined
127
+ }
128
+ const method = (defaultExport as Record<string, unknown>)?.[name]
129
+ return typeof method === 'function' ? method.bind(defaultExport) : undefined
130
+ }
131
+
132
+ async function fetchHandler(input: string | Request, init?: RequestInit): Promise<Response> {
133
+ let request: Request
134
+ if (typeof input === 'string') {
135
+ const url = input.startsWith('/') ? `http://localhost${input}` : input
136
+ request = new Request(url, init)
137
+ } else {
138
+ request = init ? new Request(input, init) : input
139
+ }
140
+
141
+ const ctx = new ExecutionContext()
142
+ return runWithExecutionContext(ctx, () =>
143
+ runWithFetchMock(fetchMock, async () => {
144
+ let response: Response
145
+ if (classBasedExport) {
146
+ const instance = new (defaultExport as new(ctx: ExecutionContext, env: unknown) => Record<string, unknown>)(ctx, env)
147
+ response = await (instance.fetch as (r: Request) => Promise<Response>)(request)
148
+ } else {
149
+ const handler = (defaultExport as Record<string, unknown>)?.fetch
150
+ if (typeof handler !== 'function') {
151
+ throw new Error('No fetch handler found')
152
+ }
153
+ response = await (handler as (r: Request, e: unknown, c: ExecutionContext) => Promise<Response>)(request, env, ctx)
154
+ }
155
+ await ctx._awaitAll()
156
+ return response
157
+ }))
158
+ }
159
+
160
+ async function queueHandler(queueName: string, messages: { body: unknown; contentType?: string }[]): Promise<void> {
161
+ const handler = getHandler('queue')
162
+ if (!handler) throw new Error('No queue handler found')
163
+
164
+ const builtMessages = messages.map((msg, i) => ({
165
+ id: randomUUIDv7(),
166
+ timestamp: new Date(),
167
+ body: msg.body,
168
+ attempts: 1,
169
+ ack() {},
170
+ retry(_options?: { delaySeconds?: number }) {},
171
+ }))
172
+
173
+ const batch = {
174
+ queue: queueName,
175
+ messages: builtMessages,
176
+ ackAll() {},
177
+ retryAll(_options?: { delaySeconds?: number }) {},
178
+ }
179
+
180
+ const ctx = new ExecutionContext()
181
+ await runWithExecutionContext(ctx, () =>
182
+ runWithFetchMock(fetchMock, async () => {
183
+ await handler(batch, env, ctx)
184
+ await ctx._awaitAll()
185
+ }))
186
+ }
187
+
188
+ async function scheduledHandler(opts?: { cron?: string; scheduledTime?: number }): Promise<void> {
189
+ const handler = getHandler('scheduled')
190
+ if (!handler) throw new Error('No scheduled handler found')
191
+
192
+ const controller = createScheduledController(opts?.cron ?? '* * * * *', opts?.scheduledTime ?? Date.now())
193
+ const ctx = new ExecutionContext()
194
+ await runWithExecutionContext(ctx, () =>
195
+ runWithFetchMock(fetchMock, async () => {
196
+ await handler(controller, env, ctx)
197
+ await ctx._awaitAll()
198
+ }))
199
+ }
200
+
201
+ async function emailHandler(opts: { from: string; to: string; raw: Uint8Array | string }): Promise<void> {
202
+ const handler = getHandler('email')
203
+ if (!handler) throw new Error('No email handler found')
204
+
205
+ const rawBytes = typeof opts.raw === 'string' ? new TextEncoder().encode(opts.raw) : opts.raw
206
+ const messageId = randomUUIDv7()
207
+ db.run(
208
+ "INSERT INTO email_messages (id, binding, from_addr, to_addr, raw, raw_size, status, created_at) VALUES (?, ?, ?, ?, ?, ?, 'received', ?)",
209
+ [messageId, '_incoming', opts.from, opts.to, rawBytes, rawBytes.byteLength, Date.now()],
210
+ )
211
+
212
+ const message = new ForwardableEmailMessage(db, messageId, opts.from, opts.to, rawBytes)
213
+ const ctx = new ExecutionContext()
214
+ await runWithExecutionContext(ctx, () =>
215
+ runWithFetchMock(fetchMock, async () => {
216
+ await handler(message, env, ctx)
217
+ await ctx._awaitAll()
218
+ }))
219
+ }
220
+
221
+ // --- Test helper factories ---
222
+
223
+ const testWorkflows: TestWorkflowBinding[] = []
224
+ const testDOs: TestDurableObjectNamespace[] = []
225
+
226
+ function workflowHelper(bindingName: string): TestWorkflowBinding {
227
+ const entry = registry.workflows.find(e => e.bindingName === bindingName)
228
+ if (!entry) throw new Error(`Workflow binding "${bindingName}" not found. Available: ${registry.workflows.map(e => e.bindingName).join(', ')}`)
229
+ const tw = new TestWorkflowBinding(entry.binding as SqliteWorkflowBinding, db)
230
+ testWorkflows.push(tw)
231
+ return tw
232
+ }
233
+
234
+ function durableObjectHelper(bindingName: string): TestDurableObjectNamespace {
235
+ const entry = registry.durableObjects.find(e => e.bindingName === bindingName)
236
+ if (!entry) {
237
+ throw new Error(`Durable Object binding "${bindingName}" not found. Available: ${registry.durableObjects.map(e => e.bindingName).join(', ')}`)
238
+ }
239
+ const td = new TestDurableObjectNamespace(entry.namespace as DurableObjectNamespaceImpl)
240
+ testDOs.push(td)
241
+ return td
242
+ }
243
+
244
+ async function advanceTime(ms: number): Promise<void> {
245
+ if (!clock) throw new Error('advanceTime requires clock: true in createTestEnv options')
246
+ clock.advance(ms)
247
+ // Fire ready DO alarms
248
+ for (const entry of registry.durableObjects) {
249
+ await Promise.all(entry.namespace._fireReadyAlarms())
250
+ }
251
+ }
252
+
253
+ function dispose(): void {
254
+ for (const tw of testWorkflows) tw.dispose()
255
+ for (const td of testDOs) td.dispose()
256
+ for (const entry of registry.durableObjects) {
257
+ entry.namespace.destroy()
258
+ }
259
+ for (const entry of registry.workflows) {
260
+ entry.binding.abortRunning()
261
+ }
262
+ db.close()
263
+ for (const dir of tmpDirs) {
264
+ try {
265
+ rmSync(dir, { recursive: true, force: true })
266
+ } catch {}
267
+ }
268
+ // Clean up global state
269
+ setGlobalEnv({})
270
+ testCachesRef.current = null
271
+ fetchMock.reset()
272
+ }
273
+
274
+ return {
275
+ env: env as Env,
276
+ db,
277
+ fetch: fetchHandler,
278
+ queue: queueHandler,
279
+ scheduled: scheduledHandler,
280
+ email: emailHandler,
281
+ workflow: workflowHelper as TestEnv<Env>['workflow'],
282
+ durableObject: durableObjectHelper as TestEnv<Env>['durableObject'],
283
+ clock,
284
+ fetchMock,
285
+ advanceTime,
286
+ dispose,
287
+ }
288
+ }
@@ -0,0 +1,68 @@
1
+ import { plugin } from 'bun'
2
+ import type { SqliteCacheStorage } from '../bindings/cache'
3
+ import { setupCloudflareGlobals } from '../setup-globals'
4
+ import { registerVirtualModules } from '../virtual-modules'
5
+ import { getActiveFetchMock } from './fetch-mock'
6
+
7
+ /**
8
+ * Mutable ref for per-test caches instance.
9
+ * Set by `createTestEnv()`, cleared by `dispose()`.
10
+ * The `caches` global getter reads from this ref.
11
+ */
12
+ export const testCachesRef: { current: SqliteCacheStorage | null } = { current: null }
13
+
14
+ let initialized = false
15
+
16
+ /**
17
+ * Sets up the test environment:
18
+ * - Registers cloudflare:* virtual modules via Bun.plugin
19
+ * - Sets up global Cloudflare APIs (HTMLRewriter, WebSocketPair, crypto, etc.)
20
+ * - Overrides `caches` global to use in-memory storage from testCachesRef
21
+ * - Overrides `globalThis.fetch` to support ALS-scoped fetch mocking
22
+ *
23
+ * Idempotent — safe to call multiple times.
24
+ */
25
+ export function setupTestEnv() {
26
+ if (initialized) return
27
+ initialized = true
28
+
29
+ setupCloudflareGlobals()
30
+
31
+ // Override caches to use the per-test in-memory ref instead of filesystem
32
+ Object.defineProperty(globalThis, 'caches', {
33
+ get: () => {
34
+ if (!testCachesRef.current) {
35
+ throw new Error('caches is not available — call createTestEnv() first')
36
+ }
37
+ return testCachesRef.current
38
+ },
39
+ configurable: true,
40
+ })
41
+
42
+ // Override globalThis.fetch to support ALS-scoped fetch mocking
43
+ const _originalFetch = globalThis.fetch
44
+ const mockedFetch = async (input: string | Request | URL, init?: RequestInit) => {
45
+ const mock = getActiveFetchMock()
46
+ if (!mock) return _originalFetch(input, init)
47
+
48
+ const request = new Request(input as any, init)
49
+ const result = await mock._handle(request)
50
+ if (result) return result.response
51
+
52
+ // Passthrough — call original fetch and record it
53
+ const response = await _originalFetch(input, init)
54
+ mock._recordPassthrough(new Request(input as any, init), response)
55
+ return response
56
+ }
57
+ globalThis.fetch = mockedFetch as typeof globalThis.fetch
58
+
59
+ plugin({
60
+ name: 'cloudflare-workers-test-shim',
61
+ setup(build) {
62
+ registerVirtualModules(build)
63
+ },
64
+ })
65
+ }
66
+
67
+ // Auto-run on import (preload behavior)
68
+ setupTestEnv()
@@ -0,0 +1,68 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import type { TestClock } from './clock'
3
+ import type { TestDurableObjectNamespace } from './durable-object'
4
+ import type { FetchMock } from './fetch-mock'
5
+ import type { TestWorkflowBinding } from './workflow'
6
+
7
+ export interface WorkerHandlers {
8
+ fetch?(request: Request, env: Record<string, unknown>, ctx: unknown): Promise<Response> | Response
9
+ queue?(batch: unknown, env: Record<string, unknown>, ctx: unknown): Promise<void> | void
10
+ scheduled?(controller: unknown, env: Record<string, unknown>, ctx: unknown): Promise<void> | void
11
+ email?(message: unknown, env: Record<string, unknown>, ctx: unknown): Promise<void> | void
12
+ [key: string]: unknown
13
+ }
14
+
15
+ /** Worker module with a default export (class or object) plus named exports (DO/Workflow classes) */
16
+ export interface WorkerModule {
17
+ default: (new(ctx: unknown, env: unknown) => Record<string, unknown>) | WorkerHandlers
18
+ [key: string]: unknown
19
+ }
20
+
21
+ export interface TestEnvOptions {
22
+ /** Worker: file path (string), inline handlers object, or module with default + named exports */
23
+ worker?: string | WorkerHandlers | WorkerModule
24
+ /** Binding declarations — keys become binding names in env */
25
+ bindings?: Record<string, BindingSpec>
26
+ /** Path to wrangler.toml/.json/.jsonc to load bindings from */
27
+ wrangler?: string
28
+ /** Plain string variables to add to env */
29
+ vars?: Record<string, string>
30
+ /** Enable test clock for time control. Pass true to create a new TestClock, or pass a TestClock instance. */
31
+ clock?: boolean | TestClock
32
+ }
33
+
34
+ export type BindingSpec =
35
+ | 'kv'
36
+ | 'r2'
37
+ | 'd1'
38
+ | 'queue'
39
+ | { type: 'durable-object'; className: string }
40
+ | { type: 'workflow'; className: string }
41
+ | { type: 'service'; service: string; entrypoint?: string }
42
+
43
+ export interface TestEnv<Env = Record<string, unknown>> {
44
+ /** The built env object with all bindings */
45
+ env: Env
46
+ /** The shared in-memory database used by bindings */
47
+ db: Database
48
+ /** Dispatch a fetch request to the worker */
49
+ fetch(input: string | Request, init?: RequestInit): Promise<Response>
50
+ /** Dispatch a queue batch to the worker's queue handler */
51
+ queue(queueName: string, messages: { body: unknown; contentType?: string }[]): Promise<void>
52
+ /** Dispatch a scheduled event to the worker */
53
+ scheduled(options?: { cron?: string; scheduledTime?: number }): Promise<void>
54
+ /** Dispatch an email event to the worker */
55
+ email(options: { from: string; to: string; raw: Uint8Array | string }): Promise<void>
56
+ /** Get a test-friendly workflow binding wrapper */
57
+ workflow(bindingName: string & keyof Env): TestWorkflowBinding
58
+ /** Get a test-friendly durable object namespace wrapper */
59
+ durableObject(bindingName: string & keyof Env): TestDurableObjectNamespace
60
+ /** Test clock for time control (null if not enabled) */
61
+ clock: TestClock | null
62
+ /** Fetch mock for intercepting outgoing HTTP requests */
63
+ fetchMock: FetchMock
64
+ /** Advance time and fire ready DO alarms */
65
+ advanceTime(ms: number): Promise<void>
66
+ /** Cleanup: close DB, remove temp dirs, destroy DO namespaces */
67
+ dispose(): void
68
+ }