Files
ComfyAssets-ComfyUI_PromptM…/tests/comfyui/text-link.spec.mjs
T

205 lines
13 KiB
JavaScript

// #343: on the pinned real host, a STRING link dragged with the mouse drives each PromptManager node's text (and the
// Pair's negative) in Classic and Nodes 2.0, across renderer switches, a workflow reload, disconnect and removal.
import { readFile } from 'node:fs/promises';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { installHostHooks } from './host-fixture.mjs';
installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']);
const editors = { PromptManagerFocused: 'Main prompt', PromptManagerSplit: 'Main prompt', PromptManagerPair: 'Positive prompt' };
const note = label => `${label} comes from a link. Your typed text is kept for when you disconnect it.`;
/** Viewport position of a node's slot: the slot dot in Nodes 2.0, the canvas slot position in Classic. */
function slotPosition(page, nodeId, kind, name) {
return page.evaluate(async ({ nodeId, kind, name }) => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(nodeId);
const slots = kind === 'out' ? node.outputs : node.inputs;
const index = typeof name === 'number' ? name : slots.findIndex(slot => slot.name === name);
if (LiteGraph.vueNodesMode) {
const dot = document.querySelector(`[data-slot-key="${nodeId}-${kind}-${index}"]`);
if (!dot) return null;
const box = dot.getBoundingClientRect();
return [box.x + box.width / 2, box.y + box.height / 2];
}
const position = app.canvas.ds.convertOffsetToCanvas(kind === 'out' ? node.getOutputPos(index) : node.getInputPos(index));
const box = app.canvas.canvas.getBoundingClientRect();
return [box.x + position[0], box.y + position[1]];
}, { nodeId, kind, name });
}
async function drag(page, from, to) {
await page.mouse.move(...from);
await page.mouse.down();
await page.mouse.move((from[0] + to[0]) / 2, (from[1] + to[1]) / 2, { steps: 5 });
await page.mouse.move(...to, { steps: 10 });
await page.mouse.up();
}
const center = box => [box.x + box.width / 2, box.y + box.height / 2];
const linkedInputs = (page, id) => page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
return app.graph.getNodeById(id).inputs.filter(input => input.link != null).map(input => input.name);
}, id);
const queued = (page, id) => page.evaluate(async id => (await (await import('/scripts/app.js')).app.graphToPrompt()).output[String(id)].inputs, id);
async function setup(page, type) {
return page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(type);
app.graph.add(node);
node.pos = [500, 80];
node.setSize([type === 'PromptManagerPair' ? 1100 : 650, 760]);
const sources = ['text', 'negative_text'].map((name, index) => {
const source = LiteGraph.createNode('PrimitiveString');
app.graph.add(source);
source.pos = [40, 80 + index * 160];
source.widgets.find(widget => widget.name === 'value').value = `Linked ${name} ${type}`;
return source.id;
});
app.canvas.ds.scale = .8;
app.canvas.ds.offset = [40, 40];
app.canvas.setDirty(true, true);
return { id: node.id, sources };
}, type);
}
/** The prompt box a user drops the link on: the editor's textarea, or the core text box of the Classic nodes. */
async function promptBox(page, type, id) {
if (editors[type]) return page.getByRole('textbox', { name: editors[type], exact: true });
// The Classic nodes keep the core text widget: Nodes 2.0 draws it as its own textarea, Classic as widget.inputEl.
if (await page.evaluate(() => LiteGraph.vueNodesMode)) return page.locator(`[data-node-id="${id}"] textarea`).first();
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
app.graph.getNodeById(id).widgets.find(widget => widget.name === 'text').inputEl.dataset.pmTestPromptBox = String(id);
}, id);
return page.locator(`textarea[data-pm-test-prompt-box="${id}"]`);
}
/** What the node shows while its text is linked. */
function linkedNote(page, type) {
return editors[type] ? page.getByText(note(editors[type]), { exact: true }) : page.locator('.pm-text-link-note').getByText(note('Text'), { exact: true });
}
for (const type of ['PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair', 'PromptManager', 'PromptManagerText']) {
test(`${type}: a STRING link dropped on the prompt box drives text and the typed text returns on disconnect`, async ({ page }, testInfo) => {
test.setTimeout(90000);
const { id, sources } = await setup(page, type);
const typed = `Typed ${type} ${testInfo.project.name}`;
const box = await promptBox(page, type, id);
await box.fill(typed);
await expect(linkedNote(page, type)).toBeHidden();
await page.screenshot({ path: testInfo.outputPath(`${type}-unlinked.png`) });
// The drop lands on text, never on prepend_text (#342).
await drag(page, await slotPosition(page, sources[0], 'out', 0), center(await box.boundingBox()));
await expect.poll(() => linkedInputs(page, id)).toEqual(['text']);
const links = { text: [String(sources[0]), 0] };
if (type === 'PromptManagerPair') {
await page.getByRole('textbox', { name: 'Negative prompt', exact: true }).fill(`Typed negative ${testInfo.project.name}`);
await drag(page, await slotPosition(page, sources[1], 'out', 0), await slotPosition(page, id, 'in', 'negative_text'));
await expect.poll(() => linkedInputs(page, id)).toEqual(['text', 'negative_text']);
links.negative_text = [String(sources[1]), 0];
await expect(page.getByText(note('Negative prompt'), { exact: true })).toBeVisible();
}
await expect(linkedNote(page, type)).toBeVisible();
expect(await queued(page, id)).toMatchObject(links);
await page.screenshot({ path: testInfo.outputPath(`${type}-linked.png`) });
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);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(linkedNote(page, type)).toBeVisible();
expect(await queued(page, id)).toMatchObject(links);
}
const saved = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
await app.loadGraphData(workflow);
return workflow.nodes.find(node => String(node.id) === String(id)).widgets_values;
}, id);
expect(saved[0]).toBe(typed);
await expect(linkedNote(page, type)).toBeVisible();
expect(await queued(page, id)).toMatchObject(links);
const local = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
for (const input of [...node.inputs]) if (input.link != null) node.disconnectInput(node.inputs.indexOf(input));
return (await app.graphToPrompt()).output[String(id)].inputs;
}, id);
expect(local.text).toBe(typed);
if (type === 'PromptManagerPair') expect(local.negative_text).toBe(`Typed negative ${testInfo.project.name}`);
await expect(linkedNote(page, type)).toBeHidden();
const restored = await promptBox(page, type, id);
await expect(restored).toBeVisible();
await expect(restored).toHaveValue(typed);
await page.screenshot({ path: testInfo.outputPath(`${type}-disconnected.png`) });
await page.evaluate(async id => { const { app } = await import('/scripts/app.js'); app.graph.remove(app.graph.getNodeById(id)); }, id);
await expect(page.locator('.pm-focused, .pm-classic, .pm-text-link-note')).toHaveCount(0);
});
}
test('PromptSearchList text is a core widget socket and takes a dragged STRING link too', async ({ page }) => {
const { id, sources } = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('PromptSearchList');
app.graph.add(node);
node.pos = [500, 80];
const source = LiteGraph.createNode('PrimitiveString');
app.graph.add(source);
source.pos = [40, 80];
app.canvas.ds.scale = .8;
app.canvas.ds.offset = [40, 40];
app.canvas.setDirty(true, true);
return { id: node.id, sources: [source.id] };
});
// A core widget's socket shows while a link is dragged; aim at where the frontend places it.
await expect.poll(async () => Boolean(await slotPosition(page, id, 'in', 'text'))).toBe(true);
await drag(page, await slotPosition(page, sources[0], 'out', 0), await slotPosition(page, id, 'in', 'text'));
await expect.poll(() => linkedInputs(page, id)).toEqual(['text']);
expect((await queued(page, id)).text).toEqual([String(sources[0]), 0]);
});
test('workflows saved before #343 load unchanged and their linked text keeps working', async ({ page }) => {
const workflow = JSON.parse(await readFile(new URL('./fixtures/workflow-before-343.json', import.meta.url), 'utf8'));
const result = await page.evaluate(async workflow => {
const { app } = await import('/scripts/app.js');
await app.loadGraphData(structuredClone(workflow));
const output = (await app.graphToPrompt()).output;
const nodes = Object.fromEntries(app.graph._nodes.map(node => [node.id, node]));
const reloaded = app.graph.serialize();
return {
values: Object.fromEntries(reloaded.nodes.map(node => [node.id, node.widgets_values])),
links: Object.fromEntries(Object.entries(output).map(([id, node]) => [id, { text: node.inputs.text, negative_text: node.inputs.negative_text }])),
widgetBound: Object.fromEntries(Object.values(nodes).filter(node => node.type.startsWith('PromptManager')).map(node => [node.id, node.inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).map(input => input.name)])),
};
}, workflow);
for (const node of workflow.nodes) {
// Every saved widget value comes back as it was saved, except the typed text main lost under a link (null).
if (node.widgets_values?.[0] !== null) expect(result.values[node.id], `${node.type} ${node.id}`).toEqual(node.widgets_values);
}
expect(result.links).toMatchObject({
1: { text: 'Saved PromptManager text' }, 2: { text: 'Saved PromptManagerText text' }, 3: { text: 'Saved PromptManagerFocused text' },
4: { text: 'Saved PromptManagerSplit text' }, 5: { text: 'Saved PromptManagerPair text', negative_text: 'Saved pair negative' },
6: { text: ['7', 0] }, 8: { text: ['9', 0], negative_text: ['10', 0] }, 11: { text: ['12', 0] },
});
// The editor nodes' text inputs are sockets now; the Classic nodes keep their core widget sockets.
expect(result.widgetBound).toEqual({ 1: ['text'], 2: ['text'], 3: [], 4: [], 5: [], 6: [], 8: [], 11: ['text'] });
// The linked sockets are drawn where a user can see and re-drag them, in either renderer.
for (const [node, name] of [[6, 'text'], [8, 'text'], [8, 'negative_text']]) {
await page.evaluate(async node => { const { app } = await import('/scripts/app.js'); app.canvas.centerOnNode(app.graph.getNodeById(node)); app.canvas.setDirty(true, true); }, node);
await expect.poll(async () => {
const position = await slotPosition(page, node, 'in', name);
return Boolean(position && position[0] > 0 && position[1] > 0 && position[0] < 1600 && position[1] < 1100);
}, { message: `${node}.${name} socket on screen` }).toBe(true);
}
const unplugged = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
for (const id of [6, 8]) { const node = app.graph.getNodeById(id); for (const input of [...node.inputs]) if (input.link != null) node.disconnectInput(node.inputs.indexOf(input)); }
const output = (await app.graphToPrompt()).output;
return { focused: output['6'].inputs.text, positive: output['8'].inputs.text, negative: output['8'].inputs.negative_text };
});
expect(unplugged).toEqual({ focused: 'Typed under focused link', positive: 'Typed pair positive', negative: 'Typed pair negative' });
});