Auto link node on creation (#110)

* Auto link node on creation

* Handle corner case

* Add some browser tests

* Add auto link test

* Force enable

* Confirm setting before running test

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Chenlei Hu
2024-07-11 12:25:46 -04:00
committed by GitHub
parent ffc4f0c98e
commit 86ee0767c3
9 changed files with 154 additions and 43 deletions

View File

@@ -1,5 +1,5 @@
import type { Page, Locator } from '@playwright/test';
import { test as base } from '@playwright/test';
import type { Page, Locator } from "@playwright/test";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
dotenv.config();
@@ -8,6 +8,27 @@ interface Position {
y: number;
}
class ComfyNodeSearchBox {
public readonly input: Locator;
public readonly dropdown: Locator;
constructor(public readonly page: Page) {
this.input = page.locator(
'.comfy-vue-node-search-container input[type="text"]'
);
this.dropdown = page.locator(
".comfy-vue-node-search-container .p-autocomplete-list"
);
}
async fillAndSelectFirstNode(nodeName: string) {
await this.input.waitFor({ state: "visible" });
await this.input.fill(nodeName);
await this.dropdown.waitFor({ state: "visible" });
await this.dropdown.locator("li").nth(0).click();
}
}
export class ComfyPage {
public readonly url: string;
// All canvas position operations are based on default view of canvas.
@@ -17,13 +38,15 @@ export class ComfyPage {
// Buttons
public readonly resetViewButton: Locator;
constructor(
public readonly page: Page,
) {
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188';
this.canvas = page.locator('#graph-canvas');
this.widgetTextBox = page.getByPlaceholder('text').nth(1);
this.resetViewButton = page.getByRole('button', { name: 'Reset View' });
// Search box
public readonly searchBox: ComfyNodeSearchBox;
constructor(public readonly page: Page) {
this.url = process.env.PLAYWRIGHT_TEST_URL || "http://localhost:8188";
this.canvas = page.locator("#graph-canvas");
this.widgetTextBox = page.getByPlaceholder("text").nth(1);
this.resetViewButton = page.getByRole("button", { name: "Reset View" });
this.searchBox = new ComfyNodeSearchBox(page);
}
async goto() {
@@ -47,8 +70,8 @@ export class ComfyPage {
await this.canvas.click({
position: {
x: 618,
y: 191
}
y: 191,
},
});
await this.nextFrame();
}
@@ -57,8 +80,8 @@ export class ComfyPage {
await this.canvas.click({
position: {
x: 622,
y: 400
}
y: 400,
},
});
await this.nextFrame();
}
@@ -67,8 +90,8 @@ export class ComfyPage {
await this.canvas.click({
position: {
x: 35,
y: 31
}
y: 31,
},
});
await this.nextFrame();
}
@@ -82,10 +105,7 @@ export class ComfyPage {
}
async dragNode2() {
await this.dragAndDrop(
{ x: 622, y: 400 },
{ x: 622, y: 300 },
);
await this.dragAndDrop({ x: 622, y: 400 }, { x: 622, y: 300 });
await this.nextFrame();
}
@@ -113,15 +133,15 @@ export class ComfyPage {
async adjustWidgetValue() {
// Adjust Empty Latent Image's width input.
const page = this.page;
await page.locator('#graph-canvas').click({
await page.locator("#graph-canvas").click({
position: {
x: 724,
y: 645
}
y: 645,
},
});
await page.locator('input[type="text"]').click();
await page.locator('input[type="text"]').fill('128');
await page.locator('input[type="text"]').press('Enter');
await page.locator('input[type="text"]').fill("128");
await page.locator('input[type="text"]').press("Enter");
await this.nextFrame();
}
@@ -140,7 +160,7 @@ export class ComfyPage {
}
async rightClickCanvas() {
await this.page.mouse.click(10, 10, { button: 'right' });
await this.page.mouse.click(10, 10, { button: "right" });
await this.nextFrame();
}
@@ -153,7 +173,7 @@ export class ComfyPage {
await this.canvas.click({
position: {
x: 724,
y: 625
y: 625,
},
});
this.page.mouse.move(10, 10);
@@ -164,9 +184,9 @@ export class ComfyPage {
await this.canvas.click({
position: {
x: 724,
y: 645
y: 645,
},
button: 'right'
button: "right",
});
this.page.mouse.move(10, 10);
await this.nextFrame();
@@ -174,24 +194,24 @@ export class ComfyPage {
async select2Nodes() {
// Select 2 CLIP nodes.
await this.page.keyboard.down('Control');
await this.page.keyboard.down("Control");
await this.clickTextEncodeNode1();
await this.clickTextEncodeNode2();
await this.page.keyboard.up('Control');
await this.page.keyboard.up("Control");
await this.nextFrame();
}
async ctrlC() {
await this.page.keyboard.down('Control');
await this.page.keyboard.press('KeyC');
await this.page.keyboard.up('Control');
await this.page.keyboard.down("Control");
await this.page.keyboard.press("KeyC");
await this.page.keyboard.up("Control");
await this.nextFrame();
}
async ctrlV() {
await this.page.keyboard.down('Control');
await this.page.keyboard.press('KeyV');
await this.page.keyboard.up('Control');
await this.page.keyboard.down("Control");
await this.page.keyboard.press("KeyV");
await this.page.keyboard.up("Control");
await this.nextFrame();
}
}
@@ -202,25 +222,27 @@ export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
await comfyPage.goto();
// Unify font for consistent screenshots.
await page.addStyleTag({
url: "https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
url: "https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap",
});
await page.addStyleTag({
url: "https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
url: "https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap",
});
await page.addStyleTag({
content: `
* {
font-family: 'Roboto Mono', 'Noto Color Emoji';
}`
}`,
});
await page.waitForFunction(() => document.fonts.ready);
await page.waitForFunction(() => window['app'] != undefined);
await page.evaluate(() => { window['app']['canvas'].show_info = false; });
await page.waitForFunction(() => window["app"] != undefined);
await page.evaluate(() => {
window["app"]["canvas"].show_info = false;
});
await comfyPage.nextFrame();
// Reset view to force re-rendering of canvas. So that info fields like fps
// become hidden.
await comfyPage.resetView();
await use(comfyPage);
},
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -0,0 +1,47 @@
import { expect } from "@playwright/test";
import { ComfyPage, comfyPageFixture } from "./ComfyPage";
export const test = comfyPageFixture.extend<{ comfyPage: ComfyPage }>({
comfyPage: async ({ comfyPage }, use) => {
await comfyPage.page.evaluate(async () => {
await window["app"].ui.settings.setSettingValueAsync(
"Comfy.NodeSearchBoxImpl",
"default"
);
});
await use(comfyPage);
},
});
test.describe("Node search box", () => {
test("Can trigger on empty canvas double click", async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas();
await expect(comfyPage.searchBox.input).toHaveCount(1);
});
test("Can trigger on link release", async ({ comfyPage }) => {
await comfyPage.page.keyboard.down("Shift");
await comfyPage.disconnectEdge();
await expect(comfyPage.searchBox.input).toHaveCount(1);
});
test("Does not trigger on link release (no shift)", async ({ comfyPage }) => {
await comfyPage.disconnectEdge();
await expect(comfyPage.searchBox.input).toHaveCount(0);
});
test("Can add node", async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas();
await expect(comfyPage.searchBox.input).toHaveCount(1);
await comfyPage.searchBox.fillAndSelectFirstNode("KSampler");
await expect(comfyPage.canvas).toHaveScreenshot("added-node.png");
});
test("Can auto link node", async ({ comfyPage }) => {
await comfyPage.page.keyboard.down("Shift");
await comfyPage.disconnectEdge();
await comfyPage.page.keyboard.up("Shift");
await comfyPage.searchBox.fillAndSelectFirstNode("CLIPTextEncode");
await expect(comfyPage.canvas).toHaveScreenshot("auto-linked-node.png");
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -36,6 +36,7 @@ export default defineConfig({
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
timeout: 3000,
},
// {

View File

@@ -23,7 +23,13 @@ import { app } from "@/scripts/app";
import { inject, onMounted, onUnmounted, reactive, Ref, ref } from "vue";
import NodeSearchBox from "./NodeSearchBox.vue";
import Dialog from "primevue/dialog";
import { LiteGraph, LiteGraphCanvasEvent } from "@comfyorg/litegraph";
import {
INodeSlot,
LiteGraph,
LiteGraphCanvasEvent,
LGraphNode,
LinkReleaseContext,
} from "@comfyorg/litegraph";
import {
FilterAndValue,
NodeSearchService,
@@ -63,12 +69,47 @@ const closeDialog = () => {
clearFilters();
visible.value = false;
};
const connectNodeOnLinkRelease = (
node: LGraphNode,
context: LinkReleaseContext
) => {
const destIsInput = context.node_from !== undefined;
const srcNode = (
destIsInput ? context.node_from : context.node_to
) as LGraphNode;
const srcSlotIndex: number = context.slot_from.slot_index;
const linkDataType = destIsInput
? context.type_filter_in
: context.type_filter_out;
const destSlots = destIsInput ? node.inputs : node.outputs;
const destSlotIndex = destSlots.findIndex(
(slot: INodeSlot) => slot.type === linkDataType
);
if (destSlotIndex === -1) {
console.warn(
`Could not find slot with type ${linkDataType} on node ${node.title}`
);
return;
}
if (destIsInput) {
srcNode.connect(srcSlotIndex, node, destSlotIndex);
} else {
node.connect(destSlotIndex, srcNode, srcSlotIndex);
}
};
const addNode = (nodeDef: ComfyNodeDef) => {
closeDialog();
const node = LiteGraph.createNode(nodeDef.name, nodeDef.display_name, {});
if (node) {
node.pos = getNewNodeLocation();
app.graph.add(node);
const eventDetail = triggerEvent.value.detail;
if (eventDetail.subType === "empty-release") {
connectNodeOnLinkRelease(node, eventDetail.linkReleaseContext);
}
}
};
const nodeSearchService = (