diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs index ccfec488..bfad38c1 100644 --- a/tests/browser/system-concept.spec.mjs +++ b/tests/browser/system-concept.spec.mjs @@ -614,10 +614,21 @@ const WATCHED = new Set(['watching', 'stopping']); const REFRESH_FILES = { title: 'Refresh files', description: 'Read every source again to find files added or removed outside ComfyUI.', label: 'Refresh' }; +// D147 (#247): the row production adds directly below "Watch for new outputs", in that row's markup. +const FOREIGN_RUNS = { id: 'foreign-runs', label: 'Save prompts from other workflows', + description: 'New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.' }; + +/** D147: the concept's "Watch for new outputs" row as the inserted "Save prompts from other workflows" row. */ +function foreignRunsRow(monitoringRow, settings) { + return { ...monitoringRow, text: [['label', FOREIGN_RUNS.label, FOREIGN_RUNS.id], ['p', FOREIGN_RUNS.description, null]], + control: { ...monitoringRow.control, id: FOREIGN_RUNS.id, checked: settings.save_foreign_runs } }; +} + /** The concept's Sources with the recorded deviations applied, given the server's real settings and sources. */ function mapSourcesConcept(reference, { settings, sources }) { const [sample] = reference.sources; const scan = sample.actions[0]; + const rowAt = reference.surface.tree.indexOf('div.setting-row'); return { ...reference, // The real sources replace the two samples (without the "sample path" note). D98: each configured one has Remove, @@ -625,13 +636,15 @@ function mapSourcesConcept(reference, { settings, sources }) { sources: sources.effective.map(source => ({ ...sample, title: source.label, description: `${source.path} · ${WATCH_TEXT[source.watch]}`, actions: [{ ...scan, text: WATCHED.has(source.watch) ? 'Scan changes' : 'Scan folder' }, { ...scan, text: 'Remove' }] })), - rows: reference.rows.map(row => { - if (row.control.id === 'monitoring') return { ...row, control: { ...row.control, checked: settings.monitoring_enabled } }; + rows: reference.rows.flatMap(row => { + if (row.control.id === 'monitoring') return [{ ...row, control: { ...row.control, checked: settings.monitoring_enabled } }, + foreignRunsRow(row, settings)]; // D113: the range ends at the server's core count (the concept's fixed 16) and shows the real worker count. - return { ...row, control: { ...row.control, max: String(settings.cpu_count), value: String(settings.worker_threads) } }; + return [{ ...row, control: { ...row.control, max: String(settings.cpu_count), value: String(settings.worker_threads) } }]; }), - // D98: "Refresh files" closes the surface. - surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.operation'] }, + // D147: the inserted row after the first setting row; D98: "Refresh files" closes the surface. + surface: { ...reference.surface, tree: [...reference.surface.tree.slice(0, rowAt + 1), 'div.setting-row', + ...reference.surface.tree.slice(rowAt + 1), 'div.operation'] }, operations: [...reference.operations, { ...reference.operations[0], title: REFRESH_FILES.title, description: REFRESH_FILES.description, actions: [{ ...reference.operations[0].actions[0], text: REFRESH_FILES.label }] }], }; @@ -685,10 +698,12 @@ test('sources comparison detects mutations', async ({ page, request }) => { const expected = mapSourcesConcept(concept, server); const production = await productionSources(page, 'dark', { notice: true }); expect(diffInventory(expected, production)).toEqual([]); - // Unmapped, the concept differs only in the real sources, the D98 operation and the D113 range. + // Unmapped, the concept differs only in the real sources, the D98 operation, the D147 row (which moves the D113 + // range to the third row) and the D113 range. expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.')) .filter((path, index, all) => all.indexOf(path) === index).sort()) - .toEqual(['operations.2', 'operations.length', 'rows.1', 'sources.0', 'sources.1', 'surface.tree'].sort()); + .toEqual(['operations.2', 'operations.length', 'rows.1', 'rows.2', 'rows.length', 'sources.0', 'sources.1', + 'surface.tree'].sort()); const measure = () => page.evaluate(measureSources, '#workspace-main'); const paths = async () => diffInventory(expected, await measure()).map(item => item.path); // A removed notice and a removed source row. @@ -702,6 +717,13 @@ test('sources comparison detects mutations', async ({ page, request }) => { await page.evaluate(() => { document.querySelector('#workspace-main label[for="monitoring"]').textContent = 'Watch for outputs'; }); expect(await paths()).toEqual(['rows.0.text.0.1']); await page.evaluate(() => { document.querySelector('#workspace-main label[for="monitoring"]').textContent = 'Watch for new outputs'; }); + // D147: the inserted row is compared too: a missing one and a changed label are caught. + const undoForeign = await mutate(page, { remove: ['#workspace-main .setting-row:has(#foreign-runs)'] }); + expect(await paths()).toEqual(expect.arrayContaining(['rows.length', 'surface.tree.length'])); + await undoForeign(); + await page.evaluate(() => { document.querySelector('#workspace-main label[for="foreign-runs"]').textContent = 'Save other prompts'; }); + expect(await paths()).toEqual(['rows.1.text.0.1']); + await page.evaluate(() => { document.querySelector('#workspace-main label[for="foreign-runs"]').textContent = 'Save prompts from other workflows'; }); await page.evaluate(() => { document.querySelector('#workspace-main .panel-heading button').textContent = 'Add source'; }); expect(await paths()).toEqual(['heading.action.text']); await page.evaluate(() => { document.querySelector('#workspace-main .panel-heading button').textContent = '+ Add source'; }); diff --git a/tests/browser/workspace-system-sources.spec.mjs b/tests/browser/workspace-system-sources.spec.mjs index 556ff4e8..52aba04c 100644 --- a/tests/browser/workspace-system-sources.spec.mjs +++ b/tests/browser/workspace-system-sources.spec.mjs @@ -268,6 +268,43 @@ test('Watch for new outputs saves monitoring_enabled; a restart applies it', asy await expect(sourceList(page).locator('.operation p')).toHaveText(['output · watching', 'input · watching']); }); +test('D147: Save prompts from other workflows saves save_foreign_runs live; off while monitoring is off (#247)', async ({ page, request }, testInfo) => { + const layout = await openSources(page); + const rows = layout.locator('section.surface > .setting-row'); + await expect(rows.locator(':scope > div > label')).toHaveText(['Watch for new outputs', 'Save prompts from other workflows', 'Worker threads']); + const foreign = layout.getByRole('switch', { name: 'Save prompts from other workflows', exact: true }); + const foreignRow = rows.filter({ has: page.locator('#foreign-runs') }); + await expect(foreignRow.locator(':scope > div > p')).toHaveText('New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.'); + await expect(foreign).toBeChecked(); + await expect(foreign).toBeEnabled(); + // Keyboard on desktop, a tap on the touch project. + if (testInfo.project.name === 'touch') await foreign.tap(); + else { + await foreign.focus(); + await page.keyboard.press('Space'); + } + await expect(foreign).not.toBeChecked(); + await expect(statusOf(page)).toHaveText('Unsaved changes.'); + const saved = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(SETTINGS_URL)); + await saveButton(page).click(); + const response = await saved; + expect(response.request().postDataJSON()).toEqual(expect.objectContaining({ save_foreign_runs: false })); + expect((await response.json()).restart_required_fields).toEqual([]); + // Applied live: the monitor reads it for every file, so no restart is named. + await expect(statusOf(page)).toHaveText('Saved to the server configuration.'); + expect((await serverJson(request, SETTINGS_URL)).settings.save_foreign_runs).toBe(false); + // With Watch for new outputs drafted off nothing is watched: the row is disabled and says why. + const monitoring = layout.getByRole('switch', { name: 'Watch for new outputs', exact: true }); + await monitoring.uncheck(); + await expect(foreign).toBeDisabled(); + await expect(foreignRow.locator(':scope > div > p')).toHaveText(['New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.', + 'Applies while Watch for new outputs is on.']); + await monitoring.check(); + await expect(foreign).toBeEnabled(); + await expect(foreignRow.locator(':scope > div > p')).toHaveCount(1); + await expect(statusOf(page)).toHaveText('No unsaved changes.'); +}); + test('worker range max is cpu_count', async ({ page, request }) => { const { settings } = await serverJson(request, SETTINGS_URL); const layout = await openSources(page); diff --git a/tests/js/workspace-system-sources.test.js b/tests/js/workspace-system-sources.test.js index ad08662c..fe6604aa 100644 --- a/tests/js/workspace-system-sources.test.js +++ b/tests/js/workspace-system-sources.test.js @@ -157,3 +157,17 @@ test('a valid configured source missing from the effective list is another spell assert.deepEqual(sourceRows({ sources, saved, current: saved }).rows.at(-1), { key: 'output/.', title: 'output', description: 'output/. · same folder as another source', scan: null, remove: 'output/.' }); }); + +test('D147: "Save prompts from other workflows" drafts save_foreign_runs, disabled while monitoring is off (#247)', async () => { + const { FOREIGN_RUNS, foreignRunsState } = await sourcesModule; + assert.deepEqual(FOREIGN_RUNS, { id: 'foreign-runs', key: 'save_foreign_runs', label: 'Save prompts from other workflows', + description: 'New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.', + off: 'Applies while Watch for new outputs is on.' }); + assert.deepEqual(foreignRunsState({ value: true, monitoring: true }), { disabled: false, checked: true, note: null }); + assert.deepEqual(foreignRunsState({ value: false, monitoring: true }), { disabled: false, checked: false, note: null }); + // Monitoring drafted or saved off: nothing is watched, so the row says why it is disabled and keeps its value. + assert.deepEqual(foreignRunsState({ value: true, monitoring: false }), { disabled: true, checked: true, note: FOREIGN_RUNS.off }); + // Not read yet (or the host read failed): disabled without a value, the host rows' own note applies. + assert.deepEqual(foreignRunsState({ value: undefined, monitoring: undefined }), { disabled: true, checked: null, note: null }); + assert.deepEqual(foreignRunsState({ value: undefined, monitoring: false }), { disabled: true, checked: null, note: null }); +}); diff --git a/web/js/workspace/system-sources.mjs b/web/js/workspace/system-sources.mjs index 0433c864..c7bb0a10 100644 --- a/web/js/workspace/system-sources.mjs +++ b/web/js/workspace/system-sources.mjs @@ -14,6 +14,8 @@ * it to the host draft of `gallery_root_paths`; Remove takes one out. Both wait for the one Save row. * - "Watch for new outputs" (`monitoring_enabled`) and "Worker threads" (`worker_threads`, a range up to the server's * `cpu_count`, D113) are host drafts too. "Enrich existing metadata" reviews a scan of every source. + * - D147 (#247): "Save prompts from other workflows" (`save_foreign_runs`), a host draft below "Watch for new outputs", + * applied by the server at once (the monitor reads it for every file); disabled, saying why, while monitoring is off. * - "Thumbnail generation → Review options" opens "Thumbnail maintenance" (thumbnail-review.mjs, S5 Task 14), which * starts the thumbnail Activity job. * @@ -63,6 +65,20 @@ export const LOAD_FAILED_TEXT = 'Sources could not be loaded. Your saved data ha export const DUPLICATE_TEXT = 'This folder is already a source.'; export const VALIDATE_FAILED_TEXT = 'The folder could not be checked. Try again.'; export const REFRESH_FAILED_TEXT = 'Files could not be refreshed. Try again.'; +/** D147 (#247): the row the concept does not draw, below "Watch for new outputs". */ +export const FOREIGN_RUNS = Object.freeze({ id: 'foreign-runs', key: 'save_foreign_runs', label: 'Save prompts from other workflows', + description: 'New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.', + off: 'Applies while Watch for new outputs is on.' }); + +/** + * D147: the row for the drafted `save_foreign_runs` (`value`) and `monitoring_enabled` (`monitoring`): disabled until + * read, and while monitoring is off, when `note` says why (the value is kept and saved as it is). + */ +export function foreignRunsState({ value, monitoring }) { + if (value === undefined) return { disabled: true, checked: null, note: null }; + const off = monitoring === false; + return { disabled: off, checked: value === true, note: off ? FOREIGN_RUNS.off : null }; +} // Labels of the sources added in this page session (validate-source answers them), for the rows not saved yet. const addedLabels = new Map(); @@ -267,13 +283,16 @@ export function mountSources(container, { api, activity, scope, drafts, notify = const monitoring = settingRow({ id: 'monitoring', label: 'Watch for new outputs', description: 'New files enter the same library. Existing scans do not count as new runs.', control: { type: 'toggle' }, scope, onChange: value => drafts.set('host', 'monitoring_enabled', value) }); + const foreign = settingRow({ id: FOREIGN_RUNS.id, label: FOREIGN_RUNS.label, description: FOREIGN_RUNS.description, + control: { type: 'toggle' }, scope, onChange: value => drafts.set('host', FOREIGN_RUNS.key, value) }); const workers = settingRow({ id: 'workers', label: 'Worker threads', description: 'Balance background scans and thumbnail work against generation.', control: { type: 'range', value: 4 }, scope, onChange: value => drafts.set('host', 'worker_threads', value) }); - const hostRows = [[monitoring, 'monitoring', 'monitoring_enabled'], [workers, 'workers', 'worker_threads']] + const hostRows = [[monitoring, 'monitoring', 'monitoring_enabled'], [foreign, FOREIGN_RUNS.id, FOREIGN_RUNS.key], + [workers, 'workers', 'worker_threads']] .map(([row, id, key]) => ({ input: controlOf(row, id), key, text: row.children[0], note: null })); for (const row of hostRows) row.input.dataset.key = row.key; - surface.append(monitoring, workers, + surface.append(monitoring, foreign, workers, operationRow({ scope, title: 'Enrich existing metadata', description: 'Resolve both prompts, deduplicate recipes, preserve uncertain links.', label: 'Review scan', onClick: scanAll }), operationRow({ scope, title: 'Thumbnail generation', description: 'Generate missing previews; rebuilding all is a separate explicit option.', @@ -348,6 +367,10 @@ export function mountSources(container, { api, activity, scope, drafts, notify = for (const row of hostRows) { const value = drafts.get('host', row.key); row.input.disabled = value === undefined; + if (row.key === FOREIGN_RUNS.key) { + paintForeign(row, value, unavailable); + continue; + } if (row.key === 'worker_threads') { // D113: the range ends at the server's core count (the concept's fixed 16). const cpus = drafts.get('host', 'cpu_count'); @@ -360,6 +383,19 @@ export function mountSources(container, { api, activity, scope, drafts, notify = } } + // D147: disabled while monitoring is drafted off, with the reason under the description. + function paintForeign(row, value, unavailable) { + const state = foreignRunsState({ value, monitoring: drafts.get('host', 'monitoring_enabled') }); + row.input.disabled = state.disabled; + if (state.checked !== null) row.input.checked = state.checked; + const note = unavailable && value === undefined ? HOST_UNAVAILABLE : state.note; + if (note && row.note?.textContent !== note) { + row.note?.remove(); + row.note = el('p', note, 'small'); + row.text.append(row.note); + } else if (!note && row.note) { row.note.remove(); row.note = null; } + } + // The sources changed since this page read them (a refused per-source scan): read them and the saved values again. function reread() { load();