@telestack/storage 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.
@@ -0,0 +1,332 @@
1
+ import { UploadState, UploadTaskSnapshot, Observer, UploadOptions, UploadResult, FileMetadata } from './types';
2
+ import { HttpClient } from './HttpClient';
3
+ import { ImageHelper } from './ImageHelper';
4
+ import { CryptoHelper } from './CryptoHelper';
5
+
6
+ /**
7
+ * A cancellable, resumable, and observable upload task.
8
+ * Designed directly after Firebase Storage's UploadTask but optimized for Telestack resumable multipart pipelines.
9
+ */
10
+ export class UploadTask implements Promise<UploadResult> {
11
+ private _state: UploadState = 'processing';
12
+ private _bytesTransferred = 0;
13
+ private _totalBytes: number = 0;
14
+ private _data: Blob;
15
+ private _observers: Observer<UploadTaskSnapshot>[] = [];
16
+ private _promise: Promise<UploadResult>;
17
+ private _resolve!: (res: UploadResult) => void;
18
+ private _reject!: (err: Error) => void;
19
+
20
+ // Resumable internals
21
+ private _uploadId?: string;
22
+ private _parts: { PartNumber: number; ETag: string }[] = [];
23
+ private _currentPartIndex = 0;
24
+ private _activeXhr?: XMLHttpRequest;
25
+ private _isResumable = false;
26
+
27
+ // Simple upload internals
28
+ private _simpleFileMetadata?: FileMetadata;
29
+
30
+ constructor(
31
+ private readonly client: HttpClient,
32
+ private readonly path: string,
33
+ data: Blob,
34
+ private readonly name: string,
35
+ private readonly contentType: string,
36
+ private readonly options: UploadOptions,
37
+ ) {
38
+ this._data = data;
39
+ this._totalBytes = data.size;
40
+ this._promise = new Promise((resolve, reject) => {
41
+ this._resolve = resolve;
42
+ this._reject = reject;
43
+ });
44
+
45
+ // Auto-start
46
+ this._start();
47
+ }
48
+
49
+ /** Register observers for state changes, errors, and completion. */
50
+ on(
51
+ event: 'state_changed',
52
+ nextOrObserver?: Observer<UploadTaskSnapshot> | ((snap: UploadTaskSnapshot) => void),
53
+ error?: (err: Error) => void,
54
+ complete?: () => void
55
+ ): () => void {
56
+ const observer: Observer<UploadTaskSnapshot> = typeof nextOrObserver === 'function'
57
+ ? { next: nextOrObserver, error, complete }
58
+ : (nextOrObserver || { error, complete });
59
+
60
+ this._observers.push(observer);
61
+
62
+ // Immediately emit current state
63
+ if (observer.next) observer.next(this.snapshot);
64
+
65
+ // Return an unsubscribe function
66
+ return () => {
67
+ this._observers = this._observers.filter(o => o !== observer);
68
+ };
69
+ }
70
+
71
+ /** Suspend the upload. Only works elegantly on resumable uploads, but supported for all. */
72
+ pause(): boolean {
73
+ if (this._state !== 'running') return false;
74
+ this._state = 'paused';
75
+ if (this._activeXhr) {
76
+ this._activeXhr.abort(); // abort current chunk/request
77
+ this._activeXhr = undefined;
78
+ }
79
+ this._notifyObservers();
80
+ return true;
81
+ }
82
+
83
+ /** Resume a paused upload. */
84
+ resume(): boolean {
85
+ if (this._state !== 'paused') return false;
86
+ this._state = 'running';
87
+ this._notifyObservers();
88
+
89
+ if (this._isResumable) {
90
+ this._continueResumable().catch(this._handleError);
91
+ } else {
92
+ // Simple upload must restart entirely if paused mid-flight
93
+ this._bytesTransferred = 0;
94
+ this._notifyObservers();
95
+ this._startSimple().catch(this._handleError);
96
+ }
97
+ return true;
98
+ }
99
+
100
+ /** Permanently cancel the upload, cleaning up partial server state if necessary. */
101
+ cancel(): boolean {
102
+ if (this._state === 'success' || this._state === 'error' || this._state === 'canceled') return false;
103
+
104
+ this._state = 'canceled';
105
+ if (this._activeXhr) {
106
+ this._activeXhr.abort();
107
+ this._activeXhr = undefined;
108
+ }
109
+
110
+ // Cleanup Server State
111
+ if (this._isResumable && this._uploadId) {
112
+ this.client.post('/files/resumable/abort', { path: this.path, uploadId: this._uploadId }).catch(() => { });
113
+ } else if (!this._isResumable && this._simpleFileMetadata) {
114
+ this.client.delete(`/files/${encodeURIComponent(this.path)}`).catch(() => { });
115
+ }
116
+
117
+ this._notifyObservers();
118
+ const err = new Error('Upload canceled by user');
119
+ err.name = 'UploadCanceled';
120
+ this._handleError(err);
121
+ return true;
122
+ }
123
+
124
+ get snapshot(): UploadTaskSnapshot {
125
+ return {
126
+ bytesTransferred: this._bytesTransferred,
127
+ totalBytes: this._totalBytes,
128
+ state: this._state,
129
+ task: this,
130
+ };
131
+ }
132
+
133
+ // Promise implementation
134
+ then<TResult1 = UploadResult, TResult2 = never>(
135
+ onfulfilled?: ((value: UploadResult) => TResult1 | PromiseLike<TResult1>) | null,
136
+ onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
137
+ ): Promise<TResult1 | TResult2> {
138
+ return this._promise.then(onfulfilled, onrejected);
139
+ }
140
+ catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null): Promise<UploadResult | TResult> {
141
+ return this._promise.catch(onrejected);
142
+ }
143
+ finally(onfinally?: (() => void) | null): Promise<UploadResult> {
144
+ return this._promise.finally(onfinally);
145
+ }
146
+ readonly [Symbol.toStringTag] = "UploadTask";
147
+
148
+ // ── Internal Upload Logic ──────────────────────────────────────────────
149
+
150
+ private async _start() {
151
+ try {
152
+ // Processing Pipeline
153
+ if (this.options.compressImage && this._data.type.startsWith('image/')) {
154
+ this._data = await ImageHelper.compress(this._data, this.options.compressImage);
155
+ }
156
+
157
+ if (this.options.encryptionKey) {
158
+ this._data = await CryptoHelper.encrypt(this._data, this.options.encryptionKey);
159
+ }
160
+
161
+ this._totalBytes = this._data.size;
162
+
163
+ // If the upload was canceled during the processing delay, halt.
164
+ if (this._state !== 'processing') return;
165
+
166
+ this._state = 'running';
167
+ this._notifyObservers();
168
+
169
+ const MULTIPART_THRESHOLD = 50 * 1024 * 1024; // 50MB
170
+ this._isResumable = this._totalBytes >= MULTIPART_THRESHOLD;
171
+
172
+ if (this._isResumable) {
173
+ await this._startResumable();
174
+ } else {
175
+ await this._startSimple();
176
+ }
177
+ } catch (err: any) {
178
+ this._handleError(err);
179
+ }
180
+ }
181
+
182
+ private async _startSimple() {
183
+ // Init
184
+ const res = await this.client.post<{ uploadUrl: string; file: FileMetadata }>('/files/upload-url', {
185
+ path: this.path, name: this.name, size: this._totalBytes, contentType: this.contentType,
186
+ userId: this.options.userId, metadata: this.options.metadata,
187
+ });
188
+
189
+ this._simpleFileMetadata = res.file;
190
+ if (this._state !== 'running') return; // aborted during init
191
+
192
+ // Upload
193
+ const xhr = new XMLHttpRequest();
194
+ this._activeXhr = xhr;
195
+
196
+ await new Promise<void>((resolve, reject) => {
197
+ xhr.open('PUT', res.uploadUrl);
198
+ xhr.setRequestHeader('Content-Type', this.contentType);
199
+
200
+ xhr.upload.onprogress = (e) => {
201
+ if (e.lengthComputable && this._state === 'running') {
202
+ this._bytesTransferred = e.loaded;
203
+ this._notifyObservers();
204
+ }
205
+ };
206
+
207
+ xhr.onload = () => {
208
+ if (xhr.status >= 200 && xhr.status < 300) resolve();
209
+ else reject(new Error(`Upload failed: ${xhr.status} ${xhr.responseText}`));
210
+ };
211
+ xhr.onerror = () => reject(new Error('Network error during upload'));
212
+ xhr.onabort = () => reject(new Error('Upload aborted'));
213
+
214
+ xhr.send(this._data);
215
+ });
216
+
217
+ this._activeXhr = undefined;
218
+ if (this._state !== 'running') return;
219
+
220
+ // Complete
221
+ await this.client.post('/files/complete-upload', { path: this.path });
222
+
223
+ this._bytesTransferred = this._totalBytes;
224
+ this._state = 'success';
225
+ this._notifyObservers();
226
+ this._resolve({ file: res.file, resumable: false });
227
+ }
228
+
229
+ private async _startResumable() {
230
+ // 1. Init Session
231
+ const init = await this.client.post<{ uploadId: string; file: FileMetadata }>('/files/resumable/init', {
232
+ path: this.path, name: this.name, size: this._totalBytes, contentType: this.contentType,
233
+ userId: this.options.userId, metadata: this.options.metadata,
234
+ });
235
+
236
+ this._uploadId = init.uploadId;
237
+ this._simpleFileMetadata = init.file;
238
+
239
+ await this._continueResumable();
240
+ }
241
+
242
+ private async _continueResumable() {
243
+ if (!this._uploadId || !this._simpleFileMetadata) return;
244
+
245
+ const chunkSize = this.options.chunkSize ?? 10 * 1024 * 1024;
246
+ const totalParts = Math.ceil(this._totalBytes / chunkSize);
247
+
248
+ try {
249
+ while (this._currentPartIndex < totalParts) {
250
+ if (this._state !== 'running') return; // Paused or Canceled
251
+
252
+ const start = this._currentPartIndex * chunkSize;
253
+ const end = Math.min(start + chunkSize, this._totalBytes);
254
+ const chunk = this._data.slice(start, end);
255
+
256
+ // Get part URL
257
+ const partRes = await this.client.post<{ uploadUrl: string }>('/files/resumable/part-url', {
258
+ path: this.path, uploadId: this._uploadId, partNumber: this._currentPartIndex + 1
259
+ });
260
+
261
+ if (this._state !== 'running') return;
262
+
263
+ // Upload Chunk
264
+ const etag = await this._uploadChunkWithProgress(partRes.uploadUrl, chunk, start);
265
+
266
+ this._parts.push({ PartNumber: this._currentPartIndex + 1, ETag: etag });
267
+ this._currentPartIndex++;
268
+ this._bytesTransferred = Math.min(this._currentPartIndex * chunkSize, this._totalBytes);
269
+ this._notifyObservers();
270
+ }
271
+
272
+ if (this._state === 'running') {
273
+ // Complete
274
+ await this.client.post('/files/resumable/complete', {
275
+ path: this.path, uploadId: this._uploadId, parts: this._parts
276
+ });
277
+
278
+ this._state = 'success';
279
+ this._notifyObservers();
280
+ this._resolve({ file: this._simpleFileMetadata, resumable: true });
281
+ }
282
+ } catch (err: any) {
283
+ if (err.message === 'Upload aborted') return; // Gracefully handled by pause/cancel
284
+ this._handleError(err);
285
+ }
286
+ }
287
+
288
+ private _uploadChunkWithProgress(url: string, chunk: Blob, startOffset: number): Promise<string> {
289
+ return new Promise((resolve, reject) => {
290
+ const xhr = new XMLHttpRequest();
291
+ this._activeXhr = xhr;
292
+
293
+ xhr.open('PUT', url);
294
+ xhr.setRequestHeader('Content-Type', this.contentType);
295
+
296
+ xhr.upload.onprogress = (e) => {
297
+ if (e.lengthComputable && this._state === 'running') {
298
+ this._bytesTransferred = startOffset + e.loaded;
299
+ this._notifyObservers();
300
+ }
301
+ };
302
+
303
+ xhr.onload = () => {
304
+ if (xhr.status >= 200 && xhr.status < 300) {
305
+ const etag = xhr.getResponseHeader('ETag') || '';
306
+ resolve(etag.replace(/"/g, ''));
307
+ } else reject(new Error(`Part upload failed: ${xhr.status}`));
308
+ };
309
+ xhr.onerror = () => reject(new Error('Network error during part upload'));
310
+ xhr.onabort = () => reject(new Error('Upload aborted'));
311
+
312
+ xhr.send(chunk);
313
+ });
314
+ }
315
+
316
+ private _notifyObservers() {
317
+ const snap = this.snapshot;
318
+ this._observers.forEach(obs => {
319
+ if (snap.state === 'success' && obs.complete) obs.complete();
320
+ else if (obs.next) obs.next(snap);
321
+ });
322
+ }
323
+
324
+ private _handleError = (err: Error) => {
325
+ if (this._state === 'canceled') return; // Error naturally triggered via abort
326
+ this._state = 'error';
327
+ this._activeXhr = undefined;
328
+ this._notifyObservers();
329
+ this._observers.forEach(o => o.error?.(err));
330
+ this._reject(err);
331
+ }
332
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // TelestackStorage Web SDK — Main Entry Point
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ export { TelestackStorage } from './TelestackStorage.js';
5
+ export { StorageRef, FileRef, DirRef } from './StorageRef.js';
6
+ export { UploadTask } from './UploadTask.js';
7
+ export { BatchBuilder } from './BatchBuilder.js';
8
+ export { QueryBuilder } from './QueryBuilder.js';
9
+ export { HttpClient, TelestackError } from './HttpClient.js';
10
+ export { CryptoHelper } from './CryptoHelper.js';
11
+ export { ImageHelper } from './ImageHelper.js';
12
+
13
+ export type {
14
+ TelestackConfig,
15
+ FileMetadata,
16
+ UploadOptions,
17
+ DownloadOptions,
18
+ ListOptions,
19
+ SearchOptions,
20
+ SearchResult,
21
+ UploadResult,
22
+ UploadState,
23
+ UploadTaskSnapshot,
24
+ Observer,
25
+ TagSet,
26
+ BatchCopyResult,
27
+ BatchDeleteResult,
28
+ } from './types';
package/src/types.ts ADDED
@@ -0,0 +1,91 @@
1
+ export interface TelestackConfig {
2
+ baseUrl: string;
3
+ apiKey?: string;
4
+ token?: string;
5
+ tenantId: string;
6
+ defaultUploadOptions?: Partial<UploadOptions>;
7
+ }
8
+
9
+ export interface FileMetadata {
10
+ id: string;
11
+ tenant_id: string;
12
+ path: string;
13
+ name: string;
14
+ size: number;
15
+ content_type: string;
16
+ owner_id: string;
17
+ status: 'pending' | 'active' | 'deleted';
18
+ metadata: Record<string, any>;
19
+ created_at: string;
20
+ updated_at: string;
21
+ }
22
+
23
+ export interface UploadOptions {
24
+ tenantId?: string;
25
+ userId: string;
26
+ metadata?: Record<string, any>;
27
+ chunkSize?: number; // default 10MB
28
+ encryptionKey?: string; // Base64 AES-GCM key for E2EE
29
+ compressImage?: {
30
+ maxWidth?: number;
31
+ maxHeight?: number;
32
+ quality?: number; // 0.0 to 1.0
33
+ };
34
+ }
35
+
36
+ export interface DownloadOptions {
37
+ versionId?: string;
38
+ }
39
+
40
+ export interface ListOptions {
41
+ prefix?: string;
42
+ limit?: number;
43
+ }
44
+
45
+ export interface SearchOptions {
46
+ query?: string;
47
+ metadata?: Record<string, any>;
48
+ limit?: number;
49
+ }
50
+
51
+ export interface SearchResult {
52
+ success: boolean;
53
+ files: FileMetadata[];
54
+ }
55
+
56
+ export interface UploadResult {
57
+ file: FileMetadata;
58
+ resumable: boolean;
59
+ }
60
+
61
+ export interface BatchDeleteResult {
62
+ success: boolean;
63
+ deletedCount: number;
64
+ errors: any[];
65
+ }
66
+
67
+ export interface BatchCopyResult {
68
+ success: boolean;
69
+ results: any[];
70
+ }
71
+
72
+ export interface TagSet {
73
+ Key: string;
74
+ Value: string;
75
+ }
76
+
77
+ /** Upload State Enum exactly like Firebase */
78
+ export type UploadState = 'processing' | 'running' | 'paused' | 'success' | 'error' | 'canceled';
79
+
80
+ export interface UploadTaskSnapshot {
81
+ bytesTransferred: number;
82
+ totalBytes: number;
83
+ state: UploadState;
84
+ task: import('./UploadTask').UploadTask;
85
+ }
86
+
87
+ export type Observer<T> = {
88
+ next?: (value: T) => void;
89
+ error?: (error: Error) => void;
90
+ complete?: () => void;
91
+ };
package/test-e2e.js ADDED
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const jsdom_1 = require("jsdom");
4
+ const dom = new jsdom_1.JSDOM();
5
+ global.XMLHttpRequest = dom.window.XMLHttpRequest;
6
+ global.File = dom.window.File;
7
+ global.Blob = dom.window.Blob;
8
+ const jose_1 = require("jose");
9
+ const index_js_1 = require("./dist/index.js");
10
+ const TENANT_ID = 'test-tenant';
11
+ const USER_ID = 'user-sdk-test';
12
+ const JWT_SECRET = new TextEncoder().encode('telestack-super-secret-key-1234567890');
13
+ async function getAuthToken() {
14
+ return await new jose_1.SignJWT({
15
+ sub: USER_ID,
16
+ tenant_id: TENANT_ID,
17
+ role: 'admin',
18
+ email: 'test@example.com'
19
+ })
20
+ .setProtectedHeader({ alg: 'HS256' })
21
+ .setIssuedAt()
22
+ .setIssuer('telestack-auth')
23
+ .setAudience('telestack-storage')
24
+ .setExpirationTime('2h')
25
+ .sign(JWT_SECRET);
26
+ }
27
+ async function runSdkTests() {
28
+ const token = await getAuthToken();
29
+ const storage = new index_js_1.TelestackStorage({
30
+ baseUrl: 'http://localhost:8787',
31
+ tenantId: TENANT_ID,
32
+ token: token,
33
+ });
34
+ console.log('🧪 Starting Telestack Storage Web SDK Comprehensive Tests...\n');
35
+ try {
36
+ // --- 1. Simple Upload ---
37
+ console.log('1️⃣ Testing Simple Upload (UploadTask)...');
38
+ const simpleContent = new TextEncoder().encode('Hello Telestack SDK Simple Test!');
39
+ const simpleBlob = new Blob([simpleContent], { type: 'text/plain' });
40
+ const simpleTask = storage.ref('sdk-tests/simple.txt').put(simpleBlob, { userId: USER_ID });
41
+ await new Promise((resolve, reject) => {
42
+ simpleTask.on('state_changed', (snap) => console.log(` Progress: ${(snap.bytesTransferred / snap.totalBytes * 100).toFixed(0)}%`), (err) => reject(err), () => resolve());
43
+ });
44
+ console.log('✅ Simple Upload Completed\n');
45
+ // --- 2. Querying Metadata ---
46
+ console.log('2️⃣ Testing Query Builder...');
47
+ const queryResults = await storage.query()
48
+ .nameContains('simple.txt')
49
+ .limit(5)
50
+ .get();
51
+ console.log(` Found ${queryResults.length} files matching query.`);
52
+ if (queryResults.length === 0)
53
+ throw new Error('Query failed to find uploaded file.');
54
+ console.log('✅ Query Builder Passed\n');
55
+ // --- 3. Directory Listing ---
56
+ console.log('3️⃣ Testing Directory Refs (listAll)...');
57
+ const dirFiles = await storage.dir('sdk-tests/').listAll();
58
+ console.log(` Found ${dirFiles.length} files in directory.`);
59
+ console.log('✅ Directory Listing Passed\n');
60
+ // --- 4. Resumable Upload (Chunking) ---
61
+ console.log('4️⃣ Testing Resumable Upload (Chunks + Pause/Resume)...');
62
+ // Generate a 60MB string dynamically for testing multipart chunking
63
+ const bigContentSize = 60 * 1024 * 1024;
64
+ const bigArray = new Uint8Array(bigContentSize);
65
+ bigArray.fill(65); // Fill with 'A'
66
+ const bigBlob = new Blob([bigArray], { type: 'application/zip' });
67
+ const resumableTask = storage.ref('sdk-tests/big-resumable.bin').put(bigBlob, {
68
+ userId: USER_ID,
69
+ chunkSize: 20 * 1024 * 1024, // 20MB chunks
70
+ metadata: { test: 'sdk-resumable' }
71
+ });
72
+ await new Promise((resolve, reject) => {
73
+ let paused = false;
74
+ resumableTask.on('state_changed', (snap) => {
75
+ console.log(` Resumable Progress: ${(snap.bytesTransferred / snap.totalBytes * 100).toFixed(0)}% (${snap.bytesTransferred} bytes)`);
76
+ // Pause halfway
77
+ if (snap.bytesTransferred > 20 * 1024 * 1024 && !paused) {
78
+ paused = true;
79
+ console.log(' ⏸️ Pausing upload...');
80
+ resumableTask.pause();
81
+ setTimeout(() => {
82
+ console.log(' ▶️ Resuming upload...');
83
+ resumableTask.resume();
84
+ }, 2000);
85
+ }
86
+ }, (err) => reject(err), () => resolve());
87
+ });
88
+ console.log('✅ Resumable Multipart Upload Passed\n');
89
+ // --- 5. Download URL & Metadata CRUD ---
90
+ console.log('5️⃣ Testing Download URL & Metadata Update...');
91
+ const simpleRef = storage.ref('sdk-tests/simple.txt');
92
+ const url = await simpleRef.getDownloadUrl();
93
+ console.log(' Download URL:', url.substring(0, 50) + '...');
94
+ await simpleRef.updateMetadata({ newField: 'UpdatedSDKMetadata' });
95
+ const updatedMeta = await simpleRef.getMetadata();
96
+ if (updatedMeta.metadata.newField !== 'UpdatedSDKMetadata')
97
+ throw new Error('Metadata update failed');
98
+ console.log('✅ Metadata Update Passed\n');
99
+ // --- 5.5 End-to-End Encryption (E2EE) ---
100
+ console.log('🔒 Testing End-to-End Encryption (E2EE)...');
101
+ const { CryptoHelper } = require('./dist/index.js');
102
+ const e2eKey = await CryptoHelper.generateKey();
103
+ const secretContent = new TextEncoder().encode('Super Secret Confidential Data!');
104
+ const secretBlob = new Blob([secretContent], { type: 'text/plain' });
105
+ const secureTask = storage.ref('sdk-tests/secure-data.txt').put(secretBlob, {
106
+ userId: USER_ID,
107
+ encryptionKey: e2eKey
108
+ });
109
+ await new Promise((resolve, reject) => {
110
+ secureTask.on('state_changed', () => { }, (err) => reject(err), () => resolve());
111
+ });
112
+ // Verify we can fetch and decrypt it successfully
113
+ console.log(' Downloading and decrypting E2EE blob...');
114
+ const decryptedBlob = await storage.ref('sdk-tests/secure-data.txt').getDecryptedBlob(e2eKey);
115
+ const decryptedText = await decryptedBlob.text();
116
+ if (decryptedText !== 'Super Secret Confidential Data!') {
117
+ throw new Error('E2EE Decryption failed or ciphertext mismatch');
118
+ }
119
+ console.log('✅ End-to-End Encryption (E2EE) Passed\n');
120
+ // --- 6. Batch Operations ---
121
+ console.log('6️⃣ Testing Batch/Mass Operations over network...');
122
+ console.log(' Moving files to /archive...');
123
+ const moveRes = await storage.batch()
124
+ .move(['sdk-tests/simple.txt', 'sdk-tests/big-resumable.bin'], 'sdk-archive/tests/')
125
+ .run();
126
+ if (!moveRes.success)
127
+ throw new Error('Batch Move failed');
128
+ console.log('✅ Batch Move Passed\n');
129
+ // --- Final Cleanup ---
130
+ console.log('🧹 Formatting cleanup (Batch Delete)...');
131
+ const finalDel = await storage.batch()
132
+ .delete(['sdk-archive/tests/simple.txt', 'sdk-archive/tests/big-resumable.bin', 'sdk-tests/secure-data.txt'])
133
+ .run();
134
+ console.log(` Deleted ${finalDel.deletedCount} files.`);
135
+ console.log('\n🎉 ALL WEB SDK TESTS PASSED SUCCESSFULLY! The SDK is robust.');
136
+ }
137
+ catch (err) {
138
+ console.error('\n❌ WEB SDK TEST FAILED:', err.message || err);
139
+ process.exit(1);
140
+ }
141
+ }
142
+ runSdkTests();