@soham20/smart-offline-sdk 0.2.1 → 1.0.1

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,578 @@
1
+ /**
2
+ * SmartOffline SDK - Test Utilities
3
+ *
4
+ * Provides comprehensive testing utilities for the SmartOffline SDK.
5
+ * These utilities can be used both in browser console and in test frameworks.
6
+ *
7
+ * Usage in browser console:
8
+ * ```javascript
9
+ * // Run all tests
10
+ * await runSmartOfflineTests()
11
+ *
12
+ * // Use cache inspector
13
+ * const inspector = new CacheInspector()
14
+ * await inspector.showAll()
15
+ * ```
16
+ */
17
+
18
+ import type {
19
+ SmartOfflineConfig,
20
+ CacheEvent,
21
+ UsageData,
22
+ } from "./SmartOfflineSetup";
23
+
24
+ // ============================================================================
25
+ // TYPES
26
+ // ============================================================================
27
+
28
+ export interface TestResult {
29
+ name: string;
30
+ passed: boolean;
31
+ message: string;
32
+ duration?: number;
33
+ }
34
+
35
+ export interface CacheInspectorData {
36
+ cachedItems: Array<{ url: string; size: number; contentType: string }>;
37
+ usageData: UsageData[];
38
+ logs: Array<{ type: string; url: string; timestamp: number }>;
39
+ }
40
+
41
+ // ============================================================================
42
+ // ALGORITHM FUNCTIONS (for testing)
43
+ // ============================================================================
44
+
45
+ /**
46
+ * Matches URL against wildcard pattern
47
+ * This is the same algorithm used in smart-offline-sw.js
48
+ */
49
+ export function matchesPattern(url: string, pattern: string): boolean {
50
+ if (!pattern.includes("*")) {
51
+ return url.includes(pattern);
52
+ }
53
+ const regexPattern = pattern
54
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
55
+ .replace(/\*/g, ".*");
56
+ return new RegExp(regexPattern).test(url);
57
+ }
58
+
59
+ /**
60
+ * Calculates if a URL should be treated as HIGH priority
61
+ * This is the core caching algorithm
62
+ */
63
+ export function isHighPriority(
64
+ usage: UsageData | null,
65
+ url: string,
66
+ config: Partial<SmartOfflineConfig> = {},
67
+ ): boolean {
68
+ const defaultConfig: SmartOfflineConfig = {
69
+ pages: [],
70
+ apis: [],
71
+ debug: false,
72
+ frequencyThreshold: 3,
73
+ recencyThreshold: 24 * 60 * 60 * 1000,
74
+ maxResourceSize: Infinity,
75
+ networkQuality: "auto",
76
+ significance: {},
77
+ weights: { frequency: 1, recency: 1, size: 1 },
78
+ customPriorityFn: null,
79
+ enableDetailedLogs: false,
80
+ serviceWorkerPath: "/smart-offline-sw.js",
81
+ serviceWorkerScope: "/",
82
+ };
83
+
84
+ const finalConfig = { ...defaultConfig, ...config };
85
+
86
+ // Custom priority function takes precedence
87
+ if (
88
+ finalConfig.customPriorityFn &&
89
+ typeof finalConfig.customPriorityFn === "function"
90
+ ) {
91
+ try {
92
+ return finalConfig.customPriorityFn(usage, url, finalConfig) > 50;
93
+ } catch (e) {
94
+ console.error("Custom priority function error:", e);
95
+ }
96
+ }
97
+
98
+ // Check significance overrides
99
+ for (const pattern in finalConfig.significance) {
100
+ if (url.includes(pattern)) {
101
+ if (finalConfig.significance[pattern] === "high") return true;
102
+ if (finalConfig.significance[pattern] === "low") return false;
103
+ }
104
+ }
105
+
106
+ // No usage data = low priority
107
+ if (!usage) return false;
108
+
109
+ // Calculate weighted priority score
110
+ const weights = finalConfig.weights || { frequency: 1, recency: 1, size: 1 };
111
+
112
+ // Frequency score: 0-100 based on access count vs threshold
113
+ const frequencyScore = Math.min(
114
+ 100,
115
+ (usage.count / finalConfig.frequencyThreshold) * 100,
116
+ );
117
+
118
+ // Recency score: 100 for just accessed, 0 for older than threshold
119
+ const timeSinceAccess = Date.now() - usage.lastAccessed;
120
+ const recencyScore = Math.max(
121
+ 0,
122
+ 100 - (timeSinceAccess / finalConfig.recencyThreshold) * 100,
123
+ );
124
+
125
+ // Weighted average
126
+ const totalWeight = weights.frequency + weights.recency;
127
+ const weightedScore =
128
+ (frequencyScore * weights.frequency + recencyScore * weights.recency) /
129
+ totalWeight;
130
+
131
+ return weightedScore > 50;
132
+ }
133
+
134
+ /**
135
+ * Determines if a URL should be cached based on pattern matching
136
+ */
137
+ export function shouldCacheUrl(
138
+ url: string,
139
+ config: Partial<SmartOfflineConfig> = {},
140
+ ): { isPage: boolean; isAPI: boolean } {
141
+ const pages = config.pages || [];
142
+ const apis = config.apis || [];
143
+ const isPage = pages.some((p) => matchesPattern(url, p));
144
+ const isAPI = apis.some((a) => matchesPattern(url, a));
145
+ return { isPage, isAPI };
146
+ }
147
+
148
+ // ============================================================================
149
+ // TEST SUITE CLASS
150
+ // ============================================================================
151
+
152
+ export class SmartOfflineTestSuite {
153
+ private results: TestResult[] = [];
154
+ private config: Partial<SmartOfflineConfig>;
155
+
156
+ constructor(config: Partial<SmartOfflineConfig> = {}) {
157
+ this.config = {
158
+ pages: ["/admin/*", "/admin/charts", "/admin/charts/*", "/grapher/*"],
159
+ apis: ["/admin/api/*"],
160
+ frequencyThreshold: 3,
161
+ recencyThreshold: 24 * 60 * 60 * 1000,
162
+ ...config,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Run all tests
168
+ */
169
+ async runAll(): Promise<TestResult[]> {
170
+ this.results = [];
171
+
172
+ // Algorithm tests
173
+ this.testPatternMatching();
174
+ this.testFrequencyPriority();
175
+ this.testRecencyPriority();
176
+ this.testWeightedScoring();
177
+ this.testSignificanceOverrides();
178
+
179
+ // Browser environment tests (if available)
180
+ if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
181
+ await this.testServiceWorkerActive();
182
+ await this.testCacheAPIAvailable();
183
+ await this.testIndexedDBAvailable();
184
+ }
185
+
186
+ return this.results;
187
+ }
188
+
189
+ /**
190
+ * Test pattern matching
191
+ */
192
+ private testPatternMatching(): void {
193
+ const tests = [
194
+ { url: "/admin/charts", pattern: "/admin/charts", expected: true },
195
+ { url: "/admin/charts/123", pattern: "/admin/charts/*", expected: true },
196
+ { url: "/grapher/gdp", pattern: "/grapher/*", expected: true },
197
+ { url: "/random", pattern: "/admin/*", expected: false },
198
+ ];
199
+
200
+ let passed = true;
201
+ let message = "All patterns matched correctly";
202
+
203
+ for (const test of tests) {
204
+ const result = matchesPattern(test.url, test.pattern);
205
+ if (result !== test.expected) {
206
+ passed = false;
207
+ message = `Pattern ${test.pattern} failed for ${test.url}`;
208
+ break;
209
+ }
210
+ }
211
+
212
+ this.results.push({
213
+ name: "Pattern Matching",
214
+ passed,
215
+ message,
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Test frequency-based priority
221
+ */
222
+ private testFrequencyPriority(): void {
223
+ const highUsage: UsageData = {
224
+ url: "/test",
225
+ count: 5,
226
+ lastAccessed: Date.now(),
227
+ };
228
+
229
+ const lowUsage: UsageData = {
230
+ url: "/test",
231
+ count: 1,
232
+ lastAccessed: Date.now() - 25 * 60 * 60 * 1000,
233
+ };
234
+
235
+ const highResult = isHighPriority(highUsage, "/test", this.config);
236
+ const lowResult = isHighPriority(lowUsage, "/test", this.config);
237
+
238
+ this.results.push({
239
+ name: "Frequency Priority",
240
+ passed: highResult === true && lowResult === false,
241
+ message:
242
+ highResult === true && lowResult === false
243
+ ? "High frequency URLs correctly prioritized"
244
+ : `Expected high:true, low:false but got high:${highResult}, low:${lowResult}`,
245
+ });
246
+ }
247
+
248
+ /**
249
+ * Test recency-based priority
250
+ */
251
+ private testRecencyPriority(): void {
252
+ const recentUsage: UsageData = {
253
+ url: "/test",
254
+ count: 1,
255
+ lastAccessed: Date.now() - 1000,
256
+ };
257
+
258
+ const oldUsage: UsageData = {
259
+ url: "/test",
260
+ count: 1,
261
+ lastAccessed: Date.now() - 48 * 60 * 60 * 1000,
262
+ };
263
+
264
+ const recentResult = isHighPriority(recentUsage, "/test", this.config);
265
+ const oldResult = isHighPriority(oldUsage, "/test", this.config);
266
+
267
+ this.results.push({
268
+ name: "Recency Priority",
269
+ passed: recentResult === true && oldResult === false,
270
+ message:
271
+ recentResult === true && oldResult === false
272
+ ? "Recent URLs correctly prioritized"
273
+ : `Expected recent:true, old:false but got recent:${recentResult}, old:${oldResult}`,
274
+ });
275
+ }
276
+
277
+ /**
278
+ * Test weighted scoring
279
+ */
280
+ private testWeightedScoring(): void {
281
+ const configWithWeights: Partial<SmartOfflineConfig> = {
282
+ ...this.config,
283
+ frequencyThreshold: 10,
284
+ weights: { frequency: 10, recency: 1, size: 1 },
285
+ };
286
+
287
+ // High frequency should win with high frequency weight
288
+ const highFreq: UsageData = {
289
+ url: "/test",
290
+ count: 5,
291
+ lastAccessed: Date.now() - 20 * 60 * 60 * 1000,
292
+ };
293
+
294
+ const result = isHighPriority(highFreq, "/test", configWithWeights);
295
+
296
+ this.results.push({
297
+ name: "Weighted Scoring",
298
+ passed: result === true,
299
+ message:
300
+ result === true
301
+ ? "Weighted scoring works correctly"
302
+ : "Weighted scoring calculation may have issues",
303
+ });
304
+ }
305
+
306
+ /**
307
+ * Test significance overrides
308
+ */
309
+ private testSignificanceOverrides(): void {
310
+ const configWithSig: Partial<SmartOfflineConfig> = {
311
+ ...this.config,
312
+ significance: { "/important/": "high", "/logs/": "low" },
313
+ };
314
+
315
+ const importantResult = isHighPriority(
316
+ null,
317
+ "/important/page",
318
+ configWithSig,
319
+ );
320
+ const logsResult = isHighPriority(
321
+ { url: "/logs/access", count: 100, lastAccessed: Date.now() },
322
+ "/logs/access",
323
+ configWithSig,
324
+ );
325
+
326
+ this.results.push({
327
+ name: "Significance Overrides",
328
+ passed: importantResult === true && logsResult === false,
329
+ message:
330
+ importantResult === true && logsResult === false
331
+ ? "Significance overrides work correctly"
332
+ : `Expected important:true, logs:false but got important:${importantResult}, logs:${logsResult}`,
333
+ });
334
+ }
335
+
336
+ /**
337
+ * Test service worker is active
338
+ */
339
+ private async testServiceWorkerActive(): Promise<void> {
340
+ try {
341
+ const registration = await navigator.serviceWorker.getRegistration();
342
+ const passed = !!registration?.active;
343
+
344
+ this.results.push({
345
+ name: "Service Worker Active",
346
+ passed,
347
+ message: passed
348
+ ? `Service worker active at scope: ${registration?.scope}`
349
+ : "No active service worker found",
350
+ });
351
+ } catch (e) {
352
+ this.results.push({
353
+ name: "Service Worker Active",
354
+ passed: false,
355
+ message: `Error: ${e}`,
356
+ });
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Test Cache API is available
362
+ */
363
+ private async testCacheAPIAvailable(): Promise<void> {
364
+ try {
365
+ const cache = await caches.open("smart-offline-test");
366
+ const keys = await cache.keys();
367
+ await caches.delete("smart-offline-test");
368
+
369
+ this.results.push({
370
+ name: "Cache API Available",
371
+ passed: true,
372
+ message: "Cache API is accessible",
373
+ });
374
+ } catch (e) {
375
+ this.results.push({
376
+ name: "Cache API Available",
377
+ passed: false,
378
+ message: `Error: ${e}`,
379
+ });
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Test IndexedDB is available
385
+ */
386
+ private async testIndexedDBAvailable(): Promise<void> {
387
+ try {
388
+ const passed = await new Promise<boolean>((resolve) => {
389
+ const request = indexedDB.open("smart-offline-test-db", 1);
390
+ request.onsuccess = () => {
391
+ request.result.close();
392
+ indexedDB.deleteDatabase("smart-offline-test-db");
393
+ resolve(true);
394
+ };
395
+ request.onerror = () => resolve(false);
396
+ });
397
+
398
+ this.results.push({
399
+ name: "IndexedDB Available",
400
+ passed,
401
+ message: passed ? "IndexedDB is accessible" : "IndexedDB not available",
402
+ });
403
+ } catch (e) {
404
+ this.results.push({
405
+ name: "IndexedDB Available",
406
+ passed: false,
407
+ message: `Error: ${e}`,
408
+ });
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Print results to console
414
+ */
415
+ printResults(): void {
416
+ console.info("\n========================================");
417
+ console.info(" SmartOffline SDK Test Results");
418
+ console.info("========================================\n");
419
+
420
+ for (const result of this.results) {
421
+ console.info(`${result.passed ? "āœ…" : "āŒ"} ${result.name}`);
422
+ console.info(` ${result.message}\n`);
423
+ }
424
+
425
+ const passed = this.results.filter((r) => r.passed).length;
426
+ console.info("----------------------------------------");
427
+ console.info(`Total: ${passed}/${this.results.length} tests passed`);
428
+ console.info("========================================\n");
429
+ }
430
+ }
431
+
432
+ // ============================================================================
433
+ // CACHE INSPECTOR
434
+ // ============================================================================
435
+
436
+ export class CacheInspector {
437
+ /**
438
+ * Get all cached items
439
+ */
440
+ async getCachedItems(): Promise<
441
+ Array<{ url: string; size: number; contentType: string }>
442
+ > {
443
+ try {
444
+ const cache = await caches.open("smart-offline-cache-v2");
445
+ const keys = await cache.keys();
446
+
447
+ const items = [];
448
+ for (const request of keys) {
449
+ const response = await cache.match(request);
450
+ if (response) {
451
+ items.push({
452
+ url: request.url,
453
+ size: parseInt(response.headers.get("content-length") || "0", 10),
454
+ contentType: response.headers.get("content-type") || "unknown",
455
+ });
456
+ }
457
+ }
458
+ return items;
459
+ } catch {
460
+ return [];
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Get usage tracking data
466
+ */
467
+ async getUsageData(): Promise<UsageData[]> {
468
+ return new Promise((resolve) => {
469
+ try {
470
+ const request = indexedDB.open("smart-offline-usage-v2", 1);
471
+ request.onsuccess = () => {
472
+ const db = request.result;
473
+ try {
474
+ const tx = db.transaction("usage", "readonly");
475
+ const store = tx.objectStore("usage");
476
+ const getAllReq = store.getAll();
477
+ getAllReq.onsuccess = () => resolve(getAllReq.result || []);
478
+ getAllReq.onerror = () => resolve([]);
479
+ } catch {
480
+ resolve([]);
481
+ }
482
+ };
483
+ request.onerror = () => resolve([]);
484
+ } catch {
485
+ resolve([]);
486
+ }
487
+ });
488
+ }
489
+
490
+ /**
491
+ * Get cache logs
492
+ */
493
+ async getLogs(): Promise<
494
+ Array<{ type: string; url: string; timestamp: number }>
495
+ > {
496
+ return new Promise((resolve) => {
497
+ try {
498
+ const request = indexedDB.open("smart-offline-logs-v2", 1);
499
+ request.onsuccess = () => {
500
+ const db = request.result;
501
+ try {
502
+ const tx = db.transaction("logs", "readonly");
503
+ const store = tx.objectStore("logs");
504
+ const getAllReq = store.getAll();
505
+ getAllReq.onsuccess = () => resolve(getAllReq.result || []);
506
+ getAllReq.onerror = () => resolve([]);
507
+ } catch {
508
+ resolve([]);
509
+ }
510
+ };
511
+ request.onerror = () => resolve([]);
512
+ } catch {
513
+ resolve([]);
514
+ }
515
+ });
516
+ }
517
+
518
+ /**
519
+ * Show all cache data in console
520
+ */
521
+ async showAll(): Promise<void> {
522
+ console.info("\nšŸ” SmartOffline Cache Inspector\n");
523
+
524
+ const cachedItems = await this.getCachedItems();
525
+ console.info(`šŸ“¦ Cached Items (${cachedItems.length}):`);
526
+ console.table(cachedItems);
527
+
528
+ const usageData = await this.getUsageData();
529
+ console.info(`\nšŸ“Š Usage Tracking (${usageData.length} URLs):`);
530
+ console.table(
531
+ usageData.map((u) => ({
532
+ url: u.url.replace(window.location.origin, ""),
533
+ count: u.count,
534
+ lastAccessed: new Date(u.lastAccessed).toLocaleString(),
535
+ })),
536
+ );
537
+
538
+ const logs = await this.getLogs();
539
+ console.info(`\nšŸ“ Recent Logs (${logs.length}):`);
540
+ console.table(logs.slice(-20));
541
+ }
542
+
543
+ /**
544
+ * Get all data as object
545
+ */
546
+ async getAllData(): Promise<CacheInspectorData> {
547
+ return {
548
+ cachedItems: await this.getCachedItems(),
549
+ usageData: await this.getUsageData(),
550
+ logs: await this.getLogs(),
551
+ };
552
+ }
553
+ }
554
+
555
+ // ============================================================================
556
+ // CONVENIENCE FUNCTION
557
+ // ============================================================================
558
+
559
+ /**
560
+ * Run all SmartOffline tests and print results
561
+ */
562
+ export async function runSmartOfflineTests(
563
+ config?: Partial<SmartOfflineConfig>,
564
+ ): Promise<TestResult[]> {
565
+ const suite = new SmartOfflineTestSuite(config);
566
+ const results = await suite.runAll();
567
+ suite.printResults();
568
+ return results;
569
+ }
570
+
571
+ // Export for browser usage
572
+ if (typeof window !== "undefined") {
573
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
574
+ const win = window as any;
575
+ win.SmartOfflineTestSuite = SmartOfflineTestSuite;
576
+ win.runSmartOfflineTests = runSmartOfflineTests;
577
+ win.CacheInspector = CacheInspector;
578
+ }