@xiboplayer/cache 0.3.4 → 0.3.5
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 +189 -0
- package/src/cache-analyzer.test.js +291 -0
- package/src/cache.js +2 -2
- package/src/index.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/cache",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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.5"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"vitest": "^2.0.0",
|
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
|
|
9
|
+
import { createLogger } from '@xiboplayer/utils';
|
|
10
|
+
|
|
11
|
+
const log = createLogger('CacheAnalyzer');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Format bytes into human-readable string (e.g. 1.2 GB, 350 MB)
|
|
15
|
+
*/
|
|
16
|
+
function formatBytes(bytes) {
|
|
17
|
+
if (bytes === 0) return '0 B';
|
|
18
|
+
if (!Number.isFinite(bytes)) return '∞';
|
|
19
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
20
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
21
|
+
const value = bytes / Math.pow(1024, i);
|
|
22
|
+
return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class CacheAnalyzer {
|
|
26
|
+
/**
|
|
27
|
+
* @param {import('./cache.js').CacheManager} cache - CacheManager instance
|
|
28
|
+
* @param {object} [options]
|
|
29
|
+
* @param {number} [options.threshold=80] - Storage usage % above which eviction triggers
|
|
30
|
+
*/
|
|
31
|
+
constructor(cache, { threshold = 80 } = {}) {
|
|
32
|
+
this.cache = cache;
|
|
33
|
+
this.threshold = threshold;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Analyze cache health by comparing cached files against required files.
|
|
38
|
+
*
|
|
39
|
+
* @param {Array<{id: string, type: string}>} requiredFiles - Current RequiredFiles from CMS
|
|
40
|
+
* @returns {Promise<object>} Analysis report
|
|
41
|
+
*/
|
|
42
|
+
async analyze(requiredFiles) {
|
|
43
|
+
const cachedFiles = await this.cache.getAllFiles();
|
|
44
|
+
const storage = await this._getStorageEstimate();
|
|
45
|
+
|
|
46
|
+
// Build set of required file IDs (as strings for consistent comparison)
|
|
47
|
+
const requiredIds = new Set(requiredFiles.map(f => String(f.id)));
|
|
48
|
+
|
|
49
|
+
// Categorize cached files
|
|
50
|
+
const required = [];
|
|
51
|
+
const orphaned = [];
|
|
52
|
+
|
|
53
|
+
for (const file of cachedFiles) {
|
|
54
|
+
if (requiredIds.has(String(file.id))) {
|
|
55
|
+
required.push(file);
|
|
56
|
+
} else {
|
|
57
|
+
orphaned.push(file);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Sort orphaned by cachedAt ascending (oldest first — evict these first)
|
|
62
|
+
orphaned.sort((a, b) => (a.cachedAt || 0) - (b.cachedAt || 0));
|
|
63
|
+
|
|
64
|
+
const orphanedSize = orphaned.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
65
|
+
|
|
66
|
+
const report = {
|
|
67
|
+
timestamp: Date.now(),
|
|
68
|
+
storage: {
|
|
69
|
+
usage: storage.usage,
|
|
70
|
+
quota: storage.quota,
|
|
71
|
+
percent: storage.quota > 0 ? Math.round((storage.usage / storage.quota) * 100) : 0,
|
|
72
|
+
},
|
|
73
|
+
files: {
|
|
74
|
+
required: required.length,
|
|
75
|
+
orphaned: orphaned.length,
|
|
76
|
+
total: cachedFiles.length,
|
|
77
|
+
},
|
|
78
|
+
orphaned: orphaned.map(f => ({
|
|
79
|
+
id: f.id,
|
|
80
|
+
type: f.type,
|
|
81
|
+
size: f.size || 0,
|
|
82
|
+
cachedAt: f.cachedAt || 0,
|
|
83
|
+
})),
|
|
84
|
+
orphanedSize,
|
|
85
|
+
evicted: [],
|
|
86
|
+
threshold: this.threshold,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Log summary
|
|
90
|
+
log.info(`Storage: ${formatBytes(storage.usage)} / ${formatBytes(storage.quota)} (${report.storage.percent}%)`);
|
|
91
|
+
log.info(`Cache: ${required.length} required, ${orphaned.length} orphaned (${formatBytes(orphanedSize)} reclaimable)`);
|
|
92
|
+
|
|
93
|
+
if (orphaned.length > 0) {
|
|
94
|
+
for (const f of orphaned) {
|
|
95
|
+
const age = Date.now() - (f.cachedAt || 0);
|
|
96
|
+
const days = Math.floor(age / 86400000);
|
|
97
|
+
const hours = Math.floor((age % 86400000) / 3600000);
|
|
98
|
+
const ageStr = days > 0 ? `${days}d ago` : `${hours}h ago`;
|
|
99
|
+
log.info(` Orphaned: ${f.type}/${f.id} (${formatBytes(f.size || 0)}, cached ${ageStr})`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Evict only when storage exceeds threshold
|
|
104
|
+
if (report.storage.percent > this.threshold && orphaned.length > 0) {
|
|
105
|
+
log.warn(`Storage exceeds ${this.threshold}% threshold — evicting orphaned files`);
|
|
106
|
+
const targetBytes = storage.usage - (storage.quota * this.threshold / 100);
|
|
107
|
+
report.evicted = await this._evict(orphaned, targetBytes);
|
|
108
|
+
} else {
|
|
109
|
+
log.info(`No eviction needed (threshold: ${this.threshold}%)`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return report;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get storage estimate from the browser.
|
|
117
|
+
* Falls back to { usage: 0, quota: Infinity } in environments without the API.
|
|
118
|
+
*/
|
|
119
|
+
async _getStorageEstimate() {
|
|
120
|
+
try {
|
|
121
|
+
if (typeof navigator !== 'undefined' && navigator.storage?.estimate) {
|
|
122
|
+
const { usage, quota } = await navigator.storage.estimate();
|
|
123
|
+
return { usage: usage || 0, quota: quota || Infinity };
|
|
124
|
+
}
|
|
125
|
+
} catch (e) {
|
|
126
|
+
log.warn('Storage estimate unavailable:', e.message);
|
|
127
|
+
}
|
|
128
|
+
return { usage: 0, quota: Infinity };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Evict orphaned files (oldest first) until targetBytes are freed.
|
|
133
|
+
*
|
|
134
|
+
* Deletes from both IndexedDB metadata and Cache API.
|
|
135
|
+
*
|
|
136
|
+
* @param {Array} orphanedFiles - Files to evict, sorted oldest-first
|
|
137
|
+
* @param {number} targetBytes - Bytes to free
|
|
138
|
+
* @returns {Promise<Array>} Evicted file records
|
|
139
|
+
*/
|
|
140
|
+
async _evict(orphanedFiles, targetBytes) {
|
|
141
|
+
const evicted = [];
|
|
142
|
+
let freedBytes = 0;
|
|
143
|
+
|
|
144
|
+
for (const file of orphanedFiles) {
|
|
145
|
+
if (freedBytes >= targetBytes) break;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
// Delete from IndexedDB metadata
|
|
149
|
+
await this._deleteFileMetadata(file.id);
|
|
150
|
+
|
|
151
|
+
// Delete from Cache API
|
|
152
|
+
const cacheKey = this.cache.getCacheKey(file.type, file.id);
|
|
153
|
+
if (this.cache.cache) {
|
|
154
|
+
await this.cache.cache.delete(cacheKey);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
freedBytes += file.size || 0;
|
|
158
|
+
evicted.push({
|
|
159
|
+
id: file.id,
|
|
160
|
+
type: file.type,
|
|
161
|
+
size: file.size || 0,
|
|
162
|
+
cachedAt: file.cachedAt || 0,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
log.info(` Evicted: ${file.type}/${file.id} (${formatBytes(file.size || 0)})`);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
log.warn(` Failed to evict ${file.type}/${file.id}:`, err.message);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
log.info(`Evicted ${evicted.length} files, freed ${formatBytes(freedBytes)}`);
|
|
172
|
+
return evicted;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Delete a file record from IndexedDB.
|
|
177
|
+
*/
|
|
178
|
+
async _deleteFileMetadata(id) {
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
const tx = this.cache.db.transaction('files', 'readwrite');
|
|
181
|
+
const store = tx.objectStore('files');
|
|
182
|
+
const request = store.delete(id);
|
|
183
|
+
request.onsuccess = () => resolve();
|
|
184
|
+
request.onerror = () => reject(request.error);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export { formatBytes };
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CacheAnalyzer Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for stale media detection, storage health reporting, and eviction logic.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
8
|
+
import { CacheAnalyzer, formatBytes } from './cache-analyzer.js';
|
|
9
|
+
|
|
10
|
+
describe('CacheAnalyzer', () => {
|
|
11
|
+
let analyzer;
|
|
12
|
+
let mockCache;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
// Mock CacheManager with in-memory file store
|
|
16
|
+
const files = new Map();
|
|
17
|
+
const cacheStore = new Map();
|
|
18
|
+
|
|
19
|
+
mockCache = {
|
|
20
|
+
db: null, // Will be set up per-test if eviction is tested
|
|
21
|
+
cache: {
|
|
22
|
+
delete: vi.fn(async (key) => cacheStore.delete(key)),
|
|
23
|
+
},
|
|
24
|
+
getAllFiles: vi.fn(async () => [...files.values()]),
|
|
25
|
+
getCacheKey: vi.fn((type, id) => `/player/pwa/cache/${type}/${id}`),
|
|
26
|
+
// Helper to add test files
|
|
27
|
+
_files: files,
|
|
28
|
+
_addFile(record) {
|
|
29
|
+
files.set(record.id, record);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
analyzer = new CacheAnalyzer(mockCache);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('formatBytes', () => {
|
|
37
|
+
it('should format bytes correctly', () => {
|
|
38
|
+
expect(formatBytes(0)).toBe('0 B');
|
|
39
|
+
expect(formatBytes(1024)).toBe('1.0 KB');
|
|
40
|
+
expect(formatBytes(1024 * 1024)).toBe('1.0 MB');
|
|
41
|
+
expect(formatBytes(1.5 * 1024 * 1024 * 1024)).toBe('1.5 GB');
|
|
42
|
+
expect(formatBytes(Infinity)).toBe('∞');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('analyze', () => {
|
|
47
|
+
it('should categorize required vs orphaned files', async () => {
|
|
48
|
+
mockCache._addFile({ id: '1', type: 'media', size: 1000, cachedAt: 100 });
|
|
49
|
+
mockCache._addFile({ id: '2', type: 'media', size: 2000, cachedAt: 200 });
|
|
50
|
+
mockCache._addFile({ id: '3', type: 'layout', size: 500, cachedAt: 300 });
|
|
51
|
+
|
|
52
|
+
const requiredFiles = [
|
|
53
|
+
{ id: '1', type: 'media' },
|
|
54
|
+
{ id: '3', type: 'layout' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const report = await analyzer.analyze(requiredFiles);
|
|
58
|
+
|
|
59
|
+
expect(report.files.required).toBe(2);
|
|
60
|
+
expect(report.files.orphaned).toBe(1);
|
|
61
|
+
expect(report.files.total).toBe(3);
|
|
62
|
+
expect(report.orphaned).toHaveLength(1);
|
|
63
|
+
expect(report.orphaned[0].id).toBe('2');
|
|
64
|
+
expect(report.orphanedSize).toBe(2000);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should report zero orphaned when all files are required', async () => {
|
|
68
|
+
mockCache._addFile({ id: '1', type: 'media', size: 1000, cachedAt: 100 });
|
|
69
|
+
mockCache._addFile({ id: '2', type: 'media', size: 2000, cachedAt: 200 });
|
|
70
|
+
|
|
71
|
+
const requiredFiles = [
|
|
72
|
+
{ id: '1', type: 'media' },
|
|
73
|
+
{ id: '2', type: 'media' },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const report = await analyzer.analyze(requiredFiles);
|
|
77
|
+
|
|
78
|
+
expect(report.files.required).toBe(2);
|
|
79
|
+
expect(report.files.orphaned).toBe(0);
|
|
80
|
+
expect(report.orphaned).toHaveLength(0);
|
|
81
|
+
expect(report.orphanedSize).toBe(0);
|
|
82
|
+
expect(report.evicted).toHaveLength(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should handle empty cache', async () => {
|
|
86
|
+
const report = await analyzer.analyze([{ id: '1', type: 'media' }]);
|
|
87
|
+
|
|
88
|
+
expect(report.files.required).toBe(0);
|
|
89
|
+
expect(report.files.orphaned).toBe(0);
|
|
90
|
+
expect(report.files.total).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should handle empty required files list', async () => {
|
|
94
|
+
mockCache._addFile({ id: '1', type: 'media', size: 5000, cachedAt: 100 });
|
|
95
|
+
|
|
96
|
+
const report = await analyzer.analyze([]);
|
|
97
|
+
|
|
98
|
+
expect(report.files.required).toBe(0);
|
|
99
|
+
expect(report.files.orphaned).toBe(1);
|
|
100
|
+
expect(report.orphanedSize).toBe(5000);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should sort orphaned files oldest first', async () => {
|
|
104
|
+
mockCache._addFile({ id: 'new', type: 'media', size: 100, cachedAt: 3000 });
|
|
105
|
+
mockCache._addFile({ id: 'old', type: 'media', size: 100, cachedAt: 1000 });
|
|
106
|
+
mockCache._addFile({ id: 'mid', type: 'media', size: 100, cachedAt: 2000 });
|
|
107
|
+
|
|
108
|
+
const report = await analyzer.analyze([]);
|
|
109
|
+
|
|
110
|
+
expect(report.orphaned[0].id).toBe('old');
|
|
111
|
+
expect(report.orphaned[1].id).toBe('mid');
|
|
112
|
+
expect(report.orphaned[2].id).toBe('new');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should compare IDs as strings for mixed types', async () => {
|
|
116
|
+
mockCache._addFile({ id: '42', type: 'media', size: 100, cachedAt: 100 });
|
|
117
|
+
|
|
118
|
+
// RequiredFiles may use numeric IDs
|
|
119
|
+
const report = await analyzer.analyze([{ id: 42, type: 'media' }]);
|
|
120
|
+
|
|
121
|
+
expect(report.files.required).toBe(1);
|
|
122
|
+
expect(report.files.orphaned).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should not evict when storage is under threshold', async () => {
|
|
126
|
+
mockCache._addFile({ id: '1', type: 'media', size: 100, cachedAt: 100 });
|
|
127
|
+
|
|
128
|
+
// Mock storage at 50% (under default 80% threshold)
|
|
129
|
+
vi.stubGlobal('navigator', {
|
|
130
|
+
storage: {
|
|
131
|
+
estimate: vi.fn(async () => ({ usage: 500, quota: 1000 })),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const report = await analyzer.analyze([]);
|
|
136
|
+
|
|
137
|
+
expect(report.storage.percent).toBe(50);
|
|
138
|
+
expect(report.evicted).toHaveLength(0);
|
|
139
|
+
|
|
140
|
+
vi.unstubAllGlobals();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should include storage info in report', async () => {
|
|
144
|
+
vi.stubGlobal('navigator', {
|
|
145
|
+
storage: {
|
|
146
|
+
estimate: vi.fn(async () => ({ usage: 2_000_000_000, quota: 5_000_000_000 })),
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const report = await analyzer.analyze([]);
|
|
151
|
+
|
|
152
|
+
expect(report.storage.usage).toBe(2_000_000_000);
|
|
153
|
+
expect(report.storage.quota).toBe(5_000_000_000);
|
|
154
|
+
expect(report.storage.percent).toBe(40);
|
|
155
|
+
expect(report.threshold).toBe(80);
|
|
156
|
+
|
|
157
|
+
vi.unstubAllGlobals();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should handle missing storage API gracefully', async () => {
|
|
161
|
+
// No navigator.storage
|
|
162
|
+
vi.stubGlobal('navigator', {});
|
|
163
|
+
|
|
164
|
+
const report = await analyzer.analyze([]);
|
|
165
|
+
|
|
166
|
+
expect(report.storage.usage).toBe(0);
|
|
167
|
+
expect(report.storage.quota).toBe(Infinity);
|
|
168
|
+
expect(report.storage.percent).toBe(0);
|
|
169
|
+
expect(report.evicted).toHaveLength(0);
|
|
170
|
+
|
|
171
|
+
vi.unstubAllGlobals();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should handle files with missing size or cachedAt', async () => {
|
|
175
|
+
mockCache._addFile({ id: '1', type: 'media' }); // no size, no cachedAt
|
|
176
|
+
|
|
177
|
+
const report = await analyzer.analyze([]);
|
|
178
|
+
|
|
179
|
+
expect(report.files.orphaned).toBe(1);
|
|
180
|
+
expect(report.orphanedSize).toBe(0);
|
|
181
|
+
expect(report.orphaned[0].size).toBe(0);
|
|
182
|
+
expect(report.orphaned[0].cachedAt).toBe(0);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('eviction', () => {
|
|
187
|
+
let deletedIds;
|
|
188
|
+
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
deletedIds = [];
|
|
191
|
+
|
|
192
|
+
// Mock IndexedDB transaction for metadata deletion
|
|
193
|
+
const mockStore = {
|
|
194
|
+
delete: vi.fn((id) => {
|
|
195
|
+
deletedIds.push(id);
|
|
196
|
+
return { set onsuccess(fn) { fn(); }, set onerror(_) {} };
|
|
197
|
+
}),
|
|
198
|
+
};
|
|
199
|
+
mockCache.db = {
|
|
200
|
+
transaction: vi.fn(() => ({
|
|
201
|
+
objectStore: vi.fn(() => mockStore),
|
|
202
|
+
})),
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should evict orphaned files when storage exceeds threshold', async () => {
|
|
207
|
+
mockCache._addFile({ id: 'old', type: 'media', size: 500, cachedAt: 1000 });
|
|
208
|
+
mockCache._addFile({ id: 'newer', type: 'media', size: 300, cachedAt: 2000 });
|
|
209
|
+
mockCache._addFile({ id: 'required', type: 'media', size: 1000, cachedAt: 500 });
|
|
210
|
+
|
|
211
|
+
// 90% usage — exceeds 80% threshold
|
|
212
|
+
vi.stubGlobal('navigator', {
|
|
213
|
+
storage: {
|
|
214
|
+
estimate: vi.fn(async () => ({ usage: 9000, quota: 10000 })),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const report = await analyzer.analyze([{ id: 'required', type: 'media' }]);
|
|
219
|
+
|
|
220
|
+
expect(report.evicted.length).toBeGreaterThan(0);
|
|
221
|
+
// Should evict oldest first
|
|
222
|
+
expect(report.evicted[0].id).toBe('old');
|
|
223
|
+
expect(deletedIds).toContain('old');
|
|
224
|
+
// Cache API delete should also be called
|
|
225
|
+
expect(mockCache.cache.delete).toHaveBeenCalled();
|
|
226
|
+
|
|
227
|
+
vi.unstubAllGlobals();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should stop evicting once enough space is freed', async () => {
|
|
231
|
+
// 3 orphaned files, but only need to free a small amount
|
|
232
|
+
mockCache._addFile({ id: 'a', type: 'media', size: 2000, cachedAt: 1000 });
|
|
233
|
+
mockCache._addFile({ id: 'b', type: 'media', size: 2000, cachedAt: 2000 });
|
|
234
|
+
mockCache._addFile({ id: 'c', type: 'media', size: 2000, cachedAt: 3000 });
|
|
235
|
+
|
|
236
|
+
// 85% usage, threshold 80% → need to free 5% of 10000 = 500 bytes
|
|
237
|
+
// First file (2000 bytes) should be enough
|
|
238
|
+
vi.stubGlobal('navigator', {
|
|
239
|
+
storage: {
|
|
240
|
+
estimate: vi.fn(async () => ({ usage: 8500, quota: 10000 })),
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const report = await analyzer.analyze([]);
|
|
245
|
+
|
|
246
|
+
expect(report.evicted).toHaveLength(1);
|
|
247
|
+
expect(report.evicted[0].id).toBe('a'); // oldest
|
|
248
|
+
|
|
249
|
+
vi.unstubAllGlobals();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should never evict required files', async () => {
|
|
253
|
+
mockCache._addFile({ id: 'keep', type: 'media', size: 5000, cachedAt: 100 });
|
|
254
|
+
mockCache._addFile({ id: 'orphan', type: 'media', size: 100, cachedAt: 200 });
|
|
255
|
+
|
|
256
|
+
vi.stubGlobal('navigator', {
|
|
257
|
+
storage: {
|
|
258
|
+
estimate: vi.fn(async () => ({ usage: 9500, quota: 10000 })),
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const report = await analyzer.analyze([{ id: 'keep', type: 'media' }]);
|
|
263
|
+
|
|
264
|
+
// Only the orphan can be evicted, even though 'keep' is older and larger
|
|
265
|
+
const evictedIds = report.evicted.map(f => f.id);
|
|
266
|
+
expect(evictedIds).not.toContain('keep');
|
|
267
|
+
|
|
268
|
+
vi.unstubAllGlobals();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should respect custom threshold', async () => {
|
|
272
|
+
analyzer = new CacheAnalyzer(mockCache, { threshold: 50 });
|
|
273
|
+
|
|
274
|
+
mockCache._addFile({ id: '1', type: 'media', size: 100, cachedAt: 100 });
|
|
275
|
+
|
|
276
|
+
// 60% usage — under 80% but over 50%
|
|
277
|
+
vi.stubGlobal('navigator', {
|
|
278
|
+
storage: {
|
|
279
|
+
estimate: vi.fn(async () => ({ usage: 6000, quota: 10000 })),
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const report = await analyzer.analyze([]);
|
|
284
|
+
|
|
285
|
+
expect(report.threshold).toBe(50);
|
|
286
|
+
expect(report.evicted.length).toBeGreaterThan(0);
|
|
287
|
+
|
|
288
|
+
vi.unstubAllGlobals();
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
});
|
package/src/cache.js
CHANGED
|
@@ -405,9 +405,9 @@ export class CacheManager {
|
|
|
405
405
|
}
|
|
406
406
|
|
|
407
407
|
// Rewrite absolute CMS signed URLs to local cache paths
|
|
408
|
-
// Matches: https://cms/xmds.php?file
|
|
408
|
+
// Matches: https://cms/xmds.php?file=... or https://cms/pwa/file?file=...
|
|
409
409
|
// These absolute URLs bypass the <base> tag entirely, causing slow CMS fetches
|
|
410
|
-
const cmsUrlRegex = /https?:\/\/[^"'\s)]+xmds\.php\?[^"'\s)]*file=([^&"'\s)]+)[^"'\s)]*/g;
|
|
410
|
+
const cmsUrlRegex = /https?:\/\/[^"'\s)]+(?:xmds\.php|pwa\/file)\?[^"'\s)]*file=([^&"'\s)]+)[^"'\s)]*/g;
|
|
411
411
|
const staticResources = [];
|
|
412
412
|
modifiedHtml = modifiedHtml.replace(cmsUrlRegex, (match, filename) => {
|
|
413
413
|
const localPath = `${BASE}/cache/static/${filename}`;
|
package/src/index.js
CHANGED
|
@@ -4,3 +4,4 @@ export const VERSION = pkg.version;
|
|
|
4
4
|
export { CacheManager, cacheManager } from './cache.js';
|
|
5
5
|
export { CacheProxy } from './cache-proxy.js';
|
|
6
6
|
export { DownloadManager, FileDownload, LayoutTaskBuilder, isUrlExpired } from './download-manager.js';
|
|
7
|
+
export { CacheAnalyzer } from './cache-analyzer.js';
|