@getfamiliar/shared 0.1.4 → 0.1.6
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/build/EventBus.d.ts +2 -2
- package/build/EventBus.js +2 -2
- package/build/Mail.d.ts +18 -2
- package/build/Plugin.d.ts +23 -11
- package/build/Storage.d.ts +263 -0
- package/build/Storage.js +33 -0
- package/build/ToolRunner.d.ts +13 -2
- package/build/ToolRunner.js +28 -12
- package/build/index.d.ts +3 -1
- package/build/index.js +2 -0
- package/build/markdownOutput.d.ts +39 -0
- package/build/markdownOutput.js +50 -0
- package/build/markdownTerminal.js +7 -2
- package/build/testing/FakeStorageProvider.d.ts +133 -0
- package/build/testing/FakeStorageProvider.js +459 -0
- package/build/testing/StorageProviderContract.d.ts +32 -0
- package/build/testing/StorageProviderContract.js +168 -0
- package/build/testing/index.d.ts +2 -0
- package/build/testing/index.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { StorageError, } from "../Storage.js";
|
|
3
|
+
/** Google-style native doc mime types and their export mapping. */
|
|
4
|
+
export const FAKE_NATIVE_EXPORTS = {
|
|
5
|
+
"application/vnd.google-apps.document": {
|
|
6
|
+
mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
7
|
+
ext: ".docx",
|
|
8
|
+
},
|
|
9
|
+
"application/vnd.google-apps.spreadsheet": {
|
|
10
|
+
mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
11
|
+
ext: ".xlsx",
|
|
12
|
+
},
|
|
13
|
+
"application/vnd.google-apps.presentation": {
|
|
14
|
+
mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
15
|
+
ext: ".pptx",
|
|
16
|
+
},
|
|
17
|
+
"application/vnd.google-apps.drawing": { mime: "application/pdf", ext: ".pdf" },
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* In-memory {@link StorageProvider} for service, tool, CLI and contract
|
|
21
|
+
* tests. Imitates OneDrive (path addressing, unique names), Google
|
|
22
|
+
* Drive (id addressing, duplicate names, native docs) or Dropbox
|
|
23
|
+
* (path addressing, no content search). Content lives in memory;
|
|
24
|
+
* download / upload still go through real host files so the byte paths
|
|
25
|
+
* are exercised.
|
|
26
|
+
*/
|
|
27
|
+
export class FakeStorageProvider {
|
|
28
|
+
pluginId;
|
|
29
|
+
flavor;
|
|
30
|
+
drives = new Map();
|
|
31
|
+
options;
|
|
32
|
+
nextId = 1;
|
|
33
|
+
clock = Date.parse("2026-01-01T00:00:00Z");
|
|
34
|
+
/**
|
|
35
|
+
* @param options - Behaviour knobs; see {@link FakeStorageOptions}.
|
|
36
|
+
*/
|
|
37
|
+
constructor(options = {}) {
|
|
38
|
+
this.options = options;
|
|
39
|
+
this.pluginId = options.pluginId ?? "fake";
|
|
40
|
+
this.flavor = options.flavor ?? "onedrive";
|
|
41
|
+
const accounts = options.accounts ?? { "a@x": [{ selector: null, name: "Primary" }] };
|
|
42
|
+
for (const [account, drives] of Object.entries(accounts)) {
|
|
43
|
+
for (const d of drives) {
|
|
44
|
+
const id = `drv-${account}-${d.selector ?? "primary"}`;
|
|
45
|
+
const rootId = this.newId();
|
|
46
|
+
const drive = {
|
|
47
|
+
account,
|
|
48
|
+
selector: d.selector,
|
|
49
|
+
id,
|
|
50
|
+
name: d.name,
|
|
51
|
+
kind: d.selector === null ? "personal" : "team",
|
|
52
|
+
};
|
|
53
|
+
const nodes = new Map();
|
|
54
|
+
nodes.set(rootId, this.makeNode(rootId, null, "", "folder", null, null));
|
|
55
|
+
this.drives.set(driveKey(account, d.selector), { drive, rootId, nodes });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** @returns The capabilities of the imitated provider. */
|
|
60
|
+
capabilities(_drive) {
|
|
61
|
+
const isGdrive = this.flavor === "gdrive";
|
|
62
|
+
return {
|
|
63
|
+
addressing: isGdrive ? "id" : "path",
|
|
64
|
+
uniqueNames: !isGdrive,
|
|
65
|
+
contentSearch: this.options.contentSearch ?? this.flavor !== "dropbox",
|
|
66
|
+
searchSnippets: false,
|
|
67
|
+
trash: this.options.trash ?? true,
|
|
68
|
+
nativeDocs: isGdrive,
|
|
69
|
+
serverSideCopy: true,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** @returns All configured accounts. */
|
|
73
|
+
async listAccounts() {
|
|
74
|
+
return [...new Set([...this.drives.values()].map((d) => d.drive.account))];
|
|
75
|
+
}
|
|
76
|
+
/** @returns A fake login command. */
|
|
77
|
+
loginCommand(account) {
|
|
78
|
+
return `familiar ${this.pluginId} login ${account}`;
|
|
79
|
+
}
|
|
80
|
+
/** @returns The account's drives, filtered and capped. */
|
|
81
|
+
async listDrives(account, options) {
|
|
82
|
+
this.assertAccount(account);
|
|
83
|
+
if (this.options.brokenAccounts?.includes(account)) {
|
|
84
|
+
throw new StorageError("ProviderUnavailable", `fake outage for ${account}`);
|
|
85
|
+
}
|
|
86
|
+
const search = options.search?.toLowerCase();
|
|
87
|
+
return [...this.drives.values()]
|
|
88
|
+
.map((d) => d.drive)
|
|
89
|
+
.filter((d) => d.account === account)
|
|
90
|
+
.filter((d) => search === undefined || d.name.toLowerCase().includes(search))
|
|
91
|
+
.slice(0, options.limit);
|
|
92
|
+
}
|
|
93
|
+
/** @returns The drive for `selector`. */
|
|
94
|
+
async openDrive(account, selector) {
|
|
95
|
+
this.assertAccount(account);
|
|
96
|
+
const state = this.drives.get(driveKey(account, selector));
|
|
97
|
+
if (!state) {
|
|
98
|
+
throw new StorageError("NotFound", `no drive "${selector ?? "(primary)"}" for ${account}`);
|
|
99
|
+
}
|
|
100
|
+
return state.drive;
|
|
101
|
+
}
|
|
102
|
+
/** @returns The root folder id. */
|
|
103
|
+
async rootRealId(drive) {
|
|
104
|
+
return this.state(drive).rootId;
|
|
105
|
+
}
|
|
106
|
+
/** @returns The item or `null`. */
|
|
107
|
+
async stat(drive, realId) {
|
|
108
|
+
const node = this.state(drive).nodes.get(realId);
|
|
109
|
+
return node && !node.trashed ? this.toItem(drive, node) : null;
|
|
110
|
+
}
|
|
111
|
+
/** Native path resolution (only for path-addressed flavors). */
|
|
112
|
+
get resolvePath() {
|
|
113
|
+
if (this.flavor === "gdrive") {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
return async (drive, path) => {
|
|
117
|
+
const state = this.state(drive);
|
|
118
|
+
const segments = path.split("/").filter((s) => s.length > 0);
|
|
119
|
+
let current = [state.nodes.get(state.rootId)];
|
|
120
|
+
for (const seg of segments) {
|
|
121
|
+
current = current.flatMap((parent) => this.children(state, parent.realId).filter((c) => c.name.toLowerCase() === seg.toLowerCase()));
|
|
122
|
+
}
|
|
123
|
+
return current.map((n) => this.toItem(drive, n));
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** @returns One page of children. */
|
|
127
|
+
async list(drive, folderRealId, options) {
|
|
128
|
+
const state = this.state(drive);
|
|
129
|
+
const folder = state.nodes.get(folderRealId);
|
|
130
|
+
if (!folder || folder.trashed) {
|
|
131
|
+
throw new StorageError("NotFound", `folder ${folderRealId} not found`);
|
|
132
|
+
}
|
|
133
|
+
const all = this.children(state, folderRealId).map((n) => this.toItem(drive, n));
|
|
134
|
+
return paginate(all, options);
|
|
135
|
+
}
|
|
136
|
+
/** @returns One page of name / content hits. */
|
|
137
|
+
async search(drive, query, options) {
|
|
138
|
+
if (this.options.searchDelayMs !== undefined) {
|
|
139
|
+
await delay(this.options.searchDelayMs, options.signal);
|
|
140
|
+
}
|
|
141
|
+
if (this.options.searchError) {
|
|
142
|
+
throw this.options.searchError;
|
|
143
|
+
}
|
|
144
|
+
this.assertAccount(drive.account);
|
|
145
|
+
const state = this.state(drive);
|
|
146
|
+
const needle = query.text.toLowerCase();
|
|
147
|
+
const contentSearch = this.capabilities(drive).contentSearch;
|
|
148
|
+
const hits = [];
|
|
149
|
+
for (const node of state.nodes.values()) {
|
|
150
|
+
if (node.trashed || node.realId === state.rootId) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (node.name.toLowerCase().includes(needle)) {
|
|
154
|
+
hits.push({ item: this.toItem(drive, node), matchedIn: "name" });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (contentSearch && node.content?.toString("utf8").toLowerCase().includes(needle)) {
|
|
158
|
+
hits.push({ item: this.toItem(drive, node), matchedIn: "content" });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return paginate(hits, options);
|
|
162
|
+
}
|
|
163
|
+
/** Write the bytes (or the native export) to `destPath`. */
|
|
164
|
+
async download(drive, realId, destPath) {
|
|
165
|
+
const node = this.requireNode(drive, realId);
|
|
166
|
+
if (node.kind === "folder") {
|
|
167
|
+
throw new StorageError("Unsupported", `${node.name} is a folder`);
|
|
168
|
+
}
|
|
169
|
+
if (node.kind === "native") {
|
|
170
|
+
const mapping = node.mimeType ? FAKE_NATIVE_EXPORTS[node.mimeType] : undefined;
|
|
171
|
+
if (!mapping) {
|
|
172
|
+
throw new StorageError("Unsupported", `${this.pluginId}: native type ${node.mimeType} cannot be exported`);
|
|
173
|
+
}
|
|
174
|
+
const bytes = Buffer.from(`exported:${node.name}`);
|
|
175
|
+
await writeFile(destPath, bytes);
|
|
176
|
+
return {
|
|
177
|
+
bytes: bytes.length,
|
|
178
|
+
mimeType: mapping.mime,
|
|
179
|
+
fileName: node.name + mapping.ext,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const bytes = node.content ?? Buffer.alloc(0);
|
|
183
|
+
await writeFile(destPath, bytes);
|
|
184
|
+
return { bytes: bytes.length, mimeType: node.mimeType, fileName: node.name };
|
|
185
|
+
}
|
|
186
|
+
/** Upload from a host file with conflict / revision semantics. */
|
|
187
|
+
async upload(drive, parentRealId, name, source, options) {
|
|
188
|
+
const state = this.state(drive);
|
|
189
|
+
this.requireFolder(state, parentRealId);
|
|
190
|
+
const content = await readFile(source.path);
|
|
191
|
+
if (options.conflict === "replace") {
|
|
192
|
+
if (options.replaceRealId === undefined) {
|
|
193
|
+
throw new Error("fake: conflict=replace requires replaceRealId");
|
|
194
|
+
}
|
|
195
|
+
const existing = this.requireNode(drive, options.replaceRealId);
|
|
196
|
+
if (existing.kind !== "file") {
|
|
197
|
+
throw new StorageError("Unsupported", `${existing.name} is not a file`);
|
|
198
|
+
}
|
|
199
|
+
this.checkRevision(existing, options.ifRevision);
|
|
200
|
+
existing.content = content;
|
|
201
|
+
existing.revision += 1;
|
|
202
|
+
existing.modifiedUtc = this.tick();
|
|
203
|
+
if (source.mimeType) {
|
|
204
|
+
existing.mimeType = source.mimeType;
|
|
205
|
+
}
|
|
206
|
+
return this.toItem(drive, existing);
|
|
207
|
+
}
|
|
208
|
+
const clash = this.flavor === "gdrive" ? undefined : this.childNamed(state, parentRealId, name);
|
|
209
|
+
if (clash) {
|
|
210
|
+
if (options.conflict === "fail") {
|
|
211
|
+
throw new StorageError("NameConflict", `${name} already exists`);
|
|
212
|
+
}
|
|
213
|
+
name = this.freeName(state, parentRealId, name);
|
|
214
|
+
}
|
|
215
|
+
const node = this.makeNode(this.newId(), parentRealId, name, "file", source.mimeType ?? "application/octet-stream", content);
|
|
216
|
+
state.nodes.set(node.realId, node);
|
|
217
|
+
return this.toItem(drive, node);
|
|
218
|
+
}
|
|
219
|
+
/** Create a folder. */
|
|
220
|
+
async createFolder(drive, parentRealId, name, options) {
|
|
221
|
+
const state = this.state(drive);
|
|
222
|
+
this.requireFolder(state, parentRealId);
|
|
223
|
+
const existing = this.childNamed(state, parentRealId, name);
|
|
224
|
+
if (existing) {
|
|
225
|
+
if (options.conflict === "reuse" && existing.kind === "folder") {
|
|
226
|
+
return this.toItem(drive, existing);
|
|
227
|
+
}
|
|
228
|
+
if (options.conflict === "fail" || options.conflict === "reuse") {
|
|
229
|
+
throw new StorageError("NameConflict", `${name} already exists`);
|
|
230
|
+
}
|
|
231
|
+
name = this.freeName(state, parentRealId, name);
|
|
232
|
+
}
|
|
233
|
+
const node = this.makeNode(this.newId(), parentRealId, name, "folder", null, null);
|
|
234
|
+
state.nodes.set(node.realId, node);
|
|
235
|
+
return this.toItem(drive, node);
|
|
236
|
+
}
|
|
237
|
+
/** Move / rename. */
|
|
238
|
+
async move(drive, realId, target, options) {
|
|
239
|
+
const state = this.state(drive);
|
|
240
|
+
const node = this.requireNode(drive, realId);
|
|
241
|
+
this.checkRevision(node, options.ifRevision);
|
|
242
|
+
const parent = target.parentRealId ?? node.parentRealId ?? state.rootId;
|
|
243
|
+
this.requireFolder(state, parent);
|
|
244
|
+
const name = target.name ?? node.name;
|
|
245
|
+
const clash = this.childNamed(state, parent, name);
|
|
246
|
+
if (clash && clash.realId !== node.realId && this.flavor !== "gdrive") {
|
|
247
|
+
throw new StorageError("NameConflict", `${name} already exists`);
|
|
248
|
+
}
|
|
249
|
+
node.parentRealId = parent;
|
|
250
|
+
node.name = name;
|
|
251
|
+
node.revision += 1;
|
|
252
|
+
node.modifiedUtc = this.tick();
|
|
253
|
+
return this.toItem(drive, node);
|
|
254
|
+
}
|
|
255
|
+
/** Server-side copy (files only in the fake). */
|
|
256
|
+
async copy(drive, realId, target, options) {
|
|
257
|
+
const state = this.state(drive);
|
|
258
|
+
const node = this.requireNode(drive, realId);
|
|
259
|
+
this.requireFolder(state, target.parentRealId);
|
|
260
|
+
let name = target.name ?? node.name;
|
|
261
|
+
if (this.childNamed(state, target.parentRealId, name) && this.flavor !== "gdrive") {
|
|
262
|
+
if (options.conflict === "fail") {
|
|
263
|
+
throw new StorageError("NameConflict", `${name} already exists`);
|
|
264
|
+
}
|
|
265
|
+
name = this.freeName(state, target.parentRealId, name);
|
|
266
|
+
}
|
|
267
|
+
const copy = this.makeNode(this.newId(), target.parentRealId, name, node.kind, node.mimeType, node.content);
|
|
268
|
+
state.nodes.set(copy.realId, copy);
|
|
269
|
+
return this.toItem(drive, copy);
|
|
270
|
+
}
|
|
271
|
+
/** Soft delete (recursive). */
|
|
272
|
+
async trash(drive, realId, options) {
|
|
273
|
+
if (!this.capabilities(drive).trash) {
|
|
274
|
+
throw new StorageError("Unsupported", `${this.pluginId} has no trash`);
|
|
275
|
+
}
|
|
276
|
+
const state = this.state(drive);
|
|
277
|
+
const node = this.requireNode(drive, realId);
|
|
278
|
+
this.checkRevision(node, options.ifRevision);
|
|
279
|
+
const mark = (n) => {
|
|
280
|
+
n.trashed = true;
|
|
281
|
+
for (const child of this.children(state, n.realId)) {
|
|
282
|
+
mark(child);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
mark(node);
|
|
286
|
+
}
|
|
287
|
+
// ---- test seeding -------------------------------------------------
|
|
288
|
+
/**
|
|
289
|
+
* Seed an item under `parentPath` (folders are created as needed).
|
|
290
|
+
* Duplicates are allowed for the gdrive flavor.
|
|
291
|
+
*
|
|
292
|
+
* @returns The seeded item's realId.
|
|
293
|
+
*/
|
|
294
|
+
seed(account, selector, path, options = {}) {
|
|
295
|
+
const state = this.drives.get(driveKey(account, selector));
|
|
296
|
+
if (!state) {
|
|
297
|
+
throw new Error(`fake: no drive ${account}/${selector}`);
|
|
298
|
+
}
|
|
299
|
+
const segments = path.split("/").filter((s) => s.length > 0);
|
|
300
|
+
let parentId = state.rootId;
|
|
301
|
+
for (const seg of segments.slice(0, -1)) {
|
|
302
|
+
const existing = this.childNamed(state, parentId, seg);
|
|
303
|
+
if (existing) {
|
|
304
|
+
parentId = existing.realId;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const folder = this.makeNode(this.newId(), parentId, seg, "folder", null, null);
|
|
308
|
+
state.nodes.set(folder.realId, folder);
|
|
309
|
+
parentId = folder.realId;
|
|
310
|
+
}
|
|
311
|
+
const kind = options.kind ?? "file";
|
|
312
|
+
const leaf = segments[segments.length - 1] ?? "";
|
|
313
|
+
const node = this.makeNode(this.newId(), parentId, leaf, kind, kind === "folder" ? null : (options.mimeType ?? "text/plain"), kind === "file" ? Buffer.from(options.content ?? "") : null);
|
|
314
|
+
state.nodes.set(node.realId, node);
|
|
315
|
+
return node.realId;
|
|
316
|
+
}
|
|
317
|
+
/** Test helper: raw content of an item, or `null`. */
|
|
318
|
+
contentOf(account, selector, realId) {
|
|
319
|
+
return (this.drives
|
|
320
|
+
.get(driveKey(account, selector))
|
|
321
|
+
?.nodes.get(realId)
|
|
322
|
+
?.content?.toString("utf8") ?? null);
|
|
323
|
+
}
|
|
324
|
+
/** Test helper: whether an item is in the trash. */
|
|
325
|
+
isTrashed(account, selector, realId) {
|
|
326
|
+
return this.drives.get(driveKey(account, selector))?.nodes.get(realId)?.trashed === true;
|
|
327
|
+
}
|
|
328
|
+
// ---- internals ----------------------------------------------------
|
|
329
|
+
state(drive) {
|
|
330
|
+
this.assertAccount(drive.account);
|
|
331
|
+
const state = this.drives.get(driveKey(drive.account, drive.selector));
|
|
332
|
+
if (!state) {
|
|
333
|
+
throw new StorageError("NotFound", `unknown drive ${drive.id}`);
|
|
334
|
+
}
|
|
335
|
+
return state;
|
|
336
|
+
}
|
|
337
|
+
assertAccount(account) {
|
|
338
|
+
if (this.options.expiredAccounts?.includes(account)) {
|
|
339
|
+
throw new StorageError("AuthExpired", `login for ${account} expired`);
|
|
340
|
+
}
|
|
341
|
+
if (![...this.drives.values()].some((d) => d.drive.account === account)) {
|
|
342
|
+
throw new StorageError("AuthExpired", `no login for ${account}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
requireNode(drive, realId) {
|
|
346
|
+
const node = this.state(drive).nodes.get(realId);
|
|
347
|
+
if (!node || node.trashed) {
|
|
348
|
+
throw new StorageError("NotFound", `item ${realId} not found`);
|
|
349
|
+
}
|
|
350
|
+
return node;
|
|
351
|
+
}
|
|
352
|
+
requireFolder(state, realId) {
|
|
353
|
+
const node = state.nodes.get(realId);
|
|
354
|
+
if (!node || node.trashed || node.kind !== "folder") {
|
|
355
|
+
throw new StorageError("NotFound", `folder ${realId} not found`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
checkRevision(node, ifRevision) {
|
|
359
|
+
if (ifRevision !== undefined && ifRevision !== String(node.revision)) {
|
|
360
|
+
throw new StorageError("RevisionMismatch", `${node.name} changed (rev ${node.revision})`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
children(state, parentId) {
|
|
364
|
+
return [...state.nodes.values()].filter((n) => n.parentRealId === parentId && !n.trashed);
|
|
365
|
+
}
|
|
366
|
+
childNamed(state, parentId, name) {
|
|
367
|
+
return this.children(state, parentId).find((n) => n.name.toLowerCase() === name.toLowerCase());
|
|
368
|
+
}
|
|
369
|
+
freeName(state, parentId, name) {
|
|
370
|
+
const dot = name.lastIndexOf(".");
|
|
371
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
372
|
+
const ext = dot > 0 ? name.slice(dot) : "";
|
|
373
|
+
for (let i = 1;; i++) {
|
|
374
|
+
const candidate = `${stem} ${i}${ext}`;
|
|
375
|
+
if (!this.childNamed(state, parentId, candidate)) {
|
|
376
|
+
return candidate;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
pathOf(state, node) {
|
|
381
|
+
const parts = [];
|
|
382
|
+
let current = node;
|
|
383
|
+
while (current && current.realId !== state.rootId) {
|
|
384
|
+
parts.unshift(current.name);
|
|
385
|
+
current = current.parentRealId ? state.nodes.get(current.parentRealId) : undefined;
|
|
386
|
+
if (current === undefined) {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return `/${parts.join("/")}`;
|
|
391
|
+
}
|
|
392
|
+
toItem(drive, node) {
|
|
393
|
+
const state = this.state(drive);
|
|
394
|
+
return {
|
|
395
|
+
realId: node.realId,
|
|
396
|
+
parentRealId: node.parentRealId,
|
|
397
|
+
name: node.name,
|
|
398
|
+
// The gdrive flavor, like the real API, does not report paths.
|
|
399
|
+
path: this.flavor === "gdrive" ? null : this.pathOf(state, node),
|
|
400
|
+
kind: node.kind,
|
|
401
|
+
mimeType: node.mimeType,
|
|
402
|
+
size: node.kind === "file" ? (node.content?.length ?? 0) : null,
|
|
403
|
+
createdUtc: node.createdUtc,
|
|
404
|
+
modifiedUtc: node.modifiedUtc,
|
|
405
|
+
modifiedBy: "Fake User",
|
|
406
|
+
revision: String(node.revision),
|
|
407
|
+
contentHash: null,
|
|
408
|
+
webUrl: `https://fake.example/${node.realId}`,
|
|
409
|
+
isShared: false,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
makeNode(realId, parentRealId, name, kind, mimeType, content) {
|
|
413
|
+
const now = this.tick();
|
|
414
|
+
return {
|
|
415
|
+
realId,
|
|
416
|
+
parentRealId,
|
|
417
|
+
name,
|
|
418
|
+
kind,
|
|
419
|
+
mimeType,
|
|
420
|
+
content,
|
|
421
|
+
createdUtc: now,
|
|
422
|
+
modifiedUtc: now,
|
|
423
|
+
revision: 1,
|
|
424
|
+
trashed: false,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
newId() {
|
|
428
|
+
return `id${this.nextId++}`;
|
|
429
|
+
}
|
|
430
|
+
tick() {
|
|
431
|
+
this.clock += 60_000;
|
|
432
|
+
return new Date(this.clock).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/** Map key of one drive. */
|
|
436
|
+
function driveKey(account, selector) {
|
|
437
|
+
return `${account}\u0000${selector ?? ""}`;
|
|
438
|
+
}
|
|
439
|
+
/** Offset-cursor pagination over an in-memory array. */
|
|
440
|
+
function paginate(all, options) {
|
|
441
|
+
const offset = options.cursor !== undefined ? Number.parseInt(options.cursor, 10) : 0;
|
|
442
|
+
const entries = all.slice(offset, offset + options.limit);
|
|
443
|
+
const next = offset + entries.length;
|
|
444
|
+
return { entries, nextCursor: next < all.length ? String(next) : null };
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Sleep `ms`, rejecting early when `signal` aborts.
|
|
448
|
+
*
|
|
449
|
+
* @throws The signal's abort reason.
|
|
450
|
+
*/
|
|
451
|
+
function delay(ms, signal) {
|
|
452
|
+
return new Promise((resolve, reject) => {
|
|
453
|
+
const timer = setTimeout(resolve, ms);
|
|
454
|
+
signal?.addEventListener("abort", () => {
|
|
455
|
+
clearTimeout(timer);
|
|
456
|
+
reject(signal.reason);
|
|
457
|
+
}, { once: true });
|
|
458
|
+
});
|
|
459
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type StorageProvider } from "../Storage.js";
|
|
2
|
+
/** Settings for one contract run. */
|
|
3
|
+
export interface StorageContractOptions {
|
|
4
|
+
/** Account to test against; must be logged in. */
|
|
5
|
+
readonly account: string;
|
|
6
|
+
/** Drive selector (`null` = primary drive). */
|
|
7
|
+
readonly selector: string | null;
|
|
8
|
+
/**
|
|
9
|
+
* Existing folder path (absolute within the drive) the suite may write
|
|
10
|
+
* into. The suite creates and trashes a uniquely named sub-folder.
|
|
11
|
+
* Default: the drive root.
|
|
12
|
+
*/
|
|
13
|
+
readonly basePath?: string;
|
|
14
|
+
/** Also upload a file of this many bytes (exercises resumable uploads). */
|
|
15
|
+
readonly largeFileBytes?: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Shared behavioural contract every {@link StorageProvider} must pass.
|
|
19
|
+
* Registered as a `node:test` suite; run it from a provider package's
|
|
20
|
+
* test file against the in-memory fake (always) and against a real
|
|
21
|
+
* account behind an env flag, e.g.:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* const upn = process.env.FAMILIAR_LIVE_MS365;
|
|
25
|
+
* if (upn) runStorageProviderContract("ms365 live", () => makeProvider(), { account: upn, selector: null });
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @param name - Suite name.
|
|
29
|
+
* @param makeProvider - Factory for the provider under test.
|
|
30
|
+
* @param options - Account, drive and scratch-folder settings.
|
|
31
|
+
*/
|
|
32
|
+
export declare function runStorageProviderContract(name: string, makeProvider: () => StorageProvider | Promise<StorageProvider>, options: StorageContractOptions): void;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { after, before, describe, it } from "node:test";
|
|
7
|
+
import { StorageError, } from "../Storage.js";
|
|
8
|
+
/**
|
|
9
|
+
* Shared behavioural contract every {@link StorageProvider} must pass.
|
|
10
|
+
* Registered as a `node:test` suite; run it from a provider package's
|
|
11
|
+
* test file against the in-memory fake (always) and against a real
|
|
12
|
+
* account behind an env flag, e.g.:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const upn = process.env.FAMILIAR_LIVE_MS365;
|
|
16
|
+
* if (upn) runStorageProviderContract("ms365 live", () => makeProvider(), { account: upn, selector: null });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* @param name - Suite name.
|
|
20
|
+
* @param makeProvider - Factory for the provider under test.
|
|
21
|
+
* @param options - Account, drive and scratch-folder settings.
|
|
22
|
+
*/
|
|
23
|
+
export function runStorageProviderContract(name, makeProvider, options) {
|
|
24
|
+
describe(`StorageProvider contract: ${name}`, () => {
|
|
25
|
+
let provider;
|
|
26
|
+
let drive;
|
|
27
|
+
let base;
|
|
28
|
+
let localDir;
|
|
29
|
+
before(async () => {
|
|
30
|
+
provider = await makeProvider();
|
|
31
|
+
drive = await provider.openDrive(options.account, options.selector);
|
|
32
|
+
localDir = await mkdtemp(path.join(tmpdir(), "storage-contract-"));
|
|
33
|
+
const parentId = await resolveBase(provider, drive, options.basePath);
|
|
34
|
+
const suffix = randomBytes(4).toString("hex");
|
|
35
|
+
base = await provider.createFolder(drive, parentId, `familiar-contract-${suffix}`, {
|
|
36
|
+
conflict: "fail",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
after(async () => {
|
|
40
|
+
if (base) {
|
|
41
|
+
await provider.trash(drive, base.realId, {}).catch(() => undefined);
|
|
42
|
+
}
|
|
43
|
+
if (localDir) {
|
|
44
|
+
await rm(localDir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
it("lists the account and opens its drive", async () => {
|
|
48
|
+
const accounts = await provider.listAccounts();
|
|
49
|
+
assert.ok(accounts.includes(options.account), `accounts: ${accounts.join(", ")}`);
|
|
50
|
+
const drives = await provider.listDrives(options.account, { limit: 50 });
|
|
51
|
+
assert.ok(drives.some((d) => d.id === drive.id), "opened drive is listed");
|
|
52
|
+
});
|
|
53
|
+
it("rejects an unknown drive selector with NotFound", async () => {
|
|
54
|
+
await assert.rejects(provider.openDrive(options.account, "drives/definitely-not-a-drive"), isStorageError("NotFound"));
|
|
55
|
+
});
|
|
56
|
+
it("stats the root as a folder", async () => {
|
|
57
|
+
const root = await provider.stat(drive, await provider.rootRealId(drive));
|
|
58
|
+
assert.equal(root?.kind, "folder");
|
|
59
|
+
});
|
|
60
|
+
it("creates folders with fail / reuse semantics", async () => {
|
|
61
|
+
const folder = await provider.createFolder(drive, base.realId, "sub", {
|
|
62
|
+
conflict: "fail",
|
|
63
|
+
});
|
|
64
|
+
assert.equal(folder.kind, "folder");
|
|
65
|
+
const again = await provider.createFolder(drive, base.realId, "sub", {
|
|
66
|
+
conflict: "reuse",
|
|
67
|
+
});
|
|
68
|
+
assert.equal(again.realId, folder.realId);
|
|
69
|
+
if (provider.capabilities(drive).uniqueNames) {
|
|
70
|
+
await assert.rejects(provider.createFolder(drive, base.realId, "sub", { conflict: "fail" }), isStorageError("NameConflict"));
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
it("uploads, lists, stats and downloads a file byte-for-byte", async () => {
|
|
74
|
+
const src = path.join(localDir, "hello.txt");
|
|
75
|
+
await writeFile(src, "hello contract");
|
|
76
|
+
const item = await provider.upload(drive, base.realId, "hello.txt", { path: src, size: 14, mimeType: "text/plain" }, { conflict: "fail" });
|
|
77
|
+
assert.equal(item.kind, "file");
|
|
78
|
+
assert.equal(item.size, 14);
|
|
79
|
+
assert.ok(item.revision, "upload returns a revision");
|
|
80
|
+
const page = await provider.list(drive, base.realId, { limit: 50 });
|
|
81
|
+
assert.ok(page.entries.some((e) => e.realId === item.realId));
|
|
82
|
+
const stat = await provider.stat(drive, item.realId);
|
|
83
|
+
assert.equal(stat?.name, "hello.txt");
|
|
84
|
+
const dest = path.join(localDir, "hello.down");
|
|
85
|
+
const result = await provider.download(drive, item.realId, dest);
|
|
86
|
+
assert.equal(result.bytes, 14);
|
|
87
|
+
assert.equal(await readFile(dest, "utf8"), "hello contract");
|
|
88
|
+
});
|
|
89
|
+
it("honours conflict=fail and guarded replace", async () => {
|
|
90
|
+
const src = path.join(localDir, "rev.txt");
|
|
91
|
+
await writeFile(src, "v1");
|
|
92
|
+
const v1 = await provider.upload(drive, base.realId, "rev.txt", { path: src, size: 2 }, { conflict: "fail" });
|
|
93
|
+
if (provider.capabilities(drive).uniqueNames) {
|
|
94
|
+
await assert.rejects(provider.upload(drive, base.realId, "rev.txt", { path: src, size: 2 }, { conflict: "fail" }), isStorageError("NameConflict"));
|
|
95
|
+
}
|
|
96
|
+
await writeFile(src, "v2!");
|
|
97
|
+
const v2 = await provider.upload(drive, base.realId, "rev.txt", { path: src, size: 3 }, {
|
|
98
|
+
conflict: "replace",
|
|
99
|
+
replaceRealId: v1.realId,
|
|
100
|
+
ifRevision: v1.revision ?? undefined,
|
|
101
|
+
});
|
|
102
|
+
assert.equal(v2.realId, v1.realId);
|
|
103
|
+
assert.notEqual(v2.revision, v1.revision);
|
|
104
|
+
await assert.rejects(provider.upload(drive, base.realId, "rev.txt", { path: src, size: 3 }, {
|
|
105
|
+
conflict: "replace",
|
|
106
|
+
replaceRealId: v1.realId,
|
|
107
|
+
ifRevision: v1.revision ?? "stale",
|
|
108
|
+
}), isStorageError("RevisionMismatch"));
|
|
109
|
+
});
|
|
110
|
+
it("renames, moves, copies and trashes", async () => {
|
|
111
|
+
const src = path.join(localDir, "mv.txt");
|
|
112
|
+
await writeFile(src, "move me");
|
|
113
|
+
const file = await provider.upload(drive, base.realId, "mv.txt", { path: src, size: 7 }, { conflict: "fail" });
|
|
114
|
+
const dest = await provider.createFolder(drive, base.realId, "dest", {
|
|
115
|
+
conflict: "reuse",
|
|
116
|
+
});
|
|
117
|
+
const renamed = await provider.move(drive, file.realId, { name: "moved.txt" }, {});
|
|
118
|
+
assert.equal(renamed.name, "moved.txt");
|
|
119
|
+
const moved = await provider.move(drive, file.realId, { parentRealId: dest.realId }, {});
|
|
120
|
+
assert.equal(moved.parentRealId, dest.realId);
|
|
121
|
+
const copied = await provider.copy(drive, file.realId, { parentRealId: base.realId, name: "copy.txt" }, { conflict: "fail" });
|
|
122
|
+
assert.equal(copied.name, "copy.txt");
|
|
123
|
+
await provider.trash(drive, copied.realId, {});
|
|
124
|
+
assert.equal(await provider.stat(drive, copied.realId), null);
|
|
125
|
+
});
|
|
126
|
+
it("resolves paths natively when path-addressed", async () => {
|
|
127
|
+
if (provider.capabilities(drive).addressing !== "path" || !provider.resolvePath) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const basePath = (await provider.stat(drive, base.realId))?.path;
|
|
131
|
+
assert.ok(basePath, "path-addressed providers report paths");
|
|
132
|
+
const hits = await provider.resolvePath(drive, `${basePath}/sub`);
|
|
133
|
+
assert.equal(hits.length, 1);
|
|
134
|
+
assert.deepEqual(await provider.resolvePath(drive, `${basePath}/nope-${Date.now()}`), []);
|
|
135
|
+
});
|
|
136
|
+
it("uploads a large file", { skip: options.largeFileBytes === undefined }, async () => {
|
|
137
|
+
const size = options.largeFileBytes ?? 0;
|
|
138
|
+
const src = path.join(localDir, "large.bin");
|
|
139
|
+
await writeFile(src, randomBytes(size));
|
|
140
|
+
const item = await provider.upload(drive, base.realId, "large.bin", { path: src, size }, { conflict: "fail" });
|
|
141
|
+
assert.equal(item.size, size);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Resolve the base folder the suite writes into.
|
|
147
|
+
*
|
|
148
|
+
* @throws Error when `basePath` does not resolve to exactly one folder.
|
|
149
|
+
*/
|
|
150
|
+
async function resolveBase(provider, drive, basePath) {
|
|
151
|
+
const root = await provider.rootRealId(drive);
|
|
152
|
+
if (basePath === undefined || basePath === "/") {
|
|
153
|
+
return root;
|
|
154
|
+
}
|
|
155
|
+
if (!provider.resolvePath) {
|
|
156
|
+
throw new Error("basePath requires a path-addressed provider");
|
|
157
|
+
}
|
|
158
|
+
const hits = await provider.resolvePath(drive, basePath);
|
|
159
|
+
const folder = hits.length === 1 ? hits[0] : undefined;
|
|
160
|
+
if (folder?.kind !== "folder") {
|
|
161
|
+
throw new Error(`basePath ${basePath} is not a single folder`);
|
|
162
|
+
}
|
|
163
|
+
return folder.realId;
|
|
164
|
+
}
|
|
165
|
+
/** `assert.rejects` matcher for a {@link StorageError} with `code`. */
|
|
166
|
+
function isStorageError(code) {
|
|
167
|
+
return (err) => err instanceof StorageError && err.code === code;
|
|
168
|
+
}
|
package/build/testing/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
export { FAKE_NATIVE_EXPORTS, type FakeStorageFlavor, type FakeStorageOptions, FakeStorageProvider, } from "./FakeStorageProvider.js";
|
|
1
2
|
export { MockAgentRunBus, type MockAgentRunBusListener } from "./MockAgentRunBus.js";
|
|
2
3
|
export { MockBusStore } from "./MockBusStore.js";
|
|
3
4
|
export { MockEventBus } from "./MockEventBus.js";
|
|
4
5
|
export { type MockLogEntry, MockLogger } from "./MockLogger.js";
|
|
6
|
+
export { runStorageProviderContract, type StorageContractOptions, } from "./StorageProviderContract.js";
|
package/build/testing/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
export { FAKE_NATIVE_EXPORTS, FakeStorageProvider, } from "./FakeStorageProvider.js";
|
|
1
2
|
export { MockAgentRunBus } from "./MockAgentRunBus.js";
|
|
2
3
|
export { MockBusStore } from "./MockBusStore.js";
|
|
3
4
|
export { MockEventBus } from "./MockEventBus.js";
|
|
4
5
|
export { MockLogger } from "./MockLogger.js";
|
|
6
|
+
export { runStorageProviderContract, } from "./StorageProviderContract.js";
|