mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
Compare commits
1 Commits
feat/add-m
...
regen-scre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdb9b16574 |
27
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
27
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -54,33 +54,6 @@ jobs:
|
||||
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
|
||||
- name: Validate merged coverage
|
||||
run: |
|
||||
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
|
||||
if [ "$SHARD_COUNT" -eq 0 ]; then
|
||||
echo "::error::No shard coverage.lcov files found under temp/coverage-shards"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
|
||||
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
echo "### Merged coverage" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **$MERGED_SF** source files" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **$MERGED_LH / $MERGED_LF** lines hit" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Shard | Files | Lines Hit |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|-------|-------|-----------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
for f in $(find temp/coverage-shards -name 'coverage.lcov' -type f | sort); do
|
||||
SHARD=$(basename "$(dirname "$f")")
|
||||
SHARD_SF=$(grep -c '^SF:' "$f" || echo 0)
|
||||
SHARD_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' "$f")
|
||||
echo "| $SHARD | $SHARD_SF | $SHARD_LH |" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ "$MERGED_LH" -lt "$SHARD_LH" ]; then
|
||||
echo "::error::Merged LH ($MERGED_LH) < shard LH ($SHARD_LH) in $SHARD — possible data loss"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload merged coverage data
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model_a.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [500, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model_b.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model_a.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
},
|
||||
{
|
||||
"name": "fake_model_b.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
"directory": "text_encoders"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
{
|
||||
"id": "test-missing-models-in-bypassed-subgraph",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-with-missing-model",
|
||||
"pos": [450, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"inputs": [{ "name": "model", "type": "MODEL", "link": null }],
|
||||
"outputs": [{ "name": "MODEL", "type": "MODEL", "links": null }],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-with-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph with Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "input1-id",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "output1-id",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"linkIds": [2],
|
||||
"pos": { "0": 520, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": [2] },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
{
|
||||
"id": "fe4562c0-3a0b-4614-bdec-7039a58d75b8",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"pos": [627.5973510742188, 423.0972900390625],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 4,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [347.90441582814213, 417.3822440655296, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [892.5973510742188, 416.0972900390625, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [1],
|
||||
"pos": {
|
||||
"0": 447.9044189453125,
|
||||
"1": 437.3822326660156
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"pos": {
|
||||
"0": 912.5973510742188,
|
||||
"1": 436.0972900390625
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [554.8743286132812, 100.95539093017578],
|
||||
"size": [270, 262],
|
||||
"flags": { "collapsed": true },
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VAEEncode",
|
||||
"pos": [685.1265869140625, 439.1734619140625],
|
||||
"size": [140, 46],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "pixels",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [4]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8894351682943402,
|
||||
"offset": [58.7671207025881, 137.7124650620126]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"id": 1,
|
||||
"type": "ImageCropV2",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 550],
|
||||
"size": [400, 500],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
@@ -27,7 +27,14 @@
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCropV2"
|
||||
},
|
||||
"widgets_values": [{ "x": 0, "y": 0, "width": 512, "height": 512 }]
|
||||
"widgets_values": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 512,
|
||||
"height": 512
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { nextFrame, sleep } from '@e2e/fixtures/utils/timing'
|
||||
import { sleep } from '@e2e/fixtures/utils/timing'
|
||||
import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers'
|
||||
import { BottomPanel } from '@e2e/fixtures/components/BottomPanel'
|
||||
import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox'
|
||||
@@ -336,7 +336,9 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async nextFrame() {
|
||||
await nextFrame(this.page)
|
||||
await this.page.evaluate(() => {
|
||||
return new Promise<number>(requestAnimationFrame)
|
||||
})
|
||||
}
|
||||
|
||||
async delay(ms: number) {
|
||||
@@ -391,27 +393,6 @@ export class ComfyPage {
|
||||
return this.page.locator('.dom-widget')
|
||||
}
|
||||
|
||||
async expectScreenshot(
|
||||
locator: Locator,
|
||||
name: string | string[],
|
||||
options?: {
|
||||
animations?: 'disabled' | 'allow'
|
||||
caret?: 'hide' | 'initial'
|
||||
mask?: Array<Locator>
|
||||
maskColor?: string
|
||||
maxDiffPixelRatio?: number
|
||||
maxDiffPixels?: number
|
||||
omitBackground?: boolean
|
||||
scale?: 'css' | 'device'
|
||||
stylePath?: string | Array<string>
|
||||
threshold?: number
|
||||
timeout?: number
|
||||
}
|
||||
): Promise<void> {
|
||||
await this.nextFrame()
|
||||
await comfyExpect(locator).toHaveScreenshot(name, options)
|
||||
}
|
||||
|
||||
async setFocusMode(focusMode: boolean) {
|
||||
await this.page.evaluate((focusMode) => {
|
||||
;(window.app!.extensionManager as WorkspaceStore).focusMode = focusMode
|
||||
|
||||
@@ -139,27 +139,6 @@ export class Topbar {
|
||||
await this.menuLocator.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Nodes 2.0 on or off via the Comfy logo menu switch (no-op if already
|
||||
* in the requested state).
|
||||
*/
|
||||
async setVueNodesEnabled(enabled: boolean) {
|
||||
await this.openTopbarMenu()
|
||||
const nodes2Switch = this.page.getByRole('switch', { name: 'Nodes 2.0' })
|
||||
await nodes2Switch.waitFor({ state: 'visible' })
|
||||
if ((await nodes2Switch.isChecked()) !== enabled) {
|
||||
await nodes2Switch.click()
|
||||
await this.page.waitForFunction(
|
||||
(wantEnabled) =>
|
||||
window.app!.ui.settings.getSettingValue('Comfy.VueNodes.Enabled') ===
|
||||
wantEnabled,
|
||||
enabled,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
}
|
||||
await this.closeTopbarMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a submenu by hovering over a menu item
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,6 @@ export const DefaultGraphPositions = {
|
||||
textEncodeNode2: { x: 622, y: 400 },
|
||||
textEncodeNodeToggler: { x: 430, y: 171 },
|
||||
emptySpaceClick: { x: 35, y: 31 },
|
||||
emptyCanvasClick: { x: 50, y: 500 },
|
||||
|
||||
// Slot positions
|
||||
clipTextEncodeNode1InputSlot: { x: 427, y: 198 },
|
||||
@@ -40,7 +39,6 @@ export const DefaultGraphPositions = {
|
||||
textEncodeNode2: Position
|
||||
textEncodeNodeToggler: Position
|
||||
emptySpaceClick: Position
|
||||
emptyCanvasClick: Position
|
||||
clipTextEncodeNode1InputSlot: Position
|
||||
clipTextEncodeNode2InputSlot: Position
|
||||
clipTextEncodeNode2InputLinkPath: Position
|
||||
|
||||
@@ -17,17 +17,8 @@ export class AppModeHelper {
|
||||
readonly select: BuilderSelectHelper
|
||||
readonly outputHistory: OutputHistoryComponent
|
||||
readonly widgets: AppModeWidgetHelper
|
||||
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
public readonly connectOutputPopover: Locator
|
||||
/** The "Switch to Outputs" button inside the connect-output popover. */
|
||||
public readonly connectOutputSwitchButton: Locator
|
||||
/** The empty-workflow dialog shown when entering builder on an empty graph. */
|
||||
public readonly emptyWorkflowDialog: Locator
|
||||
/** "Back to workflow" button on the empty-workflow dialog. */
|
||||
public readonly emptyWorkflowBackButton: Locator
|
||||
/** "Load template" button on the empty-workflow dialog. */
|
||||
public readonly emptyWorkflowLoadTemplateButton: Locator
|
||||
/** The empty-state placeholder shown when no outputs are selected. */
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
@@ -48,18 +39,6 @@ export class AppModeHelper {
|
||||
public readonly loadTemplateButton: Locator
|
||||
/** The cancel button for an in-progress run in the output history. */
|
||||
public readonly cancelRunButton: Locator
|
||||
/** Arrange-step placeholder shown when outputs are configured but no run has happened. */
|
||||
public readonly arrangePreview: Locator
|
||||
/** Arrange-step state shown when no outputs have been configured. */
|
||||
public readonly arrangeNoOutputs: Locator
|
||||
/** "Switch to Outputs" button inside the arrange no-outputs state. */
|
||||
public readonly arrangeSwitchToOutputsButton: Locator
|
||||
/** The Vue Node switch notification popup shown on entering builder. */
|
||||
public readonly vueNodeSwitchPopup: Locator
|
||||
/** The "Dismiss" button inside the Vue Node switch popup. */
|
||||
public readonly vueNodeSwitchDismissButton: Locator
|
||||
/** The "Don't show again" checkbox inside the Vue Node switch popup. */
|
||||
public readonly vueNodeSwitchDontShowAgainCheckbox: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
@@ -68,22 +47,9 @@ export class AppModeHelper {
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
|
||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||
|
||||
this.connectOutputPopover = this.page.getByTestId(
|
||||
TestIds.builder.connectOutputPopover
|
||||
)
|
||||
this.connectOutputSwitchButton = this.page.getByTestId(
|
||||
TestIds.builder.connectOutputSwitch
|
||||
)
|
||||
this.emptyWorkflowDialog = this.page.getByTestId(
|
||||
TestIds.builder.emptyWorkflowDialog
|
||||
)
|
||||
this.emptyWorkflowBackButton = this.page.getByTestId(
|
||||
TestIds.builder.emptyWorkflowBack
|
||||
)
|
||||
this.emptyWorkflowLoadTemplateButton = this.page.getByTestId(
|
||||
TestIds.builder.emptyWorkflowLoadTemplate
|
||||
)
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
@@ -109,22 +75,6 @@ export class AppModeHelper {
|
||||
this.cancelRunButton = this.page.getByTestId(
|
||||
TestIds.outputHistory.cancelRun
|
||||
)
|
||||
this.arrangePreview = this.page.getByTestId(TestIds.appMode.arrangePreview)
|
||||
this.arrangeNoOutputs = this.page.getByTestId(
|
||||
TestIds.appMode.arrangeNoOutputs
|
||||
)
|
||||
this.arrangeSwitchToOutputsButton = this.page.getByTestId(
|
||||
TestIds.appMode.arrangeSwitchToOutputs
|
||||
)
|
||||
this.vueNodeSwitchPopup = this.page.getByTestId(
|
||||
TestIds.appMode.vueNodeSwitchPopup
|
||||
)
|
||||
this.vueNodeSwitchDismissButton = this.page.getByTestId(
|
||||
TestIds.appMode.vueNodeSwitchDismiss
|
||||
)
|
||||
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
|
||||
TestIds.appMode.vueNodeSwitchDontShowAgain
|
||||
)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
@@ -142,33 +92,8 @@ export class AppModeHelper {
|
||||
await this.comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
}
|
||||
|
||||
/** Set preference so the Vue node switch popup does not appear in builder. */
|
||||
async suppressVueNodeSwitchPopup() {
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
/** Allow the Vue node switch popup so tests can assert its behavior. */
|
||||
async allowVueNodeSwitchPopup() {
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
/** Enter builder mode via the "Workflow actions" dropdown. */
|
||||
async enterBuilder() {
|
||||
// Wait for any workflow-tab popover to dismiss before clicking —
|
||||
// the popover overlay can intercept the "Workflow actions" click.
|
||||
// Best-effort: the popover may or may not exist; if it stays visible
|
||||
// past the timeout we still proceed with the click.
|
||||
await this.page
|
||||
.locator('.workflow-popover-fade')
|
||||
.waitFor({ state: 'hidden', timeout: 5000 })
|
||||
.catch(() => {})
|
||||
|
||||
await this.page
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
.first()
|
||||
@@ -183,6 +108,7 @@ export class AppModeHelper {
|
||||
async toggleAppMode() {
|
||||
await this.comfyPage.workflow.waitForActiveWorkflow()
|
||||
await this.comfyPage.command.executeCommand('Comfy.ToggleLinear')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,30 +13,18 @@ export class BuilderStepsHelper {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
get inputsButton(): Locator {
|
||||
return this.toolbar.getByRole('button', { name: 'Inputs' })
|
||||
}
|
||||
|
||||
get outputsButton(): Locator {
|
||||
return this.toolbar.getByRole('button', { name: 'Outputs' })
|
||||
}
|
||||
|
||||
get previewButton(): Locator {
|
||||
return this.toolbar.getByRole('button', { name: 'Preview' })
|
||||
}
|
||||
|
||||
async goToInputs() {
|
||||
await this.inputsButton.click()
|
||||
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async goToOutputs() {
|
||||
await this.outputsButton.click()
|
||||
await this.toolbar.getByRole('button', { name: 'Outputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async goToPreview() {
|
||||
await this.previewButton.click()
|
||||
await this.toolbar.getByRole('button', { name: 'Preview' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
import { nextFrame } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
export class CanvasHelper {
|
||||
constructor(
|
||||
@@ -11,12 +10,18 @@ export class CanvasHelper {
|
||||
private resetViewButton: Locator
|
||||
) {}
|
||||
|
||||
private async nextFrame(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
return new Promise<number>(requestAnimationFrame)
|
||||
})
|
||||
}
|
||||
|
||||
async resetView(): Promise<void> {
|
||||
if (await this.resetViewButton.isVisible()) {
|
||||
await this.resetViewButton.click()
|
||||
}
|
||||
await this.page.mouse.move(10, 10)
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async zoom(deltaY: number, steps: number = 1): Promise<void> {
|
||||
@@ -24,7 +29,7 @@ export class CanvasHelper {
|
||||
for (let i = 0; i < steps; i++) {
|
||||
await this.page.mouse.wheel(0, deltaY)
|
||||
}
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async pan(offset: Position, safeSpot?: Position): Promise<void> {
|
||||
@@ -33,7 +38,7 @@ export class CanvasHelper {
|
||||
await this.page.mouse.down()
|
||||
await this.page.mouse.move(offset.x + safeSpot.x, offset.y + safeSpot.y)
|
||||
await this.page.mouse.up()
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async panWithTouch(offset: Position, safeSpot?: Position): Promise<void> {
|
||||
@@ -51,22 +56,22 @@ export class CanvasHelper {
|
||||
type: 'touchEnd',
|
||||
touchPoints: []
|
||||
})
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async rightClick(x: number = 10, y: number = 10): Promise<void> {
|
||||
await this.page.mouse.click(x, y, { button: 'right' })
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async doubleClick(): Promise<void> {
|
||||
await this.page.mouse.dblclick(10, 10, { delay: 5 })
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async click(position: Position): Promise<void> {
|
||||
await this.canvas.click({ position })
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +107,7 @@ export class CanvasHelper {
|
||||
} finally {
|
||||
for (const mod of modifiers) await this.page.keyboard.up(mod)
|
||||
}
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,12 +116,12 @@ export class CanvasHelper {
|
||||
async mouseDblclickAt(position: Position): Promise<void> {
|
||||
const abs = await this.toAbsolute(position)
|
||||
await this.page.mouse.dblclick(abs.x, abs.y)
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async clickEmptySpace(): Promise<void> {
|
||||
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async dragAndDrop(source: Position, target: Position): Promise<void> {
|
||||
@@ -124,7 +129,7 @@ export class CanvasHelper {
|
||||
await this.page.mouse.down()
|
||||
await this.page.mouse.move(target.x, target.y, { steps: 100 })
|
||||
await this.page.mouse.up()
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async moveMouseToEmptyArea(): Promise<void> {
|
||||
@@ -147,7 +152,7 @@ export class CanvasHelper {
|
||||
await this.page.evaluate((s) => {
|
||||
window.app!.canvas.ds.scale = s
|
||||
}, scale)
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async convertOffsetToCanvas(
|
||||
@@ -231,12 +236,12 @@ export class CanvasHelper {
|
||||
// Sweep forward
|
||||
for (let i = 0; i < steps; i++) {
|
||||
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
// Sweep back
|
||||
for (let i = steps; i > 0; i--) {
|
||||
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
await this.page.mouse.up({ button: 'middle' })
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { KeyCombo } from '@/platform/keybindings/types'
|
||||
import { nextFrame } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
export class CommandHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
@@ -21,7 +20,6 @@ export class CommandHelper {
|
||||
},
|
||||
{ commandId, metadata }
|
||||
)
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async registerCommand(
|
||||
|
||||
@@ -5,11 +5,18 @@ import type { Page } from '@playwright/test'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { nextFrame } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
export class DragDropHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
private async nextFrame(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async dragAndDropExternalResource(
|
||||
options: {
|
||||
fileName?: string
|
||||
@@ -138,7 +145,7 @@ export class DragDropHelper {
|
||||
await uploadResponsePromise
|
||||
}
|
||||
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async dragAndDropFile(
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { nextFrame } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
export class KeyboardHelper {
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
private readonly canvas: Locator
|
||||
) {}
|
||||
|
||||
async press(key: string, locator?: Locator | null): Promise<void> {
|
||||
const target = locator ?? this.canvas
|
||||
await target.press(key)
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async delete(locator?: Locator | null): Promise<void> {
|
||||
await this.press('Delete', locator)
|
||||
private async nextFrame(): Promise<void> {
|
||||
await this.page.evaluate(() => new Promise<number>(requestAnimationFrame))
|
||||
}
|
||||
|
||||
async ctrlSend(
|
||||
@@ -24,7 +16,7 @@ export class KeyboardHelper {
|
||||
): Promise<void> {
|
||||
const target = locator ?? this.page.keyboard
|
||||
await target.press(`Control+${keyToPress}`)
|
||||
await nextFrame(this.page)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async selectAll(locator?: Locator | null): Promise<void> {
|
||||
|
||||
@@ -140,11 +140,13 @@ export class NodeOperationsHelper {
|
||||
{ x: bottomRight.x - 2, y: bottomRight.y - 1 },
|
||||
target
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
if (revertAfter) {
|
||||
await this.comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: target.x - 2, y: target.y - 1 },
|
||||
bottomRight
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +158,7 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
await node.clickContextMenuOption('Convert to Group Node')
|
||||
await this.fillPromptDialog(groupNodeName)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async fillPromptDialog(value: string): Promise<void> {
|
||||
@@ -189,6 +192,7 @@ export class NodeOperationsHelper {
|
||||
y: 300
|
||||
}
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async adjustEmptyLatentWidth(): Promise<void> {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { nextFrame } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
export class SettingsHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
@@ -12,7 +10,6 @@ export class SettingsHelper {
|
||||
},
|
||||
{ id: settingId, value: settingValue }
|
||||
)
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async getSetting<T = unknown>(settingId: string): Promise<T> {
|
||||
|
||||
@@ -465,7 +465,11 @@ export class SubgraphHelper {
|
||||
const serialized = await this.page.evaluate(() =>
|
||||
window.app!.graph!.serialize()
|
||||
)
|
||||
await this.comfyPage.workflow.loadGraphData(serialized as ComfyWorkflowJSON)
|
||||
await this.page.evaluate(
|
||||
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
|
||||
serialized as ComfyWorkflowJSON
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async convertDefaultKSamplerToSubgraph(): Promise<NodeReference> {
|
||||
@@ -473,12 +477,14 @@ export class SubgraphHelper {
|
||||
const ksampler = await this.comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await this.comfyPage.nextFrame()
|
||||
return subgraphNode
|
||||
}
|
||||
|
||||
async packAllInteriorNodes(hostNodeId: string): Promise<void> {
|
||||
await this.comfyPage.vueNodes.enterSubgraph(hostNodeId)
|
||||
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvas.dispatchEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
|
||||
@@ -70,19 +70,10 @@ export class WorkflowHelper {
|
||||
)
|
||||
}
|
||||
|
||||
async loadGraphData(workflow: ComfyWorkflowJSON): Promise<void> {
|
||||
await this.comfyPage.page.evaluate(
|
||||
(wf) => window.app!.loadGraphData(wf),
|
||||
workflow
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async loadWorkflow(workflowName: string) {
|
||||
await this.comfyPage.workflowUploadInput.setInputFiles(
|
||||
assetPath(`${workflowName}.json`)
|
||||
)
|
||||
await this.waitForWorkflowIdle()
|
||||
await this.comfyPage.nextFrame()
|
||||
if (test.info().tags.includes('@vue-nodes')) {
|
||||
await this.comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
@@ -137,11 +137,7 @@ export const TestIds = {
|
||||
widgetItem: 'builder-widget-item',
|
||||
widgetLabel: 'builder-widget-label',
|
||||
outputPlaceholder: 'builder-output-placeholder',
|
||||
connectOutputPopover: 'builder-connect-output-popover',
|
||||
connectOutputSwitch: 'builder-connect-output-switch',
|
||||
emptyWorkflowDialog: 'builder-empty-workflow-dialog',
|
||||
emptyWorkflowBack: 'builder-empty-workflow-back',
|
||||
emptyWorkflowLoadTemplate: 'builder-empty-workflow-load-template'
|
||||
connectOutputPopover: 'builder-connect-output-popover'
|
||||
},
|
||||
outputHistory: {
|
||||
outputs: 'linear-outputs',
|
||||
@@ -167,13 +163,7 @@ export const TestIds = {
|
||||
emptyWorkflow: 'linear-welcome-empty-workflow',
|
||||
buildApp: 'linear-welcome-build-app',
|
||||
backToWorkflow: 'linear-welcome-back-to-workflow',
|
||||
loadTemplate: 'linear-welcome-load-template',
|
||||
arrangePreview: 'linear-arrange-preview',
|
||||
arrangeNoOutputs: 'linear-arrange-no-outputs',
|
||||
arrangeSwitchToOutputs: 'linear-arrange-switch-to-outputs',
|
||||
vueNodeSwitchPopup: 'linear-vue-node-switch-popup',
|
||||
vueNodeSwitchDismiss: 'linear-vue-node-switch-dismiss',
|
||||
vueNodeSwitchDontShowAgain: 'linear-vue-node-switch-dont-show-again'
|
||||
loadTemplate: 'linear-welcome-load-template'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
@@ -198,16 +188,6 @@ export const TestIds = {
|
||||
},
|
||||
load3dViewer: {
|
||||
sidebar: 'load3d-viewer-sidebar'
|
||||
},
|
||||
imageCompare: {
|
||||
viewport: 'image-compare-viewport',
|
||||
empty: 'image-compare-empty',
|
||||
batchNav: 'batch-nav',
|
||||
beforeBatch: 'before-batch',
|
||||
afterBatch: 'after-batch',
|
||||
batchCounter: 'batch-counter',
|
||||
batchNext: 'batch-next',
|
||||
batchPrev: 'batch-prev'
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -241,4 +221,3 @@ export type TestIdValue =
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
| (typeof TestIds.loading)[keyof typeof TestIds.loading]
|
||||
| (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer]
|
||||
| (typeof TestIds.imageCompare)[keyof typeof TestIds.imageCompare]
|
||||
|
||||
@@ -388,6 +388,7 @@ export class NodeReference {
|
||||
async copy() {
|
||||
await this.click('title')
|
||||
await this.comfyPage.clipboard.copy()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async delete(): Promise<void> {
|
||||
await this.click('title')
|
||||
@@ -433,6 +434,7 @@ export class NodeReference {
|
||||
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
|
||||
await this.clickContextMenuOption('Convert to Group Node')
|
||||
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
|
||||
await this.comfyPage.nextFrame()
|
||||
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
|
||||
`workflow>${groupNodeName}`
|
||||
)
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export function nextFrame(page: Page): Promise<number> {
|
||||
return page.evaluate(() => new Promise<number>(requestAnimationFrame))
|
||||
}
|
||||
|
||||
@@ -35,13 +35,6 @@ export async function hasCanvasContent(canvas: Locator): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function triggerSerialization(page: Page): Promise<void> {
|
||||
await page.waitForFunction(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
const node = graph?._nodes_by_id?.['1']
|
||||
const widget = node?.widgets?.find((w) => w.name === 'mask')
|
||||
return typeof widget?.serializeValue === 'function'
|
||||
})
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
if (!graph) {
|
||||
@@ -57,14 +50,9 @@ export async function triggerSerialization(page: Page): Promise<void> {
|
||||
)
|
||||
}
|
||||
|
||||
const widgetIndex = node.widgets?.findIndex((w) => w.name === 'mask') ?? -1
|
||||
if (widgetIndex === -1) {
|
||||
throw new Error('Widget "mask" not found on target node 1.')
|
||||
}
|
||||
|
||||
const widget = node.widgets?.[widgetIndex]
|
||||
const widget = node.widgets?.find((w) => w.name === 'mask')
|
||||
if (!widget) {
|
||||
throw new Error(`Widget index ${widgetIndex} not found on target node 1.`)
|
||||
throw new Error('Widget "mask" not found on target node 1.')
|
||||
}
|
||||
|
||||
if (typeof widget.serializeValue !== 'function') {
|
||||
@@ -73,6 +61,6 @@ export async function triggerSerialization(page: Page): Promise<void> {
|
||||
)
|
||||
}
|
||||
|
||||
await widget.serializeValue(node, widgetIndex)
|
||||
await widget.serializeValue(node, 0)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
|
||||
;(
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).workflow.activeWorkflow?.changeTracker.captureCanvasState()
|
||||
).workflow.activeWorkflow?.changeTracker.checkState()
|
||||
}, value)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
|
||||
|
||||
test.describe('App mode arrange step', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.appMode.suppressVueNodeSwitchPopup()
|
||||
})
|
||||
|
||||
test('Placeholder is shown when outputs are configured but no run has happened', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await setupBuilder(comfyPage)
|
||||
await appMode.steps.goToPreview()
|
||||
|
||||
await expect(appMode.steps.previewButton).toHaveAttribute(
|
||||
'aria-current',
|
||||
'step'
|
||||
)
|
||||
await expect(appMode.arrangePreview).toBeVisible()
|
||||
await expect(appMode.arrangeNoOutputs).toBeHidden()
|
||||
})
|
||||
|
||||
test('No-outputs state navigates to the Outputs step via "Switch to Outputs"', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToPreview()
|
||||
|
||||
await expect(appMode.arrangeNoOutputs).toBeVisible()
|
||||
await expect(appMode.arrangePreview).toBeHidden()
|
||||
|
||||
await appMode.arrangeSwitchToOutputsButton.click()
|
||||
|
||||
await expect(appMode.steps.outputsButton).toHaveAttribute(
|
||||
'aria-current',
|
||||
'step'
|
||||
)
|
||||
await expect(appMode.arrangeNoOutputs).toBeHidden()
|
||||
})
|
||||
|
||||
test('Connect-output popover from preview step navigates to the Outputs step', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await appMode.enterBuilder()
|
||||
// From a non-select step (preview/arrange), the popover surfaces a
|
||||
// "Switch to Outputs" shortcut alongside cancel.
|
||||
await appMode.steps.goToPreview()
|
||||
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await expect(appMode.connectOutputPopover).toBeVisible()
|
||||
await expect(appMode.connectOutputSwitchButton).toBeVisible()
|
||||
|
||||
await appMode.connectOutputSwitchButton.click()
|
||||
await expect(appMode.connectOutputPopover).toBeHidden()
|
||||
await expect(appMode.steps.outputsButton).toHaveAttribute(
|
||||
'aria-current',
|
||||
'step'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
async function enterBuilderExpectVueNodeSwitchPopup(comfyPage: ComfyPage) {
|
||||
const { appMode } = comfyPage
|
||||
await appMode.enterBuilder()
|
||||
await expect(appMode.vueNodeSwitchPopup).toBeVisible()
|
||||
}
|
||||
|
||||
async function expectVueNodesEnabled(comfyPage: ComfyPage) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting<boolean>('Comfy.VueNodes.Enabled')
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
test.describe('Vue node switch notification popup', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.appMode.allowVueNodeSwitchPopup()
|
||||
})
|
||||
|
||||
test('Popup appears when entering builder; dismiss closes without persisting and shows again on a later entry', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await enterBuilderExpectVueNodeSwitchPopup(comfyPage)
|
||||
|
||||
await appMode.vueNodeSwitchDismissButton.click()
|
||||
await expect(appMode.vueNodeSwitchPopup).toBeHidden()
|
||||
|
||||
// "Don't show again" was not checked → preference remains false
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting<boolean>(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed'
|
||||
)
|
||||
)
|
||||
.toBe(false)
|
||||
|
||||
// Disable vue nodes and re-enter builder
|
||||
await appMode.footer.exitBuilder()
|
||||
await comfyPage.menu.topbar.setVueNodesEnabled(false)
|
||||
await appMode.enterBuilder()
|
||||
|
||||
await expectVueNodesEnabled(comfyPage)
|
||||
await expect(appMode.vueNodeSwitchPopup).toBeVisible()
|
||||
})
|
||||
|
||||
test('"Don\'t show again" persists the dismissal and suppresses future popups', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await enterBuilderExpectVueNodeSwitchPopup(comfyPage)
|
||||
await expectVueNodesEnabled(comfyPage)
|
||||
|
||||
// Dismiss with dont show again checked
|
||||
await appMode.vueNodeSwitchDontShowAgainCheckbox.check()
|
||||
await appMode.vueNodeSwitchDismissButton.click()
|
||||
await expect(appMode.vueNodeSwitchPopup).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting<boolean>(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed'
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// Disable vue nodes and re-enter builder
|
||||
await appMode.footer.exitBuilder()
|
||||
await comfyPage.menu.topbar.setVueNodesEnabled(false)
|
||||
await appMode.enterBuilder()
|
||||
|
||||
await expectVueNodesEnabled(comfyPage)
|
||||
await expect(appMode.vueNodeSwitchPopup).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.appMode.suppressVueNodeSwitchPopup()
|
||||
})
|
||||
|
||||
test('Empty workflow text is visible when no nodes', async ({
|
||||
@@ -59,37 +58,4 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
|
||||
test('Empty workflow dialog blocks entering builder on an empty graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await appMode.enterBuilder()
|
||||
|
||||
await expect(appMode.emptyWorkflowDialog).toBeVisible()
|
||||
await expect(appMode.emptyWorkflowBackButton).toBeVisible()
|
||||
await expect(appMode.emptyWorkflowLoadTemplateButton).toBeVisible()
|
||||
|
||||
// Back to workflow dismisses the dialog and returns to graph mode
|
||||
await appMode.emptyWorkflowBackButton.click()
|
||||
await expect(appMode.emptyWorkflowDialog).toBeHidden()
|
||||
await expect(comfyPage.canvas).toBeVisible()
|
||||
})
|
||||
|
||||
test('Empty workflow dialog "Load template" opens the template selector', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await appMode.enterBuilder()
|
||||
|
||||
await expect(appMode.emptyWorkflowDialog).toBeVisible()
|
||||
await appMode.emptyWorkflowLoadTemplateButton.click()
|
||||
|
||||
await expect(appMode.emptyWorkflowDialog).toBeHidden()
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
|
||||
window.app!.graph!.setDirtyCanvas(true, true)
|
||||
;(
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).workflow.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
).workflow.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
await expect
|
||||
.poll(() => comfyPage.page.title())
|
||||
|
||||
@@ -157,10 +157,7 @@ test.describe('Builder input reordering', { tag: '@ui' }, () => {
|
||||
|
||||
test('Reordering inputs in one app does not corrupt another app', async ({
|
||||
comfyPage
|
||||
}, testInfo) => {
|
||||
// This test creates 2 apps, switches tabs 3 times, and enters builder 3
|
||||
// times — the default 15s timeout is insufficient in CI.
|
||||
testInfo.setTimeout(45_000)
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
const app2Widgets = ['seed', 'steps']
|
||||
const app1Reordered = ['steps', 'cfg', 'seed']
|
||||
|
||||
@@ -110,7 +110,8 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Unlock')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test.describe('Trigger button', () => {
|
||||
@@ -45,6 +46,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand(mode.activateCommand)
|
||||
await comfyPage.nextFrame()
|
||||
const { trigger } = getLocators(comfyPage.page)
|
||||
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
|
||||
await expect(modeIcon).toHaveClass(mode.iconPattern)
|
||||
@@ -101,6 +103,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
}) => {
|
||||
if (!mode.isReadOnly) {
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
@@ -153,6 +156,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand(mode.activateCommand)
|
||||
await comfyPage.nextFrame()
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
@@ -204,6 +208,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand(mode.activateCommand)
|
||||
await comfyPage.nextFrame()
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
@@ -224,7 +229,8 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
await comfyPage.canvasOps.isReadOnly(),
|
||||
'Precondition: canvas starts unlocked'
|
||||
).toBe(false)
|
||||
await comfyPage.keyboard.press('KeyH')
|
||||
await comfyPage.canvas.press('KeyH')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
const { trigger } = getLocators(comfyPage.page)
|
||||
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
|
||||
@@ -235,11 +241,13 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||
await comfyPage.nextFrame()
|
||||
expect(
|
||||
await comfyPage.canvasOps.isReadOnly(),
|
||||
'Precondition: canvas starts locked'
|
||||
).toBe(true)
|
||||
await comfyPage.keyboard.press('KeyV')
|
||||
await comfyPage.canvas.press('KeyV')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
const { trigger } = getLocators(comfyPage.page)
|
||||
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
|
||||
|
||||
@@ -71,7 +71,7 @@ async function waitForChangeTrackerSettled(
|
||||
) {
|
||||
// Visible node flags can flip before undo finishes loadGraphData() and
|
||||
// updates the tracker. Poll the tracker's own settled state so we do not
|
||||
// start the next transaction while captureCanvasState() is still gated.
|
||||
// start the next transaction while checkState() is still gated.
|
||||
await expect
|
||||
.poll(() => getChangeTrackerDebugState(comfyPage))
|
||||
.toMatchObject({
|
||||
@@ -223,7 +223,8 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
await beforeChange(comfyPage)
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect(node).toBeBypassed()
|
||||
await comfyPage.keyboard.press('KeyP')
|
||||
await comfyPage.page.keyboard.press('KeyP')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(node).toBePinned()
|
||||
await afterChange(comfyPage)
|
||||
}
|
||||
@@ -272,42 +273,4 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
|
||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
|
||||
})
|
||||
|
||||
test('Undo preserves viewport offset', async ({ comfyPage }) => {
|
||||
// Pan to a distinct offset so we can detect drift
|
||||
await comfyPage.canvasOps.pan({ x: 200, y: 150 })
|
||||
|
||||
const viewportBefore = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
})
|
||||
|
||||
// Make a graph change so we have something to undo
|
||||
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
|
||||
await node.click('title')
|
||||
await node.click('collapse')
|
||||
await expect(node).toBeCollapsed()
|
||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
|
||||
|
||||
// Undo the collapse — viewport should be preserved
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(node).not.toBeCollapsed()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
}),
|
||||
{ timeout: 2_000 }
|
||||
)
|
||||
.toEqual({
|
||||
scale: expect.closeTo(viewportBefore.scale, 2),
|
||||
offset: [
|
||||
expect.closeTo(viewportBefore.offset[0], 0),
|
||||
expect.closeTo(viewportBefore.offset[1], 0)
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ test.describe(
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Prevents captureCanvasState from corrupting workflow state during tab switch', async ({
|
||||
test('Prevents checkState from corrupting workflow state during tab switch', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Tab 0: default workflow (7 nodes)
|
||||
@@ -21,9 +21,9 @@ test.describe(
|
||||
// Save tab 0 so it has a unique name for tab switching
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
|
||||
|
||||
// Register an extension that forces captureCanvasState during graph loading.
|
||||
// Register an extension that forces checkState during graph loading.
|
||||
// This simulates the bug scenario where a user clicks during graph loading
|
||||
// which triggers a captureCanvasState call on the wrong graph, corrupting the activeState.
|
||||
// which triggers a checkState call on the wrong graph, corrupting the activeState.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.registerExtension({
|
||||
name: 'TestCheckStateDuringLoad',
|
||||
@@ -35,7 +35,7 @@ test.describe(
|
||||
// ; (workflow.changeTracker.constructor as unknown as { isLoadingGraph: boolean }).isLoadingGraph = false
|
||||
|
||||
// Simulate the user clicking during graph loading
|
||||
workflow.changeTracker.captureCanvasState()
|
||||
workflow.changeTracker.checkState()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,23 +74,18 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
return node!.id
|
||||
})
|
||||
|
||||
// Wait for the asset widget to mount AND its value to resolve.
|
||||
// The widget type becomes 'asset' before the value is populated,
|
||||
// so poll for both conditions together to avoid a race where the
|
||||
// type check passes but the value is still the placeholder.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate((id) => {
|
||||
async () => {
|
||||
return await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
const widget = node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)
|
||||
if (widget?.type !== 'asset') return 'waiting:type'
|
||||
const val = String(widget?.value ?? '')
|
||||
return val === 'Select model' ? 'waiting:value' : val
|
||||
}, nodeId),
|
||||
{ timeout: 15_000 }
|
||||
return String(widget?.value ?? '')
|
||||
}, nodeId)
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(CLOUD_ASSETS[0].name)
|
||||
})
|
||||
|
||||
@@ -64,29 +64,3 @@ test.describe(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'Collapsed node links inside subgraph on first entry',
|
||||
{ tag: ['@canvas', '@node', '@vue-nodes', '@subgraph', '@screenshot'] },
|
||||
() => {
|
||||
test('renders collapsed node links correctly after fitView on first subgraph entry', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-collapsed-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// fitView runs on first entry and re-syncs slot layouts for the
|
||||
// pre-collapsed KSampler. Screenshot captures the rendered canvas
|
||||
// links to guard against regressing the stale-coordinate bug.
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'subgraph-entry-collapsed-node-links.png'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
@@ -157,15 +157,18 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'custom-color-palette-obsidian-dark-all-colors.png'
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light_red')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'custom-color-palette-light-red.png'
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'dark')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('default-color-palette.png')
|
||||
})
|
||||
|
||||
@@ -178,6 +181,7 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
|
||||
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'custom-color-palette-obsidian-dark.png'
|
||||
)
|
||||
@@ -186,6 +190,7 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
|
||||
'Comfy.ColorPalette',
|
||||
'custom_obsidian_dark'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'custom-color-palette-obsidian-dark.png'
|
||||
)
|
||||
@@ -207,12 +212,15 @@ test.describe(
|
||||
|
||||
// Drag mouse to force canvas to redraw
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'node-opacity-0.5.png')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 1.0)
|
||||
|
||||
await comfyPage.page.mouse.move(8, 8)
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'node-opacity-1.png')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
|
||||
})
|
||||
|
||||
test('should persist color adjustments when changing themes', async ({
|
||||
@@ -221,8 +229,8 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.2)
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'arc')
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-opacity-0.2-arc-theme.png'
|
||||
)
|
||||
})
|
||||
@@ -232,6 +240,7 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
@@ -270,6 +279,7 @@ test.describe(
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-lightened-colors.png'
|
||||
)
|
||||
|
||||
@@ -146,9 +146,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
await ksamplerNodes[0].copy()
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.emptyCanvasClick
|
||||
})
|
||||
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.clipboard.paste()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
|
||||
@@ -157,6 +155,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
||||
const loadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
await loadImageNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const uploadPromise = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
@@ -176,9 +175,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
|
||||
|
||||
// Step 3: Click empty canvas area, paste image → creates new LoadImage
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.emptyCanvasClick
|
||||
})
|
||||
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const uploadPromise2 = comfyPage.page.waitForResponse(
|
||||
|
||||
@@ -52,7 +52,8 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
test("'Alt+=' zooms in", async ({ comfyPage }) => {
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.keyboard.press('Alt+Equal')
|
||||
await comfyPage.canvas.press('Alt+Equal')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale())
|
||||
@@ -62,7 +63,8 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
test("'Alt+-' zooms out", async ({ comfyPage }) => {
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.keyboard.press('Alt+Minus')
|
||||
await comfyPage.canvas.press('Alt+Minus')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale())
|
||||
@@ -80,7 +82,8 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.keyboard.press('Period')
|
||||
await comfyPage.canvas.press('Period')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale())
|
||||
@@ -90,7 +93,8 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
test("'h' locks canvas", async ({ comfyPage }) => {
|
||||
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
|
||||
await comfyPage.keyboard.press('KeyH')
|
||||
await comfyPage.canvas.press('KeyH')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
})
|
||||
@@ -98,9 +102,11 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
test("'v' unlocks canvas", async ({ comfyPage }) => {
|
||||
// Lock first
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('KeyV')
|
||||
await comfyPage.canvas.press('KeyV')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
})
|
||||
@@ -115,13 +121,16 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
const node = nodes[0]
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => node.isCollapsed()).toBe(false)
|
||||
|
||||
await comfyPage.keyboard.press('Alt+KeyC')
|
||||
await comfyPage.canvas.press('Alt+KeyC')
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => node.isCollapsed()).toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Alt+KeyC')
|
||||
await comfyPage.canvas.press('Alt+KeyC')
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => node.isCollapsed()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -131,6 +140,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
const node = nodes[0]
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Normal mode is ALWAYS (0)
|
||||
const getMode = () =>
|
||||
@@ -140,11 +150,13 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
|
||||
await expect.poll(() => getMode()).toBe(0)
|
||||
|
||||
await comfyPage.keyboard.press('Control+KeyM')
|
||||
await comfyPage.canvas.press('Control+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
// NEVER (2) = muted
|
||||
await expect.poll(() => getMode()).toBe(2)
|
||||
|
||||
await comfyPage.keyboard.press('Control+KeyM')
|
||||
await comfyPage.canvas.press('Control+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => getMode()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -227,14 +239,16 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
|
||||
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
|
||||
// The dialog appearing proves the keybinding was intercepted by the app.
|
||||
await comfyPage.keyboard.press('Control+s')
|
||||
await comfyPage.page.keyboard.press('Control+s')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The Save As dialog should appear (p-dialog overlay)
|
||||
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
|
||||
await expect(dialogOverlay).toBeVisible()
|
||||
|
||||
// Dismiss the dialog
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
|
||||
@@ -251,7 +265,8 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.keyboard.press('Control+o')
|
||||
await comfyPage.page.keyboard.press('Control+o')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
|
||||
@@ -273,9 +288,11 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
// Select all nodes
|
||||
await comfyPage.keyboard.press('Control+a')
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.keyboard.press('Control+Shift+KeyE')
|
||||
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// After conversion, node count should decrease
|
||||
// (multiple nodes replaced by single subgraph node)
|
||||
|
||||
@@ -145,27 +145,15 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
|
||||
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
|
||||
await expect(settingRow).toBeVisible()
|
||||
|
||||
// Wait for the search filter to fully settle — PrimeVue re-renders
|
||||
// the entire settings list after typing, and the combobox element is
|
||||
// replaced during re-render. Wait until the filtered list stabilises
|
||||
// before interacting with the combobox.
|
||||
const settingItems = dialog.root.locator('[data-setting-id]')
|
||||
await expect
|
||||
.poll(() => settingItems.count(), { timeout: 5000 })
|
||||
.toBeLessThanOrEqual(5)
|
||||
|
||||
const select = settingRow.getByRole('combobox')
|
||||
await expect(select).toBeVisible()
|
||||
await expect(select).toBeEnabled()
|
||||
|
||||
// Open the dropdown via its combobox role and verify it expanded.
|
||||
// Retry because the PrimeVue Select may still re-render after the
|
||||
// filter settles, causing the first click to land on a stale element.
|
||||
// Retry because the PrimeVue Select may re-render during search
|
||||
// filtering, causing the first click to land on a stale element.
|
||||
const select = settingRow.getByRole('combobox')
|
||||
await expect(async () => {
|
||||
const expanded = await select.getAttribute('aria-expanded')
|
||||
if (expanded !== 'true') await select.click()
|
||||
await expect(select).toHaveAttribute('aria-expanded', 'true')
|
||||
}).toPass({ timeout: 10_000 })
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// Pick the option that is not the current value
|
||||
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'
|
||||
|
||||
@@ -214,34 +214,4 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
await expect(overlay).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Count independence from node selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('missing model count stays constant when a node is selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: ErrorOverlay previously read the selection-filtered
|
||||
// missingModelGroups from useErrorGroups, so selecting one of two
|
||||
// missing-model nodes would shrink the overlay label from
|
||||
// "2 required models are missing" to "1". The overlay must show
|
||||
// the workflow total regardless of canvas selection.
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models_distinct')
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(overlay).toContainText(/2 required models are missing/i)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
|
||||
await expect(overlay).toContainText(/2 required models are missing/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,30 +55,4 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
|
||||
await comfyPage.setFocusMode(true)
|
||||
await expect(comfyPage.menu.sideToolbar).toBeHidden()
|
||||
})
|
||||
|
||||
test('Focus mode toggle preserves properties panel width', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open the properties panel
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeVisible()
|
||||
|
||||
// Record the initial panel width
|
||||
const initialBox = await comfyPage.menu.propertiesPanel.root.boundingBox()
|
||||
expect(initialBox).not.toBeNull()
|
||||
const initialWidth = initialBox!.width
|
||||
|
||||
// Toggle focus mode on then off
|
||||
await comfyPage.setFocusMode(true)
|
||||
await comfyPage.setFocusMode(false)
|
||||
|
||||
// Properties panel should be visible again with the same width
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await comfyPage.menu.propertiesPanel.root.boundingBox()
|
||||
return box ? Math.abs(box.width - initialWidth) : Infinity
|
||||
})
|
||||
.toBeLessThan(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,8 +24,8 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
TestIds.canvas.toggleLinkVisibilityButton
|
||||
)
|
||||
await button.click()
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'canvas-with-hidden-links.png'
|
||||
)
|
||||
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
|
||||
@@ -36,8 +36,8 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
.toBe(hiddenLinkRenderMode)
|
||||
|
||||
await button.click()
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'canvas-with-visible-links.png'
|
||||
)
|
||||
await expect
|
||||
|
||||
@@ -170,6 +170,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'groupnodes/group_node_identical_nodes_hidden_inputs'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const groupNodeId = 19
|
||||
const groupNodeName = 'two_VAE_decode'
|
||||
@@ -335,9 +336,12 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
)
|
||||
|
||||
await test.step('Load workflow containing a group node pasted from a different workflow', async () => {
|
||||
await comfyPage.workflow.loadGraphData(
|
||||
currentGraphState as ComfyWorkflowJSON
|
||||
await comfyPage.page.evaluate(
|
||||
(workflow) =>
|
||||
window.app!.loadGraphData(workflow as ComfyWorkflowJSON),
|
||||
currentGraphState
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await verifyNodeLoaded(comfyPage, 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,6 +60,7 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
|
||||
await comfyPage.canvas.click({ position: outerPos })
|
||||
@@ -83,6 +84,7 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
||||
false
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
|
||||
await comfyPage.canvas.click({ position: outerPos })
|
||||
@@ -105,6 +107,7 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select the outer group (cascades to children)
|
||||
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
|
||||
|
||||
@@ -3,7 +3,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -88,6 +87,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Shows empty state when no images are set',
|
||||
{ tag: '@smoke' },
|
||||
@@ -95,7 +98,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node.getByTestId(TestIds.imageCompare.empty)).toBeVisible()
|
||||
await expect(node.getByTestId('image-compare-empty')).toBeVisible()
|
||||
await expect(node.locator('img')).toHaveCount(0)
|
||||
await expect(node.getByRole('presentation')).toHaveCount(0)
|
||||
}
|
||||
@@ -123,6 +126,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slider defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Slider defaults to 50% with both images set',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
@@ -157,6 +164,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slider interaction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Mouse hover moves slider position',
|
||||
{ tag: '@smoke' },
|
||||
@@ -172,7 +183,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
const handle = node.getByRole('presentation')
|
||||
const beforeImg = node.locator('img[alt="Before image"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
|
||||
const viewport = node.getByTestId('image-compare-viewport')
|
||||
await expect(afterImg).toBeVisible()
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
@@ -213,7 +224,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.getByRole('presentation')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
|
||||
const viewport = node.getByTestId('image-compare-viewport')
|
||||
await expect(afterImg).toBeVisible()
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
@@ -250,7 +261,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.getByRole('presentation')
|
||||
const compareArea = node.getByTestId(TestIds.imageCompare.viewport)
|
||||
const compareArea = node.getByTestId('image-compare-viewport')
|
||||
await expect(compareArea).toBeVisible()
|
||||
|
||||
await expect
|
||||
@@ -281,6 +292,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
.toBeCloseTo(100, 0)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single image modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Only before image shows without slider when afterImages is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -309,6 +324,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
await expect(node.getByRole('presentation')).toBeHidden()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Batch navigation appears when before side has multiple images',
|
||||
{ tag: '@smoke' },
|
||||
@@ -323,21 +342,13 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
|
||||
await expect(
|
||||
node.getByTestId(TestIds.imageCompare.batchNav)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('1 / 3')
|
||||
await expect(node.getByTestId('batch-nav')).toBeVisible()
|
||||
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 3')
|
||||
// after-batch renders only when afterBatchCount > 1
|
||||
await expect(
|
||||
node.getByTestId(TestIds.imageCompare.afterBatch)
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
|
||||
).toBeDisabled()
|
||||
await expect(node.getByTestId('after-batch')).toBeHidden()
|
||||
await expect(beforeBatch.getByTestId('batch-prev')).toBeDisabled()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -351,7 +362,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.getByTestId(TestIds.imageCompare.batchNav)).toBeHidden()
|
||||
await expect(node.getByTestId('batch-nav')).toBeHidden()
|
||||
})
|
||||
|
||||
test(
|
||||
@@ -367,10 +378,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
const counter = beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
const nextBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
|
||||
const prevBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
const counter = beforeBatch.getByTestId('batch-counter')
|
||||
const nextBtn = beforeBatch.getByTestId('batch-next')
|
||||
const prevBtn = beforeBatch.getByTestId('batch-prev')
|
||||
|
||||
await nextBtn.click()
|
||||
await expect(counter).toHaveText('2 / 3')
|
||||
@@ -396,10 +407,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
const counter = beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
const nextBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
|
||||
const prevBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
const counter = beforeBatch.getByTestId('batch-counter')
|
||||
const nextBtn = beforeBatch.getByTestId('batch-next')
|
||||
const prevBtn = beforeBatch.getByTestId('batch-prev')
|
||||
|
||||
await nextBtn.click()
|
||||
await nextBtn.click()
|
||||
@@ -425,18 +436,14 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
const afterBatch = node.getByTestId(TestIds.imageCompare.afterBatch)
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
const afterBatch = node.getByTestId('after-batch')
|
||||
|
||||
await beforeBatch.getByTestId(TestIds.imageCompare.batchNext).click()
|
||||
await afterBatch.getByTestId(TestIds.imageCompare.batchNext).click()
|
||||
await beforeBatch.getByTestId('batch-next').click()
|
||||
await afterBatch.getByTestId('batch-next').click()
|
||||
|
||||
await expect(
|
||||
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('2 / 3')
|
||||
await expect(
|
||||
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('2 / 2')
|
||||
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('2 / 3')
|
||||
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('2 / 2')
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||
'src',
|
||||
url2
|
||||
@@ -447,9 +454,11 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node sizing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('ImageCompare node enforces minimum size', async ({ comfyPage }) => {
|
||||
const minWidth = 400
|
||||
const minHeight = 350
|
||||
const size = await comfyPage.page.evaluate(() => {
|
||||
const graphNode = window.app!.graph.getNodeById(1)
|
||||
if (!graphNode?.size) return null
|
||||
@@ -463,13 +472,17 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
expect(
|
||||
size.width,
|
||||
'ImageCompare node minimum width'
|
||||
).toBeGreaterThanOrEqual(minWidth)
|
||||
).toBeGreaterThanOrEqual(400)
|
||||
expect(
|
||||
size.height,
|
||||
'ImageCompare node minimum height'
|
||||
).toBeGreaterThanOrEqual(minHeight)
|
||||
).toBeGreaterThanOrEqual(350)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visual regression screenshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const { pct, expectedClipMin, expectedClipMax } of [
|
||||
{ pct: 25, expectedClipMin: 70, expectedClipMax: 80 },
|
||||
{ pct: 75, expectedClipMin: 20, expectedClipMax: 30 }
|
||||
@@ -487,7 +500,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeImg = node.locator('img[alt="Before image"]')
|
||||
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
|
||||
const viewport = node.getByTestId('image-compare-viewport')
|
||||
await waitForImagesLoaded(node)
|
||||
await expect(viewport).toBeVisible()
|
||||
await moveToPercentage(comfyPage.page, viewport, pct)
|
||||
@@ -503,6 +516,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Widget handles image load failure gracefully', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -569,14 +586,9 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await node
|
||||
.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
.getByTestId(TestIds.imageCompare.batchNext)
|
||||
.click()
|
||||
await node.getByTestId('before-batch').getByTestId('batch-next').click()
|
||||
await expect(
|
||||
node
|
||||
.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
node.getByTestId('before-batch').getByTestId('batch-counter')
|
||||
).toHaveText('2 / 2')
|
||||
|
||||
await setImageCompareValue(comfyPage, {
|
||||
@@ -589,9 +601,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
green1Url
|
||||
)
|
||||
await expect(
|
||||
node
|
||||
.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
node.getByTestId('before-batch').getByTestId('batch-counter')
|
||||
).toHaveText('1 / 2')
|
||||
})
|
||||
|
||||
@@ -646,35 +656,23 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
|
||||
const afterBatch = node.getByTestId(TestIds.imageCompare.afterBatch)
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
const afterBatch = node.getByTestId('after-batch')
|
||||
|
||||
await expect(
|
||||
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('1 / 20')
|
||||
await expect(
|
||||
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('1 / 20')
|
||||
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 20')
|
||||
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('1 / 20')
|
||||
|
||||
const beforeNext = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
|
||||
const afterNext = afterBatch.getByTestId(TestIds.imageCompare.batchNext)
|
||||
const beforeNext = beforeBatch.getByTestId('batch-next')
|
||||
const afterNext = afterBatch.getByTestId('batch-next')
|
||||
for (let i = 0; i < 19; i++) {
|
||||
await beforeNext.click()
|
||||
await afterNext.click()
|
||||
}
|
||||
|
||||
await expect(
|
||||
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('20 / 20')
|
||||
await expect(
|
||||
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
|
||||
).toHaveText('20 / 20')
|
||||
await expect(
|
||||
beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
|
||||
).toBeEnabled()
|
||||
await expect(
|
||||
afterBatch.getByTestId(TestIds.imageCompare.batchPrev)
|
||||
).toBeEnabled()
|
||||
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('20 / 20')
|
||||
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('20 / 20')
|
||||
await expect(beforeBatch.getByTestId('batch-prev')).toBeEnabled()
|
||||
await expect(afterBatch.getByTestId('batch-prev')).toBeEnabled()
|
||||
await expect(beforeNext).toBeDisabled()
|
||||
await expect(afterNext).toBeDisabled()
|
||||
})
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Image Crop', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test(
|
||||
'Shows empty state when no input image is connected',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node.getByText('No input image connected')).toBeVisible()
|
||||
await expect(node.locator('img[alt="Crop preview"]')).toHaveCount(0)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Renders bounding box coordinate inputs',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node.getByText('X')).toBeVisible()
|
||||
await expect(node.getByText('Y')).toBeVisible()
|
||||
await expect(node.getByText('Width')).toBeVisible()
|
||||
await expect(node.getByText('Height')).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Renders ratio selector and lock button',
|
||||
{ tag: '@ui' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node.getByText('Ratio')).toBeVisible()
|
||||
await expect(node.getByRole('button', { name: /lock/i })).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Lock button toggles aspect ratio lock',
|
||||
{ tag: '@ui' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
|
||||
const lockButton = node.getByRole('button', {
|
||||
name: 'Lock aspect ratio'
|
||||
})
|
||||
await expect(lockButton).toBeVisible()
|
||||
|
||||
await lockButton.click()
|
||||
await expect(
|
||||
node.getByRole('button', { name: 'Unlock aspect ratio' })
|
||||
).toBeVisible()
|
||||
|
||||
await node.getByRole('button', { name: 'Unlock aspect ratio' }).click()
|
||||
await expect(
|
||||
node.getByRole('button', { name: 'Lock aspect ratio' })
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Ratio selector offers expected presets',
|
||||
{ tag: '@ui' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
|
||||
const trigger = node.getByRole('combobox')
|
||||
await trigger.click()
|
||||
|
||||
const expectedRatios = ['1:1', '3:4', '4:3', '16:9', '9:16', 'Custom']
|
||||
for (const label of expectedRatios) {
|
||||
await expect(
|
||||
comfyPage.page.getByRole('option', { name: label, exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Programmatically setting widget value updates bounding box inputs',
|
||||
{ tag: '@ui' },
|
||||
async ({ comfyPage }) => {
|
||||
const newBounds = { x: 50, y: 100, width: 200, height: 300 }
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
({ bounds }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
|
||||
if (widget) {
|
||||
widget.value = bounds
|
||||
widget.callback?.(bounds)
|
||||
}
|
||||
},
|
||||
{ bounds: newBounds }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const inputs = node.locator('input[inputmode="decimal"]')
|
||||
|
||||
await expect.poll(() => inputs.nth(0).inputValue()).toBe('50')
|
||||
|
||||
await expect.poll(() => inputs.nth(1).inputValue()).toBe('100')
|
||||
|
||||
await expect.poll(() => inputs.nth(2).inputValue()).toBe('200')
|
||||
|
||||
await expect.poll(() => inputs.nth(3).inputValue()).toBe('300')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -31,9 +31,11 @@ test.describe('Item Interaction', { tag: ['@screenshot', '@node'] }, () => {
|
||||
test('Can pin/unpin items with keyboard shortcut', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('groups/mixed_graph_items')
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.keyboard.press('KeyP')
|
||||
await comfyPage.canvas.press('KeyP')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('pinned-all.png')
|
||||
await comfyPage.keyboard.press('KeyP')
|
||||
await comfyPage.canvas.press('KeyP')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('unpinned-all.png')
|
||||
})
|
||||
})
|
||||
@@ -74,11 +76,13 @@ test.describe('Node Interaction', () => {
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.textEncodeNode1
|
||||
})
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'selected-node1.png')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.textEncodeNode2
|
||||
})
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'selected-node2.png')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -170,7 +174,8 @@ test.describe('Node Interaction', () => {
|
||||
await comfyPage.nodeOps.dragTextEncodeNode2()
|
||||
// Move mouse away to avoid hover highlight on the node at the drop position.
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'dragged-node1.png', {
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png', {
|
||||
maxDiffPixels: 50
|
||||
})
|
||||
})
|
||||
@@ -180,6 +185,7 @@ test.describe('Node Interaction', () => {
|
||||
// Pin this suite to the legacy canvas path so Alt+drag exercises
|
||||
// LGraphCanvas, not the Vue node drag handler.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('Can duplicate a regular node via Alt+drag', async ({ comfyPage }) => {
|
||||
@@ -279,6 +285,7 @@ test.describe('Node Interaction', () => {
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Node.AutoSnapLinkToSlot', true)
|
||||
await comfyPage.settings.setSetting('Comfy.Node.SnapHighlightsNode', true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyMouse.move(DefaultGraphPositions.clipTextEncodeNode1InputSlot)
|
||||
await comfyMouse.drag(DefaultGraphPositions.clipTextEncodeNode2InputSlot)
|
||||
@@ -352,8 +359,8 @@ test.describe('Node Interaction', () => {
|
||||
modifiers: ['Control', 'Alt'],
|
||||
position: loadCheckpointClipSlotPos
|
||||
})
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'batch-disconnect-links-disconnected.png'
|
||||
)
|
||||
}
|
||||
@@ -403,8 +410,8 @@ test.describe('Node Interaction', () => {
|
||||
await expect.poll(() => targetNode.isCollapsed()).toBe(false)
|
||||
// Move mouse away to avoid hover highlight differences.
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'text-encode-toggled-back-open.png'
|
||||
)
|
||||
}
|
||||
@@ -507,7 +514,8 @@ test.describe('Node Interaction', () => {
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.nextFrame()
|
||||
// Confirm group title
|
||||
await comfyPage.keyboard.press('Enter')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'group-selected-nodes.png'
|
||||
)
|
||||
@@ -1163,8 +1171,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
await comfyPage.page.mouse.down({ button: 'middle' })
|
||||
await comfyPage.page.mouse.move(150, 150)
|
||||
await comfyPage.page.mouse.up({ button: 'middle' })
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'legacy-middle-drag-pan.png'
|
||||
)
|
||||
})
|
||||
@@ -1172,14 +1180,14 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
test('Mouse wheel should zoom in/out', async ({ comfyPage }) => {
|
||||
await comfyPage.page.mouse.move(400, 300)
|
||||
await comfyPage.page.mouse.wheel(0, -120)
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'legacy-wheel-zoom-in.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.mouse.wheel(0, 240)
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'legacy-wheel-zoom-out.png'
|
||||
)
|
||||
})
|
||||
@@ -1239,8 +1247,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
await comfyPage.page.mouse.down({ button: 'middle' })
|
||||
await comfyPage.page.mouse.move(150, 150)
|
||||
await comfyPage.page.mouse.up({ button: 'middle' })
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-middle-drag-pan.png'
|
||||
)
|
||||
})
|
||||
@@ -1250,16 +1258,16 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.wheel(0, -120)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-ctrl-wheel-zoom-in.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.wheel(0, 240)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-ctrl-wheel-zoom-out.png'
|
||||
)
|
||||
})
|
||||
@@ -1351,31 +1359,33 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'standard-initial.png')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('standard-initial.png')
|
||||
|
||||
await comfyPage.page.mouse.move(400, 300)
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.page.mouse.wheel(0, 120)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-shift-wheel-pan-right.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.page.mouse.wheel(0, -240)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-shift-wheel-pan-left.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.page.mouse.wheel(0, 120)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-shift-wheel-pan-center.png'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -112,8 +112,9 @@ test.describe('Load3D', () => {
|
||||
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
|
||||
|
||||
await load3d.waitForModelLoaded()
|
||||
await comfyPage.expectScreenshot(
|
||||
load3d.node,
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(load3d.node).toHaveScreenshot(
|
||||
'load3d-uploaded-cube-obj.png',
|
||||
{ maxDiffPixelRatio: 0.1 }
|
||||
)
|
||||
@@ -141,8 +142,9 @@ test.describe('Load3D', () => {
|
||||
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
|
||||
|
||||
await load3d.waitForModelLoaded()
|
||||
await comfyPage.expectScreenshot(
|
||||
load3d.node,
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(load3d.node).toHaveScreenshot(
|
||||
'load3d-dropped-cube-obj.png',
|
||||
{ maxDiffPixelRatio: 0.1 }
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -28,85 +27,6 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function openMaskEditorDialog(comfyPage: ComfyPage) {
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
|
||||
await expect(canvasContainer).toBeVisible()
|
||||
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
async function getMaskCanvasPixelData(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const canvases = document.querySelectorAll(
|
||||
'#maskEditorCanvasContainer canvas'
|
||||
)
|
||||
// The mask canvas is the 3rd canvas (index 2, z-30)
|
||||
const maskCanvas = canvases[2] as HTMLCanvasElement
|
||||
if (!maskCanvas) return null
|
||||
const ctx = maskCanvas.getContext('2d')
|
||||
if (!ctx) return null
|
||||
const data = ctx.getImageData(0, 0, maskCanvas.width, maskCanvas.height)
|
||||
let nonTransparentPixels = 0
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) nonTransparentPixels++
|
||||
}
|
||||
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
|
||||
})
|
||||
}
|
||||
|
||||
function pollMaskPixelCount(page: Page): Promise<number> {
|
||||
return getMaskCanvasPixelData(page).then(
|
||||
(d) => d?.nonTransparentPixels ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
async function drawStrokeOnPointerZone(
|
||||
page: Page,
|
||||
dialog: ReturnType<typeof page.locator>
|
||||
) {
|
||||
const pointerZone = dialog.locator(
|
||||
'.maskEditor-ui-container [class*="w-[calc"]'
|
||||
)
|
||||
await expect(pointerZone).toBeVisible()
|
||||
|
||||
const box = await pointerZone.boundingBox()
|
||||
if (!box) throw new Error('Pointer zone bounding box not found')
|
||||
|
||||
const startX = box.x + box.width * 0.3
|
||||
const startY = box.y + box.height * 0.5
|
||||
const endX = box.x + box.width * 0.7
|
||||
const endY = box.y + box.height * 0.5
|
||||
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(endX, endY, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
|
||||
return { startX, startY, endX, endY, box }
|
||||
}
|
||||
|
||||
async function drawStrokeAndExpectPixels(
|
||||
comfyPage: ComfyPage,
|
||||
dialog: ReturnType<typeof comfyPage.page.locator>
|
||||
) {
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test(
|
||||
'opens mask editor from image preview button',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
@@ -132,7 +52,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
await expect(dialog.getByText('Save')).toBeVisible()
|
||||
await expect(dialog.getByText('Cancel')).toBeVisible()
|
||||
|
||||
await comfyPage.expectScreenshot(dialog, 'mask-editor-dialog-open.png')
|
||||
await expect(dialog).toHaveScreenshot('mask-editor-dialog-open.png')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -159,245 +79,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.expectScreenshot(
|
||||
dialog,
|
||||
await expect(dialog).toHaveScreenshot(
|
||||
'mask-editor-dialog-from-context-menu.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('draws a brush stroke on the mask canvas', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
|
||||
expect(dataBefore).not.toBeNull()
|
||||
expect(dataBefore!.nonTransparentPixels).toBe(0)
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
})
|
||||
|
||||
test('undo reverts a brush stroke', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
const undoButton = dialog.locator('button[title="Undo"]')
|
||||
await expect(undoButton).toBeVisible()
|
||||
await undoButton.click()
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
})
|
||||
|
||||
test('redo restores an undone stroke', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
const undoButton = dialog.locator('button[title="Undo"]')
|
||||
await undoButton.click()
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
|
||||
const redoButton = dialog.locator('button[title="Redo"]')
|
||||
await expect(redoButton).toBeVisible()
|
||||
await redoButton.click()
|
||||
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('clear button removes all mask content', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
const clearButton = dialog.getByRole('button', { name: 'Clear' })
|
||||
await expect(clearButton).toBeVisible()
|
||||
await clearButton.click()
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
})
|
||||
|
||||
test('cancel closes the dialog without saving', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
|
||||
await cancelButton.click()
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('invert button inverts the mask', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
|
||||
expect(dataBefore).not.toBeNull()
|
||||
const pixelsBefore = dataBefore!.nonTransparentPixels
|
||||
|
||||
const invertButton = dialog.getByRole('button', { name: 'Invert' })
|
||||
await expect(invertButton).toBeVisible()
|
||||
await invertButton.click()
|
||||
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.toBeGreaterThan(pixelsBefore)
|
||||
})
|
||||
|
||||
test('keyboard shortcut Ctrl+Z triggers undo', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
const modifier = process.platform === 'darwin' ? 'Meta+z' : 'Control+z'
|
||||
await comfyPage.page.keyboard.press(modifier)
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
})
|
||||
|
||||
test(
|
||||
'tool panel shows all five tools',
|
||||
{ tag: ['@smoke'] },
|
||||
async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
const toolPanel = dialog.locator('.maskEditor-ui-container')
|
||||
await expect(toolPanel).toBeVisible()
|
||||
|
||||
// The tool panel should contain exactly 5 tool entries
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await expect(toolEntries).toHaveCount(5)
|
||||
|
||||
// First tool (MaskPen) should be selected by default
|
||||
const selectedTool = dialog.locator(
|
||||
'.maskEditor_toolPanelContainerSelected'
|
||||
)
|
||||
await expect(selectedTool).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
|
||||
test('switching tools updates the selected indicator', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await expect(toolEntries).toHaveCount(5)
|
||||
|
||||
// Click the third tool (Eraser, index 2)
|
||||
await toolEntries.nth(2).click()
|
||||
|
||||
// The third tool should now be selected
|
||||
const selectedTool = dialog.locator(
|
||||
'.maskEditor_toolPanelContainerSelected'
|
||||
)
|
||||
await expect(selectedTool).toHaveCount(1)
|
||||
|
||||
// Verify it's the eraser (3rd entry)
|
||||
await expect(toolEntries.nth(2)).toHaveClass(/Selected/)
|
||||
})
|
||||
|
||||
test('brush settings panel is visible with thickness controls', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
// The side panel should show brush settings by default
|
||||
const thicknessLabel = dialog.getByText('Thickness')
|
||||
await expect(thicknessLabel).toBeVisible()
|
||||
|
||||
const opacityLabel = dialog.getByText('Opacity').first()
|
||||
await expect(opacityLabel).toBeVisible()
|
||||
|
||||
const hardnessLabel = dialog.getByText('Hardness')
|
||||
await expect(hardnessLabel).toBeVisible()
|
||||
})
|
||||
|
||||
test('save uploads all layers and closes dialog', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
let maskUploadCount = 0
|
||||
let imageUploadCount = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadCount++
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
name: `test-mask-${maskUploadCount}.png`,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadCount++
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
name: `test-image-${imageUploadCount}.png`,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await expect(saveButton).toBeVisible()
|
||||
await saveButton.click()
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
// The save pipeline uploads multiple layers (mask + image variants)
|
||||
expect(
|
||||
maskUploadCount + imageUploadCount,
|
||||
'save should trigger upload calls'
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('save failure keeps dialog open', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
// Fail all upload routes
|
||||
await comfyPage.page.route('**/upload/mask', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.click()
|
||||
|
||||
// Dialog should remain open when save fails
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test(
|
||||
'eraser tool removes mask content',
|
||||
{ tag: ['@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
// Draw a stroke with the mask pen (default tool)
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
const pixelsAfterDraw = await getMaskCanvasPixelData(comfyPage.page)
|
||||
|
||||
// Switch to eraser tool (3rd tool, index 2)
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await toolEntries.nth(2).click()
|
||||
|
||||
// Draw over the same area with the eraser
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.toBeLessThan(pixelsAfterDraw!.nonTransparentPixels)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
@@ -17,24 +16,21 @@ function hasCanvasContent(canvas: Locator): Promise<boolean> {
|
||||
})
|
||||
}
|
||||
|
||||
function getMinimapLocators(comfyPage: ComfyPage) {
|
||||
const container = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
return {
|
||||
container,
|
||||
canvas: comfyPage.page.getByTestId(TestIds.canvas.minimapCanvas),
|
||||
viewport: comfyPage.page.getByTestId(TestIds.canvas.minimapViewport),
|
||||
toggleButton: comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
),
|
||||
closeButton: comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton)
|
||||
}
|
||||
}
|
||||
async function clickMinimapAt(
|
||||
overlay: Locator,
|
||||
page: Page,
|
||||
relX: number,
|
||||
relY: number
|
||||
) {
|
||||
const box = await overlay.boundingBox()
|
||||
expect(box, 'Minimap interaction overlay not found').toBeTruthy()
|
||||
|
||||
function getCanvasOffset(page: Page): Promise<[number, number]> {
|
||||
return page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return [ds.offset[0], ds.offset[1]] as [number, number]
|
||||
})
|
||||
// Click area — avoiding the settings button (top-left, 32×32px)
|
||||
// and close button (top-right, 32×32px)
|
||||
await page.mouse.click(
|
||||
box!.x + box!.width * relX,
|
||||
box!.y + box!.height * relY
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
@@ -46,13 +42,23 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
|
||||
test('Validate minimap is visible by default', async ({ comfyPage }) => {
|
||||
const { container, canvas, viewport } = getMinimapLocators(comfyPage)
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
|
||||
await expect(container).toBeVisible()
|
||||
await expect(canvas).toBeVisible()
|
||||
await expect(viewport).toBeVisible()
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
await expect(container).toHaveCSS('position', 'relative')
|
||||
const minimapCanvas = minimapContainer.getByTestId(
|
||||
TestIds.canvas.minimapCanvas
|
||||
)
|
||||
await expect(minimapCanvas).toBeVisible()
|
||||
|
||||
const minimapViewport = minimapContainer.getByTestId(
|
||||
TestIds.canvas.minimapViewport
|
||||
)
|
||||
await expect(minimapViewport).toBeVisible()
|
||||
|
||||
await expect(minimapContainer).toHaveCSS('position', 'relative')
|
||||
|
||||
// position and z-index validation moved to the parent container of the minimap
|
||||
const minimapMainContainer = comfyPage.page.locator(
|
||||
@@ -63,53 +69,59 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
|
||||
test('Validate minimap toggle button state', async ({ comfyPage }) => {
|
||||
const { container, toggleButton } = getMinimapLocators(comfyPage)
|
||||
const toggleButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
|
||||
await expect(toggleButton).toBeVisible()
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
|
||||
const { container, toggleButton } = getMinimapLocators(comfyPage)
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
const toggleButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
|
||||
await expect(container).toBeVisible()
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
await toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(container).toBeHidden()
|
||||
await expect(minimapContainer).toBeHidden()
|
||||
|
||||
await toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(container).toBeVisible()
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
|
||||
const { container } = getMinimapLocators(comfyPage)
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
|
||||
await expect(container).toBeVisible()
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(container).toBeHidden()
|
||||
await expect(minimapContainer).toBeHidden()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(container).toBeVisible()
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Close button hides minimap', async ({ comfyPage }) => {
|
||||
const { container, toggleButton, closeButton } =
|
||||
getMinimapLocators(comfyPage)
|
||||
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
await closeButton.click()
|
||||
await expect(container).toBeHidden()
|
||||
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
|
||||
await expect(minimap).toBeHidden()
|
||||
|
||||
const toggleButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
await expect(toggleButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -117,10 +129,12 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
'Panning canvas moves minimap viewport',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const { container } = getMinimapLocators(comfyPage)
|
||||
await expect(container).toBeVisible()
|
||||
const minimap = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await comfyPage.expectScreenshot(container, 'minimap-before-pan.png')
|
||||
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
@@ -129,192 +143,156 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
canvas.ds.offset[1] = -600
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.expectScreenshot(container, 'minimap-after-pan.png')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(minimap).toHaveScreenshot('minimap-after-pan.png')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Viewport rectangle is visible and positioned within minimap',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const { container, viewport } = getMinimapLocators(comfyPage)
|
||||
await expect(container).toBeVisible()
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
const minimapBox = await container.boundingBox()
|
||||
const viewportBox = await viewport.boundingBox()
|
||||
|
||||
expect(minimapBox).toBeTruthy()
|
||||
expect(viewportBox).toBeTruthy()
|
||||
expect(viewportBox!.width).toBeGreaterThan(0)
|
||||
expect(viewportBox!.height).toBeGreaterThan(0)
|
||||
|
||||
expect(viewportBox!.x + viewportBox!.width).toBeGreaterThan(minimapBox!.x)
|
||||
expect(viewportBox!.y + viewportBox!.height).toBeGreaterThan(
|
||||
minimapBox!.y
|
||||
)
|
||||
expect(viewportBox!.x).toBeLessThan(minimapBox!.x + minimapBox!.width)
|
||||
expect(viewportBox!.y).toBeLessThan(minimapBox!.y + minimapBox!.height)
|
||||
|
||||
await comfyPage.expectScreenshot(container, 'minimap-with-viewport.png')
|
||||
}
|
||||
)
|
||||
|
||||
test('Clicking on minimap pans the canvas to that position', async ({
|
||||
test('Minimap canvas is non-empty for a workflow with nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { container } = getMinimapLocators(comfyPage)
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
const offsetBefore = await getCanvasOffset(comfyPage.page)
|
||||
|
||||
const minimapBox = await container.boundingBox()
|
||||
expect(minimapBox).toBeTruthy()
|
||||
|
||||
// Click the top-left quadrant — canvas should pan so that region
|
||||
// becomes centered, meaning offset increases (moves right/down)
|
||||
await comfyPage.page.mouse.click(
|
||||
minimapBox!.x + minimapBox!.width * 0.2,
|
||||
minimapBox!.y + minimapBox!.height * 0.2
|
||||
const minimapCanvas = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapCanvas
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(minimapCanvas).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => getCanvasOffset(comfyPage.page))
|
||||
.not.toEqual(offsetBefore)
|
||||
})
|
||||
|
||||
test('Dragging on minimap continuously pans the canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { container } = getMinimapLocators(comfyPage)
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
const minimapBox = await container.boundingBox()
|
||||
expect(minimapBox).toBeTruthy()
|
||||
|
||||
const startX = minimapBox!.x + minimapBox!.width * 0.3
|
||||
const startY = minimapBox!.y + minimapBox!.height * 0.3
|
||||
const endX = minimapBox!.x + minimapBox!.width * 0.7
|
||||
const endY = minimapBox!.y + minimapBox!.height * 0.7
|
||||
|
||||
const offsetBefore = await getCanvasOffset(comfyPage.page)
|
||||
|
||||
// Drag from top-left toward bottom-right on the minimap
|
||||
await comfyPage.page.mouse.move(startX, startY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(endX, endY, { steps: 10 })
|
||||
|
||||
// Mid-drag: offset should already differ from initial state
|
||||
const offsetMidDrag = await getCanvasOffset(comfyPage.page)
|
||||
expect(
|
||||
offsetMidDrag[0] !== offsetBefore[0] ||
|
||||
offsetMidDrag[1] !== offsetBefore[1]
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Final offset should also differ (drag was not discarded on mouseup)
|
||||
await expect
|
||||
.poll(() => getCanvasOffset(comfyPage.page))
|
||||
.not.toEqual(offsetBefore)
|
||||
})
|
||||
|
||||
test('Minimap viewport updates when canvas is zoomed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { container, viewport } = getMinimapLocators(comfyPage)
|
||||
await expect(container).toBeVisible()
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
const viewportBefore = await viewport.boundingBox()
|
||||
expect(viewportBefore).toBeTruthy()
|
||||
|
||||
// Zoom in significantly
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.scale = 3
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Viewport rectangle should shrink when zoomed in
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await viewport.boundingBox()
|
||||
return box?.width ?? 0
|
||||
})
|
||||
.toBeLessThan(viewportBefore!.width)
|
||||
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(true)
|
||||
})
|
||||
|
||||
test('Minimap canvas is empty after all nodes are deleted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { canvas } = getMinimapLocators(comfyPage)
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
// Minimap should have content before deletion
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
|
||||
// Remove all nodes
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.canvas.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
const minimapCanvas = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapCanvas
|
||||
)
|
||||
await expect(minimapCanvas).toBeVisible()
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.vueNodes.deleteSelected()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// Minimap canvas should be empty — no nodes means nothing to render
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), { timeout: 5000 })
|
||||
.toBe(false)
|
||||
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(false)
|
||||
})
|
||||
|
||||
test('Minimap re-renders after loading a different workflow', async ({
|
||||
test('Clicking minimap corner pans the main canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { canvas } = getMinimapLocators(comfyPage)
|
||||
await expect(canvas).toBeVisible()
|
||||
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
|
||||
const overlay = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapInteractionOverlay
|
||||
)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
// Default workflow has content
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
const before = await comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
|
||||
// Load a very different workflow
|
||||
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
|
||||
await comfyPage.nextFrame()
|
||||
const transformBefore = await viewport.evaluate(
|
||||
(el: HTMLElement) => el.style.transform
|
||||
)
|
||||
|
||||
await clickMinimapAt(overlay, comfyPage.page, 0.15, 0.85)
|
||||
|
||||
// Minimap should still have content (different workflow, still has nodes)
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), { timeout: 5000 })
|
||||
.toBe(true)
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
)
|
||||
.not.toStrictEqual(before)
|
||||
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform))
|
||||
.not.toBe(transformBefore)
|
||||
})
|
||||
|
||||
test('Minimap viewport position reflects canvas pan state', async ({
|
||||
test('Clicking minimap center after FitView causes minimal canvas movement', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { container, viewport } = getMinimapLocators(comfyPage)
|
||||
await expect(container).toBeVisible()
|
||||
await expect(viewport).toBeVisible()
|
||||
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
const overlay = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapInteractionOverlay
|
||||
)
|
||||
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const positionBefore = await viewport.boundingBox()
|
||||
expect(positionBefore).toBeTruthy()
|
||||
|
||||
// Pan the canvas by a large amount to the right and down
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.offset[0] -= 500
|
||||
canvas.ds.offset[1] -= 500
|
||||
canvas.ds.offset[0] -= 1000
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The viewport indicator should have moved within the minimap
|
||||
const transformBefore = await viewport.evaluate(
|
||||
(el: HTMLElement) => el.style.transform
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.fitViewToSelectionAnimated({ duration: 1 })
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await viewport.boundingBox()
|
||||
if (!box || !positionBefore) return false
|
||||
return box.x !== positionBefore.x || box.y !== positionBefore.y
|
||||
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
.not.toBe(transformBefore)
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const before = await comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
|
||||
await clickMinimapAt(overlay, comfyPage.page, 0.5, 0.5)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
|
||||
// ~3px overlay error × ~15 canvas/minimap scale ≈ 45, rounded up
|
||||
const TOLERANCE = 50
|
||||
expect(
|
||||
Math.abs(after.x - before.x),
|
||||
`offset.x changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
|
||||
).toBeLessThan(TOLERANCE)
|
||||
expect(
|
||||
Math.abs(after.y - before.y),
|
||||
`offset.y changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
|
||||
).toBeLessThan(TOLERANCE)
|
||||
})
|
||||
|
||||
test(
|
||||
'Viewport rectangle is visible and positioned within minimap',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
await expect(async () => {
|
||||
const vb = await viewport.boundingBox()
|
||||
const mb = await minimap.boundingBox()
|
||||
expect(vb).toBeTruthy()
|
||||
expect(mb).toBeTruthy()
|
||||
expect(vb!.width).toBeGreaterThan(0)
|
||||
expect(vb!.height).toBeGreaterThan(0)
|
||||
expect(vb!.x).toBeGreaterThanOrEqual(mb!.x)
|
||||
expect(vb!.y).toBeGreaterThanOrEqual(mb!.y)
|
||||
expect(vb!.x + vb!.width).toBeLessThanOrEqual(mb!.x + mb!.width)
|
||||
expect(vb!.y + vb!.height).toBeLessThanOrEqual(mb!.y + mb!.height)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
await expect(minimap).toHaveScreenshot('minimap-with-viewport.png')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -11,10 +11,8 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'mobile-empty-canvas.png'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
|
||||
})
|
||||
|
||||
test('@mobile default workflow', async ({ comfyPage }) => {
|
||||
@@ -26,6 +24,7 @@ test.describe(
|
||||
|
||||
test('@mobile graph canvas toolbar visible', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const minimapButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
@@ -39,8 +38,9 @@ test.describe(
|
||||
|
||||
test('@mobile settings dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.settingDialog.root,
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
|
||||
'mobile-settings-dialog.png',
|
||||
{
|
||||
mask: [
|
||||
|
||||
@@ -13,6 +13,7 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
async function openMoreOptions(comfyPage: ComfyPage) {
|
||||
@@ -34,6 +35,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await ksamplerNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
|
||||
|
||||
async function addGhostAtCenter(comfyPage: ComfyPage) {
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
const centerX = Math.round(viewport.width / 2)
|
||||
@@ -52,6 +53,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
|
||||
test('positions ghost node at cursor', async ({ comfyPage }) => {
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
const centerX = Math.round(viewport.width / 2)
|
||||
@@ -108,7 +110,8 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
@@ -121,7 +124,8 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Delete')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
@@ -134,7 +138,8 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Backspace')
|
||||
await comfyPage.page.keyboard.press('Backspace')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
|
||||
@@ -303,8 +303,8 @@ test.describe('Release context menu', { tag: '@node' }, () => {
|
||||
'CLIP | CLIP'
|
||||
)
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'link-release-context-menu.png'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -370,64 +370,4 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Eraser', () => {
|
||||
test('Eraser removes previously drawn content', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
canvas.evaluate((el: HTMLCanvasElement) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const cx = Math.floor(el.width / 2)
|
||||
const cy = Math.floor(el.height / 2)
|
||||
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
|
||||
return data.every((v, i) => i % 4 !== 3 || v === 0)
|
||||
}),
|
||||
{ message: 'erased area should be transparent' }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Eraser on empty canvas adds no content', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test('Multiple strokes accumulate on the canvas', async ({ comfyPage }) => {
|
||||
const canvas = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.3 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.7 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -113,40 +113,6 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Bypass/un-bypass cycle preserves Copy URL button on the restored row', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: on un-bypass, the realtime scan produced a fresh
|
||||
// candidate without url/hash/directory — those fields were only
|
||||
// attached by the full pipeline's enrichWithEmbeddedMetadata. The
|
||||
// row's Copy URL button (v-if gated on representative.url) then
|
||||
// disappeared. Per-node scan now enriches from node.properties.models
|
||||
// which persists across mode toggles. Uses the `_from_node_properties`
|
||||
// fixture because the enrichment source is per-node metadata, not
|
||||
// the workflow-level `models[]` array (which the realtime scan
|
||||
// path does not see).
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Pasting a node with missing model increases referencing node count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -510,52 +476,6 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Loading a workflow with bypassed subgraph suppresses interior missing model error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: the initial scan pipeline only checked each node's
|
||||
// own mode, so interior nodes of a bypassed subgraph container
|
||||
// surfaced errors even though the container was excluded from
|
||||
// execution. The pipeline now post-filters candidates whose
|
||||
// ancestor path is not fully active.
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_bypassed_subgraph'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Entering a bypassed subgraph does not resurface interior missing model error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: useGraphNodeManager replays graph.onNodeAdded for
|
||||
// each interior node on subgraph entry; without an ancestor-aware
|
||||
// guard in scanSingleNodeErrors, that re-scan reintroduced the
|
||||
// error that the initial pipeline had correctly suppressed.
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_bypassed_subgraph'
|
||||
)
|
||||
|
||||
const errorsTab = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.errorsTab
|
||||
)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(errorsTab).toBeHidden()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await expect(errorsTab).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Workflow switching', () => {
|
||||
|
||||
@@ -19,10 +19,8 @@ test.describe(
|
||||
await comfyPage.page.getByText('loaders').click()
|
||||
await comfyPage.page.getByText('Load VAE').click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'add-node-node-added.png'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png')
|
||||
})
|
||||
|
||||
test('Can add group', async ({ comfyPage }) => {
|
||||
@@ -30,8 +28,8 @@ test.describe(
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
|
||||
await comfyPage.page.getByText('Add Group', { exact: true }).click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'add-group-group-added.png'
|
||||
)
|
||||
})
|
||||
@@ -47,8 +45,8 @@ test.describe(
|
||||
await comfyPage.nodeOps.promptDialogInput.fill('GroupNode2CLIP')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'hidden' })
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'right-click-node-group-node.png'
|
||||
)
|
||||
})
|
||||
@@ -62,11 +60,12 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
|
||||
await comfyPage.page.getByText('Properties Panel').click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'right-click-node-properties-panel.png'
|
||||
)
|
||||
})
|
||||
@@ -77,11 +76,12 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
|
||||
await comfyPage.page.getByText('Collapse').click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'right-click-node-collapsed.png'
|
||||
)
|
||||
})
|
||||
@@ -104,8 +104,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.getByText('Collapse').click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'right-click-node-collapsed-badge.png'
|
||||
)
|
||||
})
|
||||
@@ -116,11 +116,12 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
|
||||
await comfyPage.page.getByText('Bypass').click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'right-click-node-bypassed.png'
|
||||
)
|
||||
})
|
||||
@@ -132,7 +133,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
|
||||
await comfyPage.page.locator('.litemenu-entry:has-text("Pin")').click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.nextFrame()
|
||||
@@ -147,8 +149,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'right-click-pinned-node.png'
|
||||
)
|
||||
await comfyPage.page.locator('.litemenu-entry:has-text("Unpin")').click()
|
||||
@@ -158,8 +160,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'right-click-unpinned-node.png'
|
||||
)
|
||||
})
|
||||
@@ -204,10 +206,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.page.locator('.litemenu-entry:has-text("Pin")').click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'selected-nodes-pinned.png'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-nodes-pinned.png')
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.emptyLatentWidgetClick,
|
||||
button: 'right'
|
||||
@@ -216,8 +216,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.locator('.litemenu-entry:has-text("Unpin")').click()
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'selected-nodes-unpinned.png'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -11,13 +11,15 @@ test.describe('@canvas Selection Rectangle', { tag: '@vue-nodes' }, () => {
|
||||
const totalCount = await comfyPage.vueNodes.getNodeCount()
|
||||
|
||||
// Use canvas press for keyboard shortcuts (doesn't need click target)
|
||||
await comfyPage.keyboard.press('Control+a')
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(totalCount)
|
||||
})
|
||||
|
||||
test('Click empty space deselects all', async ({ comfyPage }) => {
|
||||
await comfyPage.keyboard.press('Control+a')
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.vueNodes.selectedNodes).not.toHaveCount(0)
|
||||
|
||||
// Deselect by Ctrl+clicking the already-selected node (reliable cross-env)
|
||||
@@ -68,7 +70,8 @@ test.describe('@canvas Selection Rectangle', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
// Use Ctrl+A to select all, which is functionally equivalent to
|
||||
// drag-selecting the entire canvas and more reliable in CI
|
||||
await comfyPage.keyboard.press('Control+a')
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.vueNodes.getNodeCount())
|
||||
|
||||
@@ -267,7 +267,8 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
.click()
|
||||
|
||||
// Undo the colorization
|
||||
await comfyPage.keyboard.press('Control+Z')
|
||||
await comfyPage.page.keyboard.press('Control+Z')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Node should be uncolored again
|
||||
const selectedNode = (
|
||||
|
||||
@@ -32,6 +32,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('delete button removes selected node', async ({ comfyPage }) => {
|
||||
@@ -68,6 +69,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
|
||||
await comfyPage.nextFrame()
|
||||
@@ -81,6 +83,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
|
||||
await comfyPage.nextFrame()
|
||||
@@ -157,6 +160,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
@@ -183,6 +187,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialGroupCount = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph.groups.length
|
||||
@@ -224,6 +229,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select the SaveImage node by panning to it
|
||||
const saveImageRef = (
|
||||
|
||||
@@ -14,6 +14,7 @@ test.describe(
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
@@ -42,6 +43,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await ksamplerNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ test.describe('Sidebar splitter width independence', () => {
|
||||
location: 'left' | 'right'
|
||||
) {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', location)
|
||||
await comfyPage.nextFrame()
|
||||
await dismissToasts(comfyPage)
|
||||
await comfyPage.menu.nodeLibraryTab.open()
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
|
||||
@@ -37,6 +37,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
const nodePos = await subgraphNode.getPosition()
|
||||
@@ -48,7 +49,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await expect(breadcrumb).toBeVisible({ timeout: 20_000 })
|
||||
const initialBreadcrumbText = (await breadcrumb.textContent()) ?? ''
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.canvas.dblclick({
|
||||
position: {
|
||||
@@ -62,7 +64,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
|
||||
await comfyPage.keyboard.press('Enter')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await expect(breadcrumb).toBeVisible()
|
||||
@@ -75,6 +78,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
const backButton = breadcrumb.locator('.back-button')
|
||||
@@ -86,11 +90,13 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await expect(backButton).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
await expect(backButton).toHaveCount(0)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
await expect(backButton).toHaveCount(0)
|
||||
@@ -100,6 +106,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.describe('Navigation Hotkeys', () => {
|
||||
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [
|
||||
{
|
||||
@@ -128,6 +135,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
@@ -137,7 +145,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.isInSubgraph(), {
|
||||
message:
|
||||
@@ -145,7 +154,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Alt+q')
|
||||
await comfyPage.page.keyboard.press('Alt+q')
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -153,6 +163,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
@@ -172,7 +183,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.settings)
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.settings)
|
||||
@@ -180,7 +192,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -192,6 +205,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
@@ -258,6 +272,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
@@ -281,7 +296,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -296,6 +312,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
@@ -311,8 +328,10 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
|
||||
@@ -18,6 +18,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
@@ -38,10 +38,13 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(String(nodeId))
|
||||
await nodeToClone.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.keyboard.press('ControlOrMeta+c')
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+c')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.keyboard.press('ControlOrMeta+v')
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+v')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getNodeCount())
|
||||
|
||||
@@ -102,6 +102,7 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
@@ -149,6 +150,7 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const testContent = 'promoted-value-sync-test'
|
||||
|
||||
@@ -316,6 +318,7 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The SaveImage node is in the recommendedNodes list, so its
|
||||
// filename_prefix widget should be auto-promoted
|
||||
@@ -400,6 +403,7 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
|
||||
@@ -451,6 +455,7 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify promotions exist
|
||||
await expect
|
||||
@@ -471,6 +476,7 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '5', 0)
|
||||
const initialNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||
|
||||
@@ -68,6 +68,7 @@ test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(parentTextarea).toBeVisible()
|
||||
@@ -87,7 +88,8 @@ test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(backToParentTextarea).toBeVisible()
|
||||
|
||||
@@ -15,7 +15,8 @@ async function exitSubgraphAndPublish(
|
||||
subgraphNode: Awaited<ReturnType<typeof createSubgraphAndNavigateInto>>,
|
||||
blueprintName: string
|
||||
) {
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
|
||||
|
||||
@@ -40,6 +40,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
|
||||
await expect(beforeReload).toHaveCount(1)
|
||||
@@ -58,6 +59,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -71,17 +73,20 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
@@ -116,6 +121,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(workflowName)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
|
||||
@@ -424,6 +424,7 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await SubgraphHelper.expectWidgetBelowHeader(subgraphNode, seedWidget)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
|
||||
await subgraphNodeRef.navigateIntoSubgraph()
|
||||
|
||||
@@ -34,8 +34,9 @@ test.describe('Viewport', { tag: ['@screenshot', '@smoke', '@canvas'] }, () => {
|
||||
{ message: 'All nodes should be within the visible viewport' }
|
||||
)
|
||||
.toBe(true)
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'viewport-fits-when-saved-offscreen.png'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -121,8 +121,8 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-groups-create-group.png'
|
||||
)
|
||||
})
|
||||
@@ -131,6 +131,7 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('groups/oversized_group')
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.FitGroupToContents')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-groups-fit-to-contents.png'
|
||||
)
|
||||
|
||||
@@ -24,8 +24,8 @@ test.describe('Vue Node Bypass', { tag: '@vue-nodes' }, () => {
|
||||
.filter({ hasText: 'Load Checkpoint' })
|
||||
.getByTestId('node-inner-wrapper')
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-bypassed-state.png'
|
||||
)
|
||||
|
||||
|
||||
@@ -5,19 +5,17 @@ import {
|
||||
|
||||
const MUTE_HOTKEY = 'Control+m'
|
||||
const MUTE_OPACITY = '0.5'
|
||||
const SELECTED_CLASS = /outline-node-component-outline/
|
||||
|
||||
test.describe('Vue Node Mute', { tag: '@vue-nodes' }, () => {
|
||||
test(
|
||||
'should allow toggling mute on a selected node with hotkey',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
|
||||
const checkpointNode =
|
||||
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await expect(checkpointNode).toHaveClass(SELECTED_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-muted-state.png'
|
||||
@@ -31,14 +29,12 @@ test.describe('Vue Node Mute', { tag: '@vue-nodes' }, () => {
|
||||
test('should allow toggling mute on multiple selected nodes with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await expect(checkpointNode).toHaveClass(SELECTED_CLASS)
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
await expect(ksamplerNode).toHaveClass(SELECTED_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
await expect(ksamplerNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Reroute Node Size', { tag: '@vue-nodes' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -10,8 +13,8 @@ test.describe('Vue Reroute Node Size', { tag: '@vue-nodes' }, () => {
|
||||
'reroute node visual appearance',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-reroute-node-compact.png'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -121,9 +121,9 @@ test.describe('Workflow Persistence', () => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(1)
|
||||
@@ -149,6 +149,7 @@ test.describe('Workflow Persistence', () => {
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBeGreaterThan(1)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(1)
|
||||
|
||||
@@ -288,8 +289,10 @@ test.describe('Workflow Persistence', () => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'zh')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'en')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
@@ -346,6 +349,7 @@ test.describe('Workflow Persistence', () => {
|
||||
|
||||
// Create B: duplicate, add a node, then save (unmodified after save)
|
||||
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
@@ -388,7 +392,7 @@ test.describe('Workflow Persistence', () => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflow called captureCanvasState on inactive tab, serializing the active graph instead'
|
||||
'PR #10745 — saveWorkflow called checkState on inactive tab, serializing the active graph instead'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -406,6 +410,7 @@ test.describe('Workflow Persistence', () => {
|
||||
|
||||
// Create B: duplicate and save
|
||||
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameB)
|
||||
|
||||
// Add a Note node in B to mark it as modified
|
||||
@@ -419,13 +424,13 @@ test.describe('Workflow Persistence', () => {
|
||||
.toBe(nodeCountA + 1)
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
// Trigger captureCanvasState so isModified is set
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
// Switch to A via topbar tab (making B inactive)
|
||||
@@ -464,7 +469,7 @@ test.describe('Workflow Persistence', () => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflowAs called captureCanvasState on inactive temp tab, serializing the active graph'
|
||||
'PR #10745 — saveWorkflowAs called checkState on inactive temp tab, serializing the active graph'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -482,19 +487,20 @@ test.describe('Workflow Persistence', () => {
|
||||
|
||||
// Create B as an unsaved workflow with a Note node
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Trigger captureCanvasState so isModified is set
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(1)
|
||||
|
||||
@@ -5,18 +5,14 @@ history by comparing serialized graph snapshots.
|
||||
|
||||
## How It Works
|
||||
|
||||
`captureCanvasState()` is the core method. It:
|
||||
`checkState()` is the core method. It:
|
||||
|
||||
1. Serializes the current graph via `app.rootGraph.serialize()`
|
||||
2. Deep-compares the result against the last known `activeState`
|
||||
3. If different, pushes `activeState` onto `undoQueue` and replaces it
|
||||
|
||||
**It is not reactive.** Changes to the graph (widget values, node positions,
|
||||
links, etc.) are only captured when `captureCanvasState()` is explicitly triggered.
|
||||
|
||||
**INVARIANT:** `captureCanvasState()` asserts that it is called on the active
|
||||
workflow's tracker. Calling it on an inactive tracker logs a warning and
|
||||
returns early, preventing cross-workflow data corruption.
|
||||
links, etc.) are only captured when `checkState()` is explicitly triggered.
|
||||
|
||||
## Automatic Triggers
|
||||
|
||||
@@ -35,7 +31,7 @@ These are set up once in `ChangeTracker.init()`:
|
||||
| Graph cleared | `api` `graphCleared` event | Full graph clear |
|
||||
| Transaction end | `litegraph:canvas` `after-change` event | Batched operations via `beforeChange`/`afterChange` |
|
||||
|
||||
## When You Must Call `captureCanvasState()` Manually
|
||||
## When You Must Call `checkState()` Manually
|
||||
|
||||
The automatic triggers above are designed around LiteGraph's native DOM
|
||||
rendering. They **do not cover**:
|
||||
@@ -54,42 +50,24 @@ rendering. They **do not cover**:
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
// After mutating the graph:
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
```
|
||||
|
||||
### Existing Manual Call Sites
|
||||
|
||||
These locations call `captureCanvasState()` directly:
|
||||
These locations already call `checkState()` explicitly:
|
||||
|
||||
- `WidgetSelectDropdown.vue` — After dropdown selection and file upload
|
||||
- `ColorPickerButton.vue` — After changing node colors
|
||||
- `NodeSearchBoxPopover.vue` — After adding a node from search
|
||||
- `builderViewOptions.ts` — After setting default view
|
||||
- `useAppSetDefaultView.ts` — After setting default view
|
||||
- `useSelectionOperations.ts` — After align, copy, paste, duplicate, group
|
||||
- `useSelectedNodeActions.ts` — After pin, bypass, collapse
|
||||
- `useGroupMenuOptions.ts` — After group operations
|
||||
- `useSubgraphOperations.ts` — After subgraph enter/exit
|
||||
- `useCanvasRefresh.ts` — After canvas refresh
|
||||
- `useCoreCommands.ts` — After metadata/subgraph commands
|
||||
- `appModeStore.ts` — After app mode transitions
|
||||
|
||||
`workflowService.ts` calls `captureCanvasState()` indirectly via
|
||||
`deactivate()` and `prepareForSave()` (see Lifecycle Methods below).
|
||||
|
||||
> **Deprecated:** `checkState()` is an alias for `captureCanvasState()` kept
|
||||
> for extension compatibility. Extension authors should migrate to
|
||||
> `captureCanvasState()`. See the `@deprecated` JSDoc on the method.
|
||||
|
||||
## Lifecycle Methods
|
||||
|
||||
| Method | Caller | Purpose |
|
||||
| ---------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `captureCanvasState()` | Event handlers, UI interactions | Snapshots canvas into activeState, pushes undo. Asserts active tracker. |
|
||||
| `deactivate()` | `beforeLoadNewGraph` only | `captureCanvasState()` (skipped during undo/redo) + `store()`. Freezes state for tab switch. Must be called while this workflow is still active. |
|
||||
| `prepareForSave()` | Save paths only | Active: calls `captureCanvasState()`. Inactive: no-op (state was frozen by `deactivate()`). |
|
||||
| `store()` | Internal to `deactivate()` | Saves viewport scale/offset, node outputs, subgraph navigation. |
|
||||
| `restore()` | `afterLoadNewGraph` | Restores viewport, outputs, subgraph navigation. |
|
||||
| `reset()` | `afterLoadNewGraph`, save | Resets initial state (marks workflow as "clean"). |
|
||||
- `workflowService.ts` — After workflow service operations
|
||||
|
||||
## Transaction Guards
|
||||
|
||||
@@ -98,7 +76,7 @@ For operations that make multiple changes that should be a single undo entry:
|
||||
```typescript
|
||||
changeTracker.beforeChange()
|
||||
// ... multiple graph mutations ...
|
||||
changeTracker.afterChange() // calls captureCanvasState() when nesting count hits 0
|
||||
changeTracker.afterChange() // calls checkState() when nesting count hits 0
|
||||
```
|
||||
|
||||
The `litegraph:canvas` custom event also supports this with `before-change` /
|
||||
@@ -106,12 +84,8 @@ The `litegraph:canvas` custom event also supports this with `before-change` /
|
||||
|
||||
## Key Invariants
|
||||
|
||||
- `captureCanvasState()` asserts it is called on the active workflow's tracker;
|
||||
inactive trackers get an early return (and a warning log)
|
||||
- `captureCanvasState()` is a no-op during `loadGraphData` (guarded by
|
||||
- `checkState()` is a no-op during `loadGraphData` (guarded by
|
||||
`isLoadingGraph`) to prevent cross-workflow corruption
|
||||
- `captureCanvasState()` is a no-op during undo/redo (guarded by
|
||||
`_restoringState`) to prevent undo history corruption
|
||||
- `captureCanvasState()` is a no-op when `changeCount > 0` (inside a transaction)
|
||||
- `checkState()` is a no-op when `changeCount > 0` (inside a transaction)
|
||||
- `undoQueue` is capped at 50 entries (`MAX_HISTORY`)
|
||||
- `graphEqual` ignores node order and `ds` (pan/zoom) when comparing
|
||||
|
||||
858
packages/registry-types/src/comfyRegistryTypes.ts
generated
858
packages/registry-types/src/comfyRegistryTypes.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -171,10 +171,14 @@ const sidebarPanelVisible = computed(
|
||||
)
|
||||
|
||||
const firstPanelVisible = computed(
|
||||
() => sidebarLocation.value === 'left' || showOffsideSplitter.value
|
||||
() =>
|
||||
!focusMode.value &&
|
||||
(sidebarLocation.value === 'left' || showOffsideSplitter.value)
|
||||
)
|
||||
const lastPanelVisible = computed(
|
||||
() => sidebarLocation.value === 'right' || showOffsideSplitter.value
|
||||
() =>
|
||||
!focusMode.value &&
|
||||
(sidebarLocation.value === 'right' || showOffsideSplitter.value)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -264,7 +268,6 @@ const splitterRefreshKey = computed(() => {
|
||||
})
|
||||
|
||||
const firstPanelStyle = computed(() => {
|
||||
if (focusMode.value) return { display: 'none' }
|
||||
if (sidebarLocation.value === 'left') {
|
||||
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
}
|
||||
@@ -272,7 +275,6 @@ const firstPanelStyle = computed(() => {
|
||||
})
|
||||
|
||||
const lastPanelStyle = computed(() => {
|
||||
if (focusMode.value) return { display: 'none' }
|
||||
if (sidebarLocation.value === 'right') {
|
||||
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
}
|
||||
@@ -291,13 +293,9 @@ const lastPanelStyle = computed(() => {
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
/* Hide gutter when adjacent panel is not visible */
|
||||
:deep(
|
||||
[data-pc-name='splitterpanel'][style*='display: none'] + .p-splitter-gutter
|
||||
),
|
||||
:deep(
|
||||
.p-splitter-gutter + [data-pc-name='splitterpanel'][style*='display: none']
|
||||
) {
|
||||
/* Hide sidebar gutter when sidebar is not visible */
|
||||
:deep(.side-bar-panel[style*='display: none'] + .p-splitter-gutter),
|
||||
:deep(.p-splitter-gutter + .side-bar-panel[style*='display: none']) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ const mockActiveWorkflow = ref<{
|
||||
isTemporary: boolean
|
||||
initialMode?: string
|
||||
isModified?: boolean
|
||||
changeTracker?: { captureCanvasState: () => void }
|
||||
changeTracker?: { checkState: () => void }
|
||||
} | null>({
|
||||
isTemporary: true,
|
||||
initialMode: 'app'
|
||||
|
||||
@@ -47,12 +47,7 @@
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
<PopoverClose as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
data-testid="builder-connect-output-switch"
|
||||
@click="emit('switch')"
|
||||
>
|
||||
<Button variant="secondary" size="md" @click="emit('switch')">
|
||||
{{ t('builderToolbar.switchToOutputs') }}
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<BuilderDialog
|
||||
data-testid="builder-empty-workflow-dialog"
|
||||
:show-close="false"
|
||||
>
|
||||
<BuilderDialog :show-close="false">
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.emptyWorkflowTitle') }}
|
||||
</template>
|
||||
@@ -20,17 +17,11 @@
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
data-testid="builder-empty-workflow-back"
|
||||
@click="$emit('backToWorkflow')"
|
||||
>
|
||||
{{ $t('linearMode.backToWorkflow') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
data-testid="builder-empty-workflow-load-template"
|
||||
@click="$emit('loadTemplate')"
|
||||
>
|
||||
<Button variant="secondary" size="lg" @click="$emit('loadTemplate')">
|
||||
{{ $t('linearMode.loadTemplate') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<NotificationPopup
|
||||
v-if="appModeStore.showVueNodeSwitchPopup"
|
||||
data-testid="linear-vue-node-switch-popup"
|
||||
:title="$t('appBuilder.vueNodeSwitch.title')"
|
||||
show-close
|
||||
position="bottom-left"
|
||||
@@ -16,7 +15,6 @@
|
||||
<input
|
||||
v-model="dontShowAgain"
|
||||
type="checkbox"
|
||||
data-testid="linear-vue-node-switch-dont-show-again"
|
||||
class="accent-primary-background"
|
||||
/>
|
||||
{{ $t('appBuilder.vueNodeSwitch.dontShowAgain') }}
|
||||
@@ -27,7 +25,6 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
data-testid="linear-vue-node-switch-dismiss"
|
||||
class="font-normal"
|
||||
@click="dismiss"
|
||||
>
|
||||
|
||||
@@ -49,10 +49,10 @@ describe('setWorkflowDefaultView', () => {
|
||||
expect(app.rootGraph.extra.linearMode).toBe(false)
|
||||
})
|
||||
|
||||
it('calls changeTracker.captureCanvasState', () => {
|
||||
it('calls changeTracker.checkState', () => {
|
||||
const workflow = createMockLoadedWorkflow()
|
||||
setWorkflowDefaultView(workflow, true)
|
||||
expect(workflow.changeTracker.captureCanvasState).toHaveBeenCalledOnce()
|
||||
expect(workflow.changeTracker.checkState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('tracks telemetry with correct default_view', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ export function setWorkflowDefaultView(
|
||||
workflow.initialMode = openAsApp ? 'app' : 'graph'
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.captureCanvasState()
|
||||
workflow.changeTracker?.checkState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ function createMockWorkflow(
|
||||
const changeTracker = Object.assign(
|
||||
new ChangeTracker(workflow, structuredClone(defaultGraph)),
|
||||
{
|
||||
captureCanvasState: vi.fn() as Mock
|
||||
checkState: vi.fn() as Mock
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ const applyColor = (colorOption: ColorOption | null) => {
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
currentColorOption.value = canvasColorOption
|
||||
showColorPicker.value = false
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker.checkState()
|
||||
}
|
||||
|
||||
const currentColorOption = ref<CanvasColorOption | null>(null)
|
||||
|
||||
@@ -293,8 +293,8 @@ const {
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
filteredMissingModelGroups: missingModelGroups,
|
||||
filteredMissingMediaGroups: missingMediaGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
|
||||
|
||||
@@ -58,10 +58,8 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
function makeMissingNodeType(
|
||||
@@ -756,48 +754,4 @@ describe('useErrorGroups', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unfiltered vs selection-filtered model/media groups', () => {
|
||||
it('exposes both unfiltered (missingModelGroups) and filtered (filteredMissingModelGroups)', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
expect(groups.missingModelGroups).toBeDefined()
|
||||
expect(groups.filteredMissingModelGroups).toBeDefined()
|
||||
expect(groups.missingMediaGroups).toBeDefined()
|
||||
expect(groups.filteredMissingMediaGroups).toBeDefined()
|
||||
})
|
||||
|
||||
it('missingModelGroups returns total candidates regardless of selection (ErrorOverlay contract)', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingModels([
|
||||
makeModel('a.safetensors', { nodeId: '1', directory: 'checkpoints' }),
|
||||
makeModel('b.safetensors', { nodeId: '2', directory: 'checkpoints' })
|
||||
])
|
||||
// Simulate canvas selection of a single node so the filtered
|
||||
// variant actually narrows. Without this, both sides return the
|
||||
// same value trivially and the test can't prove the contract.
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([{ id: '1' }])
|
||||
await nextTick()
|
||||
|
||||
// Unfiltered total stays at one group of two models regardless of
|
||||
// the selection — ErrorOverlay reads this for the overlay label
|
||||
// and must not shrink with canvas selection.
|
||||
expect(groups.missingModelGroups.value).toHaveLength(1)
|
||||
expect(groups.missingModelGroups.value[0].models).toHaveLength(2)
|
||||
|
||||
// Filtered variant does narrow under the same selection state —
|
||||
// this is how the errors tab scopes cards to the selected node.
|
||||
// Exact filtered output depends on the app.rootGraph lookup
|
||||
// (mocked to return undefined here); what matters is that the
|
||||
// filtered shape is a different reference and does not blindly
|
||||
// mirror the unfiltered one.
|
||||
expect(groups.filteredMissingModelGroups.value).not.toBe(
|
||||
groups.missingModelGroups.value
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -833,10 +833,8 @@ export function useErrorGroups(
|
||||
missingNodeCache,
|
||||
groupedErrorMessages,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
filteredMissingModelGroups,
|
||||
filteredMissingMediaGroups,
|
||||
missingModelGroups: filteredMissingModelGroups,
|
||||
missingMediaGroups: filteredMissingMediaGroups,
|
||||
swapNodeGroups
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
disconnectOnReset = false
|
||||
|
||||
// Notify changeTracker - new step should be added
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
window.requestAnimationFrame(closeDialog)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export function useCanvasRefresh() {
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
canvasStore.canvas?.graph?.afterChange()
|
||||
canvasStore.canvas?.emitAfterChange()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -728,109 +728,6 @@ describe('realtime verification staleness guards', () => {
|
||||
|
||||
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('skips adding verified model when rootGraph switched before verification resolved', async () => {
|
||||
// Workflow A has a pending candidate on node id=1. A is replaced
|
||||
// by workflow B (fresh LGraph, potentially has a node with the
|
||||
// same id). Late verification from A must not leak into B.
|
||||
const graphA = new LGraph()
|
||||
const nodeA = new LGraphNode('CheckpointLoaderSimple')
|
||||
graphA.add(nodeA)
|
||||
const rootSpy = vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graphA)
|
||||
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(nodeA.id),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: true,
|
||||
name: 'stale_from_A.safetensors',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
let resolveVerify: (() => void) | undefined
|
||||
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
||||
const verifySpy = vi
|
||||
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
await verifyPromise
|
||||
for (const c of candidates) c.isMissing = true
|
||||
})
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
|
||||
installErrorClearingHooks(graphA)
|
||||
|
||||
nodeA.mode = LGraphEventMode.ALWAYS
|
||||
graphA.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: nodeA.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
||||
|
||||
// Workflow swap: app.rootGraph now points at graphB.
|
||||
const graphB = new LGraph()
|
||||
const nodeB = new LGraphNode('CheckpointLoaderSimple')
|
||||
graphB.add(nodeB)
|
||||
rootSpy.mockReturnValue(graphB)
|
||||
|
||||
resolveVerify!()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
// A's verification finished but rootGraph is now B — the late
|
||||
// result must not be added to the store.
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('does not surface interior missing model when entering a bypassed subgraph', async () => {
|
||||
// Repro: root has a bypassed subgraph container, interior node is
|
||||
// itself active. useGraphNodeManager replays `onNodeAdded` for each
|
||||
// interior node on subgraph entry, which previously reached
|
||||
// scanSingleNodeErrors without an ancestor check and resurfaced the
|
||||
// error that the initial pipeline post-filter had correctly dropped.
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode.mode = LGraphEventMode.BYPASS
|
||||
const rootGraph = subgraphNode.graph as LGraph
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
// Any scanner output would surface the error if the ancestor guard
|
||||
// didn't short-circuit first — return a concrete missing candidate.
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: `${subgraphNode.id}:${interiorNode.id}`,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'fake.safetensors',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
|
||||
installErrorClearingHooks(subgraph)
|
||||
|
||||
// Simulate useGraphNodeManager replaying onNodeAdded for existing
|
||||
// interior nodes after Vue node manager init on subgraph entry.
|
||||
subgraph.onNodeAdded?.(interiorNode)
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
|
||||
@@ -41,8 +41,7 @@ import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode,
|
||||
getExecutionIdForNodeInGraph,
|
||||
getNodeByExecutionId,
|
||||
isAncestorPathActive
|
||||
getNodeByExecutionId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
function resolvePromotedExecId(
|
||||
@@ -173,14 +172,6 @@ function scanAndAddNodeErrors(node: LGraphNode): void {
|
||||
|
||||
function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
if (!app.rootGraph) return
|
||||
// Skip when any enclosing subgraph is muted/bypassed. Callers only
|
||||
// verify each node's own mode; entering a bypassed subgraph (via
|
||||
// useGraphNodeManager replaying onNodeAdded for existing interior
|
||||
// nodes) reaches this point without the ancestor check. A null
|
||||
// execId means the node has no current graph (e.g. detached mid
|
||||
// lifecycle) — also skip, since we cannot verify its scope.
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return
|
||||
|
||||
const modelCandidates = scanNodeModelCandidates(
|
||||
app.rootGraph,
|
||||
@@ -246,27 +237,16 @@ function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
*/
|
||||
function isCandidateStillActive(nodeId: unknown): boolean {
|
||||
if (!app.rootGraph || nodeId == null) return false
|
||||
const execId = String(nodeId)
|
||||
const node = getNodeByExecutionId(app.rootGraph, execId)
|
||||
const node = getNodeByExecutionId(app.rootGraph, String(nodeId))
|
||||
if (!node) return false
|
||||
if (isNodeInactive(node.mode)) return false
|
||||
// Also reject if any enclosing subgraph was bypassed between scan
|
||||
// kick-off and verification resolving — mirrors the pipeline-level
|
||||
// ancestor post-filter so realtime and initial-load paths stay
|
||||
// symmetric.
|
||||
return isAncestorPathActive(app.rootGraph, execId)
|
||||
return !isNodeInactive(node.mode)
|
||||
}
|
||||
|
||||
async function verifyAndAddPendingModels(
|
||||
pending: MissingModelCandidate[]
|
||||
): Promise<void> {
|
||||
// Capture rootGraph at scan time so a late verification for workflow
|
||||
// A cannot leak into workflow B after a switch — execution IDs (esp.
|
||||
// root-level like "1") collide across workflows.
|
||||
const rootGraphAtScan = app.rootGraph
|
||||
try {
|
||||
await verifyAssetSupportedCandidates(pending)
|
||||
if (app.rootGraph !== rootGraphAtScan) return
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
)
|
||||
@@ -279,10 +259,8 @@ async function verifyAndAddPendingModels(
|
||||
async function verifyAndAddPendingMedia(
|
||||
pending: MissingMediaCandidate[]
|
||||
): Promise<void> {
|
||||
const rootGraphAtScan = app.rootGraph
|
||||
try {
|
||||
await verifyCloudMediaCandidates(pending)
|
||||
if (app.rootGraph !== rootGraphAtScan) return
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ export function useGroupMenuOptions() {
|
||||
groupContext.resizeTo(groupContext.children, padding)
|
||||
groupContext.graph?.change()
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -119,7 +119,7 @@ export function useGroupMenuOptions() {
|
||||
})
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
groupContext.graph?.change()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
bump()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ export function useSelectedNodeActions() {
|
||||
})
|
||||
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const toggleNodeCollapse = () => {
|
||||
@@ -33,7 +33,7 @@ export function useSelectedNodeActions() {
|
||||
})
|
||||
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const toggleNodePin = () => {
|
||||
@@ -43,7 +43,7 @@ export function useSelectedNodeActions() {
|
||||
})
|
||||
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const toggleNodeBypass = () => {
|
||||
|
||||
@@ -47,7 +47,7 @@ export function useSelectionOperations() {
|
||||
canvas.pasteFromClipboard({ connectInputs: false })
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const duplicateSelection = () => {
|
||||
@@ -73,7 +73,7 @@ export function useSelectionOperations() {
|
||||
canvas.pasteFromClipboard({ connectInputs: false })
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const deleteSelection = () => {
|
||||
@@ -92,7 +92,7 @@ export function useSelectionOperations() {
|
||||
canvas.setDirty(true, true)
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const renameSelection = async () => {
|
||||
@@ -122,7 +122,7 @@ export function useSelectionOperations() {
|
||||
const titledItem = item as { title: string }
|
||||
titledItem.title = newTitle
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -145,7 +145,7 @@ export function useSelectionOperations() {
|
||||
}
|
||||
})
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useSubgraphOperations() {
|
||||
canvas.select(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const doUnpack = (
|
||||
@@ -46,7 +46,7 @@ export function useSubgraphOperations() {
|
||||
nodeOutputStore.revokeSubgraphPreviews(subgraphNode)
|
||||
graph.unpackSubgraph(subgraphNode, { skipMissingNodes })
|
||||
}
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const unpackSubgraph = () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user