@xiboplayer/cache 0.3.4 → 0.3.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/package.json +2 -2
- package/src/cache-analyzer.js +176 -0
- package/src/cache-analyzer.test.js +270 -0
- package/src/cache-proxy.js +26 -0
- package/src/cache.js +8 -659
- package/src/cache.test.js +15 -616
- package/src/index.js +2 -0
- package/src/widget-html.js +181 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/cache",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Offline caching and download management with parallel chunk downloads",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"spark-md5": "^3.0.2",
|
|
15
|
-
"@xiboplayer/utils": "0.3.
|
|
15
|
+
"@xiboplayer/utils": "0.3.6"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"vitest": "^2.0.0",
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CacheAnalyzer - Stale media detection and storage health monitoring
|
|
3
|
+
*
|
|
4
|
+
* Compares cached files against RequiredFiles from the CMS to identify
|
|
5
|
+
* orphaned media that is no longer needed. Logs a summary every collection
|
|
6
|
+
* cycle. Only evicts when storage pressure exceeds a configurable threshold.
|
|
7
|
+
*
|
|
8
|
+
* Works entirely through CacheProxy (postMessage to SW) — no IndexedDB,
|
|
9
|
+
* no direct Cache API access.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createLogger } from '@xiboplayer/utils';
|
|
13
|
+
|
|
14
|
+
const log = createLogger('CacheAnalyzer');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Format bytes into human-readable string (e.g. 1.2 GB, 350 MB)
|
|
18
|
+
*/
|
|
19
|
+
function formatBytes(bytes) {
|
|
20
|
+
if (bytes === 0) return '0 B';
|
|
21
|
+
if (!Number.isFinite(bytes)) return '∞';
|
|
22
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
23
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
24
|
+
const value = bytes / Math.pow(1024, i);
|
|
25
|
+
return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class CacheAnalyzer {
|
|
29
|
+
/**
|
|
30
|
+
* @param {import('./cache-proxy.js').CacheProxy} cache - CacheProxy instance
|
|
31
|
+
* @param {object} [options]
|
|
32
|
+
* @param {number} [options.threshold=80] - Storage usage % above which eviction triggers
|
|
33
|
+
*/
|
|
34
|
+
constructor(cache, { threshold = 80 } = {}) {
|
|
35
|
+
this.cache = cache;
|
|
36
|
+
this.threshold = threshold;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Analyze cache health by comparing cached files against required files.
|
|
41
|
+
*
|
|
42
|
+
* @param {Array<{id: string, type: string}>} requiredFiles - Current RequiredFiles from CMS
|
|
43
|
+
* @returns {Promise<object>} Analysis report
|
|
44
|
+
*/
|
|
45
|
+
async analyze(requiredFiles) {
|
|
46
|
+
const cachedFiles = await this.cache.getAllFiles();
|
|
47
|
+
const storage = await this._getStorageEstimate();
|
|
48
|
+
|
|
49
|
+
// Build set of required file IDs (as strings for consistent comparison)
|
|
50
|
+
const requiredIds = new Set(requiredFiles.map(f => String(f.id)));
|
|
51
|
+
|
|
52
|
+
// Categorize cached files
|
|
53
|
+
const required = [];
|
|
54
|
+
const orphaned = [];
|
|
55
|
+
|
|
56
|
+
for (const file of cachedFiles) {
|
|
57
|
+
if (requiredIds.has(String(file.id))) {
|
|
58
|
+
required.push(file);
|
|
59
|
+
} else {
|
|
60
|
+
orphaned.push(file);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Sort orphaned by cachedAt ascending (oldest first — evict these first)
|
|
65
|
+
orphaned.sort((a, b) => (a.cachedAt || 0) - (b.cachedAt || 0));
|
|
66
|
+
|
|
67
|
+
const orphanedSize = orphaned.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
68
|
+
|
|
69
|
+
const report = {
|
|
70
|
+
timestamp: Date.now(),
|
|
71
|
+
storage: {
|
|
72
|
+
usage: storage.usage,
|
|
73
|
+
quota: storage.quota,
|
|
74
|
+
percent: storage.quota > 0 ? Math.round((storage.usage / storage.quota) * 100) : 0,
|
|
75
|
+
},
|
|
76
|
+
files: {
|
|
77
|
+
required: required.length,
|
|
78
|
+
orphaned: orphaned.length,
|
|
79
|
+
total: cachedFiles.length,
|
|
80
|
+
},
|
|
81
|
+
orphaned: orphaned.map(f => ({
|
|
82
|
+
id: f.id,
|
|
83
|
+
type: f.type,
|
|
84
|
+
size: f.size || 0,
|
|
85
|
+
cachedAt: f.cachedAt || 0,
|
|
86
|
+
})),
|
|
87
|
+
orphanedSize,
|
|
88
|
+
evicted: [],
|
|
89
|
+
threshold: this.threshold,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Log summary
|
|
93
|
+
log.info(`Storage: ${formatBytes(storage.usage)} / ${formatBytes(storage.quota)} (${report.storage.percent}%)`);
|
|
94
|
+
log.info(`Cache: ${required.length} required, ${orphaned.length} orphaned (${formatBytes(orphanedSize)} reclaimable)`);
|
|
95
|
+
|
|
96
|
+
if (orphaned.length > 0) {
|
|
97
|
+
for (const f of orphaned) {
|
|
98
|
+
const age = Date.now() - (f.cachedAt || 0);
|
|
99
|
+
const days = Math.floor(age / 86400000);
|
|
100
|
+
const hours = Math.floor((age % 86400000) / 3600000);
|
|
101
|
+
const ageStr = days > 0 ? `${days}d ago` : `${hours}h ago`;
|
|
102
|
+
log.info(` Orphaned: ${f.type}/${f.id} (${formatBytes(f.size || 0)}, cached ${ageStr})`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Evict only when storage exceeds threshold
|
|
107
|
+
if (report.storage.percent > this.threshold && orphaned.length > 0) {
|
|
108
|
+
log.warn(`Storage exceeds ${this.threshold}% threshold — evicting orphaned files`);
|
|
109
|
+
const targetBytes = storage.usage - (storage.quota * this.threshold / 100);
|
|
110
|
+
report.evicted = await this._evict(orphaned, targetBytes);
|
|
111
|
+
} else {
|
|
112
|
+
log.info(`No eviction needed (threshold: ${this.threshold}%)`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return report;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get storage estimate from the browser.
|
|
120
|
+
* Falls back to { usage: 0, quota: Infinity } in environments without the API.
|
|
121
|
+
*/
|
|
122
|
+
async _getStorageEstimate() {
|
|
123
|
+
try {
|
|
124
|
+
if (typeof navigator !== 'undefined' && navigator.storage?.estimate) {
|
|
125
|
+
const { usage, quota } = await navigator.storage.estimate();
|
|
126
|
+
return { usage: usage || 0, quota: quota || Infinity };
|
|
127
|
+
}
|
|
128
|
+
} catch (e) {
|
|
129
|
+
log.warn('Storage estimate unavailable:', e.message);
|
|
130
|
+
}
|
|
131
|
+
return { usage: 0, quota: Infinity };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Evict orphaned files (oldest first) until targetBytes are freed.
|
|
136
|
+
* Delegates deletion to CacheProxy.deleteFiles() which routes to SW.
|
|
137
|
+
*
|
|
138
|
+
* @param {Array} orphanedFiles - Files to evict, sorted oldest-first
|
|
139
|
+
* @param {number} targetBytes - Bytes to free
|
|
140
|
+
* @returns {Promise<Array>} Evicted file records
|
|
141
|
+
*/
|
|
142
|
+
async _evict(orphanedFiles, targetBytes) {
|
|
143
|
+
const toEvict = [];
|
|
144
|
+
let plannedBytes = 0;
|
|
145
|
+
|
|
146
|
+
for (const file of orphanedFiles) {
|
|
147
|
+
if (plannedBytes >= targetBytes) break;
|
|
148
|
+
toEvict.push(file);
|
|
149
|
+
plannedBytes += file.size || 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (toEvict.length === 0) return [];
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const filesToDelete = toEvict.map(f => ({ type: f.type, id: f.id }));
|
|
156
|
+
await this.cache.deleteFiles(filesToDelete);
|
|
157
|
+
|
|
158
|
+
for (const f of toEvict) {
|
|
159
|
+
log.info(` Evicted: ${f.type}/${f.id} (${formatBytes(f.size || 0)})`);
|
|
160
|
+
}
|
|
161
|
+
log.info(`Evicted ${toEvict.length} files, freed ${formatBytes(plannedBytes)}`);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
log.warn('Eviction failed:', err.message);
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return toEvict.map(f => ({
|
|
168
|
+
id: f.id,
|
|
169
|
+
type: f.type,
|
|
170
|
+
size: f.size || 0,
|
|
171
|
+
cachedAt: f.cachedAt || 0,
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export { formatBytes };
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CacheAnalyzer Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for stale media detection, storage health reporting, and eviction logic.
|
|
5
|
+
* Mock follows the CacheProxy interface (getAllFiles, deleteFiles).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
9
|
+
import { CacheAnalyzer, formatBytes } from './cache-analyzer.js';
|
|
10
|
+
|
|
11
|
+
describe('CacheAnalyzer', () => {
|
|
12
|
+
let analyzer;
|
|
13
|
+
let mockCache;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
// Mock CacheProxy with in-memory file store
|
|
17
|
+
const files = new Map();
|
|
18
|
+
|
|
19
|
+
mockCache = {
|
|
20
|
+
getAllFiles: vi.fn(async () => [...files.values()]),
|
|
21
|
+
deleteFiles: vi.fn(async (filesToDelete) => ({
|
|
22
|
+
deleted: filesToDelete.length,
|
|
23
|
+
total: filesToDelete.length,
|
|
24
|
+
})),
|
|
25
|
+
// Helper to add test files
|
|
26
|
+
_files: files,
|
|
27
|
+
_addFile(record) {
|
|
28
|
+
files.set(record.id, record);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
analyzer = new CacheAnalyzer(mockCache);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('formatBytes', () => {
|
|
36
|
+
it('should format bytes correctly', () => {
|
|
37
|
+
expect(formatBytes(0)).toBe('0 B');
|
|
38
|
+
expect(formatBytes(1024)).toBe('1.0 KB');
|
|
39
|
+
expect(formatBytes(1024 * 1024)).toBe('1.0 MB');
|
|
40
|
+
expect(formatBytes(1.5 * 1024 * 1024 * 1024)).toBe('1.5 GB');
|
|
41
|
+
expect(formatBytes(Infinity)).toBe('∞');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('analyze', () => {
|
|
46
|
+
it('should categorize required vs orphaned files', async () => {
|
|
47
|
+
mockCache._addFile({ id: '1', type: 'media', size: 1000, cachedAt: 100 });
|
|
48
|
+
mockCache._addFile({ id: '2', type: 'media', size: 2000, cachedAt: 200 });
|
|
49
|
+
mockCache._addFile({ id: '3', type: 'layout', size: 500, cachedAt: 300 });
|
|
50
|
+
|
|
51
|
+
const requiredFiles = [
|
|
52
|
+
{ id: '1', type: 'media' },
|
|
53
|
+
{ id: '3', type: 'layout' },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const report = await analyzer.analyze(requiredFiles);
|
|
57
|
+
|
|
58
|
+
expect(report.files.required).toBe(2);
|
|
59
|
+
expect(report.files.orphaned).toBe(1);
|
|
60
|
+
expect(report.files.total).toBe(3);
|
|
61
|
+
expect(report.orphaned).toHaveLength(1);
|
|
62
|
+
expect(report.orphaned[0].id).toBe('2');
|
|
63
|
+
expect(report.orphanedSize).toBe(2000);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should report zero orphaned when all files are required', async () => {
|
|
67
|
+
mockCache._addFile({ id: '1', type: 'media', size: 1000, cachedAt: 100 });
|
|
68
|
+
mockCache._addFile({ id: '2', type: 'media', size: 2000, cachedAt: 200 });
|
|
69
|
+
|
|
70
|
+
const requiredFiles = [
|
|
71
|
+
{ id: '1', type: 'media' },
|
|
72
|
+
{ id: '2', type: 'media' },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const report = await analyzer.analyze(requiredFiles);
|
|
76
|
+
|
|
77
|
+
expect(report.files.required).toBe(2);
|
|
78
|
+
expect(report.files.orphaned).toBe(0);
|
|
79
|
+
expect(report.orphaned).toHaveLength(0);
|
|
80
|
+
expect(report.orphanedSize).toBe(0);
|
|
81
|
+
expect(report.evicted).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should handle empty cache', async () => {
|
|
85
|
+
const report = await analyzer.analyze([{ id: '1', type: 'media' }]);
|
|
86
|
+
|
|
87
|
+
expect(report.files.required).toBe(0);
|
|
88
|
+
expect(report.files.orphaned).toBe(0);
|
|
89
|
+
expect(report.files.total).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should handle empty required files list', async () => {
|
|
93
|
+
mockCache._addFile({ id: '1', type: 'media', size: 5000, cachedAt: 100 });
|
|
94
|
+
|
|
95
|
+
const report = await analyzer.analyze([]);
|
|
96
|
+
|
|
97
|
+
expect(report.files.required).toBe(0);
|
|
98
|
+
expect(report.files.orphaned).toBe(1);
|
|
99
|
+
expect(report.orphanedSize).toBe(5000);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should sort orphaned files oldest first', async () => {
|
|
103
|
+
mockCache._addFile({ id: 'new', type: 'media', size: 100, cachedAt: 3000 });
|
|
104
|
+
mockCache._addFile({ id: 'old', type: 'media', size: 100, cachedAt: 1000 });
|
|
105
|
+
mockCache._addFile({ id: 'mid', type: 'media', size: 100, cachedAt: 2000 });
|
|
106
|
+
|
|
107
|
+
const report = await analyzer.analyze([]);
|
|
108
|
+
|
|
109
|
+
expect(report.orphaned[0].id).toBe('old');
|
|
110
|
+
expect(report.orphaned[1].id).toBe('mid');
|
|
111
|
+
expect(report.orphaned[2].id).toBe('new');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should compare IDs as strings for mixed types', async () => {
|
|
115
|
+
mockCache._addFile({ id: '42', type: 'media', size: 100, cachedAt: 100 });
|
|
116
|
+
|
|
117
|
+
// RequiredFiles may use numeric IDs
|
|
118
|
+
const report = await analyzer.analyze([{ id: 42, type: 'media' }]);
|
|
119
|
+
|
|
120
|
+
expect(report.files.required).toBe(1);
|
|
121
|
+
expect(report.files.orphaned).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should not evict when storage is under threshold', async () => {
|
|
125
|
+
mockCache._addFile({ id: '1', type: 'media', size: 100, cachedAt: 100 });
|
|
126
|
+
|
|
127
|
+
// Mock storage at 50% (under default 80% threshold)
|
|
128
|
+
vi.stubGlobal('navigator', {
|
|
129
|
+
storage: {
|
|
130
|
+
estimate: vi.fn(async () => ({ usage: 500, quota: 1000 })),
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const report = await analyzer.analyze([]);
|
|
135
|
+
|
|
136
|
+
expect(report.storage.percent).toBe(50);
|
|
137
|
+
expect(report.evicted).toHaveLength(0);
|
|
138
|
+
|
|
139
|
+
vi.unstubAllGlobals();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should include storage info in report', async () => {
|
|
143
|
+
vi.stubGlobal('navigator', {
|
|
144
|
+
storage: {
|
|
145
|
+
estimate: vi.fn(async () => ({ usage: 2_000_000_000, quota: 5_000_000_000 })),
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const report = await analyzer.analyze([]);
|
|
150
|
+
|
|
151
|
+
expect(report.storage.usage).toBe(2_000_000_000);
|
|
152
|
+
expect(report.storage.quota).toBe(5_000_000_000);
|
|
153
|
+
expect(report.storage.percent).toBe(40);
|
|
154
|
+
expect(report.threshold).toBe(80);
|
|
155
|
+
|
|
156
|
+
vi.unstubAllGlobals();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should handle missing storage API gracefully', async () => {
|
|
160
|
+
// No navigator.storage
|
|
161
|
+
vi.stubGlobal('navigator', {});
|
|
162
|
+
|
|
163
|
+
const report = await analyzer.analyze([]);
|
|
164
|
+
|
|
165
|
+
expect(report.storage.usage).toBe(0);
|
|
166
|
+
expect(report.storage.quota).toBe(Infinity);
|
|
167
|
+
expect(report.storage.percent).toBe(0);
|
|
168
|
+
expect(report.evicted).toHaveLength(0);
|
|
169
|
+
|
|
170
|
+
vi.unstubAllGlobals();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should handle files with missing size or cachedAt', async () => {
|
|
174
|
+
mockCache._addFile({ id: '1', type: 'media' }); // no size, no cachedAt
|
|
175
|
+
|
|
176
|
+
const report = await analyzer.analyze([]);
|
|
177
|
+
|
|
178
|
+
expect(report.files.orphaned).toBe(1);
|
|
179
|
+
expect(report.orphanedSize).toBe(0);
|
|
180
|
+
expect(report.orphaned[0].size).toBe(0);
|
|
181
|
+
expect(report.orphaned[0].cachedAt).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('eviction', () => {
|
|
186
|
+
it('should evict orphaned files when storage exceeds threshold', async () => {
|
|
187
|
+
mockCache._addFile({ id: 'old', type: 'media', size: 500, cachedAt: 1000 });
|
|
188
|
+
mockCache._addFile({ id: 'newer', type: 'media', size: 300, cachedAt: 2000 });
|
|
189
|
+
mockCache._addFile({ id: 'required', type: 'media', size: 1000, cachedAt: 500 });
|
|
190
|
+
|
|
191
|
+
// 90% usage — exceeds 80% threshold
|
|
192
|
+
vi.stubGlobal('navigator', {
|
|
193
|
+
storage: {
|
|
194
|
+
estimate: vi.fn(async () => ({ usage: 9000, quota: 10000 })),
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const report = await analyzer.analyze([{ id: 'required', type: 'media' }]);
|
|
199
|
+
|
|
200
|
+
expect(report.evicted.length).toBeGreaterThan(0);
|
|
201
|
+
// Should evict oldest first
|
|
202
|
+
expect(report.evicted[0].id).toBe('old');
|
|
203
|
+
// deleteFiles should be called on the cache proxy
|
|
204
|
+
expect(mockCache.deleteFiles).toHaveBeenCalled();
|
|
205
|
+
|
|
206
|
+
vi.unstubAllGlobals();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should stop evicting once enough space is freed', async () => {
|
|
210
|
+
// 3 orphaned files, but only need to free a small amount
|
|
211
|
+
mockCache._addFile({ id: 'a', type: 'media', size: 2000, cachedAt: 1000 });
|
|
212
|
+
mockCache._addFile({ id: 'b', type: 'media', size: 2000, cachedAt: 2000 });
|
|
213
|
+
mockCache._addFile({ id: 'c', type: 'media', size: 2000, cachedAt: 3000 });
|
|
214
|
+
|
|
215
|
+
// 85% usage, threshold 80% → need to free 5% of 10000 = 500 bytes
|
|
216
|
+
// First file (2000 bytes) should be enough
|
|
217
|
+
vi.stubGlobal('navigator', {
|
|
218
|
+
storage: {
|
|
219
|
+
estimate: vi.fn(async () => ({ usage: 8500, quota: 10000 })),
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const report = await analyzer.analyze([]);
|
|
224
|
+
|
|
225
|
+
expect(report.evicted).toHaveLength(1);
|
|
226
|
+
expect(report.evicted[0].id).toBe('a'); // oldest
|
|
227
|
+
|
|
228
|
+
vi.unstubAllGlobals();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should never evict required files', async () => {
|
|
232
|
+
mockCache._addFile({ id: 'keep', type: 'media', size: 5000, cachedAt: 100 });
|
|
233
|
+
mockCache._addFile({ id: 'orphan', type: 'media', size: 100, cachedAt: 200 });
|
|
234
|
+
|
|
235
|
+
vi.stubGlobal('navigator', {
|
|
236
|
+
storage: {
|
|
237
|
+
estimate: vi.fn(async () => ({ usage: 9500, quota: 10000 })),
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const report = await analyzer.analyze([{ id: 'keep', type: 'media' }]);
|
|
242
|
+
|
|
243
|
+
// Only the orphan can be evicted, even though 'keep' is older and larger
|
|
244
|
+
const evictedIds = report.evicted.map(f => f.id);
|
|
245
|
+
expect(evictedIds).not.toContain('keep');
|
|
246
|
+
|
|
247
|
+
vi.unstubAllGlobals();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should respect custom threshold', async () => {
|
|
251
|
+
analyzer = new CacheAnalyzer(mockCache, { threshold: 50 });
|
|
252
|
+
|
|
253
|
+
mockCache._addFile({ id: '1', type: 'media', size: 100, cachedAt: 100 });
|
|
254
|
+
|
|
255
|
+
// 60% usage — under 80% but over 50%
|
|
256
|
+
vi.stubGlobal('navigator', {
|
|
257
|
+
storage: {
|
|
258
|
+
estimate: vi.fn(async () => ({ usage: 6000, quota: 10000 })),
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const report = await analyzer.analyze([]);
|
|
263
|
+
|
|
264
|
+
expect(report.threshold).toBe(50);
|
|
265
|
+
expect(report.evicted.length).toBeGreaterThan(0);
|
|
266
|
+
|
|
267
|
+
vi.unstubAllGlobals();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|
package/src/cache-proxy.js
CHANGED
|
@@ -416,6 +416,32 @@ export class CacheProxy extends EventEmitter {
|
|
|
416
416
|
return this.backendType === 'service-worker';
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
/**
|
|
420
|
+
* Get all cached files from Service Worker
|
|
421
|
+
* @returns {Promise<Array<{id: string, type: string, size: number, cachedAt: number}>>}
|
|
422
|
+
*/
|
|
423
|
+
async getAllFiles() {
|
|
424
|
+
if (!this.backend) {
|
|
425
|
+
throw new Error('CacheProxy not initialized');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return new Promise((resolve) => {
|
|
429
|
+
const channel = new MessageChannel();
|
|
430
|
+
|
|
431
|
+
channel.port1.onmessage = (event) => {
|
|
432
|
+
const { success, files } = event.data;
|
|
433
|
+
resolve(success ? files : []);
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
navigator.serviceWorker.controller.postMessage(
|
|
437
|
+
{ type: 'GET_ALL_FILES' },
|
|
438
|
+
[channel.port2]
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
setTimeout(() => resolve([]), 5000);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
419
445
|
/**
|
|
420
446
|
* Delete files from cache (purge obsolete media)
|
|
421
447
|
* @param {Array<{type: string, id: string}>} files - Files to delete
|