diff --git a/tests/auto_refresh.test.cjs b/tests/auto_refresh.test.cjs new file mode 100644 index 0000000..a84b07b --- /dev/null +++ b/tests/auto_refresh.test.cjs @@ -0,0 +1,89 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +function harness() { + const events = new Map(), storage = new Map(), warnings = [], timers = new Map(); + let extension, reloads = 0; + const api = { + addEventListener: (name, fn) => events.set(name, fn), + fetchApi: async () => ({ ok: true, json: async () => ({ revision: '200', user_id: 'a' }) }), + }; + const app = { + isGraphReady: true, + graphToPrompt: async () => ({ workflow: { nodes: [] } }), + extensionManager: { + workflow: { openWorkflows: [] }, + toast: { add: item => warnings.push(item) }, + setting: { settings: {} }, + }, + registerExtension: value => { extension = value; }, + }; + const sessionStorage = { + getItem: key => storage.get(key), setItem: (key, value) => storage.set(key, value), + removeItem: key => storage.delete(key), + }; + const source = fs.readFileSync(require.resolve('../web/js/autoRefresh.js'), 'utf8') + .replace(/^import .*;\r?\n/gm, '') + .replace('import.meta.url', JSON.stringify('http://localhost/autoRefresh.js?am_revision=100')); + vm.runInNewContext(source, { + app, api, sessionStorage, URL, AbortSignal, console: { error() {} }, + setTimeout: fn => { const id = {}; timers.set(id, fn); return id; }, + clearTimeout: id => timers.delete(id), + window: { addEventListener() {}, location: { reload: () => reloads++ } }, + document: { addEventListener() {} }, + }); + extension.setup(); + return { app, api, warnings, storage, sessionStorage, events, reloads: () => reloads }; +} + +test('server unavailable does not warn or reload', async () => { + const h = harness(); + h.api.fetchApi = async () => { throw new TypeError('Failed to fetch'); }; + await h.events.get('reconnected')(); + assert.equal(h.warnings.length, 0); + assert.equal(h.reloads(), 0); +}); + +for (const fail of [true, false]) { + test(`disconnect during snapshot (${fail ? 'failure' : 'success'}) waits for reconnect`, async () => { + const h = harness(); + h.app.graphToPrompt = async () => { + h.events.get('reconnecting')?.(); + if (fail) throw new TypeError('Failed to fetch'); + return { workflow: { nodes: [] } }; + }; + await h.events.get('reconnected')(); + assert.equal(h.warnings.length, 0); + assert.equal(h.reloads(), 0); + assert.equal(h.storage.has('AccountManager.RefreshRevision'), false); + h.app.graphToPrompt = async () => ({ workflow: { nodes: [] } }); + await h.events.get('reconnected')(); + assert.equal(h.reloads(), 1); + }); +} + +test('real local storage failure still pauses refresh', async () => { + const h = harness(); + h.sessionStorage.setItem = () => { throw new Error('Quota exceeded'); }; + await h.events.get('reconnected')(); + await h.events.get('reconnected')(); + assert.equal(h.warnings.length, 1); + assert.equal(h.reloads(), 0); +}); + +test('disconnect while version response is pending discards the response', async () => { + const h = harness(); + const fetch = h.api.fetchApi; + h.api.fetchApi = async () => { + h.events.get('reconnecting')(); + return fetch(); + }; + await h.events.get('reconnected')(); + assert.equal(h.reloads(), 0); + assert.equal(h.warnings.length, 0); + h.api.fetchApi = fetch; + await h.events.get('reconnected')(); + assert.equal(h.reloads(), 1); +}); diff --git a/web/js/autoRefresh.js b/web/js/autoRefresh.js index 0f7285e..de687fa 100644 --- a/web/js/autoRefresh.js +++ b/web/js/autoRefresh.js @@ -8,6 +8,8 @@ let busy = false; let pending = false; let reloading = false; let paused = false; +let disconnected = false; +let connectionEpoch = 0; let retryTimer; function readStored(key) { @@ -80,7 +82,7 @@ async function restoreWorkflows(info) { } async function checkForUpdate() { - if (reloading || paused) return; + if (reloading || paused || disconnected) return; if (busy) { pending = true; return; @@ -91,6 +93,7 @@ async function checkForUpdate() { return; } busy = true; + const epoch = connectionEpoch; try { let info; try { @@ -102,13 +105,16 @@ async function checkForUpdate() { } catch { return; // A stopped server or expired login must not trigger a reload. } + if (epoch !== connectionEpoch || disconnected) return; if (!/^\d+$/.test(info.revision) || !info.user_id) return; try { await restoreWorkflows(info); } catch (error) { + if (epoch !== connectionEpoch || disconnected) return; pauseRefresh("工作流恢复未完成,备份已保留。请先检查并保存当前工作流。", error); return; } + if (epoch !== connectionEpoch || disconnected) return; let seen; try { seen = readStored(SEEN_KEY); @@ -125,9 +131,11 @@ async function checkForUpdate() { if (BigInt(info.revision) <= baseline) return; try { const backup = await snapshotWorkflows(info); + if (epoch !== connectionEpoch || disconnected) return; sessionStorage.setItem(BACKUP_KEY, JSON.stringify(backup)); sessionStorage.setItem(SEEN_KEY, JSON.stringify({ revision: info.revision, user_id: info.user_id })); } catch (error) { + if (epoch !== connectionEpoch || disconnected) return; pauseRefresh("无法安全暂存工作流,请先保存后手动刷新。", error); return; } @@ -148,7 +156,18 @@ async function checkForUpdate() { app.registerExtension({ name: "ComfyUI.AccountManager.AutoRefresh", setup() { - api.addEventListener("reconnected", checkForUpdate); + // ComfyUI already displays connection status; do not add a save warning while offline. + const onDisconnect = () => { + disconnected = true; + connectionEpoch++; + clearTimeout(retryTimer); + }; + api.addEventListener("reconnecting", onDisconnect); + window.addEventListener("offline", onDisconnect); + api.addEventListener("reconnected", () => { + disconnected = false; + return checkForUpdate(); + }); window.addEventListener("online", checkForUpdate); window.addEventListener("pageshow", checkForUpdate); document.addEventListener("visibilitychange", () => {