* feat(ui): add Focused Prompt Manager node (#186) * feat(ui): autocomplete saved Focused metadata with shared controls (#186) * feat(ui): autocomplete Focused library filters (#186)
347 lines
20 KiB
JavaScript
347 lines
20 KiB
JavaScript
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
|
import { installHostHooks } from './host-fixture.mjs';
|
|
|
|
installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptSearchList', 'PromptManagerFocused']);
|
|
|
|
async function addFocused(page) {
|
|
return page.evaluate(async () => {
|
|
const { app } = await import('/scripts/app.js');
|
|
const node = LiteGraph.createNode('PromptManagerFocused');
|
|
if (!node) throw new Error('PromptManagerFocused must be registered by the real host');
|
|
app.graph.add(node);
|
|
node.setSize([680, 1050]);
|
|
app.canvas.ds.scale = .8;
|
|
app.canvas.centerOnNode(node);
|
|
return node.id;
|
|
});
|
|
}
|
|
|
|
async function model(page, id) {
|
|
return page.evaluate(async id => {
|
|
const { app } = await import('/scripts/app.js');
|
|
return Object.fromEntries(app.graph.getNodeById(id).widgets.map(widget => [widget.name, widget.value]));
|
|
}, id);
|
|
}
|
|
|
|
async function seed(request, text, category = 'Focused host tests', tags = ['focused-host']) {
|
|
const response = await request.post('/prompt_manager/save', { data: { text, category, tags } });
|
|
expect(response.ok()).toBe(true);
|
|
expect((await response.json()).success).toBe(true);
|
|
}
|
|
|
|
async function browse(page, text) {
|
|
const root = page.locator('.pm-focused');
|
|
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
|
|
await root.getByRole('button', { name: 'Recent', exact: true }).click();
|
|
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
|
|
return root;
|
|
}
|
|
|
|
test('Focused definition preserves Classic encoding and exposes composition STRING sockets', async ({ request }) => {
|
|
const definitions = await (await request.get('/object_info')).json();
|
|
const focused = definitions.PromptManagerFocused;
|
|
expect(focused).toBeDefined();
|
|
expect(focused.display_name).toBe('Prompt Manager (Focused)');
|
|
expect(focused.output).toEqual(definitions.PromptManager.output);
|
|
expect(focused.input.required).toEqual(definitions.PromptManager.input.required);
|
|
for (const name of ['prepend_text', 'append_text']) {
|
|
expect(focused.input.optional[name][0]).toBe('STRING');
|
|
expect(focused.input.optional[name][1]).toMatchObject({ forceInput: true, default: '' });
|
|
}
|
|
});
|
|
|
|
test('Focused preview, explicit load, composition, renderer changes and workflow reload preserve state', async ({ page, request }, testInfo) => {
|
|
const text = `Focused selected ${testInfo.project.name}`;
|
|
await seed(request, text);
|
|
const id = await addFocused(page);
|
|
const root = page.locator('.pm-focused');
|
|
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
|
|
await expect(root).toBeVisible();
|
|
expect(await editor.evaluate(async (element, id) => {
|
|
const { app } = await import('/scripts/app.js');
|
|
return app.graph.getNodeById(id).widgets.find(widget => widget.name === 'text').inputEl === element;
|
|
}, id)).toBe(true);
|
|
await editor.fill('Original editing text');
|
|
await browse(page, text);
|
|
await expect(editor).toHaveValue('Original editing text');
|
|
expect((await model(page, id)).text).toBe('Original editing text');
|
|
await root.getByRole('button', { name: 'Use prompt', exact: true }).click();
|
|
await expect(editor).toHaveValue(text);
|
|
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host' });
|
|
await root.getByText('Composition · prepend / append', { exact: true }).click();
|
|
await root.getByRole('textbox', { name: 'Prepend', exact: true }).fill('LoRA trigger');
|
|
await root.getByRole('textbox', { name: 'Append', exact: true }).fill('high detail');
|
|
await root.getByText('Composed text preview', { exact: true }).click();
|
|
await expect(root).toContainText(`LoRA trigger ${text} high detail`);
|
|
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
|
|
await page.evaluate(async renderer => {
|
|
const { app } = await import('/scripts/app.js');
|
|
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
|
app.canvas.centerOnNode(app.graph._nodes[0]);
|
|
}, renderer);
|
|
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
|
await expect(root).toHaveCount(1);
|
|
await expect(editor).toHaveValue(text);
|
|
}
|
|
const queued = await page.evaluate(async id => {
|
|
const { app } = await import('/scripts/app.js');
|
|
const prompt = await app.graphToPrompt();
|
|
await app.loadGraphData(app.graph.serialize());
|
|
return prompt.output[String(id)].inputs;
|
|
}, id);
|
|
expect(queued).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host', prepend_text: 'LoRA trigger', append_text: 'high detail' });
|
|
await expect(editor).toHaveValue(text);
|
|
await expect.poll(() => model(page, id)).toMatchObject({ text, prepend_text: 'LoRA trigger', append_text: 'high detail' });
|
|
await page.screenshot({ path: testInfo.outputPath('focused-reloaded.png') });
|
|
await page.evaluate(async id => { const { app } = await import('/scripts/app.js'); app.graph.remove(app.graph.getNodeById(id)); }, id);
|
|
await expect(root).toHaveCount(0);
|
|
});
|
|
|
|
test('Focused search preview respects metadata opt-out', async ({ page, request }, testInfo) => {
|
|
const text = `Focused search ${testInfo.project.name}`;
|
|
await seed(request, text, 'Incoming category', ['incoming-tag']);
|
|
const id = await addFocused(page);
|
|
const root = page.locator('.pm-focused');
|
|
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
|
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Keep category');
|
|
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('keep-tag');
|
|
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
|
|
await root.getByRole('searchbox', { name: 'Search saved prompts', exact: true }).fill(text);
|
|
await root.getByRole('button', { name: 'Search', exact: true }).click();
|
|
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
|
|
await root.getByRole('switch', { name: 'Load metadata', exact: true }).uncheck();
|
|
await root.getByRole('button', { name: 'Use prompt', exact: true }).click();
|
|
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Keep category', tags: 'keep-tag' });
|
|
});
|
|
|
|
test('Focused connected STRING inputs remain linked through UI actions and reload', async ({ page, request }, testInfo) => {
|
|
const text = `Connected library ${testInfo.project.name}`;
|
|
await seed(request, text);
|
|
const id = await addFocused(page);
|
|
const root = page.locator('.pm-focused');
|
|
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Local main');
|
|
await root.getByText('Composition · prepend / append', { exact: true }).click();
|
|
await root.getByRole('textbox', { name: 'Prepend', exact: true }).fill('Local prepend');
|
|
await root.getByRole('textbox', { name: 'Append', exact: true }).fill('Local append');
|
|
await root.getByText('Composition · prepend / append', { exact: true }).click();
|
|
const links = await page.evaluate(async id => {
|
|
const { app } = await import('/scripts/app.js');
|
|
const { convertToInput } = await import('/extensions/core/widgetInputs.js');
|
|
const target = app.graph.getNodeById(id);
|
|
convertToInput(target, target.widgets.find(widget => widget.name === 'text'));
|
|
const result = {};
|
|
for (const name of ['text', 'prepend_text', 'append_text']) {
|
|
const source = LiteGraph.createNode('PrimitiveString');
|
|
app.graph.add(source);
|
|
source.pos = [target.pos[0] - 360, target.pos[1] + Object.keys(result).length * 150];
|
|
source.widgets.find(widget => widget.name === 'value').value = `Connected ${name}`;
|
|
const slot = target.inputs.findIndex(input => input.name === name);
|
|
if (slot < 0) throw new Error(`Missing ${name} input socket`);
|
|
source.connect(0, target, slot);
|
|
result[name] = [String(source.id), 0];
|
|
}
|
|
app.canvas.centerOnNode(target);
|
|
return result;
|
|
}, id);
|
|
await browse(page, text);
|
|
await expect(root.getByRole('textbox', { name: 'Main prompt', exact: true })).toBeHidden();
|
|
await expect(root.getByRole('button', { name: 'Use prompt', exact: true })).toBeDisabled();
|
|
await root.getByText('Composition · prepend / append', { exact: true }).click();
|
|
for (const name of ['Prepend', 'Append']) await expect(root.getByRole('textbox', { name, exact: true })).toBeHidden();
|
|
const guardedValues = await model(page, id);
|
|
await root.locator('textarea, input').evaluateAll(inputs => {
|
|
for (const input of inputs) {
|
|
if (!input.hidden && !input.closest('[hidden]')) continue;
|
|
input.value = 'Attempt to replace upstream text';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
});
|
|
expect(await model(page, id)).toMatchObject(guardedValues);
|
|
for (let reload = 0; reload < 2; reload++) {
|
|
const inputs = await page.evaluate(async ({ id, reload }) => {
|
|
const { app } = await import('/scripts/app.js');
|
|
if (reload) await app.loadGraphData(app.graph.serialize());
|
|
return (await app.graphToPrompt()).output[String(id)].inputs;
|
|
}, { id, reload });
|
|
expect(inputs).toMatchObject(links);
|
|
}
|
|
await expect(root.getByRole('button', { name: 'Use prompt', exact: true, includeHidden: true })).toBeDisabled();
|
|
const disconnected = await page.evaluate(async id => {
|
|
const { app } = await import('/scripts/app.js');
|
|
const node = app.graph.getNodeById(id);
|
|
for (const name of ['text', 'prepend_text', 'append_text']) node.disconnectInput(node.inputs.findIndex(input => input.name === name));
|
|
return (await app.graphToPrompt()).output[String(id)].inputs;
|
|
}, id);
|
|
expect(disconnected).toMatchObject({ text: 'Local main', prepend_text: 'Local prepend', append_text: 'Local append' });
|
|
for (const [name, value] of [['Main prompt', 'Local main'], ['Prepend', 'Local prepend'], ['Append', 'Local append']]) {
|
|
const input = page.getByRole('textbox', { name, exact: true });
|
|
await expect(input).toHaveCount(1);
|
|
await expect(input).toBeEditable();
|
|
await expect(input).toHaveValue(value);
|
|
}
|
|
await page.screenshot({ path: testInfo.outputPath('focused-disconnected.png') });
|
|
});
|
|
|
|
test('Focused follows actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
|
|
await addFocused(page);
|
|
for (const palette of ['light', 'dark']) {
|
|
await page.evaluate(async palette => {
|
|
const { app } = await import('/scripts/app.js');
|
|
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
|
|
}, palette);
|
|
const root = page.locator('.pm-focused');
|
|
await expect(root).toHaveAttribute('data-ca-theme', palette);
|
|
await expect(root.getByRole('button', { name: 'Browse library', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
|
await page.screenshot({ path: testInfo.outputPath(`focused-${palette}.png`) });
|
|
}
|
|
});
|
|
|
|
test('Focused Web UI button opens actual standalone pages', async ({ page }) => {
|
|
await addFocused(page);
|
|
const popupPromise = page.waitForEvent('popup');
|
|
await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click();
|
|
const popup = await popupPromise;
|
|
await popup.waitForLoadState('domcontentloaded');
|
|
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
|
|
await expect(popup.locator('body')).toContainText('Prompt');
|
|
await expect(page.locator('.pm-focused').getByRole('status')).toHaveText('Web UI opened.');
|
|
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
|
|
await expect(popup.locator('#addPromptBtn')).toBeVisible();
|
|
await popup.close();
|
|
});
|
|
|
|
test('Focused metadata completion uses saved categories and tags across renderers and reload', async ({ page, request }, testInfo) => {
|
|
const category = `Host completion category ${testInfo.project.name}`;
|
|
const tag = `host-completion-${testInfo.project.name}`;
|
|
await seed(request, `Saved autocomplete ${testInfo.project.name}`, category, [tag]);
|
|
const id = await addFocused(page);
|
|
const root = page.locator('.pm-focused');
|
|
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Autocomplete workflow');
|
|
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
|
const categoryInput = root.getByRole('combobox', { name: 'Category', exact: true });
|
|
const tagInput = root.getByRole('combobox', { name: 'Tags', exact: true });
|
|
await categoryInput.fill(category.slice(0, -3));
|
|
const categories = page.getByRole('listbox', { name: 'Saved categories', exact: true });
|
|
await expect(categories.getByRole('option', { name: category, exact: true })).toBeVisible();
|
|
await categoryInput.press('ArrowDown');
|
|
await expect(categoryInput).toHaveAttribute('aria-activedescendant', /.+/);
|
|
await categoryInput.press('Enter');
|
|
await expect(categoryInput).toHaveValue(category);
|
|
await expect(categoryInput).toHaveAttribute('aria-expanded', 'false');
|
|
for (const palette of ['light', 'dark']) {
|
|
await page.evaluate(async palette => {
|
|
const { app } = await import('/scripts/app.js');
|
|
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
|
|
}, palette);
|
|
await tagInput.fill(`keep, ${tag.slice(0, -3)}`);
|
|
const tags = page.getByRole('listbox', { name: 'Saved tags', exact: true });
|
|
const option = tags.getByRole('option', { name: tag, exact: true });
|
|
await expect(option).toBeVisible();
|
|
await expect(tags).toHaveAttribute('data-ca-theme', palette);
|
|
const bounds = await tags.boundingBox();
|
|
const viewport = page.viewportSize();
|
|
expect(bounds.x).toBeGreaterThanOrEqual(0);
|
|
expect(bounds.y).toBeGreaterThanOrEqual(0);
|
|
expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width);
|
|
expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height);
|
|
await page.screenshot({ path: testInfo.outputPath(`focused-metadata-${palette}.png`) });
|
|
await option.click();
|
|
await expect(tagInput).toHaveValue(`keep, ${tag}, `);
|
|
}
|
|
const expected = { text: 'Autocomplete workflow', category, tags: `keep, ${tag}, ` };
|
|
await expect.poll(() => model(page, id)).toMatchObject(expected);
|
|
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
|
|
await page.evaluate(async renderer => {
|
|
const { app } = await import('/scripts/app.js');
|
|
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
|
app.canvas.centerOnNode(app.graph._nodes[0]);
|
|
}, renderer);
|
|
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
|
await expect(categoryInput).toHaveValue(category);
|
|
await expect(tagInput).toHaveValue(expected.tags);
|
|
}
|
|
const queued = await page.evaluate(async id => {
|
|
const { app } = await import('/scripts/app.js');
|
|
const prompt = await app.graphToPrompt();
|
|
await app.loadGraphData(app.graph.serialize());
|
|
return prompt.output[String(id)].inputs;
|
|
}, id);
|
|
expect(queued).toMatchObject(expected);
|
|
await expect(categoryInput).toHaveValue(category);
|
|
await expect(tagInput).toHaveValue(expected.tags);
|
|
await categoryInput.fill(category.slice(0, -3));
|
|
await expect(categories).toBeVisible();
|
|
await page.evaluate(async id => {
|
|
const { app } = await import('/scripts/app.js');
|
|
app.graph.remove(app.graph.getNodeById(id));
|
|
}, id);
|
|
await expect(page.getByRole('listbox', { name: 'Saved categories', includeHidden: true })).toHaveCount(0);
|
|
await expect(page.getByRole('listbox', { name: 'Saved tags', includeHidden: true })).toHaveCount(0);
|
|
});
|
|
|
|
test('Focused library autocomplete filters saved prompts without editing draft metadata', async ({ page, request }, testInfo) => {
|
|
const category = `Library category ${testInfo.project.name}`;
|
|
const tag = `library-filter-${testInfo.project.name}`;
|
|
const matchingText = `Matching library prompt ${testInfo.project.name}`;
|
|
await seed(request, matchingText, category, [tag]);
|
|
await seed(request, `Wrong category ${testInfo.project.name}`, `Other category ${testInfo.project.name}`, [tag]);
|
|
await seed(request, `Wrong tag ${testInfo.project.name}`, category, [`other-filter-${testInfo.project.name}`]);
|
|
const id = await addFocused(page);
|
|
const root = page.locator('.pm-focused');
|
|
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Keep my draft');
|
|
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
|
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Draft category');
|
|
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('draft-tag');
|
|
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
|
|
const filterCategory = root.getByRole('combobox', { name: 'Filter category', exact: true });
|
|
const filterTags = root.getByRole('combobox', { name: 'Filter tags', exact: true });
|
|
await filterCategory.fill(category.slice(0, -3));
|
|
await expect(page.getByRole('listbox', { name: 'Saved categories', exact: true }).getByRole('option', { name: category, exact: true })).toBeVisible();
|
|
await filterCategory.press('ArrowDown');
|
|
await filterCategory.press('Enter');
|
|
await expect(filterCategory).toHaveValue(category);
|
|
for (const palette of ['light', 'dark']) {
|
|
await page.evaluate(async palette => {
|
|
const { app } = await import('/scripts/app.js');
|
|
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
|
|
}, palette);
|
|
await filterTags.fill(tag.slice(0, -3));
|
|
const option = page.getByRole('listbox', { name: 'Saved tags', exact: true }).getByRole('option', { name: tag, exact: true });
|
|
await expect(option).toBeVisible();
|
|
await page.screenshot({ path: testInfo.outputPath(`focused-library-filters-${palette}.png`) });
|
|
await option.click();
|
|
await expect(filterTags).toHaveValue(`${tag}, `);
|
|
}
|
|
const draft = { text: 'Keep my draft', category: 'Draft category', tags: 'draft-tag' };
|
|
await root.getByRole('button', { name: 'Search', exact: true }).click();
|
|
const results = root.locator('.pm-editor-results').getByRole('button');
|
|
await expect(results).toHaveCount(1);
|
|
await expect(results.first()).toContainText(matchingText);
|
|
await results.first().click();
|
|
await expect.poll(() => model(page, id)).toMatchObject(draft);
|
|
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
|
|
await page.evaluate(async renderer => {
|
|
const { app } = await import('/scripts/app.js');
|
|
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
|
app.canvas.centerOnNode(app.graph._nodes[0]);
|
|
}, renderer);
|
|
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
|
await expect(filterCategory).toHaveValue(category);
|
|
await expect(filterTags).toHaveValue(`${tag}, `);
|
|
await expect.poll(() => model(page, id)).toMatchObject(draft);
|
|
}
|
|
const queued = await page.evaluate(async id => {
|
|
const { app } = await import('/scripts/app.js');
|
|
const prompt = await app.graphToPrompt();
|
|
await app.loadGraphData(app.graph.serialize());
|
|
return prompt.output[String(id)].inputs;
|
|
}, id);
|
|
expect(queued).toMatchObject(draft);
|
|
await expect(filterCategory).toHaveValue(category);
|
|
await expect(filterTags).toHaveValue(`${tag}, `);
|
|
await root.getByRole('button', { name: 'Search', exact: true }).click();
|
|
await expect(results).toHaveCount(1);
|
|
await expect(results.first()).toContainText(matchingText);
|
|
await expect.poll(() => model(page, id)).toMatchObject(draft);
|
|
});
|