@liquidmetal-ai/precip 1.0.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/.prettierrc +9 -0
- package/CHANGELOG.md +8 -0
- package/eslint.config.mjs +28 -0
- package/package.json +53 -0
- package/src/engine/agent.ts +478 -0
- package/src/engine/llm-provider.test.ts +275 -0
- package/src/engine/llm-provider.ts +330 -0
- package/src/engine/stream-parser.ts +170 -0
- package/src/index.ts +142 -0
- package/src/mounts/mount-manager.test.ts +516 -0
- package/src/mounts/mount-manager.ts +327 -0
- package/src/mounts/mount-registry.ts +196 -0
- package/src/mounts/zod-to-string.test.ts +154 -0
- package/src/mounts/zod-to-string.ts +213 -0
- package/src/presets/agent-tools.ts +57 -0
- package/src/presets/index.ts +5 -0
- package/src/sandbox/README.md +1321 -0
- package/src/sandbox/bridges/README.md +571 -0
- package/src/sandbox/bridges/actor.test.ts +229 -0
- package/src/sandbox/bridges/actor.ts +195 -0
- package/src/sandbox/bridges/bridge-fixes.test.ts +614 -0
- package/src/sandbox/bridges/bucket.test.ts +300 -0
- package/src/sandbox/bridges/cleanup-reproduction.test.ts +225 -0
- package/src/sandbox/bridges/console-multiple.test.ts +187 -0
- package/src/sandbox/bridges/console.test.ts +157 -0
- package/src/sandbox/bridges/console.ts +122 -0
- package/src/sandbox/bridges/fetch.ts +93 -0
- package/src/sandbox/bridges/index.ts +78 -0
- package/src/sandbox/bridges/readable-stream.ts +323 -0
- package/src/sandbox/bridges/response.test.ts +154 -0
- package/src/sandbox/bridges/response.ts +123 -0
- package/src/sandbox/bridges/review-fixes.test.ts +331 -0
- package/src/sandbox/bridges/search.test.ts +475 -0
- package/src/sandbox/bridges/search.ts +264 -0
- package/src/sandbox/bridges/shared/body-methods.ts +93 -0
- package/src/sandbox/bridges/shared/cleanup.ts +112 -0
- package/src/sandbox/bridges/shared/convert.ts +76 -0
- package/src/sandbox/bridges/shared/headers.ts +181 -0
- package/src/sandbox/bridges/shared/index.ts +36 -0
- package/src/sandbox/bridges/shared/json-helpers.ts +77 -0
- package/src/sandbox/bridges/shared/path-parser.ts +109 -0
- package/src/sandbox/bridges/shared/promise-helper.ts +108 -0
- package/src/sandbox/bridges/shared/registry-setup.ts +84 -0
- package/src/sandbox/bridges/shared/response-object.ts +280 -0
- package/src/sandbox/bridges/shared/result-builder.ts +130 -0
- package/src/sandbox/bridges/shared/scope-helpers.ts +44 -0
- package/src/sandbox/bridges/shared/stream-reader.ts +90 -0
- package/src/sandbox/bridges/storage-bridge.test.ts +893 -0
- package/src/sandbox/bridges/storage.ts +421 -0
- package/src/sandbox/bridges/text-decoder.ts +190 -0
- package/src/sandbox/bridges/text-encoder.ts +102 -0
- package/src/sandbox/bridges/types.ts +39 -0
- package/src/sandbox/bridges/utils.ts +123 -0
- package/src/sandbox/index.ts +6 -0
- package/src/sandbox/quickjs-wasm.d.ts +9 -0
- package/src/sandbox/sandbox.test.ts +191 -0
- package/src/sandbox/sandbox.ts +831 -0
- package/src/sandbox/test-helper.ts +43 -0
- package/src/sandbox/test-mocks.ts +154 -0
- package/src/sandbox/user-stream.test.ts +77 -0
- package/src/skills/frontmatter.test.ts +305 -0
- package/src/skills/frontmatter.ts +200 -0
- package/src/skills/index.ts +9 -0
- package/src/skills/skills-loader.test.ts +237 -0
- package/src/skills/skills-loader.ts +200 -0
- package/src/tools/actor-storage-tools.ts +250 -0
- package/src/tools/code-tools.test.ts +199 -0
- package/src/tools/code-tools.ts +444 -0
- package/src/tools/file-tools.ts +206 -0
- package/src/tools/registry.ts +125 -0
- package/src/tools/script-tools.ts +145 -0
- package/src/tools/smartbucket-tools.ts +203 -0
- package/src/tools/sql-tools.ts +213 -0
- package/src/tools/tool-factory.ts +119 -0
- package/src/types.ts +512 -0
- package/tsconfig.eslint.json +5 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +33 -0
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the unified storage bridge with actor-storage, write options, and list options
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
6
|
+
import { executeWithAsyncHost } from '../test-helper.js';
|
|
7
|
+
import { installStorage, type StorageMountInfo } from './storage.js';
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Mock Factories
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
function createMockBucket(
|
|
14
|
+
files: Record<
|
|
15
|
+
string,
|
|
16
|
+
{ content: string; contentType?: string; customMetadata?: Record<string, string> }
|
|
17
|
+
>
|
|
18
|
+
) {
|
|
19
|
+
return {
|
|
20
|
+
async get(key: string) {
|
|
21
|
+
const file = files[key];
|
|
22
|
+
if (!file) return null;
|
|
23
|
+
|
|
24
|
+
const encoder = new TextEncoder();
|
|
25
|
+
const data = encoder.encode(file.content);
|
|
26
|
+
let offset = 0;
|
|
27
|
+
const chunkSize = 100;
|
|
28
|
+
|
|
29
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
30
|
+
pull(controller) {
|
|
31
|
+
if (offset >= data.length) {
|
|
32
|
+
controller.close();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const chunk = data.slice(offset, offset + chunkSize);
|
|
36
|
+
controller.enqueue(chunk);
|
|
37
|
+
offset += chunkSize;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
key,
|
|
43
|
+
body: stream,
|
|
44
|
+
size: data.length,
|
|
45
|
+
uploaded: new Date('2024-01-15T00:00:00Z'),
|
|
46
|
+
httpMetadata: { contentType: file.contentType || 'application/octet-stream' },
|
|
47
|
+
customMetadata: file.customMetadata || {}
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async put(key: string, content: string, options?: any) {
|
|
52
|
+
files[key] = {
|
|
53
|
+
content,
|
|
54
|
+
contentType: options?.httpMetadata?.contentType,
|
|
55
|
+
customMetadata: options?.customMetadata
|
|
56
|
+
};
|
|
57
|
+
return { key, size: content.length, uploaded: new Date(), etag: 'test', version: '1' };
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async list(options?: any) {
|
|
61
|
+
const prefix = options?.prefix || '';
|
|
62
|
+
let entries = Object.keys(files).filter(k => k.startsWith(prefix));
|
|
63
|
+
|
|
64
|
+
if (options?.startAfter) {
|
|
65
|
+
entries = entries.filter(k => k > options.startAfter);
|
|
66
|
+
}
|
|
67
|
+
if (options?.limit) {
|
|
68
|
+
entries = entries.slice(0, options.limit);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const objects = entries.map(key => ({
|
|
72
|
+
key,
|
|
73
|
+
size: files[key].content.length,
|
|
74
|
+
uploaded: new Date('2024-01-15T00:00:00Z')
|
|
75
|
+
}));
|
|
76
|
+
return { objects, truncated: false, delimitedPrefixes: [] };
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async delete(key: string) {
|
|
80
|
+
delete files[key];
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createMockKvCache(
|
|
86
|
+
data: Record<string, { value: string; metadata?: any; expiration?: number }>
|
|
87
|
+
) {
|
|
88
|
+
return {
|
|
89
|
+
async get(key: string) {
|
|
90
|
+
return data[key]?.value ?? null;
|
|
91
|
+
},
|
|
92
|
+
async getWithMetadata(key: string) {
|
|
93
|
+
const entry = data[key];
|
|
94
|
+
return {
|
|
95
|
+
value: entry?.value ?? null,
|
|
96
|
+
metadata: entry?.metadata ?? null,
|
|
97
|
+
cacheStatus: null
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
async put(key: string, value: string, options?: any) {
|
|
101
|
+
data[key] = {
|
|
102
|
+
value,
|
|
103
|
+
metadata: options?.metadata,
|
|
104
|
+
expiration:
|
|
105
|
+
options?.expiration ||
|
|
106
|
+
(options?.expirationTtl ? Date.now() / 1000 + options.expirationTtl : undefined)
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
async list(options?: any) {
|
|
110
|
+
const prefix = options?.prefix || '';
|
|
111
|
+
const keys = Object.keys(data)
|
|
112
|
+
.filter(k => k.startsWith(prefix))
|
|
113
|
+
.map(name => ({
|
|
114
|
+
name,
|
|
115
|
+
expiration: data[name].expiration,
|
|
116
|
+
metadata: data[name].metadata
|
|
117
|
+
}));
|
|
118
|
+
return { keys, list_complete: true, cacheStatus: null };
|
|
119
|
+
},
|
|
120
|
+
async delete(key: string) {
|
|
121
|
+
delete data[key];
|
|
122
|
+
},
|
|
123
|
+
async clear() {
|
|
124
|
+
const total = Object.keys(data).length;
|
|
125
|
+
for (const key of Object.keys(data)) delete data[key];
|
|
126
|
+
return { deleted: total, total };
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createMockActorStorage(data: Map<string, unknown> = new Map()) {
|
|
132
|
+
return {
|
|
133
|
+
async get(keyOrKeys: string | string[]) {
|
|
134
|
+
if (Array.isArray(keyOrKeys)) {
|
|
135
|
+
const result = new Map();
|
|
136
|
+
for (const key of keyOrKeys) {
|
|
137
|
+
const value = data.get(key);
|
|
138
|
+
if (value !== undefined) result.set(key, value);
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
return data.get(keyOrKeys);
|
|
143
|
+
},
|
|
144
|
+
async put(keyOrEntries: string | Record<string, any>, value?: any) {
|
|
145
|
+
if (typeof keyOrEntries === 'string') {
|
|
146
|
+
data.set(keyOrEntries, value);
|
|
147
|
+
} else {
|
|
148
|
+
for (const [k, v] of Object.entries(keyOrEntries)) {
|
|
149
|
+
data.set(k, v);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
async delete(keyOrKeys: string | string[]) {
|
|
154
|
+
if (Array.isArray(keyOrKeys)) {
|
|
155
|
+
let count = 0;
|
|
156
|
+
for (const key of keyOrKeys) {
|
|
157
|
+
if (data.delete(key)) count++;
|
|
158
|
+
}
|
|
159
|
+
return count;
|
|
160
|
+
}
|
|
161
|
+
return data.delete(keyOrKeys);
|
|
162
|
+
},
|
|
163
|
+
async deleteAll() {
|
|
164
|
+
data.clear();
|
|
165
|
+
},
|
|
166
|
+
async list(options?: any) {
|
|
167
|
+
const result = new Map();
|
|
168
|
+
let entries = Array.from(data.entries()).sort(([a], [b]) => a.localeCompare(b));
|
|
169
|
+
|
|
170
|
+
if (options?.prefix) {
|
|
171
|
+
entries = entries.filter(([key]) => key.startsWith(options.prefix));
|
|
172
|
+
}
|
|
173
|
+
if (options?.start) {
|
|
174
|
+
entries = entries.filter(([key]) => key >= options.start);
|
|
175
|
+
}
|
|
176
|
+
if (options?.startAfter) {
|
|
177
|
+
entries = entries.filter(([key]) => key > options.startAfter);
|
|
178
|
+
}
|
|
179
|
+
if (options?.end) {
|
|
180
|
+
entries = entries.filter(([key]) => key <= options.end);
|
|
181
|
+
}
|
|
182
|
+
if (options?.reverse) {
|
|
183
|
+
entries.reverse();
|
|
184
|
+
}
|
|
185
|
+
if (options?.limit) {
|
|
186
|
+
entries = entries.slice(0, options.limit);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const [key, value] of entries) {
|
|
190
|
+
result.set(key, value);
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
},
|
|
194
|
+
async getAlarm() {
|
|
195
|
+
return null;
|
|
196
|
+
},
|
|
197
|
+
async setAlarm() {},
|
|
198
|
+
async deleteAlarm() {}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ============================================================================
|
|
203
|
+
// Actor Storage Tests
|
|
204
|
+
// ============================================================================
|
|
205
|
+
|
|
206
|
+
describe('Storage Bridge - Actor Storage', () => {
|
|
207
|
+
it('should read string values from actor storage', async () => {
|
|
208
|
+
const encoder = new TextEncoder();
|
|
209
|
+
const data = new Map<string, unknown>([
|
|
210
|
+
['counter', encoder.encode('42')],
|
|
211
|
+
['prefs', encoder.encode(JSON.stringify({ theme: 'dark', lang: 'en' }))]
|
|
212
|
+
]);
|
|
213
|
+
const mockStorage = createMockActorStorage(data);
|
|
214
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
215
|
+
['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any, mode: 'rw' }]
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
const result = await executeWithAsyncHost(
|
|
219
|
+
`
|
|
220
|
+
const counterRes = await read("/state/counter");
|
|
221
|
+
const prefsRes = await read("/state/prefs");
|
|
222
|
+
|
|
223
|
+
// Verify consistent Response-like interface
|
|
224
|
+
const hasBody = counterRes.body !== null;
|
|
225
|
+
const hasText = typeof counterRes.text === 'function';
|
|
226
|
+
const hasJson = typeof counterRes.json === 'function';
|
|
227
|
+
const ok = counterRes.ok;
|
|
228
|
+
const status = counterRes.status;
|
|
229
|
+
|
|
230
|
+
const counter = await counterRes.text();
|
|
231
|
+
const prefs = await prefsRes.json();
|
|
232
|
+
return { counter, prefs, hasBody, hasText, hasJson, ok, status };
|
|
233
|
+
`,
|
|
234
|
+
{},
|
|
235
|
+
{
|
|
236
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
237
|
+
}
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
expect(result.success).toBe(true);
|
|
241
|
+
expect(result.result.counter).toBe('42');
|
|
242
|
+
expect(result.result.prefs).toEqual({ theme: 'dark', lang: 'en' });
|
|
243
|
+
// Same Response-like shape as bucket reads
|
|
244
|
+
expect(result.result.hasBody).toBe(true);
|
|
245
|
+
expect(result.result.hasText).toBe(true);
|
|
246
|
+
expect(result.result.hasJson).toBe(true);
|
|
247
|
+
expect(result.result.ok).toBe(true);
|
|
248
|
+
expect(result.result.status).toBe(200);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should write values to actor storage as bytes', async () => {
|
|
252
|
+
const data = new Map<string, unknown>();
|
|
253
|
+
const mockStorage = createMockActorStorage(data);
|
|
254
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
255
|
+
['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any, mode: 'rw' }]
|
|
256
|
+
]);
|
|
257
|
+
|
|
258
|
+
const result = await executeWithAsyncHost(
|
|
259
|
+
`
|
|
260
|
+
await write("/state/counter", "42");
|
|
261
|
+
await write("/state/prefs", JSON.stringify({ theme: "dark", lang: "en" }));
|
|
262
|
+
await write("/state/name", "Alice");
|
|
263
|
+
return { success: true };
|
|
264
|
+
`,
|
|
265
|
+
{},
|
|
266
|
+
{
|
|
267
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
268
|
+
}
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(result.success).toBe(true);
|
|
272
|
+
// Values are stored as Uint8Array
|
|
273
|
+
const decoder = new TextDecoder();
|
|
274
|
+
expect(decoder.decode(data.get('counter') as Uint8Array)).toBe('42');
|
|
275
|
+
expect(decoder.decode(data.get('prefs') as Uint8Array)).toBe('{"theme":"dark","lang":"en"}');
|
|
276
|
+
expect(decoder.decode(data.get('name') as Uint8Array)).toBe('Alice');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should list keys from actor storage', async () => {
|
|
280
|
+
const encoder = new TextEncoder();
|
|
281
|
+
const data = new Map<string, unknown>([
|
|
282
|
+
['user:alice', encoder.encode('{"name":"Alice"}')],
|
|
283
|
+
['user:bob', encoder.encode('{"name":"Bob"}')],
|
|
284
|
+
['config:theme', encoder.encode('dark')]
|
|
285
|
+
]);
|
|
286
|
+
const mockStorage = createMockActorStorage(data);
|
|
287
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
288
|
+
['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any, mode: 'rw' }]
|
|
289
|
+
]);
|
|
290
|
+
|
|
291
|
+
const result = await executeWithAsyncHost(
|
|
292
|
+
`
|
|
293
|
+
const all = await list("/state/");
|
|
294
|
+
const users = await list("/state/user:");
|
|
295
|
+
return { allCount: all.count, userCount: users.count, userKeys: users.files.map(f => f.key) };
|
|
296
|
+
`,
|
|
297
|
+
{},
|
|
298
|
+
{
|
|
299
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
300
|
+
}
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
expect(result.success).toBe(true);
|
|
304
|
+
expect(result.result.allCount).toBe(3);
|
|
305
|
+
expect(result.result.userCount).toBe(2);
|
|
306
|
+
expect(result.result.userKeys).toEqual(['/state/user:alice', '/state/user:bob']);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should support range queries on actor storage list', async () => {
|
|
310
|
+
const data = new Map<string, unknown>([
|
|
311
|
+
['a', new Uint8Array([1])],
|
|
312
|
+
['b', new Uint8Array([2])],
|
|
313
|
+
['c', new Uint8Array([3])],
|
|
314
|
+
['d', new Uint8Array([4])],
|
|
315
|
+
['e', new Uint8Array([5])]
|
|
316
|
+
]);
|
|
317
|
+
const mockStorage = createMockActorStorage(data);
|
|
318
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
319
|
+
['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any, mode: 'rw' }]
|
|
320
|
+
]);
|
|
321
|
+
|
|
322
|
+
const result = await executeWithAsyncHost(
|
|
323
|
+
`
|
|
324
|
+
const reversed = await list("/state/", { reverse: true, limit: 3 });
|
|
325
|
+
const range = await list("/state/", { start: "b", end: "d" });
|
|
326
|
+
return {
|
|
327
|
+
reversedKeys: reversed.files.map(f => f.key),
|
|
328
|
+
rangeKeys: range.files.map(f => f.key),
|
|
329
|
+
};
|
|
330
|
+
`,
|
|
331
|
+
{},
|
|
332
|
+
{
|
|
333
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
expect(result.success).toBe(true);
|
|
338
|
+
expect(result.result.reversedKeys).toEqual(['/state/e', '/state/d', '/state/c']);
|
|
339
|
+
expect(result.result.rangeKeys).toEqual(['/state/b', '/state/c', '/state/d']);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should delete from actor storage', async () => {
|
|
343
|
+
const encoder = new TextEncoder();
|
|
344
|
+
const data = new Map<string, unknown>([
|
|
345
|
+
['key1', encoder.encode('value1')],
|
|
346
|
+
['key2', encoder.encode('value2')]
|
|
347
|
+
]);
|
|
348
|
+
const mockStorage = createMockActorStorage(data);
|
|
349
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
350
|
+
['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any, mode: 'rw' }]
|
|
351
|
+
]);
|
|
352
|
+
|
|
353
|
+
const result = await executeWithAsyncHost(
|
|
354
|
+
`
|
|
355
|
+
await remove("/state/key1");
|
|
356
|
+
const remaining = await list("/state/");
|
|
357
|
+
return { count: remaining.count, keys: remaining.files.map(f => f.key) };
|
|
358
|
+
`,
|
|
359
|
+
{},
|
|
360
|
+
{
|
|
361
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
362
|
+
}
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
expect(result.success).toBe(true);
|
|
366
|
+
expect(result.result.count).toBe(1);
|
|
367
|
+
expect(result.result.keys).toEqual(['/state/key2']);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should throw when reading non-existent key from actor storage', async () => {
|
|
371
|
+
const data = new Map<string, unknown>();
|
|
372
|
+
const mockStorage = createMockActorStorage(data);
|
|
373
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
374
|
+
['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any, mode: 'rw' }]
|
|
375
|
+
]);
|
|
376
|
+
|
|
377
|
+
const result = await executeWithAsyncHost(
|
|
378
|
+
`
|
|
379
|
+
try {
|
|
380
|
+
await read("/state/nonexistent");
|
|
381
|
+
return { error: false };
|
|
382
|
+
} catch (e) {
|
|
383
|
+
return { error: true, message: String(e) };
|
|
384
|
+
}
|
|
385
|
+
`,
|
|
386
|
+
{},
|
|
387
|
+
{
|
|
388
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
389
|
+
}
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
expect(result.success).toBe(true);
|
|
393
|
+
expect(result.result.error).toBe(true);
|
|
394
|
+
expect(result.result.message).toContain('not found');
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// ============================================================================
|
|
399
|
+
// Write Options Tests
|
|
400
|
+
// ============================================================================
|
|
401
|
+
|
|
402
|
+
describe('Storage Bridge - Write Options', () => {
|
|
403
|
+
it('should pass ttl option to KV cache', async () => {
|
|
404
|
+
const data: Record<string, any> = {};
|
|
405
|
+
const mockKv = createMockKvCache(data);
|
|
406
|
+
const putSpy = vi.spyOn(mockKv, 'put');
|
|
407
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
408
|
+
['cache', { name: 'cache', type: 'kv', resource: mockKv as any, mode: 'rw' }]
|
|
409
|
+
]);
|
|
410
|
+
|
|
411
|
+
const result = await executeWithAsyncHost(
|
|
412
|
+
`
|
|
413
|
+
await write("/cache/session:abc", "data", { ttl: 300 });
|
|
414
|
+
return { success: true };
|
|
415
|
+
`,
|
|
416
|
+
{},
|
|
417
|
+
{
|
|
418
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
419
|
+
}
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
expect(result.success).toBe(true);
|
|
423
|
+
expect(putSpy).toHaveBeenCalledWith('session:abc', 'data', { expirationTtl: 300 });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('should pass expiration option to KV cache', async () => {
|
|
427
|
+
const data: Record<string, any> = {};
|
|
428
|
+
const mockKv = createMockKvCache(data);
|
|
429
|
+
const putSpy = vi.spyOn(mockKv, 'put');
|
|
430
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
431
|
+
['cache', { name: 'cache', type: 'kv', resource: mockKv as any, mode: 'rw' }]
|
|
432
|
+
]);
|
|
433
|
+
|
|
434
|
+
const result = await executeWithAsyncHost(
|
|
435
|
+
`
|
|
436
|
+
await write("/cache/temp", "value", { expiration: 1720000000 });
|
|
437
|
+
return { success: true };
|
|
438
|
+
`,
|
|
439
|
+
{},
|
|
440
|
+
{
|
|
441
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
442
|
+
}
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
expect(result.success).toBe(true);
|
|
446
|
+
expect(putSpy).toHaveBeenCalledWith('temp', 'value', { expiration: 1720000000 });
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('should pass metadata option to KV cache', async () => {
|
|
450
|
+
const data: Record<string, any> = {};
|
|
451
|
+
const mockKv = createMockKvCache(data);
|
|
452
|
+
const putSpy = vi.spyOn(mockKv, 'put');
|
|
453
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
454
|
+
['cache', { name: 'cache', type: 'kv', resource: mockKv as any, mode: 'rw' }]
|
|
455
|
+
]);
|
|
456
|
+
|
|
457
|
+
const result = await executeWithAsyncHost(
|
|
458
|
+
`
|
|
459
|
+
await write("/cache/user:123", "data", { metadata: { source: "api" } });
|
|
460
|
+
return { success: true };
|
|
461
|
+
`,
|
|
462
|
+
{},
|
|
463
|
+
{
|
|
464
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
465
|
+
}
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
expect(result.success).toBe(true);
|
|
469
|
+
expect(putSpy).toHaveBeenCalledWith('user:123', 'data', { metadata: { source: 'api' } });
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('should pass contentType option to bucket', async () => {
|
|
473
|
+
const files: Record<string, any> = {};
|
|
474
|
+
const mockBucket = createMockBucket(files);
|
|
475
|
+
const putSpy = vi.spyOn(mockBucket, 'put');
|
|
476
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
477
|
+
['uploads', { name: 'uploads', type: 'bucket', resource: mockBucket as any, mode: 'rw' }]
|
|
478
|
+
]);
|
|
479
|
+
|
|
480
|
+
const result = await executeWithAsyncHost(
|
|
481
|
+
`
|
|
482
|
+
await write("/uploads/report.json", '{"key":"value"}', { contentType: "application/json" });
|
|
483
|
+
return { success: true };
|
|
484
|
+
`,
|
|
485
|
+
{},
|
|
486
|
+
{
|
|
487
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
488
|
+
}
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
expect(result.success).toBe(true);
|
|
492
|
+
expect(putSpy).toHaveBeenCalledWith('report.json', '{"key":"value"}', {
|
|
493
|
+
httpMetadata: { contentType: 'application/json' }
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('should pass customMetadata option to bucket', async () => {
|
|
498
|
+
const files: Record<string, any> = {};
|
|
499
|
+
const mockBucket = createMockBucket(files);
|
|
500
|
+
const putSpy = vi.spyOn(mockBucket, 'put');
|
|
501
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
502
|
+
['uploads', { name: 'uploads', type: 'bucket', resource: mockBucket as any, mode: 'rw' }]
|
|
503
|
+
]);
|
|
504
|
+
|
|
505
|
+
const result = await executeWithAsyncHost(
|
|
506
|
+
`
|
|
507
|
+
await write("/uploads/file.txt", "content", { customMetadata: { author: "alice", version: "2" } });
|
|
508
|
+
return { success: true };
|
|
509
|
+
`,
|
|
510
|
+
{},
|
|
511
|
+
{
|
|
512
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
513
|
+
}
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
expect(result.success).toBe(true);
|
|
517
|
+
expect(putSpy).toHaveBeenCalledWith('file.txt', 'content', {
|
|
518
|
+
customMetadata: { author: 'alice', version: '2' }
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('should write without options (backward compatible)', async () => {
|
|
523
|
+
const data: Record<string, any> = {};
|
|
524
|
+
const mockKv = createMockKvCache(data);
|
|
525
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
526
|
+
['cache', { name: 'cache', type: 'kv', resource: mockKv as any, mode: 'rw' }]
|
|
527
|
+
]);
|
|
528
|
+
|
|
529
|
+
const result = await executeWithAsyncHost(
|
|
530
|
+
`
|
|
531
|
+
await write("/cache/simple", "value");
|
|
532
|
+
return { success: true };
|
|
533
|
+
`,
|
|
534
|
+
{},
|
|
535
|
+
{
|
|
536
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
537
|
+
}
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
expect(result.success).toBe(true);
|
|
541
|
+
expect(data['simple']?.value).toBe('value');
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// ============================================================================
|
|
546
|
+
// List Options Tests
|
|
547
|
+
// ============================================================================
|
|
548
|
+
|
|
549
|
+
describe('Storage Bridge - List Options', () => {
|
|
550
|
+
it('should pass limit to bucket list', async () => {
|
|
551
|
+
const files: Record<string, any> = {
|
|
552
|
+
'a.txt': { content: 'a' },
|
|
553
|
+
'b.txt': { content: 'b' },
|
|
554
|
+
'c.txt': { content: 'c' }
|
|
555
|
+
};
|
|
556
|
+
const mockBucket = createMockBucket(files);
|
|
557
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
558
|
+
['uploads', { name: 'uploads', type: 'bucket', resource: mockBucket as any }]
|
|
559
|
+
]);
|
|
560
|
+
|
|
561
|
+
const result = await executeWithAsyncHost(
|
|
562
|
+
`
|
|
563
|
+
const limited = await list("/uploads/", { limit: 2 });
|
|
564
|
+
return { count: limited.count };
|
|
565
|
+
`,
|
|
566
|
+
{},
|
|
567
|
+
{
|
|
568
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
569
|
+
}
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
expect(result.success).toBe(true);
|
|
573
|
+
expect(result.result.count).toBe(2);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('should pass startAfter to bucket list', async () => {
|
|
577
|
+
const files: Record<string, any> = {
|
|
578
|
+
'a.txt': { content: 'a' },
|
|
579
|
+
'b.txt': { content: 'b' },
|
|
580
|
+
'c.txt': { content: 'c' }
|
|
581
|
+
};
|
|
582
|
+
const mockBucket = createMockBucket(files);
|
|
583
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
584
|
+
['uploads', { name: 'uploads', type: 'bucket', resource: mockBucket as any }]
|
|
585
|
+
]);
|
|
586
|
+
|
|
587
|
+
const result = await executeWithAsyncHost(
|
|
588
|
+
`
|
|
589
|
+
const after = await list("/uploads/", { startAfter: "a.txt" });
|
|
590
|
+
return { keys: after.files.map(f => f.key) };
|
|
591
|
+
`,
|
|
592
|
+
{},
|
|
593
|
+
{
|
|
594
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
595
|
+
}
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
expect(result.success).toBe(true);
|
|
599
|
+
expect(result.result.keys).toEqual(['/uploads/b.txt', '/uploads/c.txt']);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('should include expiration in KV list results', async () => {
|
|
603
|
+
const data: Record<string, any> = {
|
|
604
|
+
key1: { value: 'val1', expiration: 1720000000 },
|
|
605
|
+
key2: { value: 'val2' }
|
|
606
|
+
};
|
|
607
|
+
const mockKv = createMockKvCache(data);
|
|
608
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
609
|
+
['cache', { name: 'cache', type: 'kv', resource: mockKv as any }]
|
|
610
|
+
]);
|
|
611
|
+
|
|
612
|
+
const result = await executeWithAsyncHost(
|
|
613
|
+
`
|
|
614
|
+
const listed = await list("/cache/");
|
|
615
|
+
return { files: listed.files };
|
|
616
|
+
`,
|
|
617
|
+
{},
|
|
618
|
+
{
|
|
619
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
620
|
+
}
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
expect(result.success).toBe(true);
|
|
624
|
+
const fileWithExpiration = result.result.files.find((f: any) => f.key === '/cache/key1');
|
|
625
|
+
expect(fileWithExpiration?.expiration).toBe(1720000000);
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// ============================================================================
|
|
630
|
+
// KV Read with Metadata Tests
|
|
631
|
+
// ============================================================================
|
|
632
|
+
|
|
633
|
+
describe('Storage Bridge - KV Read with Metadata', () => {
|
|
634
|
+
it('should include metadata in KV read response', async () => {
|
|
635
|
+
const data: Record<string, any> = {
|
|
636
|
+
'user:123': {
|
|
637
|
+
value: JSON.stringify({ name: 'Alice' }),
|
|
638
|
+
metadata: { source: 'api', version: '2' }
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
const mockKv = createMockKvCache(data);
|
|
642
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
643
|
+
['cache', { name: 'cache', type: 'kv', resource: mockKv as any }]
|
|
644
|
+
]);
|
|
645
|
+
|
|
646
|
+
const result = await executeWithAsyncHost(
|
|
647
|
+
`
|
|
648
|
+
const res = await read("/cache/user:123");
|
|
649
|
+
const value = await res.json();
|
|
650
|
+
return { value, metadata: res.metadata, ok: res.ok, hasBody: res.body !== null, hasText: typeof res.text === 'function' };
|
|
651
|
+
`,
|
|
652
|
+
{},
|
|
653
|
+
{
|
|
654
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
655
|
+
}
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
expect(result.success).toBe(true);
|
|
659
|
+
expect(result.result.value).toEqual({ name: 'Alice' });
|
|
660
|
+
// metadata is serialized as JSON string via createResponseObject metadata
|
|
661
|
+
expect(result.result.metadata).toBe('{"source":"api","version":"2"}');
|
|
662
|
+
expect(result.result.ok).toBe(true);
|
|
663
|
+
expect(result.result.hasBody).toBe(true);
|
|
664
|
+
expect(result.result.hasText).toBe(true);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('should throw when reading non-existent KV key', async () => {
|
|
668
|
+
const data: Record<string, any> = {};
|
|
669
|
+
const mockKv = createMockKvCache(data);
|
|
670
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
671
|
+
['cache', { name: 'cache', type: 'kv', resource: mockKv as any }]
|
|
672
|
+
]);
|
|
673
|
+
|
|
674
|
+
const result = await executeWithAsyncHost(
|
|
675
|
+
`
|
|
676
|
+
try {
|
|
677
|
+
await read("/cache/nonexistent");
|
|
678
|
+
return { error: false };
|
|
679
|
+
} catch (e) {
|
|
680
|
+
return { error: true, message: String(e) };
|
|
681
|
+
}
|
|
682
|
+
`,
|
|
683
|
+
{},
|
|
684
|
+
{
|
|
685
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
686
|
+
}
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
expect(result.success).toBe(true);
|
|
690
|
+
expect(result.result.error).toBe(true);
|
|
691
|
+
expect(result.result.message).toContain('not found');
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// ============================================================================
|
|
696
|
+
// Mixed Mount Tests (ensure unified API works across types)
|
|
697
|
+
// ============================================================================
|
|
698
|
+
|
|
699
|
+
describe('Storage Bridge - Mixed Mounts', () => {
|
|
700
|
+
it('should read/write across bucket, KV, and actor-storage using same API', async () => {
|
|
701
|
+
const bucketFiles: Record<string, any> = {
|
|
702
|
+
'readme.txt': { content: 'Hello from bucket' }
|
|
703
|
+
};
|
|
704
|
+
const kvData: Record<string, any> = {
|
|
705
|
+
'session:abc': { value: JSON.stringify({ user: 'alice' }) }
|
|
706
|
+
};
|
|
707
|
+
const actorData = new Map<string, unknown>([['counter', new TextEncoder().encode('42')]]);
|
|
708
|
+
|
|
709
|
+
const mockBucket = createMockBucket(bucketFiles);
|
|
710
|
+
const mockKv = createMockKvCache(kvData);
|
|
711
|
+
const mockStorage = createMockActorStorage(actorData);
|
|
712
|
+
|
|
713
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
714
|
+
['uploads', { name: 'uploads', type: 'bucket', resource: mockBucket as any, mode: 'rw' }],
|
|
715
|
+
['cache', { name: 'cache', type: 'kv', resource: mockKv as any, mode: 'rw' }],
|
|
716
|
+
['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any, mode: 'rw' }]
|
|
717
|
+
]);
|
|
718
|
+
|
|
719
|
+
const result = await executeWithAsyncHost(
|
|
720
|
+
`
|
|
721
|
+
// Read from each
|
|
722
|
+
const fileText = await (await read("/uploads/readme.txt")).text();
|
|
723
|
+
const kvData = await (await read("/cache/session:abc")).json();
|
|
724
|
+
const stateVal = await (await read("/state/counter")).text();
|
|
725
|
+
|
|
726
|
+
// Write to each
|
|
727
|
+
await write("/uploads/new.txt", "new file");
|
|
728
|
+
await write("/cache/temp", "cached", { ttl: 60 });
|
|
729
|
+
await write("/state/counter", "43");
|
|
730
|
+
|
|
731
|
+
// Verify writes
|
|
732
|
+
const newFile = await (await read("/uploads/new.txt")).text();
|
|
733
|
+
const newState = await (await read("/state/counter")).text();
|
|
734
|
+
|
|
735
|
+
return { fileText, kvData, stateVal, newFile, newState };
|
|
736
|
+
`,
|
|
737
|
+
{},
|
|
738
|
+
{
|
|
739
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)],
|
|
740
|
+
timeoutMs: 10000
|
|
741
|
+
}
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
expect(result.success).toBe(true);
|
|
745
|
+
expect(result.result.fileText).toBe('Hello from bucket');
|
|
746
|
+
expect(result.result.kvData).toEqual({ user: 'alice' });
|
|
747
|
+
expect(result.result.stateVal).toBe('42');
|
|
748
|
+
expect(result.result.newFile).toBe('new file');
|
|
749
|
+
expect(result.result.newState).toBe('43');
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
// ============================================================================
|
|
754
|
+
// Read-Only Mode Enforcement Tests
|
|
755
|
+
// ============================================================================
|
|
756
|
+
|
|
757
|
+
describe('Storage Bridge - Read-Only Mode Enforcement', () => {
|
|
758
|
+
it('should reject writes to read-only KV mount', async () => {
|
|
759
|
+
const data: Record<string, any> = {};
|
|
760
|
+
const mockKv = createMockKvCache(data);
|
|
761
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
762
|
+
['cache', { name: 'cache', type: 'kv', resource: mockKv as any }] // no mode = read-only
|
|
763
|
+
]);
|
|
764
|
+
|
|
765
|
+
const result = await executeWithAsyncHost(
|
|
766
|
+
`
|
|
767
|
+
try {
|
|
768
|
+
await write("/cache/key", "value");
|
|
769
|
+
return { error: false };
|
|
770
|
+
} catch (e) {
|
|
771
|
+
return { error: true, message: String(e) };
|
|
772
|
+
}
|
|
773
|
+
`,
|
|
774
|
+
{},
|
|
775
|
+
{
|
|
776
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
777
|
+
}
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
expect(result.success).toBe(true);
|
|
781
|
+
expect(result.result.error).toBe(true);
|
|
782
|
+
expect(result.result.message).toContain('read-only');
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it('should reject deletes from read-only KV mount', async () => {
|
|
786
|
+
const data: Record<string, any> = {
|
|
787
|
+
key1: { value: 'val1' }
|
|
788
|
+
};
|
|
789
|
+
const mockKv = createMockKvCache(data);
|
|
790
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
791
|
+
['cache', { name: 'cache', type: 'kv', resource: mockKv as any }]
|
|
792
|
+
]);
|
|
793
|
+
|
|
794
|
+
const result = await executeWithAsyncHost(
|
|
795
|
+
`
|
|
796
|
+
try {
|
|
797
|
+
await remove("/cache/key1");
|
|
798
|
+
return { error: false };
|
|
799
|
+
} catch (e) {
|
|
800
|
+
return { error: true, message: String(e) };
|
|
801
|
+
}
|
|
802
|
+
`,
|
|
803
|
+
{},
|
|
804
|
+
{
|
|
805
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
806
|
+
}
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
expect(result.success).toBe(true);
|
|
810
|
+
expect(result.result.error).toBe(true);
|
|
811
|
+
expect(result.result.message).toContain('read-only');
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it('should reject writes to read-only actor-storage mount', async () => {
|
|
815
|
+
const data = new Map<string, unknown>();
|
|
816
|
+
const mockStorage = createMockActorStorage(data);
|
|
817
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
818
|
+
['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any }] // no mode = read-only
|
|
819
|
+
]);
|
|
820
|
+
|
|
821
|
+
const result = await executeWithAsyncHost(
|
|
822
|
+
`
|
|
823
|
+
try {
|
|
824
|
+
await write("/state/key", "value");
|
|
825
|
+
return { error: false };
|
|
826
|
+
} catch (e) {
|
|
827
|
+
return { error: true, message: String(e) };
|
|
828
|
+
}
|
|
829
|
+
`,
|
|
830
|
+
{},
|
|
831
|
+
{
|
|
832
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
833
|
+
}
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
expect(result.success).toBe(true);
|
|
837
|
+
expect(result.result.error).toBe(true);
|
|
838
|
+
expect(result.result.message).toContain('read-only');
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('should reject deletes from read-only actor-storage mount', async () => {
|
|
842
|
+
const encoder = new TextEncoder();
|
|
843
|
+
const data = new Map<string, unknown>([['key1', encoder.encode('value1')]]);
|
|
844
|
+
const mockStorage = createMockActorStorage(data);
|
|
845
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
846
|
+
['state', { name: 'state', type: 'actor-storage', resource: mockStorage as any }]
|
|
847
|
+
]);
|
|
848
|
+
|
|
849
|
+
const result = await executeWithAsyncHost(
|
|
850
|
+
`
|
|
851
|
+
try {
|
|
852
|
+
await remove("/state/key1");
|
|
853
|
+
return { error: false };
|
|
854
|
+
} catch (e) {
|
|
855
|
+
return { error: true, message: String(e) };
|
|
856
|
+
}
|
|
857
|
+
`,
|
|
858
|
+
{},
|
|
859
|
+
{
|
|
860
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
861
|
+
}
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
expect(result.success).toBe(true);
|
|
865
|
+
expect(result.result.error).toBe(true);
|
|
866
|
+
expect(result.result.message).toContain('read-only');
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it('should allow reads from read-only mounts', async () => {
|
|
870
|
+
const data: Record<string, any> = {
|
|
871
|
+
greeting: { value: 'hello' }
|
|
872
|
+
};
|
|
873
|
+
const mockKv = createMockKvCache(data);
|
|
874
|
+
const storageMounts = new Map<string, StorageMountInfo>([
|
|
875
|
+
['cache', { name: 'cache', type: 'kv', resource: mockKv as any }] // no mode = read-only
|
|
876
|
+
]);
|
|
877
|
+
|
|
878
|
+
const result = await executeWithAsyncHost(
|
|
879
|
+
`
|
|
880
|
+
const res = await read("/cache/greeting");
|
|
881
|
+
const value = await res.text();
|
|
882
|
+
return { value };
|
|
883
|
+
`,
|
|
884
|
+
{},
|
|
885
|
+
{
|
|
886
|
+
bridgeInstallers: [ctx => installStorage(ctx, storageMounts)]
|
|
887
|
+
}
|
|
888
|
+
);
|
|
889
|
+
|
|
890
|
+
expect(result.success).toBe(true);
|
|
891
|
+
expect(result.result.value).toBe('hello');
|
|
892
|
+
});
|
|
893
|
+
});
|