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,325 @@
1
+ import type { InProcessExecutor } from '../bindings/do-executor-inprocess'
2
+ import type { DurableObjectNamespaceImpl, SqliteDurableObjectStorage } from '../bindings/durable-object'
3
+ import type { SqlStorage } from '../bindings/durable-object'
4
+ import type { CFWebSocket } from '../bindings/websocket-pair'
5
+
6
+ export class TestDurableObjectStorage {
7
+ private storage: SqliteDurableObjectStorage
8
+
9
+ constructor(storage: SqliteDurableObjectStorage) {
10
+ this.storage = storage
11
+ }
12
+
13
+ async get<T = unknown>(key: string): Promise<T | undefined>
14
+ async get<T = unknown>(keys: string[]): Promise<Map<string, T>>
15
+ async get<T = unknown>(keyOrKeys: string | string[]): Promise<T | undefined | Map<string, T>> {
16
+ return this.storage.get(keyOrKeys as any)
17
+ }
18
+
19
+ async list(
20
+ options?: { prefix?: string; start?: string; startAfter?: string; end?: string; limit?: number; reverse?: boolean },
21
+ ): Promise<Map<string, unknown>> {
22
+ return this.storage.list(options)
23
+ }
24
+
25
+ async put(key: string, value: unknown): Promise<void>
26
+ async put(entries: Record<string, unknown>): Promise<void>
27
+ async put(keyOrEntries: string | Record<string, unknown>, value?: unknown): Promise<void> {
28
+ if (typeof keyOrEntries === 'string') {
29
+ return this.storage.put(keyOrEntries, value)
30
+ }
31
+ return this.storage.put(keyOrEntries)
32
+ }
33
+
34
+ async delete(key: string): Promise<boolean>
35
+ async delete(keys: string[]): Promise<number>
36
+ async delete(keyOrKeys: string | string[]): Promise<boolean | number> {
37
+ return this.storage.delete(keyOrKeys as any)
38
+ }
39
+
40
+ async deleteAll(): Promise<void> {
41
+ return this.storage.deleteAll()
42
+ }
43
+
44
+ async getAlarm(): Promise<number | null> {
45
+ return this.storage.getAlarm()
46
+ }
47
+
48
+ async setAlarm(scheduledTime: number | Date): Promise<void> {
49
+ return this.storage.setAlarm(scheduledTime)
50
+ }
51
+
52
+ async deleteAlarm(): Promise<void> {
53
+ return this.storage.deleteAlarm()
54
+ }
55
+ }
56
+
57
+ // --- TestWebSocket ---
58
+
59
+ export class TestWebSocket {
60
+ readonly raw: CFWebSocket
61
+ private _messages: (string | ArrayBuffer)[] = []
62
+ private _messageWaiters: { resolve: (msg: string | ArrayBuffer) => void; reject: (err: Error) => void }[] = []
63
+ private _closeWaiters: { resolve: (ev: { code: number; reason: string }) => void; reject: (err: Error) => void }[] = []
64
+ private _closed = false
65
+ private _closeData: { code: number; reason: string } | null = null
66
+
67
+ constructor(ws: CFWebSocket) {
68
+ this.raw = ws
69
+
70
+ ws.addEventListener('message', (event: Event) => {
71
+ const data = (event as MessageEvent).data as string | ArrayBuffer
72
+ const waiter = this._messageWaiters.shift()
73
+ if (waiter) {
74
+ waiter.resolve(data)
75
+ } else {
76
+ this._messages.push(data)
77
+ }
78
+ })
79
+
80
+ ws.addEventListener('close', (event: Event) => {
81
+ this._closed = true
82
+ const ce = event as CloseEvent
83
+ const closeData = { code: ce.code, reason: ce.reason }
84
+ this._closeData = closeData
85
+ // Reject all pending message waiters
86
+ for (const w of this._messageWaiters) {
87
+ w.reject(new Error('WebSocket closed'))
88
+ }
89
+ this._messageWaiters = []
90
+ // Resolve close waiters
91
+ for (const w of this._closeWaiters) {
92
+ w.resolve(closeData)
93
+ }
94
+ this._closeWaiters = []
95
+ })
96
+ }
97
+
98
+ get readyState(): number {
99
+ return this.raw.readyState
100
+ }
101
+
102
+ /** All received messages so far. */
103
+ get messages(): ReadonlyArray<string | ArrayBuffer> {
104
+ return this._messages
105
+ }
106
+
107
+ /** Send data to the DO via the WebSocket. */
108
+ send(data: string | ArrayBuffer | ArrayBufferView): void {
109
+ this.raw.send(data)
110
+ }
111
+
112
+ /** Wait for the next message from the DO. */
113
+ waitForMessage(timeout = 5000): Promise<string | ArrayBuffer> {
114
+ // Check queue first
115
+ const queued = this._messages.shift()
116
+ if (queued !== undefined) return Promise.resolve(queued)
117
+
118
+ if (this._closed) return Promise.reject(new Error('WebSocket closed'))
119
+
120
+ return new Promise<string | ArrayBuffer>((resolve, reject) => {
121
+ const timer = setTimeout(() => {
122
+ const idx = this._messageWaiters.findIndex(w => w.resolve === resolve)
123
+ if (idx !== -1) this._messageWaiters.splice(idx, 1)
124
+ reject(new Error(`waitForMessage timed out after ${timeout}ms`))
125
+ }, timeout)
126
+
127
+ this._messageWaiters.push({
128
+ resolve: (msg) => {
129
+ clearTimeout(timer)
130
+ resolve(msg)
131
+ },
132
+ reject: (err) => {
133
+ clearTimeout(timer)
134
+ reject(err)
135
+ },
136
+ })
137
+ })
138
+ }
139
+
140
+ /** Wait for the WebSocket to close. */
141
+ waitForClose(timeout = 5000): Promise<{ code: number; reason: string }> {
142
+ if (this._closed) {
143
+ return Promise.resolve(this._closeData ?? { code: 1000, reason: '' })
144
+ }
145
+
146
+ return new Promise<{ code: number; reason: string }>((resolve, reject) => {
147
+ const timer = setTimeout(() => {
148
+ const idx = this._closeWaiters.findIndex(w => w.resolve === resolve)
149
+ if (idx !== -1) this._closeWaiters.splice(idx, 1)
150
+ reject(new Error(`waitForClose timed out after ${timeout}ms`))
151
+ }, timeout)
152
+
153
+ this._closeWaiters.push({
154
+ resolve: (ev) => {
155
+ clearTimeout(timer)
156
+ resolve(ev)
157
+ },
158
+ reject: (err) => {
159
+ clearTimeout(timer)
160
+ reject(err)
161
+ },
162
+ })
163
+ })
164
+ }
165
+
166
+ /** Close the WebSocket from the client side. */
167
+ close(code?: number, reason?: string): void {
168
+ this.raw.close(code, reason)
169
+ }
170
+
171
+ /** Read the server-side WebSocket's serialized attachment. */
172
+ deserializeAttachment(): unknown {
173
+ const peer = this.raw._peer
174
+ if (!peer) return null
175
+ return peer.deserializeAttachment()
176
+ }
177
+
178
+ /** Write to the server-side WebSocket's serialized attachment. */
179
+ serializeAttachment(value: unknown): void {
180
+ const peer = this.raw._peer
181
+ if (!peer) throw new Error('No peer WebSocket')
182
+ peer.serializeAttachment(value)
183
+ }
184
+ }
185
+
186
+ // --- TestDurableObjectHandle ---
187
+
188
+ export class TestDurableObjectHandle {
189
+ private namespace: DurableObjectNamespaceImpl
190
+ private idStr: string
191
+ private _stub: unknown | null = null
192
+
193
+ constructor(namespace: DurableObjectNamespaceImpl, idStr: string) {
194
+ this.namespace = namespace
195
+ this.idStr = idStr
196
+ }
197
+
198
+ /** The DO instance ID. */
199
+ get id(): string {
200
+ return this.idStr
201
+ }
202
+
203
+ /** RPC proxy stub. Forces executor creation on first access. */
204
+ get stub(): any {
205
+ if (!this._stub) {
206
+ const doId = this.namespace.idFromString(this.idStr)
207
+ this._stub = this.namespace.get(doId)
208
+ }
209
+ return this._stub
210
+ }
211
+
212
+ private getExecutor(): InProcessExecutor {
213
+ // Ensure executor exists by accessing the stub
214
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
215
+ this.stub
216
+ const executor = this.namespace._getExecutor(this.idStr)
217
+ if (!executor) throw new Error(`Durable Object executor not found for ${this.idStr}`)
218
+ return executor as InProcessExecutor
219
+ }
220
+
221
+ /** Access KV-style storage for this DO instance. */
222
+ get storage(): TestDurableObjectStorage {
223
+ const executor = this.getExecutor()
224
+ return new TestDurableObjectStorage(executor._rawState.storage)
225
+ }
226
+
227
+ /** Access SQL storage for this DO instance. */
228
+ get sql(): SqlStorage {
229
+ const executor = this.getExecutor()
230
+ return executor._rawState.storage.sql
231
+ }
232
+
233
+ /** Get the scheduled alarm time, or null. */
234
+ async getAlarm(): Promise<number | null> {
235
+ const executor = this.getExecutor()
236
+ return executor._rawState.storage.getAlarm()
237
+ }
238
+
239
+ /** Trigger the alarm handler immediately. */
240
+ async triggerAlarm(): Promise<void> {
241
+ return this.namespace.triggerAlarm(this.idStr)
242
+ }
243
+
244
+ /** Cancel a scheduled alarm without firing it. */
245
+ async cancelAlarm(): Promise<void> {
246
+ this.namespace.cancelAlarm(this.idStr)
247
+ }
248
+
249
+ /** Delete this DO instance and all its data. */
250
+ async delete(): Promise<void> {
251
+ this.namespace.deleteInstance(this.idStr)
252
+ this._stub = null
253
+ }
254
+
255
+ /** Connect a WebSocket to this DO, simulating the upgrade handshake. */
256
+ async connectWebSocket(options?: {
257
+ path?: string
258
+ headers?: Record<string, string>
259
+ }): Promise<TestWebSocket> {
260
+ const executor = this.getExecutor()
261
+ const path = options?.path ?? '/'
262
+ const url = `http://localhost${path}`
263
+ const headers = new Headers(options?.headers)
264
+ headers.set('upgrade', 'websocket')
265
+
266
+ const request = new Request(url, { headers })
267
+ const response = await executor.executeFetch(request)
268
+
269
+ // Extract the server-side WebSocket from the response
270
+ const serverWs = (response as any).webSocket as CFWebSocket | undefined
271
+ if (!serverWs) {
272
+ throw new Error('DO fetch handler did not return a WebSocket upgrade response (no response.webSocket)')
273
+ }
274
+
275
+ // Accept the client side (simulates what Bun.serve does)
276
+ serverWs.accept()
277
+
278
+ return new TestWebSocket(serverWs)
279
+ }
280
+
281
+ /** Get all accepted WebSockets for this DO instance (via DurableObjectState). */
282
+ getWebSockets(tag?: string): WebSocket[] {
283
+ const executor = this.getExecutor()
284
+ return executor._rawState.getWebSockets(tag)
285
+ }
286
+
287
+ /** Get tags for a specific WebSocket. */
288
+ getTags(ws: WebSocket): string[] {
289
+ const executor = this.getExecutor()
290
+ return executor._rawState.getTags(ws)
291
+ }
292
+ }
293
+
294
+ export class TestDurableObjectNamespace {
295
+ private namespace: DurableObjectNamespaceImpl
296
+ private handles: TestDurableObjectHandle[] = []
297
+
298
+ constructor(namespace: DurableObjectNamespaceImpl) {
299
+ this.namespace = namespace
300
+ }
301
+
302
+ /** Get a DO handle by name (uses idFromName). */
303
+ get(name: string): TestDurableObjectHandle {
304
+ const doId = this.namespace.idFromName(name)
305
+ const handle = new TestDurableObjectHandle(this.namespace, doId.toString())
306
+ this.handles.push(handle)
307
+ return handle
308
+ }
309
+
310
+ /** Get a DO handle by raw ID string. */
311
+ getById(idStr: string): TestDurableObjectHandle {
312
+ const handle = new TestDurableObjectHandle(this.namespace, idStr)
313
+ this.handles.push(handle)
314
+ return handle
315
+ }
316
+
317
+ /** List all instance IDs in this namespace. */
318
+ listIds(): string[] {
319
+ return this.namespace._listInstanceIds()
320
+ }
321
+
322
+ dispose(): void {
323
+ this.handles = []
324
+ }
325
+ }
@@ -0,0 +1,126 @@
1
+ import { Database } from 'bun:sqlite'
2
+ import { mkdtempSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { LocalD1Database } from '../bindings/d1'
6
+ import type { DurableObjectNamespaceImpl } from '../bindings/durable-object'
7
+ import { SqliteKVNamespace } from '../bindings/kv'
8
+ import { SqliteQueueProducer } from '../bindings/queue'
9
+ import { FileR2Bucket } from '../bindings/r2'
10
+ import { createServiceBinding } from '../bindings/service-binding'
11
+ import type { SqliteWorkflowBinding } from '../bindings/workflow'
12
+ import type { WranglerConfig } from '../config'
13
+ import { runMigrations } from '../db'
14
+ import type { Clock } from './clock'
15
+ import type { BindingSpec } from './types'
16
+
17
+ interface ServiceBindingEntry {
18
+ bindingName: string
19
+ serviceName: string
20
+ entrypoint?: string
21
+ proxy: Record<string, unknown>
22
+ }
23
+
24
+ export interface TestClassRegistry {
25
+ durableObjects: { bindingName: string; className: string; namespace: DurableObjectNamespaceImpl }[]
26
+ workflows: { bindingName: string; className: string; binding: SqliteWorkflowBinding }[]
27
+ serviceBindings: ServiceBindingEntry[]
28
+ }
29
+
30
+ export interface BuiltTestEnv {
31
+ db: Database
32
+ env: Record<string, unknown>
33
+ registry: TestClassRegistry
34
+ tmpDirs: string[]
35
+ }
36
+
37
+ export function buildTestEnv(
38
+ bindings: Record<string, BindingSpec> | undefined,
39
+ vars: Record<string, string> | undefined,
40
+ clock?: Clock,
41
+ ): BuiltTestEnv {
42
+ const db = new Database(':memory:')
43
+ runMigrations(db)
44
+
45
+ const env: Record<string, unknown> = {}
46
+ const registry: TestClassRegistry = { durableObjects: [], workflows: [], serviceBindings: [] }
47
+ const tmpDirs: string[] = []
48
+
49
+ if (vars) {
50
+ for (const [key, value] of Object.entries(vars)) {
51
+ env[key] = value
52
+ }
53
+ }
54
+
55
+ if (!bindings) {
56
+ return { db, env, registry, tmpDirs }
57
+ }
58
+
59
+ for (const [bindingName, spec] of Object.entries(bindings)) {
60
+ if (spec === 'kv') {
61
+ env[bindingName] = new SqliteKVNamespace(db, bindingName, undefined, clock)
62
+ } else if (spec === 'r2') {
63
+ const tmpDir = mkdtempSync(join(tmpdir(), 'lopata-test-r2-'))
64
+ tmpDirs.push(tmpDir)
65
+ env[bindingName] = new FileR2Bucket(db, bindingName, tmpDir)
66
+ } else if (spec === 'd1') {
67
+ env[bindingName] = new LocalD1Database(new Database(':memory:'))
68
+ } else if (spec === 'queue') {
69
+ env[bindingName] = new SqliteQueueProducer(db, bindingName, 0, undefined, clock)
70
+ } else if (typeof spec === 'object') {
71
+ if (spec.type === 'durable-object') {
72
+ // Lazy import to avoid pulling in the whole DO module at parse time
73
+ const { DurableObjectNamespaceImpl } = require('../bindings/durable-object')
74
+ const namespace = new DurableObjectNamespaceImpl(db, spec.className, undefined, { evictionTimeoutMs: 0 }, undefined, clock)
75
+ env[bindingName] = namespace
76
+ registry.durableObjects.push({ bindingName, className: spec.className, namespace })
77
+ } else if (spec.type === 'workflow') {
78
+ const { SqliteWorkflowBinding } = require('../bindings/workflow')
79
+ const binding = new SqliteWorkflowBinding(db, bindingName, spec.className, undefined, clock)
80
+ env[bindingName] = binding
81
+ registry.workflows.push({ bindingName, className: spec.className, binding })
82
+ } else if (spec.type === 'service') {
83
+ const proxy = createServiceBinding(spec.service, spec.entrypoint)
84
+ env[bindingName] = proxy
85
+ registry.serviceBindings.push({
86
+ bindingName,
87
+ serviceName: spec.service,
88
+ entrypoint: spec.entrypoint,
89
+ proxy,
90
+ })
91
+ }
92
+ }
93
+ }
94
+
95
+ return { db, env, registry, tmpDirs }
96
+ }
97
+
98
+ /** Translate a WranglerConfig into a flat BindingSpec map + vars */
99
+ export function configToBindings(config: WranglerConfig): { bindings: Record<string, BindingSpec>; vars: Record<string, string> } {
100
+ const bindings: Record<string, BindingSpec> = {}
101
+ const vars: Record<string, string> = { ...config.vars }
102
+
103
+ for (const kv of config.kv_namespaces ?? []) {
104
+ bindings[kv.binding] = 'kv'
105
+ }
106
+ for (const r2 of config.r2_buckets ?? []) {
107
+ bindings[r2.binding] = 'r2'
108
+ }
109
+ for (const d1 of config.d1_databases ?? []) {
110
+ bindings[d1.binding] = 'd1'
111
+ }
112
+ for (const producer of config.queues?.producers ?? []) {
113
+ bindings[producer.binding] = 'queue'
114
+ }
115
+ for (const doBinding of config.durable_objects?.bindings ?? []) {
116
+ bindings[doBinding.name] = { type: 'durable-object', className: doBinding.class_name }
117
+ }
118
+ for (const wf of config.workflows ?? []) {
119
+ bindings[wf.binding] = { type: 'workflow', className: wf.class_name }
120
+ }
121
+ for (const svc of config.services ?? []) {
122
+ bindings[svc.binding] = { type: 'service', service: svc.service, entrypoint: svc.entrypoint }
123
+ }
124
+
125
+ return { bindings, vars }
126
+ }
@@ -0,0 +1,145 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+
3
+ export interface FetchCall {
4
+ request: Request
5
+ url: string
6
+ method: string
7
+ response: Response | null
8
+ mocked: boolean
9
+ timestamp: number
10
+ }
11
+
12
+ type MatchFn = (req: Request) => boolean
13
+ type HandlerFn = (req: Request) => Response | Promise<Response>
14
+
15
+ interface MockRoute {
16
+ match: MatchFn
17
+ handler: HandlerFn
18
+ method?: string
19
+ }
20
+
21
+ const fetchMockStorage = new AsyncLocalStorage<FetchMock>()
22
+
23
+ export function getActiveFetchMock(): FetchMock | undefined {
24
+ return fetchMockStorage.getStore()
25
+ }
26
+
27
+ export function runWithFetchMock<T>(mock: FetchMock, fn: () => T): T {
28
+ return fetchMockStorage.run(mock, fn)
29
+ }
30
+
31
+ function toHandlerFn(handler: Response | HandlerFn): HandlerFn {
32
+ if (typeof handler === 'function') return handler
33
+ return () => (handler as any).clone()
34
+ }
35
+
36
+ export class FetchMock {
37
+ private routes: MockRoute[] = []
38
+ private _calls: FetchCall[] = []
39
+ private _passthrough = true
40
+
41
+ /** Add a mock route. Unmatched requests will throw unless passthrough() is called. */
42
+ on(match: string | RegExp | ((req: Request) => boolean), handler: Response | HandlerFn): this {
43
+ const matchFn = this.toMatchFn(match)
44
+ this.routes.push({ match: matchFn, handler: toHandlerFn(handler) })
45
+ this._passthrough = false
46
+ return this
47
+ }
48
+
49
+ /** Add a mock route for GET requests only. */
50
+ onGet(match: string | RegExp | ((req: Request) => boolean), handler: Response | HandlerFn): this {
51
+ const matchFn = this.toMatchFn(match)
52
+ this.routes.push({ match: matchFn, handler: toHandlerFn(handler), method: 'GET' })
53
+ this._passthrough = false
54
+ return this
55
+ }
56
+
57
+ /** Add a mock route for POST requests only. */
58
+ onPost(match: string | RegExp | ((req: Request) => boolean), handler: Response | HandlerFn): this {
59
+ const matchFn = this.toMatchFn(match)
60
+ this.routes.push({ match: matchFn, handler: toHandlerFn(handler), method: 'POST' })
61
+ this._passthrough = false
62
+ return this
63
+ }
64
+
65
+ /** Allow unmatched requests to pass through to the real network. */
66
+ passthrough(): this {
67
+ this._passthrough = true
68
+ return this
69
+ }
70
+
71
+ /** All recorded fetch calls. */
72
+ get calls(): readonly FetchCall[] {
73
+ return this._calls
74
+ }
75
+
76
+ /** Get recorded calls, optionally filtered by URL match. */
77
+ getCalls(match?: string | RegExp | ((req: Request) => boolean)): FetchCall[] {
78
+ if (!match) return [...this._calls]
79
+ const matchFn = this.toMatchFn(match)
80
+ return this._calls.filter(c => matchFn(c.request))
81
+ }
82
+
83
+ /** Reset all routes and recorded calls. */
84
+ reset(): void {
85
+ this.routes = []
86
+ this._calls = []
87
+ this._passthrough = true
88
+ }
89
+
90
+ /** @internal Handle a fetch request from the intercepted globalThis.fetch */
91
+ async _handle(request: Request): Promise<{ response: Response; mocked: boolean } | null> {
92
+ for (const route of this.routes) {
93
+ if (route.method && request.method !== route.method) continue
94
+ if (route.match(request)) {
95
+ const response = await route.handler(request)
96
+ const call: FetchCall = {
97
+ request,
98
+ url: request.url,
99
+ method: request.method,
100
+ response,
101
+ mocked: true,
102
+ timestamp: Date.now(),
103
+ }
104
+ this._calls.push(call)
105
+ return { response, mocked: true }
106
+ }
107
+ }
108
+
109
+ // No route matched
110
+ if (this._passthrough) {
111
+ return null // let the original fetch handle it
112
+ }
113
+
114
+ // Strict mode — no route matched and passthrough is disabled
115
+ const call: FetchCall = {
116
+ request,
117
+ url: request.url,
118
+ method: request.method,
119
+ response: null,
120
+ mocked: false,
121
+ timestamp: Date.now(),
122
+ }
123
+ this._calls.push(call)
124
+ throw new Error(`FetchMock: no route matched for ${request.method} ${request.url} (passthrough disabled)`)
125
+ }
126
+
127
+ /** @internal Record a passthrough call */
128
+ _recordPassthrough(request: Request, response: Response): void {
129
+ this._calls.push({
130
+ request,
131
+ url: request.url,
132
+ method: request.method,
133
+ response,
134
+ mocked: false,
135
+ timestamp: Date.now(),
136
+ })
137
+ }
138
+
139
+ private toMatchFn(match: string | RegExp | ((req: Request) => boolean)): MatchFn {
140
+ if (typeof match === 'function') return match
141
+ if (match instanceof RegExp) return (req) => match.test(req.url)
142
+ // String: prefix match
143
+ return (req) => req.url.startsWith(match)
144
+ }
145
+ }