* feat(system): report configured, effective and watched sources
GET /prompt_manager/settings adds settings.monitoring_enabled, a sources
block (configured roots with valid/code/reason, effective roots with their
listing index and watch state, auto_detected) and the monitor state
(present, observer_alive, scheduled). Every path is a public path.
A source is "watching" only while a live observer scheduled it, "manual"
when monitoring is off or the process has no monitor, and otherwise
"restart_pending".
POST accepts monitoring_enabled as a strict bool, saved under
gallery.monitoring.enabled; restart_required_fields names it when the
running observer does not already match. A save that changes the sources
runs as a "Source change" records hold, so it is refused with 409 busy
while a scan runs. Case variants of one folder are saved once.
POST /prompt_manager/settings/validate-source {path} checks a path
lexically (no "..", no control characters, native path forms only) and
then against the gallery-root rules, answering a code and reason, and
writes nothing.
* fix(system): no existence oracle in source validation
A gallery root's containment in the allowed ComfyUI folders is now
decided before its existence and type, so a path outside them answers
"outside-allowed" whether or not it exists. validate-source also folds
the filesystem-root and home-directory codes into "outside-allowed", with
one reason, and logs the detail server-side only. Paths inside the
allowed folders still say "not-found" or "not-directory". The legacy
settings save shares the same check.
* feat(scan): scan one selected source
POST /prompt_manager/jobs/scan accepts roots: indexes into the effective
sources, strict non-negative ints (no bools or floats), deduplicated, at
most 64, non-empty when present. An index past the sources is a 400
"unknown-source" and starts nothing. The job walks only the selected
sources, resolved when it was requested, and keeps each file's listing
root_index. Its scope records files, roots (null for every source) and
output_folders, the number of folders walked, which the log's first line
counts. The legacy /scan route still scans every source, and attaching to
a scoped job keeps its payload keys.
* feat(system): manage sources and start scans natively
System > Sources & scans renders the concept's section on the server's
sources: one row per effective source with its watch state and a scan
scoped to that source, the 3.2.0 update notice with the legacy dismissal,
Add source checked by validate-source, Remove, monitoring and worker
threads as host drafts behind the one Save row, and Refresh files.
A save refused by a running scan names the job. Thumbnail generation
hands off to the legacy gallery until S5.
Deviations D98, D113 and D-NEW-S4-1 are recorded in TESTING.md.
* fix(security): decide source containment as text before any filesystem access
Every caller (validate-source, POST /settings, config.json at load) now
refuses a source through one check, sources.source_problem: the form
check first, then GalleryConfig.gallery_root_problem, which compares the
normalised spelling with the allowed folders before anything resolves,
stats or opens the path. A UNC share, the \\?\ and \\.\ namespaces, a
device or any outside folder is refused with no SMB request, no
executor stall and no existence or timing oracle; only then is the path
resolved and contained again, which still catches a symlink out. The
filesystem root and the home directory answer outside-allowed with the
same reason as any outside path, so no caller learns the home path.
Also:
- POST /settings runs the form checks and parses the roots off the loop;
an unusable source is a coded 400 invalid-source naming its public
path (as text) and its index.
- ComfyUI folders outside the base directory are path anchors, so their
sources round-trip and never collide by folder name.
- A per-source scan sends each index with its public path; a stale or
shifted index is 409 sources-changed and starts nothing.
- A source still watched after monitoring was switched off is
"stopping".
- Roots are compared as folders, not spellings, so an unchanged legacy
resend takes no source-change hold.
- Tests: no realpath/stat/lstat of an outside path (native and Windows
forms with ntpath fakes), the alike answers with /, ~ and a link to ~,
an event instead of a timer wait, and a teardown that drains jobs
before the temporary database goes.
* fix(system): say what Sources saved, refused and still watches
- A per-source scan sends the source's public path; when the sources
changed since they were read the toast says the server's reason and
the list and saved values are read again.
- "watching until restart" for a source the running monitor still
watches after monitoring was saved off, and the status adds "Restart
ComfyUI to apply: Watch for new outputs." from restart_required_fields.
- A save refused for one unusable source marks that row and the status
is the server's message naming it.
- A valid configured source the effective list holds under another
spelling reads "same folder as another source".
- The busy toast names the job the 409 named (ApiError keeps a
validated running job and refused source), falling back to the list.
- The Add source dialog's listeners live on the page scope and Enter
adds; its markup is compared with the concept (D127).
- TESTING.md: D-NEW-S4-1 is D127; D-NEW-S4-2 records the new copy; the
Sources contract describes the lexical containment, invalid-source and
sources-changed.
* fix(security): guard the Windows form probes on a token of their own
The no-filesystem guard matched "C:", which on a Windows runner is also
the temporary ComfyUI tree the save may resolve for its trusted first
entry. The probe paths now carry a token no trusted directory holds.
* docs(system): number the Sources states deviation D128
367 lines
20 KiB
JavaScript
367 lines
20 KiB
JavaScript
// System draft store (#249 System S2 Task 5): one Save row for browser, host and runtime fields (concept app.js
|
|
// renderSystem `.save-row` 705, `save-settings` 1320). It saves browser, then host with the GET revision, then runtime;
|
|
// a domain that fails keeps its fields dirty, and the status copy (D92) says what really happened.
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const source = import('../../web/js/workspace/system-state.mjs');
|
|
|
|
/** Adapters that record every call; `host` answers like POST /prompt_manager/settings. */
|
|
function adapters({ browser = {}, host = {}, runtime = {}, revision = 'sha256:one' } = {}) {
|
|
const calls = [];
|
|
const outcome = { browser: null, host: null, runtime: null };
|
|
return {
|
|
calls,
|
|
outcome,
|
|
readBrowser: () => { calls.push(['readBrowser']); return { ...browser }; },
|
|
readHost: async () => { calls.push(['readHost']); return { values: { ...host }, revision }; },
|
|
readRuntime: async () => { calls.push(['readRuntime']); return { ...runtime }; },
|
|
writeBrowser: async changes => {
|
|
calls.push(['writeBrowser', changes]);
|
|
if (outcome.browser) return outcome.browser(changes);
|
|
return { persisted: true };
|
|
},
|
|
writeHost: async (changes, options) => {
|
|
calls.push(['writeHost', changes, options]);
|
|
if (outcome.host) return outcome.host(changes, options);
|
|
return { success: true, persisted: true, revision: 'sha256:two', runtime_applied: true };
|
|
},
|
|
writeRuntime: async changes => {
|
|
calls.push(['writeRuntime', changes]);
|
|
if (outcome.runtime) return outcome.runtime(changes);
|
|
return { success: true };
|
|
},
|
|
};
|
|
}
|
|
|
|
async function loaded(domains) {
|
|
const { createSystemDrafts } = await source;
|
|
const drafts = createSystemDrafts(domains);
|
|
await Promise.all(['browser', 'host', 'runtime'].map(domain => drafts.load(domain)));
|
|
domains.calls.length = 0;
|
|
return drafts;
|
|
}
|
|
|
|
test('sections follow the concept order and the production hashes', async () => {
|
|
const { SYSTEM_SECTIONS, isSystemSection } = await source;
|
|
assert.deepEqual(SYSTEM_SECTIONS.map(([id, label]) => [id, label]), [
|
|
['overview', 'Overview'], ['appearance', 'Appearance'], ['sources', 'Sources & scans'],
|
|
['integrations', 'Integrations'], ['tagging', 'Auto-tagging'], ['maintenance', 'Maintenance'],
|
|
['backups', 'Backup & export'], ['diagnostics', 'Diagnostics & logs'],
|
|
]);
|
|
assert.equal(isSystemSection('backups'), true);
|
|
for (const value of ['general', '', 'constructor', '__proto__', undefined]) assert.equal(isSystemSection(value), false, value);
|
|
});
|
|
|
|
test('drafts read baselines and report dirty only for changed values', async () => {
|
|
const domains = adapters({ browser: { layout: 'grid' }, host: { result_timeout: 30 } });
|
|
const drafts = await loaded(domains);
|
|
assert.equal(drafts.get('browser', 'layout'), 'grid');
|
|
assert.equal(drafts.get('host', 'result_timeout'), 30);
|
|
assert.equal(drafts.dirty(), false);
|
|
drafts.set('host', 'result_timeout', 45);
|
|
assert.equal(drafts.get('host', 'result_timeout'), 45);
|
|
assert.equal(drafts.dirty(), true);
|
|
assert.equal(drafts.dirty('host'), true);
|
|
assert.equal(drafts.dirty('browser'), false);
|
|
// Setting the saved value back is clean again.
|
|
drafts.set('host', 'result_timeout', 30);
|
|
assert.equal(drafts.dirty(), false);
|
|
assert.throws(() => drafts.set('elsewhere', 'x', 1), TypeError);
|
|
assert.throws(() => drafts.get('elsewhere', 'x'), TypeError);
|
|
});
|
|
|
|
test('save commits browser then host then runtime', async () => {
|
|
const domains = adapters({ browser: { layout: 'grid' }, host: { result_timeout: 30 }, runtime: { level: 'INFO' } });
|
|
const drafts = await loaded(domains);
|
|
drafts.set('runtime', 'level', 'DEBUG');
|
|
drafts.set('host', 'result_timeout', 45);
|
|
drafts.set('browser', 'layout', 'list');
|
|
const outcome = await drafts.save();
|
|
assert.deepEqual(outcome, { browser: 'saved', host: 'saved', runtime: 'applied' });
|
|
assert.deepEqual(domains.calls, [
|
|
['writeBrowser', { layout: 'list' }],
|
|
['writeHost', { result_timeout: 45 }, { expected_revision: 'sha256:one' }],
|
|
['writeRuntime', { level: 'DEBUG' }],
|
|
]);
|
|
assert.equal(drafts.dirty(), false);
|
|
// The next host save sends the revision the last one returned.
|
|
drafts.set('host', 'result_timeout', 60);
|
|
await drafts.save();
|
|
assert.deepEqual(domains.calls.at(-1), ['writeHost', { result_timeout: 60 }, { expected_revision: 'sha256:two' }]);
|
|
});
|
|
|
|
test('host failure keeps host fields dirty and browser saved', async () => {
|
|
const domains = adapters({ browser: { layout: 'grid' }, host: { result_timeout: 30 } });
|
|
const drafts = await loaded(domains);
|
|
domains.outcome.host = () => { throw Object.assign(new Error('Settings could not be written to config.json.'), { code: 'config-write-failed' }); };
|
|
drafts.set('browser', 'layout', 'list');
|
|
drafts.set('host', 'result_timeout', 45);
|
|
const outcome = await drafts.save();
|
|
assert.deepEqual(outcome, { browser: 'saved', host: 'failed', runtime: 'clean' });
|
|
assert.equal(drafts.dirty('browser'), false);
|
|
assert.equal(drafts.dirty('host'), true);
|
|
assert.equal(drafts.get('host', 'result_timeout'), 45);
|
|
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
|
|
assert.deepEqual([...drafts.failed('browser')], []);
|
|
assert.equal(drafts.statusText(), 'Some changes were not saved. Review the highlighted settings.');
|
|
// A save answered `persisted: false` without an error is not saved either.
|
|
domains.outcome.host = () => ({ success: false, persisted: false });
|
|
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'failed', runtime: 'clean' });
|
|
// Editing the failed field clears its highlight and reads as unsaved again.
|
|
drafts.set('host', 'result_timeout', 50);
|
|
assert.deepEqual([...drafts.failed('host')], []);
|
|
assert.equal(drafts.statusText(), 'Unsaved changes.');
|
|
});
|
|
|
|
test('stale revision reports conflict and keeps the draft', async () => {
|
|
const domains = adapters({ host: { result_timeout: 30 } });
|
|
const drafts = await loaded(domains);
|
|
domains.outcome.host = () => { throw Object.assign(new Error('Settings changed since they were loaded.'), { code: 'settings-changed' }); };
|
|
drafts.set('host', 'result_timeout', 45);
|
|
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'conflict', runtime: 'clean' });
|
|
assert.equal(drafts.dirty('host'), true);
|
|
assert.equal(drafts.get('host', 'result_timeout'), 45);
|
|
assert.equal(drafts.statusText(), 'Settings changed elsewhere. Reload this section to see them.');
|
|
// The conflict read the host values again (their new revision), keeping the draft.
|
|
assert.deepEqual(domains.calls.slice(-2).map(([name]) => name), ['writeHost', 'readHost']);
|
|
// Reloading the section reads the new revision and keeps the draft (Q10).
|
|
domains.outcome.host = null;
|
|
await drafts.load('host');
|
|
assert.equal(drafts.get('host', 'result_timeout'), 45);
|
|
await drafts.save();
|
|
assert.deepEqual(domains.calls.at(-1)[2], { expected_revision: 'sha256:one' });
|
|
});
|
|
|
|
test('host save without a read revision never posts', async () => {
|
|
const { createSystemDrafts } = await source;
|
|
// Never read: an edit made before the host values (and their revision) arrived must not merge blindly.
|
|
const unread = adapters({ host: { result_timeout: 30 } });
|
|
const drafts = createSystemDrafts(unread);
|
|
drafts.set('host', 'result_timeout', 45);
|
|
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'failed', runtime: 'clean' });
|
|
assert.deepEqual(unread.calls, []);
|
|
assert.equal(drafts.dirty('host'), true);
|
|
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
|
|
// A read of an unreadable configuration has no revision either.
|
|
const unreadable = adapters({ host: { result_timeout: 30 }, revision: null });
|
|
const blind = createSystemDrafts(unreadable);
|
|
await blind.load('host');
|
|
blind.set('host', 'result_timeout', 45);
|
|
assert.equal((await blind.save()).host, 'failed');
|
|
assert.equal(unreadable.calls.filter(([name]) => name === 'writeHost').length, 0);
|
|
// Once read, the same draft saves with the revision.
|
|
await drafts.load('host');
|
|
assert.equal((await drafts.save()).host, 'saved');
|
|
assert.deepEqual(unread.calls.at(-1), ['writeHost', { result_timeout: 45 }, { expected_revision: 'sha256:one' }]);
|
|
});
|
|
|
|
test('reloadHost reads the host values and says whether it could', async () => {
|
|
const { createSystemDrafts } = await source;
|
|
const domains = adapters({ host: { result_timeout: 30 } });
|
|
const drafts = createSystemDrafts(domains);
|
|
assert.equal(await drafts.reloadHost(), true);
|
|
assert.equal(drafts.get('host', 'result_timeout'), 30);
|
|
const broken = createSystemDrafts({ ...domains, readHost: async () => { throw new Error('offline'); } });
|
|
assert.equal(await broken.reloadHost(), false);
|
|
broken.set('host', 'result_timeout', 45);
|
|
assert.equal((await broken.save()).host, 'failed');
|
|
});
|
|
|
|
test('no domain called when clean', async () => {
|
|
const domains = adapters({ browser: { layout: 'grid' } });
|
|
const drafts = await loaded(domains);
|
|
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'clean', runtime: 'clean' });
|
|
assert.deepEqual(domains.calls, []);
|
|
drafts.set('browser', 'layout', 'list');
|
|
drafts.discard();
|
|
assert.equal(drafts.dirty(), false);
|
|
assert.equal(drafts.get('browser', 'layout'), 'grid');
|
|
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'clean', runtime: 'clean' });
|
|
assert.deepEqual(domains.calls, []);
|
|
});
|
|
|
|
test('status text per outcome', async () => {
|
|
const { statusText, SETTINGS_STATUS } = await source;
|
|
assert.deepEqual(SETTINGS_STATUS, {
|
|
clean: 'No unsaved changes.',
|
|
dirty: 'Unsaved changes.',
|
|
browser: 'Saved in this browser.',
|
|
host: 'Saved to the server configuration.',
|
|
runtime: 'Applied until the server restarts.',
|
|
partial: 'Some changes were not saved. Review the highlighted settings.',
|
|
tabOnly: 'Saved for this tab only; browser storage is unavailable.',
|
|
conflict: 'Settings changed elsewhere. Reload this section to see them.',
|
|
restart: 'Restart ComfyUI to apply:',
|
|
});
|
|
const clean = { browser: 'clean', host: 'clean', runtime: 'clean' };
|
|
assert.equal(statusText(clean), 'No unsaved changes.');
|
|
assert.equal(statusText({ ...clean, browser: 'saved' }), 'Saved in this browser.');
|
|
assert.equal(statusText({ ...clean, host: 'saved' }), 'Saved to the server configuration.');
|
|
assert.equal(statusText({ ...clean, runtime: 'applied' }), 'Applied until the server restarts.');
|
|
assert.equal(statusText({ ...clean, browser: 'tab-only' }), 'Saved for this tab only; browser storage is unavailable.');
|
|
assert.equal(statusText({ browser: 'saved', host: 'saved', runtime: 'applied' }),
|
|
'Saved in this browser. Saved to the server configuration. Applied until the server restarts.');
|
|
assert.equal(statusText({ ...clean, browser: 'saved', runtime: 'failed' }),
|
|
'Some changes were not saved. Review the highlighted settings.');
|
|
assert.equal(statusText({ browser: 'failed', host: 'conflict', runtime: 'clean' }),
|
|
'Settings changed elsewhere. Reload this section to see them.');
|
|
});
|
|
|
|
test('the store says unsaved, then the save outcome, and notifies subscribers', async () => {
|
|
const domains = adapters({ browser: { layout: 'grid' } });
|
|
const drafts = await loaded(domains);
|
|
const seen = [];
|
|
const unsubscribe = drafts.subscribe(() => seen.push(drafts.statusText()));
|
|
assert.equal(drafts.statusText(), 'No unsaved changes.');
|
|
drafts.set('browser', 'layout', 'list');
|
|
domains.outcome.browser = () => ({ persisted: false });
|
|
await drafts.save();
|
|
assert.deepEqual(seen, ['Unsaved changes.', 'Saved for this tab only; browser storage is unavailable.']);
|
|
unsubscribe();
|
|
drafts.set('browser', 'layout', 'masonry');
|
|
assert.equal(seen.length, 2);
|
|
// The tab-only value is this tab's baseline now.
|
|
drafts.set('browser', 'layout', 'list');
|
|
assert.equal(drafts.dirty(), false);
|
|
});
|
|
|
|
test('an edit made while a save runs stays dirty', async () => {
|
|
const domains = adapters({ browser: { layout: 'grid' } });
|
|
const drafts = await loaded(domains);
|
|
let release;
|
|
domains.outcome.browser = () => new Promise(resolve => { release = () => resolve({ persisted: true }); });
|
|
drafts.set('browser', 'layout', 'list');
|
|
const saving = drafts.save();
|
|
// A second Save while one runs joins it instead of writing twice.
|
|
assert.equal(drafts.save(), saving);
|
|
assert.equal(drafts.saving(), true);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
drafts.set('browser', 'layout', 'masonry');
|
|
release();
|
|
assert.deepEqual(await saving, { browser: 'saved', host: 'clean', runtime: 'clean' });
|
|
assert.equal(drafts.saving(), false);
|
|
assert.equal(drafts.dirty('browser'), true);
|
|
assert.equal(drafts.get('browser', 'layout'), 'masonry');
|
|
assert.equal(domains.calls.filter(([name]) => name === 'writeBrowser').length, 1);
|
|
});
|
|
|
|
test('a failed read keeps the store usable and drafts survive a reload (Q10)', async () => {
|
|
const { createSystemDrafts } = await source;
|
|
const domains = adapters({ browser: { layout: 'grid' } });
|
|
const drafts = createSystemDrafts(domains);
|
|
await drafts.load('browser');
|
|
drafts.set('browser', 'layout', 'list');
|
|
await drafts.load('browser');
|
|
assert.equal(drafts.get('browser', 'layout'), 'list');
|
|
assert.equal(drafts.dirty(), true);
|
|
const failing = createSystemDrafts({ ...domains, readHost: async () => { throw new Error('offline'); } });
|
|
await assert.rejects(failing.load('host'), /offline/);
|
|
assert.equal(failing.dirty(), false);
|
|
assert.throws(() => createSystemDrafts({ ...domains, writeHost: null }), TypeError);
|
|
});
|
|
|
|
test('a rejected value drops its draft and is highlighted; a conflict elsewhere keeps the drafts', async () => {
|
|
const domains = adapters({ browser: { videoMute: true, videoLoop: true }, host: { result_timeout: 5 } });
|
|
const drafts = await loaded(domains);
|
|
assert.deepEqual(drafts.saved('browser'), { videoMute: true, videoLoop: true });
|
|
drafts.set('host', 'result_timeout', 12);
|
|
assert.equal(drafts.dirty('host'), true);
|
|
drafts.reject('host', 'result_timeout');
|
|
assert.equal(drafts.dirty('host'), false);
|
|
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
|
|
assert.equal(drafts.get('host', 'result_timeout'), 5);
|
|
// A valid value clears the highlight.
|
|
drafts.set('host', 'result_timeout', 7);
|
|
assert.deepEqual([...drafts.failed('host')], []);
|
|
drafts.discard();
|
|
drafts.set('browser', 'videoMute', false);
|
|
drafts.conflict('browser', []);
|
|
assert.equal(drafts.statusText(), 'Unsaved changes.');
|
|
drafts.conflict('browser', ['videoMute']);
|
|
assert.equal(drafts.statusText(), 'Settings changed elsewhere. Reload this section to see them.');
|
|
assert.deepEqual([...drafts.failed('browser')], ['videoMute']);
|
|
assert.equal(drafts.get('browser', 'videoMute'), false);
|
|
assert.throws(() => drafts.reject('nowhere', 'x'), TypeError);
|
|
});
|
|
|
|
test('a rejected value says why in the status until the next edit', async () => {
|
|
const drafts = await loaded(adapters({ host: { result_timeout: 5 } }));
|
|
drafts.reject('host', 'result_timeout');
|
|
assert.equal(drafts.statusText(), 'Some changes were not saved. Review the highlighted settings.');
|
|
drafts.set('host', 'result_timeout', 9);
|
|
assert.equal(drafts.statusText(), 'Unsaved changes.');
|
|
});
|
|
|
|
test('a browser write refused as changed elsewhere keeps the draft and reads the browser values again', async () => {
|
|
const domains = adapters({ browser: { workspaceDensity: 'comfortable' } });
|
|
const drafts = await loaded(domains);
|
|
drafts.set('browser', 'workspaceDensity', 'compact');
|
|
domains.outcome.browser = () => { throw Object.assign(new Error('changed'), { code: 'settings-changed' }); };
|
|
assert.deepEqual(await drafts.save(), { browser: 'conflict', host: 'clean', runtime: 'clean' });
|
|
assert.deepEqual(domains.calls.map(([name]) => name), ['writeBrowser', 'readBrowser']);
|
|
assert.equal(drafts.get('browser', 'workspaceDensity'), 'compact');
|
|
assert.deepEqual([...drafts.failed('browser')], ['workspaceDensity']);
|
|
assert.equal(drafts.statusText(), 'Settings changed elsewhere. Reload this section to see them.');
|
|
});
|
|
|
|
test('host read state says whether the server settings could be read', async () => {
|
|
const { createSystemDrafts } = await source;
|
|
const domains = adapters({ host: { result_timeout: 5 } });
|
|
let fail = true;
|
|
const drafts = createSystemDrafts({ ...domains, readHost: async () => { if (fail) throw new Error('offline'); return { values: {}, revision: 'r' }; } });
|
|
assert.equal(drafts.hostState(), 'pending');
|
|
assert.equal(await drafts.reloadHost(), false);
|
|
assert.equal(drafts.hostState(), 'failed');
|
|
fail = false;
|
|
assert.equal(await drafts.reloadHost(), true);
|
|
assert.equal(drafts.hostState(), 'loaded');
|
|
});
|
|
|
|
test('a host save names the settings that apply after a restart (D128)', async () => {
|
|
const { statusText, RESTART_LABELS } = await source;
|
|
assert.deepEqual(RESTART_LABELS, { monitoring_enabled: 'Watch for new outputs' });
|
|
const saved = { browser: 'clean', host: 'saved', runtime: 'clean' };
|
|
assert.equal(statusText({ ...saved, restart: ['monitoring_enabled'] }),
|
|
'Saved to the server configuration. Restart ComfyUI to apply: Watch for new outputs.');
|
|
// A field without a row label (the sources say "restart pending" per row) adds nothing.
|
|
assert.equal(statusText({ ...saved, restart: ['gallery_root_paths'] }), 'Saved to the server configuration.');
|
|
assert.equal(statusText({ ...saved, restart: [] }), 'Saved to the server configuration.');
|
|
|
|
const domains = adapters({ host: { monitoring_enabled: true } });
|
|
const drafts = await loaded(domains);
|
|
domains.outcome.host = () => ({ success: true, persisted: true, revision: 'sha256:two',
|
|
restart_required_fields: ['monitoring_enabled', 7] });
|
|
drafts.set('host', 'monitoring_enabled', false);
|
|
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'saved', runtime: 'clean', restart: ['monitoring_enabled'] });
|
|
assert.equal(drafts.statusText(), 'Saved to the server configuration. Restart ComfyUI to apply: Watch for new outputs.');
|
|
// The next edit is unsaved again.
|
|
drafts.set('host', 'monitoring_enabled', true);
|
|
assert.equal(drafts.statusText(), 'Unsaved changes.');
|
|
});
|
|
|
|
test('a host save refused for one source says the server reason and names it until the next edit', async () => {
|
|
const domains = adapters({ host: { gallery_root_paths: ['output', 'output/gone'] } });
|
|
const drafts = await loaded(domains);
|
|
const message = "output/gone can't be used: Gallery root does not exist. Remove it or reconnect it, then save.";
|
|
domains.outcome.host = () => { throw Object.assign(new Error(message), { code: 'invalid-source', source: 'output/gone' }); };
|
|
drafts.set('host', 'gallery_root_paths', ['output/gone']);
|
|
const outcome = await drafts.save();
|
|
assert.deepEqual(outcome, { browser: 'clean', host: 'failed', runtime: 'clean',
|
|
invalid: { source: 'output/gone', message } });
|
|
assert.equal(drafts.statusText(), message);
|
|
assert.equal(drafts.invalidSource(), 'output/gone');
|
|
assert.deepEqual([...drafts.failed('host')], ['gallery_root_paths']);
|
|
// Any other failure keeps the generic copy and names no source.
|
|
domains.outcome.host = () => { throw Object.assign(new Error('busy'), { code: 'busy' }); };
|
|
await drafts.save();
|
|
assert.equal(drafts.statusText(), 'Some changes were not saved. Review the highlighted settings.');
|
|
assert.equal(drafts.invalidSource(), null);
|
|
domains.outcome.host = () => { throw Object.assign(new Error(message), { code: 'invalid-source' }); };
|
|
await drafts.save();
|
|
assert.equal(drafts.invalidSource(), null);
|
|
drafts.set('host', 'gallery_root_paths', ['output']);
|
|
assert.equal(drafts.invalidSource(), null);
|
|
assert.equal(drafts.statusText(), 'Unsaved changes.');
|
|
});
|