- I1: an apply answer is a refusal only when our server sent it (a known code, or a 4xx whose body says success:false); a proxy's 5xx, an unreadable 2xx or a dropped connection says Outcome not confirmed and reads Activity again. ApiError gains 'refused'; the server's own pre-start failure is coded 'apply-failed'. - M1: the lost-response test lets the apply run, then drops its answer. - M2: a failed apply records its code in the job's scope; Activity offers Review again for an overtaken plan (Maintenance for a duplicate cleanup whose sources changed). - M3: a failed duplicate cleanup toasts the server's reason. - M4: a review button holds while its preview is read (one preview per double press). - M5: the storage guard also catches bracket and property assignment and CacheStorage/IndexedDB opens; why the Toolkit build is skipped is written down. - M6: a refused storage listing says so on the Metadata cache row. - M7, M8: D100 text corrected; workflow_storage has its title. - M9: a busy start names the job the server named.
190 lines
11 KiB
JavaScript
190 lines
11 KiB
JavaScript
// #222: what "clear the cache" may remove from this browser. Only entries Prompt Manager itself writes as a cache count,
|
|
// from an inventory of the real writers (never a guessed prefix): preferences (the workspace theme, the update notice,
|
|
// the display defaults) and other tools' storage and caches on the shared ComfyUI origin always stay.
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const CacheOwnership = require('../../web/js/cache-ownership.js');
|
|
|
|
const WEB = path.join(__dirname, '../../web');
|
|
|
|
function memoryStorage(entries) {
|
|
const map = new Map(Object.entries(entries));
|
|
return {
|
|
map,
|
|
get length() { return map.size; },
|
|
key: index => [...map.keys()][index] ?? null,
|
|
getItem: key => (map.has(key) ? map.get(key) : null),
|
|
setItem: (key, value) => map.set(key, String(value)),
|
|
removeItem: key => map.delete(key),
|
|
};
|
|
}
|
|
|
|
function memoryCaches(names, { failDelete = new Set() } = {}) {
|
|
const set = new Set(names);
|
|
return {
|
|
set,
|
|
keys: async () => [...set],
|
|
delete: async name => {
|
|
if (failDelete.has(name)) throw new Error('denied');
|
|
return set.delete(name);
|
|
},
|
|
};
|
|
}
|
|
|
|
const PREFERENCES = { gallerySettings: '{"viewMode":"grid"}', pm_workspace_theme: 'light', pm_update_notice_dismissed: '3.3.0' };
|
|
const OWNED = Object.freeze({ storageKeys: ['pm-owned-test-entry'], cacheNames: ['pm-owned-test-cache'] });
|
|
|
|
test('keeps pm_workspace_theme, pm_update_notice_dismissed and gallerySettings', async () => {
|
|
const storage = memoryStorage({ ...PREFERENCES, 'pm-owned-test-entry': 'x', pm_cached: '1', promptManager_x: '1' });
|
|
const result = await CacheOwnership.clearOwned({ storage, cacheStorage: null, owned: OWNED });
|
|
assert.deepEqual(result.removed, ['pm-owned-test-entry']);
|
|
for (const [key, value] of Object.entries(PREFERENCES)) assert.equal(storage.getItem(key), value, key);
|
|
// The old prefix rule's matches are not Prompt Manager caches either: nothing writes them.
|
|
assert.equal(storage.getItem('pm_cached'), '1');
|
|
assert.equal(storage.getItem('promptManager_x'), '1');
|
|
for (const key of Object.keys(PREFERENCES)) assert.ok(CacheOwnership.PREFERENCE_KEYS.includes(key), key);
|
|
});
|
|
|
|
test("keeps other tools' CacheStorage named gallery/thumbnail", async () => {
|
|
const caches = memoryCaches(['other-gallery', 'thumbnail-cache', 'my-gallery-thumbnails', 'pm-owned-test-cache']);
|
|
const result = await CacheOwnership.clearOwned({ storage: memoryStorage({}), cacheStorage: caches, owned: OWNED });
|
|
assert.deepEqual([...caches.set].sort(), ['my-gallery-thumbnails', 'other-gallery', 'thumbnail-cache']);
|
|
assert.deepEqual(result.removed, ['pm-owned-test-cache']);
|
|
});
|
|
|
|
test('removes only inventoried owned keys', async () => {
|
|
// The shipped inventory: no Prompt Manager writer stores a cache entry in this browser.
|
|
assert.deepEqual(CacheOwnership.OWNED, { storageKeys: [], cacheNames: [] });
|
|
assert.ok(Object.isFrozen(CacheOwnership.OWNED.storageKeys) && Object.isFrozen(CacheOwnership.OWNED.cacheNames));
|
|
const storage = memoryStorage({ ...PREFERENCES, gallery_cache_1: 'a', metadata_cache_2: 'b', other: 'c' });
|
|
const caches = memoryCaches(['gallery', 'thumbnail']);
|
|
const result = await CacheOwnership.clearOwned({ storage, cacheStorage: caches });
|
|
assert.deepEqual(result, { removed: [], failed: [], denied: false, partial: false });
|
|
assert.equal(storage.map.size, 6);
|
|
assert.equal(caches.set.size, 2);
|
|
assert.equal(CacheOwnership.clearMessage(result), CacheOwnership.NOTHING_CACHED);
|
|
});
|
|
|
|
/**
|
|
* Every browser-storage write in one file's text: `{keys}` (setItem keys, as written) and `problems` (writes no key
|
|
* can be read from: a bracket or property assignment on a Storage, a computed setItem key, an IndexedDB or
|
|
* CacheStorage open or write). The scan is textual, so it errs on the side of reporting.
|
|
*/
|
|
function storageWrites(text) {
|
|
const keys = [];
|
|
const problems = [];
|
|
for (const match of text.matchAll(/\.setItem\(\s*([^,)]+)/g)) {
|
|
const key = match[1].trim();
|
|
const literal = /^(?:'([^']+)'|"([^"]+)")$/.exec(key);
|
|
if (literal) keys.push(literal[1] ?? literal[2]);
|
|
else if (/^[A-Z_]+_KEY$/.test(key)) keys.push(`$${key}`);
|
|
else problems.push(`a computed setItem key: ${key}`);
|
|
}
|
|
if (/\b\w*[sS]torage\s*\[[^\]]+\]\s*=(?!=)/.test(text)) problems.push('a bracket assignment on a Storage');
|
|
if (/\b(?:local|session)Storage\s*\.\s*(?!getItem|setItem|removeItem|key\b|length\b|clear\b)\w+\s*=(?!=)/.test(text)) {
|
|
problems.push('a property assignment on a Storage');
|
|
}
|
|
if (/\bcaches\s*\.\s*open\s*\(/.test(text)) problems.push('opens a CacheStorage cache');
|
|
if (/\bindexedDB\s*\.\s*open\s*\(/.test(text)) problems.push('opens IndexedDB');
|
|
return { keys, problems };
|
|
}
|
|
|
|
test('the storage scan reports every way of writing browser storage (S7 review M5)', () => {
|
|
assert.deepEqual(storageWrites("localStorage.setItem('gallerySettings', x); storage.setItem(NOTICE_KEY, v);").keys,
|
|
['gallerySettings', '$NOTICE_KEY']);
|
|
for (const text of ["localStorage['pm_cache'] = 1;", 'sessionStorage[key] = value;', 'storage[name] = x;',
|
|
'localStorage.pm_cache = 1;', 'sessionStorage.someKey = "v";', 'localStorage.setItem(key, 1);',
|
|
"caches.open('pm').then(cache => cache.put(url, response));", "indexedDB.open('pm-cache', 1);"]) {
|
|
assert.equal(storageWrites(text).problems.length, 1, text);
|
|
}
|
|
for (const text of ["if (localStorage['pm'] === x) {}", 'const value = localStorage.getItem(KEY);',
|
|
'localStorage.removeItem(KEY);', 'const n = localStorage.length;']) {
|
|
assert.deepEqual(storageWrites(text), { keys: [], problems: [] }, text);
|
|
}
|
|
});
|
|
|
|
test('the inventory covers every browser storage writer in web/', () => {
|
|
// Every key a Prompt Manager page writes is a preference or an inventoried cache entry; a new writer must be
|
|
// classified here before a cache clear may remove it. Skipped: web/lib (third-party Viewer.js and Tailwind
|
|
// output) and web/js/shared/toolkit (the pinned ComfyAssets Toolkit build, never hand-edited here and verified by
|
|
// `npm run --prefix tools/vendor check`). Neither writes browser storage today; the Toolkit's own repository owns
|
|
// its storage, and a new pin is reviewed as its own change.
|
|
const files = [];
|
|
const walk = dir => {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) { if (!['lib', 'toolkit'].includes(entry.name)) walk(full); }
|
|
else if (/\.(m?js|html)$/.test(entry.name)) files.push(full);
|
|
}
|
|
};
|
|
walk(WEB);
|
|
const written = new Set();
|
|
const constants = {};
|
|
for (const file of files) {
|
|
const text = fs.readFileSync(file, 'utf8');
|
|
for (const match of text.matchAll(/(?:const|export const)\s+([A-Z_]+_KEY)\s*=\s*'([^']+)'/g)) constants[match[1]] = match[2];
|
|
const { keys, problems } = storageWrites(text);
|
|
assert.deepEqual(problems, [], path.relative(WEB, file));
|
|
for (const key of keys) written.add(key);
|
|
}
|
|
const keys = [...written].map(key => (key.startsWith('$') ? constants[key.slice(1)] : key));
|
|
assert.ok(keys.length >= 3, 'the writers were found');
|
|
for (const key of keys) {
|
|
assert.ok(key, 'every written key resolves');
|
|
assert.ok(CacheOwnership.PREFERENCE_KEYS.includes(key) || CacheOwnership.OWNED.storageKeys.includes(key), key);
|
|
}
|
|
});
|
|
|
|
test('storage denial reports partial', async () => {
|
|
const denied = { get length() { throw new Error('SecurityError'); }, key() { throw new Error('SecurityError'); },
|
|
getItem() { throw new Error('SecurityError'); }, removeItem() { throw new Error('SecurityError'); } };
|
|
const result = await CacheOwnership.clearOwned({ storage: denied, cacheStorage: null, owned: OWNED });
|
|
assert.equal(result.denied, true);
|
|
assert.equal(result.partial, true);
|
|
assert.equal(CacheOwnership.clearMessage(result), CacheOwnership.STORAGE_DENIED);
|
|
|
|
const caches = memoryCaches(['pm-owned-test-cache'], { failDelete: new Set(['pm-owned-test-cache']) });
|
|
const storage = memoryStorage({ 'pm-owned-test-entry': 'x' });
|
|
const partial = await CacheOwnership.clearOwned({ storage, cacheStorage: caches, owned: OWNED });
|
|
assert.deepEqual(partial.removed, ['pm-owned-test-entry']);
|
|
assert.deepEqual(partial.failed, ['pm-owned-test-cache']);
|
|
assert.equal(partial.partial, true);
|
|
assert.equal(CacheOwnership.clearMessage(partial), 'Cleared 1 of 2 cached entries; this browser refused the rest.');
|
|
|
|
const keysDenied = { keys: async () => { throw new Error('denied'); } };
|
|
const unreadable = await CacheOwnership.clearOwned({ storage: memoryStorage({}), cacheStorage: keysDenied, owned: OWNED });
|
|
assert.equal(unreadable.denied, true);
|
|
});
|
|
|
|
test('server thumbnail clear stays a separate explicit call', async () => {
|
|
const calls = [];
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = async (...args) => { calls.push(args); return { ok: true }; };
|
|
try {
|
|
const storage = memoryStorage({ 'pm-owned-test-entry': 'x' });
|
|
const result = await CacheOwnership.clearOwned({ storage, cacheStorage: memoryCaches([]), owned: OWNED });
|
|
assert.deepEqual(result.removed, ['pm-owned-test-entry']);
|
|
assert.equal(CacheOwnership.clearMessage(result), 'Cleared 1 cached entry from this browser.');
|
|
} finally {
|
|
globalThis.fetch = original;
|
|
}
|
|
assert.deepEqual(calls, []);
|
|
assert.equal(/fetch|clear-thumbnails|ApiClient/.test(fs.readFileSync(path.join(WEB, 'js/cache-ownership.js'), 'utf8')), false);
|
|
});
|
|
|
|
test('ownedEntries lists what a clear would remove, reading nothing it cannot', async () => {
|
|
const storage = memoryStorage({ ...PREFERENCES, 'pm-owned-test-entry': 'x' });
|
|
const caches = memoryCaches(['pm-owned-test-cache', 'gallery']);
|
|
assert.deepEqual(await CacheOwnership.ownedEntries({ storage, cacheStorage: caches, owned: OWNED }),
|
|
{ keys: ['pm-owned-test-entry'], caches: ['pm-owned-test-cache'], denied: false });
|
|
assert.deepEqual(await CacheOwnership.ownedEntries({ storage, cacheStorage: caches }),
|
|
{ keys: [], caches: [], denied: false });
|
|
assert.deepEqual(await CacheOwnership.ownedEntries({ storage: null, cacheStorage: undefined, owned: OWNED }),
|
|
{ keys: [], caches: [], denied: false });
|
|
assert.equal(CacheOwnership.clearMessage({ removed: ['a', 'b'], failed: [], denied: false, partial: false }),
|
|
'Cleared 2 cached entries from this browser.');
|
|
});
|