ai-props 2.1.1 → 2.3.0
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/.dev.vars +2 -0
- package/CHANGELOG.md +24 -0
- package/README.md +131 -118
- package/package.json +30 -4
- package/src/ai.ts +12 -31
- package/src/cascade.ts +795 -0
- package/src/client.ts +440 -0
- package/src/durable-cascade.ts +743 -0
- package/src/event-bridge.ts +478 -0
- package/src/generate.ts +14 -12
- package/src/hoc.ts +15 -19
- package/src/hono-jsx.ts +675 -0
- package/src/index.ts +30 -0
- package/src/mdx-types.ts +169 -0
- package/src/mdx-utils.ts +437 -0
- package/src/mdx.ts +1008 -0
- package/src/rpc.ts +614 -0
- package/src/streaming.ts +618 -0
- package/src/validate.ts +15 -29
- package/src/worker.ts +547 -0
- package/test/cascade.test.ts +338 -0
- package/test/durable-cascade.test.ts +319 -0
- package/test/event-bridge.test.ts +351 -0
- package/test/generate.test.ts +6 -16
- package/test/mdx.test.ts +817 -0
- package/test/worker/capnweb-rpc.test.ts +1084 -0
- package/test/worker/full-flow.integration.test.ts +1463 -0
- package/test/worker/hono-jsx.test.ts +1258 -0
- package/test/worker/mdx-parsing.test.ts +1148 -0
- package/test/worker/setup.ts +56 -0
- package/test/worker.test.ts +595 -0
- package/tsconfig.json +2 -1
- package/vitest.config.js +6 -0
- package/vitest.config.ts +15 -1
- package/vitest.workers.config.ts +58 -0
- package/wrangler.jsonc +27 -0
- package/.turbo/turbo-build.log +0 -5
- package/dist/ai.d.ts +0 -125
- package/dist/ai.d.ts.map +0 -1
- package/dist/ai.js +0 -199
- package/dist/ai.js.map +0 -1
- package/dist/cache.d.ts +0 -66
- package/dist/cache.d.ts.map +0 -1
- package/dist/cache.js +0 -183
- package/dist/cache.js.map +0 -1
- package/dist/generate.d.ts +0 -69
- package/dist/generate.d.ts.map +0 -1
- package/dist/generate.js +0 -221
- package/dist/generate.js.map +0 -1
- package/dist/hoc.d.ts +0 -164
- package/dist/hoc.d.ts.map +0 -1
- package/dist/hoc.js +0 -236
- package/dist/hoc.js.map +0 -1
- package/dist/index.d.ts +0 -15
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -21
- package/dist/index.js.map +0 -1
- package/dist/types.d.ts +0 -152
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -7
- package/dist/types.js.map +0 -1
- package/dist/validate.d.ts +0 -58
- package/dist/validate.d.ts.map +0 -1
- package/dist/validate.js +0 -253
- package/dist/validate.js.map +0 -1
- package/src/ai.js +0 -198
- package/src/cache.js +0 -182
- package/src/generate.js +0 -220
- package/src/hoc.js +0 -235
- package/src/index.js +0 -20
- package/src/types.js +0 -6
- package/src/validate.js +0 -252
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test setup for worker tests
|
|
3
|
+
*
|
|
4
|
+
* Configures the AI providers registry with gateway credentials
|
|
5
|
+
* from environment bindings before tests run.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { env } from 'cloudflare:test'
|
|
9
|
+
import { beforeAll } from 'vitest'
|
|
10
|
+
|
|
11
|
+
// Populate process.env from worker bindings
|
|
12
|
+
// This is needed because ai-providers uses process.env which isn't
|
|
13
|
+
// automatically populated from worker bindings in miniflare
|
|
14
|
+
const typedEnv = env as Record<string, string>
|
|
15
|
+
const envKeys = ['AI_GATEWAY_URL', 'AI_GATEWAY_TOKEN', 'AWS_BEARER_TOKEN_BEDROCK']
|
|
16
|
+
|
|
17
|
+
// Try multiple methods to set process.env
|
|
18
|
+
for (const key of envKeys) {
|
|
19
|
+
if (typedEnv[key]) {
|
|
20
|
+
try {
|
|
21
|
+
// Method 1: Direct assignment (may be blocked by readonly)
|
|
22
|
+
// @ts-expect-error - process.env may be readonly
|
|
23
|
+
process.env[key] = typedEnv[key]
|
|
24
|
+
} catch {
|
|
25
|
+
// Method 2: Use Object.defineProperty
|
|
26
|
+
try {
|
|
27
|
+
Object.defineProperty(process.env, key, {
|
|
28
|
+
value: typedEnv[key],
|
|
29
|
+
writable: true,
|
|
30
|
+
configurable: true,
|
|
31
|
+
enumerable: true,
|
|
32
|
+
})
|
|
33
|
+
} catch {
|
|
34
|
+
// If both methods fail, log a warning
|
|
35
|
+
console.warn(`Could not set process.env.${key}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Configure the AI providers registry with gateway credentials
|
|
42
|
+
// This must be done AFTER setting process.env but BEFORE any AI calls
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
if (typedEnv.AI_GATEWAY_URL && typedEnv.AI_GATEWAY_TOKEN) {
|
|
45
|
+
try {
|
|
46
|
+
// Dynamically import ai-providers and configure it
|
|
47
|
+
const aiProviders = await import('ai-providers')
|
|
48
|
+
await aiProviders.configureRegistry({
|
|
49
|
+
gatewayUrl: typedEnv.AI_GATEWAY_URL,
|
|
50
|
+
gatewayToken: typedEnv.AI_GATEWAY_TOKEN,
|
|
51
|
+
})
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error('[setup] Failed to configure AI providers registry:', error)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
})
|
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker Export Tests for ai-props
|
|
3
|
+
*
|
|
4
|
+
* Tests for the PropsService WorkerEntrypoint and PropsServiceCore RpcTarget.
|
|
5
|
+
* Validates AI props generation, caching, configuration, and validation methods.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
9
|
+
|
|
10
|
+
// Mock cloudflare:workers module (not available outside Cloudflare Workers runtime)
|
|
11
|
+
vi.mock('cloudflare:workers', () => {
|
|
12
|
+
class RpcTarget {}
|
|
13
|
+
class WorkerEntrypoint<T = unknown> {}
|
|
14
|
+
return { RpcTarget, WorkerEntrypoint }
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// Mock the ai-functions generateObject before importing worker
|
|
18
|
+
vi.mock('ai-functions', () => ({
|
|
19
|
+
generateObject: vi.fn().mockImplementation(async ({ schema }) => {
|
|
20
|
+
// Generate mock data based on schema
|
|
21
|
+
const mockData: Record<string, unknown> = {}
|
|
22
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
23
|
+
if (typeof value === 'string') {
|
|
24
|
+
if (value.includes('(number)')) {
|
|
25
|
+
mockData[key] = 42
|
|
26
|
+
} else if (value.includes('(boolean)')) {
|
|
27
|
+
mockData[key] = true
|
|
28
|
+
} else {
|
|
29
|
+
mockData[key] = `generated-${key}`
|
|
30
|
+
}
|
|
31
|
+
} else if (Array.isArray(value)) {
|
|
32
|
+
mockData[key] = ['item1', 'item2']
|
|
33
|
+
} else if (typeof value === 'object') {
|
|
34
|
+
mockData[key] = { nested: 'value' }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { object: mockData }
|
|
38
|
+
}),
|
|
39
|
+
schema: vi.fn((s) => s),
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
import { PropsService, PropsServiceCore } from '../src/worker.js'
|
|
43
|
+
import { resetConfig, clearCache } from '../src/index.js'
|
|
44
|
+
|
|
45
|
+
describe('PropsServiceCore (RpcTarget)', () => {
|
|
46
|
+
let service: PropsServiceCore
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
service = new PropsServiceCore()
|
|
50
|
+
resetConfig()
|
|
51
|
+
clearCache()
|
|
52
|
+
vi.clearAllMocks()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('constructor', () => {
|
|
56
|
+
it('creates a new PropsServiceCore instance', () => {
|
|
57
|
+
expect(service).toBeInstanceOf(PropsServiceCore)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('extends RpcTarget for RPC communication', () => {
|
|
61
|
+
expect(service.constructor.name).toBe('PropsServiceCore')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('generation operations', () => {
|
|
66
|
+
describe('generate()', () => {
|
|
67
|
+
it('generates props from schema', async () => {
|
|
68
|
+
const result = await service.generate({
|
|
69
|
+
schema: {
|
|
70
|
+
title: 'Page title',
|
|
71
|
+
description: 'Page description',
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
expect(result.props).toBeDefined()
|
|
76
|
+
expect(result.props.title).toBe('generated-title')
|
|
77
|
+
expect(result.props.description).toBe('generated-description')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('returns cached flag', async () => {
|
|
81
|
+
const result = await service.generate({
|
|
82
|
+
schema: { name: 'User name' },
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(result.cached).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('returns metadata with model info', async () => {
|
|
89
|
+
const result = await service.generate({
|
|
90
|
+
schema: { name: 'User name' },
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
expect(result.metadata).toBeDefined()
|
|
94
|
+
expect(result.metadata?.model).toBe('anthropic/claude-sonnet-4.5')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('uses custom model when specified', async () => {
|
|
98
|
+
const result = await service.generate({
|
|
99
|
+
schema: { name: 'User name' },
|
|
100
|
+
model: 'gpt-4',
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(result.metadata?.model).toBe('gpt-4')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('caches results for subsequent calls', async () => {
|
|
107
|
+
const schema = { name: 'User name' }
|
|
108
|
+
|
|
109
|
+
// First call
|
|
110
|
+
await service.generate({ schema })
|
|
111
|
+
|
|
112
|
+
// Second call should be cached
|
|
113
|
+
const result = await service.generate({ schema })
|
|
114
|
+
|
|
115
|
+
expect(result.cached).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('getSync()', () => {
|
|
120
|
+
it('returns cached props synchronously', async () => {
|
|
121
|
+
const schema = { name: 'User name' }
|
|
122
|
+
|
|
123
|
+
// Pre-populate cache
|
|
124
|
+
await service.generate({ schema })
|
|
125
|
+
|
|
126
|
+
// Get synchronously
|
|
127
|
+
const props = service.getSync(schema)
|
|
128
|
+
|
|
129
|
+
expect(props.name).toBe('generated-name')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('throws when not cached', () => {
|
|
133
|
+
expect(() => {
|
|
134
|
+
service.getSync({ name: 'User name' })
|
|
135
|
+
}).toThrow('Props not in cache')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('prefetch()', () => {
|
|
140
|
+
it('prefetches multiple schemas', async () => {
|
|
141
|
+
await service.prefetch([
|
|
142
|
+
{ schema: { name: 'User name' } },
|
|
143
|
+
{ schema: { title: 'Page title' } },
|
|
144
|
+
])
|
|
145
|
+
|
|
146
|
+
// Both should be cached now
|
|
147
|
+
const name = service.getSync({ name: 'User name' })
|
|
148
|
+
const title = service.getSync({ title: 'Page title' })
|
|
149
|
+
|
|
150
|
+
expect(name.name).toBe('generated-name')
|
|
151
|
+
expect(title.title).toBe('generated-title')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('generateMany()', () => {
|
|
156
|
+
it('generates multiple prop sets in parallel', async () => {
|
|
157
|
+
const results = await service.generateMany([
|
|
158
|
+
{ schema: { name: 'User name' } },
|
|
159
|
+
{ schema: { title: 'Page title' } },
|
|
160
|
+
])
|
|
161
|
+
|
|
162
|
+
expect(results).toHaveLength(2)
|
|
163
|
+
expect(results[0]?.props.name).toBe('generated-name')
|
|
164
|
+
expect(results[1]?.props.title).toBe('generated-title')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('mergeWithGenerated()', () => {
|
|
169
|
+
it('generates only missing props', async () => {
|
|
170
|
+
const result = await service.mergeWithGenerated(
|
|
171
|
+
{ name: 'User name', email: 'Email address' },
|
|
172
|
+
{ name: 'John Doe' }
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
expect(result.name).toBe('John Doe') // Preserved
|
|
176
|
+
expect(result.email).toBe('generated-email') // Generated
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('returns as-is when all props provided', async () => {
|
|
180
|
+
const result = await service.mergeWithGenerated(
|
|
181
|
+
{ name: 'User name', email: 'Email address' },
|
|
182
|
+
{ name: 'John Doe', email: 'john@example.com' }
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
expect(result.name).toBe('John Doe')
|
|
186
|
+
expect(result.email).toBe('john@example.com')
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
describe('configuration operations', () => {
|
|
192
|
+
describe('configure()', () => {
|
|
193
|
+
it('sets model configuration', () => {
|
|
194
|
+
service.configure({ model: 'gpt-4' })
|
|
195
|
+
expect(service.getConfig().model).toBe('gpt-4')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('sets cache configuration', () => {
|
|
199
|
+
service.configure({ cache: false })
|
|
200
|
+
expect(service.getConfig().cache).toBe(false)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('merges with existing config', () => {
|
|
204
|
+
service.configure({ model: 'gpt-4' })
|
|
205
|
+
service.configure({ cache: false })
|
|
206
|
+
|
|
207
|
+
const config = service.getConfig()
|
|
208
|
+
expect(config.model).toBe('gpt-4')
|
|
209
|
+
expect(config.cache).toBe(false)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('getConfig()', () => {
|
|
214
|
+
it('returns default config', () => {
|
|
215
|
+
const config = service.getConfig()
|
|
216
|
+
|
|
217
|
+
expect(config.model).toBe('anthropic/claude-sonnet-4.5')
|
|
218
|
+
expect(config.cache).toBe(true)
|
|
219
|
+
expect(config.cacheTTL).toBe(5 * 60 * 1000)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('resetConfig()', () => {
|
|
224
|
+
it('resets to default values', () => {
|
|
225
|
+
service.configure({ model: 'gpt-4', cache: false })
|
|
226
|
+
service.resetConfig()
|
|
227
|
+
|
|
228
|
+
const config = service.getConfig()
|
|
229
|
+
expect(config.model).toBe('anthropic/claude-sonnet-4.5')
|
|
230
|
+
expect(config.cache).toBe(true)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
describe('cache operations', () => {
|
|
236
|
+
describe('setCached() and getCached()', () => {
|
|
237
|
+
it('stores and retrieves props by key', () => {
|
|
238
|
+
service.setCached('test-key', { name: 'Test' })
|
|
239
|
+
|
|
240
|
+
const entry = service.getCached('test-key')
|
|
241
|
+
|
|
242
|
+
expect(entry).toBeDefined()
|
|
243
|
+
expect(entry?.props).toEqual({ name: 'Test' })
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('returns undefined for non-existent key', () => {
|
|
247
|
+
const entry = service.getCached('non-existent')
|
|
248
|
+
|
|
249
|
+
expect(entry).toBeUndefined()
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
describe('deleteCached()', () => {
|
|
254
|
+
it('removes cached entry', () => {
|
|
255
|
+
service.setCached('delete-key', { name: 'Test' })
|
|
256
|
+
|
|
257
|
+
const result = service.deleteCached('delete-key')
|
|
258
|
+
|
|
259
|
+
expect(result).toBe(true)
|
|
260
|
+
expect(service.getCached('delete-key')).toBeUndefined()
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('returns false for non-existent key', () => {
|
|
264
|
+
const result = service.deleteCached('non-existent')
|
|
265
|
+
|
|
266
|
+
expect(result).toBe(false)
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe('clearCache()', () => {
|
|
271
|
+
it('removes all cached entries', () => {
|
|
272
|
+
service.setCached('key1', { a: 1 })
|
|
273
|
+
service.setCached('key2', { b: 2 })
|
|
274
|
+
|
|
275
|
+
service.clearCache()
|
|
276
|
+
|
|
277
|
+
expect(service.getCacheSize()).toBe(0)
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
describe('getCacheSize()', () => {
|
|
282
|
+
it('returns number of cached entries', () => {
|
|
283
|
+
service.setCached('key1', { a: 1 })
|
|
284
|
+
service.setCached('key2', { b: 2 })
|
|
285
|
+
|
|
286
|
+
expect(service.getCacheSize()).toBe(2)
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
describe('createCacheKey()', () => {
|
|
291
|
+
it('creates deterministic key from schema', () => {
|
|
292
|
+
const key1 = service.createCacheKey({ name: 'User name' })
|
|
293
|
+
const key2 = service.createCacheKey({ name: 'User name' })
|
|
294
|
+
|
|
295
|
+
expect(key1).toBe(key2)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('creates different keys for different schemas', () => {
|
|
299
|
+
const key1 = service.createCacheKey({ name: 'User name' })
|
|
300
|
+
const key2 = service.createCacheKey({ title: 'Page title' })
|
|
301
|
+
|
|
302
|
+
expect(key1).not.toBe(key2)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('includes context in key', () => {
|
|
306
|
+
const key1 = service.createCacheKey({ name: 'User name' }, { id: '1' })
|
|
307
|
+
const key2 = service.createCacheKey({ name: 'User name' }, { id: '2' })
|
|
308
|
+
|
|
309
|
+
expect(key1).not.toBe(key2)
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
describe('configureCache()', () => {
|
|
314
|
+
it('sets cache TTL', () => {
|
|
315
|
+
service.configureCache(10000)
|
|
316
|
+
|
|
317
|
+
// This should create a new cache with the specified TTL
|
|
318
|
+
expect(service.getCacheSize()).toBe(0)
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
describe('validation operations', () => {
|
|
324
|
+
describe('validate()', () => {
|
|
325
|
+
it('validates props against schema', () => {
|
|
326
|
+
const result = service.validate(
|
|
327
|
+
{ name: 'John', age: 25 },
|
|
328
|
+
{ name: 'User name', age: 'Age (number)' }
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
expect(result.valid).toBe(true)
|
|
332
|
+
expect(result.errors).toHaveLength(0)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('returns errors for invalid props', () => {
|
|
336
|
+
const result = service.validate(
|
|
337
|
+
{ name: 'John', age: 'not a number' },
|
|
338
|
+
{ name: 'User name', age: 'Age (number)' }
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
expect(result.valid).toBe(false)
|
|
342
|
+
expect(result.errors.length).toBeGreaterThan(0)
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
describe('hasRequired()', () => {
|
|
347
|
+
it('returns true when all required props present', () => {
|
|
348
|
+
const result = service.hasRequired({ name: 'John', email: 'john@example.com' }, [
|
|
349
|
+
'name',
|
|
350
|
+
'email',
|
|
351
|
+
])
|
|
352
|
+
|
|
353
|
+
expect(result).toBe(true)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('returns false when required props missing', () => {
|
|
357
|
+
const result = service.hasRequired({ name: 'John' }, ['name', 'email'])
|
|
358
|
+
|
|
359
|
+
expect(result).toBe(false)
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
describe('getMissing()', () => {
|
|
364
|
+
it('returns list of missing props', () => {
|
|
365
|
+
const missing = service.getMissing(
|
|
366
|
+
{ name: 'John' },
|
|
367
|
+
{ name: 'User name', email: 'Email address', age: 'Age' }
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
expect(missing).toContain('email')
|
|
371
|
+
expect(missing).toContain('age')
|
|
372
|
+
expect(missing).not.toContain('name')
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
describe('isComplete()', () => {
|
|
377
|
+
it('returns true when all schema props present', () => {
|
|
378
|
+
const result = service.isComplete(
|
|
379
|
+
{ name: 'John', email: 'john@example.com' },
|
|
380
|
+
{ name: 'User name', email: 'Email address' }
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
expect(result).toBe(true)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('returns false when props missing', () => {
|
|
387
|
+
const result = service.isComplete(
|
|
388
|
+
{ name: 'John' },
|
|
389
|
+
{ name: 'User name', email: 'Email address' }
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
expect(result).toBe(false)
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
describe('sanitize()', () => {
|
|
397
|
+
it('removes extra keys not in schema', () => {
|
|
398
|
+
const sanitized = service.sanitize(
|
|
399
|
+
{ name: 'John', extra: 'value', unknown: 123 },
|
|
400
|
+
{ name: 'User name' }
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
expect(sanitized).toEqual({ name: 'John' })
|
|
404
|
+
expect(sanitized).not.toHaveProperty('extra')
|
|
405
|
+
expect(sanitized).not.toHaveProperty('unknown')
|
|
406
|
+
})
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
describe('mergeDefaults()', () => {
|
|
410
|
+
it('merges props with defaults', () => {
|
|
411
|
+
const result = service.mergeDefaults(
|
|
412
|
+
{ name: 'John' },
|
|
413
|
+
{ name: 'Default', email: 'default@example.com' },
|
|
414
|
+
{ name: 'User name', email: 'Email address' }
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
expect(result.name).toBe('John')
|
|
418
|
+
expect(result.email).toBe('default@example.com')
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('preserves provided props over defaults', () => {
|
|
422
|
+
const result = service.mergeDefaults(
|
|
423
|
+
{ name: 'John', email: 'john@example.com' },
|
|
424
|
+
{ name: 'Default', email: 'default@example.com' },
|
|
425
|
+
{ name: 'User name', email: 'Email address' }
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
expect(result.name).toBe('John')
|
|
429
|
+
expect(result.email).toBe('john@example.com')
|
|
430
|
+
})
|
|
431
|
+
})
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
describe('PropsService (WorkerEntrypoint)', () => {
|
|
436
|
+
describe('class definition', () => {
|
|
437
|
+
it('exports PropsService class', async () => {
|
|
438
|
+
const { default: PropsServiceClass } = await import('../src/worker.js')
|
|
439
|
+
expect(PropsServiceClass).toBeDefined()
|
|
440
|
+
expect(typeof PropsServiceClass).toBe('function')
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('PropsService has connect method in prototype', () => {
|
|
444
|
+
expect(typeof PropsService.prototype.connect).toBe('function')
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('is named PropsService', () => {
|
|
448
|
+
expect(PropsService.name).toBe('PropsService')
|
|
449
|
+
})
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
describe('connect()', () => {
|
|
453
|
+
// Note: WorkerEntrypoint classes cannot be instantiated directly in tests.
|
|
454
|
+
// They require the Cloudflare Workers runtime context.
|
|
455
|
+
// We verify the connect method behavior by testing that:
|
|
456
|
+
// 1. The method exists on the prototype
|
|
457
|
+
// 2. The return type (PropsServiceCore) is properly constructable and functional
|
|
458
|
+
|
|
459
|
+
it('returns a PropsServiceCore instance', () => {
|
|
460
|
+
// Since we can't instantiate PropsService directly (requires Workers runtime),
|
|
461
|
+
// we verify that PropsServiceCore (the return type of connect()) works correctly
|
|
462
|
+
const core = new PropsServiceCore()
|
|
463
|
+
expect(core).toBeInstanceOf(PropsServiceCore)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('returns RpcTarget for RPC communication', () => {
|
|
467
|
+
// Test that PropsServiceCore (what connect() returns) has all required methods
|
|
468
|
+
const core = new PropsServiceCore()
|
|
469
|
+
|
|
470
|
+
expect(core).toBeDefined()
|
|
471
|
+
expect(typeof core.generate).toBe('function')
|
|
472
|
+
expect(typeof core.getSync).toBe('function')
|
|
473
|
+
expect(typeof core.prefetch).toBe('function')
|
|
474
|
+
expect(typeof core.generateMany).toBe('function')
|
|
475
|
+
expect(typeof core.mergeWithGenerated).toBe('function')
|
|
476
|
+
expect(typeof core.configure).toBe('function')
|
|
477
|
+
expect(typeof core.getConfig).toBe('function')
|
|
478
|
+
expect(typeof core.resetConfig).toBe('function')
|
|
479
|
+
expect(typeof core.getCached).toBe('function')
|
|
480
|
+
expect(typeof core.setCached).toBe('function')
|
|
481
|
+
expect(typeof core.deleteCached).toBe('function')
|
|
482
|
+
expect(typeof core.clearCache).toBe('function')
|
|
483
|
+
expect(typeof core.getCacheSize).toBe('function')
|
|
484
|
+
expect(typeof core.createCacheKey).toBe('function')
|
|
485
|
+
expect(typeof core.configureCache).toBe('function')
|
|
486
|
+
expect(typeof core.validate).toBe('function')
|
|
487
|
+
expect(typeof core.hasRequired).toBe('function')
|
|
488
|
+
expect(typeof core.getMissing).toBe('function')
|
|
489
|
+
expect(typeof core.isComplete).toBe('function')
|
|
490
|
+
expect(typeof core.sanitize).toBe('function')
|
|
491
|
+
expect(typeof core.mergeDefaults).toBe('function')
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('creates independent service instances', () => {
|
|
495
|
+
// Each PropsServiceCore instance should be independent
|
|
496
|
+
const core1 = new PropsServiceCore()
|
|
497
|
+
const core2 = new PropsServiceCore()
|
|
498
|
+
|
|
499
|
+
// Configure core1
|
|
500
|
+
core1.configure({ model: 'gpt-4' })
|
|
501
|
+
|
|
502
|
+
// Each instance should be independent
|
|
503
|
+
expect(core1).not.toBe(core2)
|
|
504
|
+
// Note: config is global, so this will affect both
|
|
505
|
+
// In production, each worker instance would have its own state
|
|
506
|
+
})
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
describe('Integration: Real Props Generation', () => {
|
|
511
|
+
let service: PropsServiceCore
|
|
512
|
+
|
|
513
|
+
beforeEach(() => {
|
|
514
|
+
service = new PropsServiceCore()
|
|
515
|
+
resetConfig()
|
|
516
|
+
clearCache()
|
|
517
|
+
vi.clearAllMocks()
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('generates props and validates them', async () => {
|
|
521
|
+
const schema = {
|
|
522
|
+
title: 'Page title',
|
|
523
|
+
description: 'Page description',
|
|
524
|
+
published: 'Is published (boolean)',
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Generate props
|
|
528
|
+
const result = await service.generate({ schema })
|
|
529
|
+
|
|
530
|
+
expect(result.props).toBeDefined()
|
|
531
|
+
|
|
532
|
+
// Validate generated props
|
|
533
|
+
const validation = service.validate(result.props, schema)
|
|
534
|
+
|
|
535
|
+
expect(validation.valid).toBe(true)
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('generates partial props and merges with provided', async () => {
|
|
539
|
+
const schema = {
|
|
540
|
+
name: 'User name',
|
|
541
|
+
email: 'Email address',
|
|
542
|
+
bio: 'User bio',
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Generate only missing props
|
|
546
|
+
const result = await service.mergeWithGenerated(schema, {
|
|
547
|
+
name: 'John Doe',
|
|
548
|
+
email: 'john@example.com',
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
expect(result.name).toBe('John Doe')
|
|
552
|
+
expect(result.email).toBe('john@example.com')
|
|
553
|
+
expect(result.bio).toBe('generated-bio')
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('prefetches and retrieves props synchronously', async () => {
|
|
557
|
+
const schemas = [
|
|
558
|
+
{ schema: { productName: 'Product name' } },
|
|
559
|
+
{ schema: { categoryName: 'Category name' } },
|
|
560
|
+
]
|
|
561
|
+
|
|
562
|
+
// Prefetch all
|
|
563
|
+
await service.prefetch(schemas)
|
|
564
|
+
|
|
565
|
+
// Retrieve synchronously
|
|
566
|
+
const product = service.getSync({ productName: 'Product name' })
|
|
567
|
+
const category = service.getSync({ categoryName: 'Category name' })
|
|
568
|
+
|
|
569
|
+
expect(product.productName).toBe('generated-productName')
|
|
570
|
+
expect(category.categoryName).toBe('generated-categoryName')
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
it('sanitizes and merges props with defaults', async () => {
|
|
574
|
+
const schema = {
|
|
575
|
+
name: 'User name',
|
|
576
|
+
role: 'User role',
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const defaults = {
|
|
580
|
+
name: 'Anonymous',
|
|
581
|
+
role: 'viewer',
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Sanitize user input (remove extra fields)
|
|
585
|
+
const userInput = { name: 'John', extra: 'ignored', role: 'admin' }
|
|
586
|
+
const sanitized = service.sanitize(userInput, schema)
|
|
587
|
+
|
|
588
|
+
// Merge with defaults
|
|
589
|
+
const result = service.mergeDefaults(sanitized, defaults, schema)
|
|
590
|
+
|
|
591
|
+
expect(result.name).toBe('John')
|
|
592
|
+
expect(result.role).toBe('admin')
|
|
593
|
+
expect(result).not.toHaveProperty('extra')
|
|
594
|
+
})
|
|
595
|
+
})
|
package/tsconfig.json
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
"extends": "../../tsconfig.base.json",
|
|
3
3
|
"compilerOptions": {
|
|
4
4
|
"rootDir": "src",
|
|
5
|
-
"outDir": "dist"
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"types": ["@cloudflare/workers-types", "node"]
|
|
6
7
|
},
|
|
7
8
|
"include": ["src/**/*"],
|
|
8
9
|
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
package/vitest.config.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { defineConfig } from 'vitest/config';
|
|
2
2
|
export default defineConfig({
|
|
3
3
|
test: {
|
|
4
|
+
// CRITICAL: Limit concurrency to prevent resource exhaustion
|
|
5
|
+
maxConcurrency: 1,
|
|
6
|
+
maxWorkers: 1,
|
|
7
|
+
minWorkers: 1,
|
|
8
|
+
fileParallelism: false,
|
|
9
|
+
|
|
4
10
|
globals: true,
|
|
5
11
|
environment: 'node',
|
|
6
12
|
include: ['test/**/*.test.ts'],
|
package/vitest.config.ts
CHANGED
|
@@ -2,14 +2,28 @@ import { defineConfig } from 'vitest/config'
|
|
|
2
2
|
|
|
3
3
|
export default defineConfig({
|
|
4
4
|
test: {
|
|
5
|
+
// CRITICAL: Limit concurrency to prevent resource exhaustion
|
|
6
|
+
maxConcurrency: 1,
|
|
7
|
+
maxWorkers: 1,
|
|
8
|
+
minWorkers: 1,
|
|
9
|
+
fileParallelism: false,
|
|
10
|
+
|
|
5
11
|
globals: true,
|
|
6
12
|
environment: 'node',
|
|
7
13
|
include: ['test/**/*.test.ts'],
|
|
14
|
+
// Exclude worker tests that require cloudflare:test module
|
|
15
|
+
exclude: ['test/worker/**/*.test.ts', '**/node_modules/**'],
|
|
8
16
|
coverage: {
|
|
9
17
|
provider: 'v8',
|
|
10
18
|
reporter: ['text', 'json', 'html'],
|
|
11
19
|
include: ['src/**/*.ts'],
|
|
12
|
-
exclude: ['src/**/*.d.ts'],
|
|
20
|
+
exclude: ['src/**/*.d.ts', '**/*.test.ts', '**/__tests__/**'],
|
|
21
|
+
thresholds: {
|
|
22
|
+
statements: 65,
|
|
23
|
+
branches: 60,
|
|
24
|
+
functions: 60,
|
|
25
|
+
lines: 65,
|
|
26
|
+
},
|
|
13
27
|
},
|
|
14
28
|
},
|
|
15
29
|
})
|