From 7b32a2fb6e9da5194f7877694fe56762373092e5 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Tue, 22 Jul 2025 10:35:49 -0700 Subject: [PATCH] [tests] Add browser tests for subgraph functionalities (#4495) --- browser_tests/assets/basic-subgraph.json | 244 +++++++++ ...bgraph-with-multiple-promoted-widgets.json | 412 ++++++++++++++++ .../subgraph-with-promoted-text-widget.json | 341 +++++++++++++ .../assets/subgraph-with-text-widget.json | 153 ++++++ browser_tests/fixtures/ComfyPage.ts | 403 ++++++++++++++- .../fixtures/utils/litegraphUtils.ts | 227 ++++++++- .../tests/domWidgetPromotion.spec.ts | 286 ----------- browser_tests/tests/subgraph.spec.ts | 463 ++++++++++++++++++ .../tests/subgraphBreadcrumb.spec.ts | 82 ---- tests-ui/tests/domWidgetStore.test.ts | 151 ++++++ 10 files changed, 2382 insertions(+), 380 deletions(-) create mode 100644 browser_tests/assets/basic-subgraph.json create mode 100644 browser_tests/assets/subgraph-with-multiple-promoted-widgets.json create mode 100644 browser_tests/assets/subgraph-with-promoted-text-widget.json create mode 100644 browser_tests/assets/subgraph-with-text-widget.json delete mode 100644 browser_tests/tests/domWidgetPromotion.spec.ts create mode 100644 browser_tests/tests/subgraph.spec.ts delete mode 100644 browser_tests/tests/subgraphBreadcrumb.spec.ts create mode 100644 tests-ui/tests/domWidgetStore.test.ts diff --git a/browser_tests/assets/basic-subgraph.json b/browser_tests/assets/basic-subgraph.json new file mode 100644 index 000000000..98891ea55 --- /dev/null +++ b/browser_tests/assets/basic-subgraph.json @@ -0,0 +1,244 @@ +{ + "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": [ + 144.15234375, + 46 + ], + "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": {}, + "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 +} \ No newline at end of file diff --git a/browser_tests/assets/subgraph-with-multiple-promoted-widgets.json b/browser_tests/assets/subgraph-with-multiple-promoted-widgets.json new file mode 100644 index 000000000..d40b6f783 --- /dev/null +++ b/browser_tests/assets/subgraph-with-multiple-promoted-widgets.json @@ -0,0 +1,412 @@ +{ + "id": "c4a254bb-935e-4013-b380-5e36954de4b0", + "revision": 0, + "last_node_id": 11, + "last_link_id": 9, + "nodes": [ + { + "id": 11, + "type": "422723e8-4bf6-438c-823f-881ca81acead", + "pos": [ + 791.59912109375, + 386.13336181640625 + ], + "size": [ + 210, + 202 + ], + "flags": {}, + "order": 0, + "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": "CONDITIONING", + "type": "CONDITIONING", + "links": null + } + ], + "properties": {}, + "widgets_values": [ + "", + "" + ] + } + ], + "links": [], + "groups": [], + "definitions": { + "subgraphs": [ + { + "id": "422723e8-4bf6-438c-823f-881ca81acead", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 12, + "lastLinkId": 16, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "New Subgraph", + "inputNode": { + "id": -10, + "bounding": [ + 481.59912109375, + 379.13336181640625, + 120, + 160 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 1121.59912109375, + 379.13336181640625, + 128.6640625, + 60 + ] + }, + "inputs": [ + { + "id": "0f07c10e-5705-4764-9b24-b69606c6dbcc", + "name": "text", + "type": "STRING", + "linkIds": [ + 10 + ], + "pos": { + "0": 581.59912109375, + "1": 399.13336181640625 + } + }, + { + "id": "736e5a03-0f7f-4e48-93e4-fd66ea6c30f1", + "name": "text_1", + "type": "STRING", + "linkIds": [ + 11 + ], + "pos": { + "0": 581.59912109375, + "1": 419.13336181640625 + } + }, + { + "id": "b62e7a0b-cc7e-4ca5-a4e1-c81607a13f58", + "name": "model", + "type": "MODEL", + "linkIds": [ + 13 + ], + "pos": { + "0": 581.59912109375, + "1": 439.13336181640625 + } + }, + { + "id": "7a2628da-4879-4f82-a7d3-7b1c00db50a5", + "name": "positive", + "type": "CONDITIONING", + "linkIds": [ + 14 + ], + "pos": { + "0": 581.59912109375, + "1": 459.13336181640625 + } + }, + { + "id": "651cf4ad-e8bf-47f6-b181-8f8aeacd6669", + "name": "negative", + "type": "CONDITIONING", + "linkIds": [ + 15 + ], + "pos": { + "0": 581.59912109375, + "1": 479.13336181640625 + } + }, + { + "id": "c41765ea-61ef-4a77-8cc6-74113903078f", + "name": "latent_image", + "type": "LATENT", + "linkIds": [ + 16 + ], + "pos": { + "0": 581.59912109375, + "1": 499.13336181640625 + } + } + ], + "outputs": [ + { + "id": "55dd1505-12bd-4cb4-8e75-031a97bb4387", + "name": "CONDITIONING", + "type": "CONDITIONING", + "linkIds": [ + 12 + ], + "pos": { + "0": 1141.59912109375, + "1": 399.13336181640625 + } + } + ], + "widgets": [], + "nodes": [ + { + "id": 10, + "type": "CLIPTextEncode", + "pos": [ + 661.59912109375, + 314.13336181640625 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 10 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ] + }, + { + "id": 11, + "type": "CLIPTextEncode", + "pos": [ + 668.755859375, + 571.7766723632812 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 11 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 12 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ] + }, + { + "id": 12, + "type": "KSampler", + "pos": [ + 671.7379760742188, + 1.621593713760376 + ], + "size": [ + 270, + 262 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 13 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 14 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 15 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 16 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": null + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 20, + 8, + "euler", + "simple", + 1 + ] + } + ], + "groups": [], + "links": [ + { + "id": 10, + "origin_id": -10, + "origin_slot": 0, + "target_id": 10, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 11, + "origin_id": -10, + "origin_slot": 1, + "target_id": 11, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 12, + "origin_id": 11, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 13, + "origin_id": -10, + "origin_slot": 2, + "target_id": 12, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 14, + "origin_id": -10, + "origin_slot": 3, + "target_id": 12, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 15, + "origin_id": -10, + "origin_slot": 4, + "target_id": 12, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 16, + "origin_id": -10, + "origin_slot": 5, + "target_id": 12, + "target_slot": 3, + "type": "LATENT" + } + ], + "extra": {} + } + ] + }, + "config": {}, + "extra": { + "ds": { + "scale": 0.9581355200690549, + "offset": [ + 184.687451089395, + 80.38288288288285 + ] + }, + "frontendVersion": "1.24.1" + }, + "version": 0.4 +} \ No newline at end of file diff --git a/browser_tests/assets/subgraph-with-promoted-text-widget.json b/browser_tests/assets/subgraph-with-promoted-text-widget.json new file mode 100644 index 000000000..025aec800 --- /dev/null +++ b/browser_tests/assets/subgraph-with-promoted-text-widget.json @@ -0,0 +1,341 @@ +{ + "id": "c4a254bb-935e-4013-b380-5e36954de4b0", + "revision": 0, + "last_node_id": 11, + "last_link_id": 9, + "nodes": [ + { + "id": 11, + "type": "422723e8-4bf6-438c-823f-881ca81acead", + "pos": [ + 400, + 300 + ], + "size": [ + 210, + 168 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "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": [], + "properties": {}, + "widgets_values": [ + "" + ] + } + ], + "links": [], + "groups": [], + "definitions": { + "subgraphs": [ + { + "id": "422723e8-4bf6-438c-823f-881ca81acead", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 11, + "lastLinkId": 15, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "New Subgraph", + "inputNode": { + "id": -10, + "bounding": [ + 481.59912109375, + 379.13336181640625, + 120, + 160 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 1121.59912109375, + 379.13336181640625, + 120, + 40 + ] + }, + "inputs": [ + { + "id": "0f07c10e-5705-4764-9b24-b69606c6dbcc", + "name": "text", + "type": "STRING", + "linkIds": [ + 10 + ], + "pos": { + "0": 581.59912109375, + "1": 399.13336181640625 + } + }, + { + "id": "214a5060-24dd-4299-ab78-8027dc5b9c59", + "name": "clip", + "type": "CLIP", + "linkIds": [ + 11 + ], + "pos": { + "0": 581.59912109375, + "1": 419.13336181640625 + } + }, + { + "id": "8ab94c5d-e7df-433c-9177-482a32340552", + "name": "model", + "type": "MODEL", + "linkIds": [ + 12 + ], + "pos": { + "0": 581.59912109375, + "1": 439.13336181640625 + } + }, + { + "id": "8a4cd719-8c67-473b-9b44-ac0582d02641", + "name": "positive", + "type": "CONDITIONING", + "linkIds": [ + 13 + ], + "pos": { + "0": 581.59912109375, + "1": 459.13336181640625 + } + }, + { + "id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135", + "name": "negative", + "type": "CONDITIONING", + "linkIds": [ + 14 + ], + "pos": { + "0": 581.59912109375, + "1": 479.13336181640625 + } + }, + { + "id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693", + "name": "latent_image", + "type": "LATENT", + "linkIds": [ + 15 + ], + "pos": { + "0": 581.59912109375, + "1": 499.13336181640625 + } + } + ], + "outputs": [], + "widgets": [], + "nodes": [ + { + "id": 10, + "type": "CLIPTextEncode", + "pos": [ + 661.59912109375, + 314.13336181640625 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 11 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 10 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ] + }, + { + "id": 11, + "type": "KSampler", + "pos": [ + 674.1234741210938, + 570.5839233398438 + ], + "size": [ + 270, + 262 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 12 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 13 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 14 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 15 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": null + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 20, + 8, + "euler", + "simple", + 1 + ] + } + ], + "groups": [], + "links": [ + { + "id": 10, + "origin_id": -10, + "origin_slot": 0, + "target_id": 10, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 11, + "origin_id": -10, + "origin_slot": 1, + "target_id": 10, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 12, + "origin_id": -10, + "origin_slot": 2, + "target_id": 11, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 13, + "origin_id": -10, + "origin_slot": 3, + "target_id": 11, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 14, + "origin_id": -10, + "origin_slot": 4, + "target_id": 11, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 15, + "origin_id": -10, + "origin_slot": 5, + "target_id": 11, + "target_slot": 3, + "type": "LATENT" + } + ], + "extra": {} + } + ] + }, + "config": {}, + "extra": { + "ds": { + "scale": 0.9581355200690549, + "offset": [ + 258.6405769416877, + 147.17927927927929 + ] + }, + "frontendVersion": "1.24.1" + }, + "version": 0.4 +} \ No newline at end of file diff --git a/browser_tests/assets/subgraph-with-text-widget.json b/browser_tests/assets/subgraph-with-text-widget.json new file mode 100644 index 000000000..c23b5e683 --- /dev/null +++ b/browser_tests/assets/subgraph-with-text-widget.json @@ -0,0 +1,153 @@ +{ + "id": "c4a254bb-935e-4013-b380-5e36954de4b0", + "revision": 0, + "last_node_id": 11, + "last_link_id": 9, + "nodes": [ + { + "id": 11, + "type": "422723e8-4bf6-438c-823f-881ca81acead", + "pos": [ + 791.59912109375, + 386.13336181640625 + ], + "size": [ + 140, + 26 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": null + } + ], + "outputs": [], + "properties": {}, + "widgets_values": [] + } + ], + "links": [], + "groups": [], + "definitions": { + "subgraphs": [ + { + "id": "422723e8-4bf6-438c-823f-881ca81acead", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 10, + "lastLinkId": 10, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "New Subgraph", + "inputNode": { + "id": -10, + "bounding": [ + 481.59912109375, + 379.13336181640625, + 120, + 60 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 1121.59912109375, + 379.13336181640625, + 120, + 40 + ] + }, + "inputs": [ + { + "id": "79e69fca-ad12-499b-8d9b-9f1656b85354", + "name": "clip", + "type": "CLIP", + "linkIds": [ + 10 + ], + "pos": { + "0": 581.59912109375, + "1": 399.13336181640625 + } + } + ], + "outputs": [], + "widgets": [], + "nodes": [ + { + "id": 10, + "type": "CLIPTextEncode", + "pos": [ + 661.59912109375, + 314.13336181640625 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 10 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ] + } + ], + "groups": [], + "links": [ + { + "id": 10, + "origin_id": -10, + "origin_slot": 0, + "target_id": 10, + "target_slot": 0, + "type": "CLIP" + } + ], + "extra": {} + } + ] + }, + "config": {}, + "extra": { + "frontendVersion": "1.24.1", + "VHS_latentpreview": false, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true, + "ds": { + "scale": 0.9581355200690549, + "offset": [ + 258.6405769416877, + 147.17927927927929 + ] + } + }, + "version": 0.4 +} \ No newline at end of file diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index 6722ec3e5..bcffa3cc6 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -21,7 +21,7 @@ import { } from './components/SidebarTab' import { Topbar } from './components/Topbar' import type { Position, Size } from './types' -import { NodeReference } from './utils/litegraphUtils' +import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils' import TaskHistory from './utils/taskHistory' dotenv.config() @@ -888,11 +888,412 @@ export class ComfyPage { }) } + /** + * Right-clicks on a subgraph output slot to open the context menu. + * Must be called when inside a subgraph. + * + * Similar to rightClickSubgraphInputSlot but for output slots. + * + * @param outputName Optional name of the specific output slot to target. + * If not provided, tries all available output slots until one works. + * @returns Promise that resolves when the context menu appears + */ + async rightClickSubgraphOutputSlot(outputName?: string): Promise { + const foundSlot = await this.page.evaluate(async (targetOutputName) => { + const app = window['app'] + const currentGraph = app.canvas.graph + + // Check if we're in a subgraph + if (currentGraph.constructor.name !== 'Subgraph') { + throw new Error( + 'Not in a subgraph - this method only works inside subgraphs' + ) + } + + // Get the output node + const outputNode = currentGraph.outputNode + if (!outputNode) { + throw new Error('No output node found in subgraph') + } + + // Get available outputs + const outputs = currentGraph.outputs + if (!outputs || outputs.length === 0) { + throw new Error('No output slots found in subgraph') + } + + // Filter to specific output if requested + const outputsToTry = targetOutputName + ? outputs.filter((out) => out.name === targetOutputName) + : outputs + + if (outputsToTry.length === 0) { + throw new Error( + targetOutputName + ? `Output slot '${targetOutputName}' not found` + : 'No output slots available to try' + ) + } + + // Try right-clicking on each output slot position until one works + for (const output of outputsToTry) { + if (!output.pos) continue + + const testX = output.pos[0] + const testY = output.pos[1] + + // Create a right-click event at the output slot position + const rightClickEvent = { + canvasX: testX, + canvasY: testY, + button: 2, // Right mouse button + preventDefault: () => {}, + stopPropagation: () => {} + } + + // Trigger the output node's right-click handler + if (outputNode.onPointerDown) { + outputNode.onPointerDown( + rightClickEvent, + app.canvas.pointer, + app.canvas.linkConnector + ) + } + + // Wait briefly for menu to appear + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Check if litegraph context menu appeared + const menuExists = document.querySelector('.litemenu-entry') + if (menuExists) { + return { success: true, outputName: output.name, x: testX, y: testY } + } + } + + return { success: false } + }, outputName) + + if (!foundSlot.success) { + throw new Error( + outputName + ? `Could not open context menu for output slot '${outputName}'` + : 'Could not find any output slot position to right-click' + ) + } + + // Wait for the litegraph context menu to be visible + await this.page.waitForSelector('.litemenu-entry', { + state: 'visible', + timeout: 5000 + }) + } + + /** + * Get a reference to a subgraph input slot + */ + async getSubgraphInputSlot( + slotName?: string + ): Promise { + return new SubgraphSlotReference('input', slotName || '', this) + } + + /** + * Get a reference to a subgraph output slot + */ + async getSubgraphOutputSlot( + slotName?: string + ): Promise { + return new SubgraphSlotReference('output', slotName || '', this) + } + + /** + * Connect a regular node output to a subgraph input. + * This creates a new input slot on the subgraph if targetInputName is not provided. + */ + async connectToSubgraphInput( + sourceNode: NodeReference, + sourceSlotIndex: number, + targetInputName?: string + ): Promise { + const sourceSlot = await sourceNode.getOutput(sourceSlotIndex) + const targetSlot = await this.getSubgraphInputSlot(targetInputName) + + const targetPosition = targetInputName + ? await targetSlot.getPosition() // Connect to existing slot + : await targetSlot.getOpenSlotPosition() // Create new slot + + await this.dragAndDrop(await sourceSlot.getPosition(), targetPosition) + await this.nextFrame() + } + + /** + * Connect a subgraph input to a regular node input. + * This creates a new input slot on the subgraph if sourceInputName is not provided. + */ + async connectFromSubgraphInput( + targetNode: NodeReference, + targetSlotIndex: number, + sourceInputName?: string + ): Promise { + const sourceSlot = await this.getSubgraphInputSlot(sourceInputName) + const targetSlot = await targetNode.getInput(targetSlotIndex) + + const sourcePosition = sourceInputName + ? await sourceSlot.getPosition() // Connect from existing slot + : await sourceSlot.getOpenSlotPosition() // Create new slot + + const targetPosition = await targetSlot.getPosition() + + // Debug: Log the positions we're trying to use + console.log('Drag positions:', { + source: sourcePosition, + target: targetPosition + }) + + await this.dragAndDrop(sourcePosition, targetPosition) + await this.nextFrame() + } + + /** + * Connect a regular node output to a subgraph output. + * This creates a new output slot on the subgraph if targetOutputName is not provided. + */ + async connectToSubgraphOutput( + sourceNode: NodeReference, + sourceSlotIndex: number, + targetOutputName?: string + ): Promise { + const sourceSlot = await sourceNode.getOutput(sourceSlotIndex) + const targetSlot = await this.getSubgraphOutputSlot(targetOutputName) + + const targetPosition = targetOutputName + ? await targetSlot.getPosition() // Connect to existing slot + : await targetSlot.getOpenSlotPosition() // Create new slot + + await this.dragAndDrop(await sourceSlot.getPosition(), targetPosition) + await this.nextFrame() + } + + /** + * Connect a subgraph output to a regular node input. + * This creates a new output slot on the subgraph if sourceOutputName is not provided. + */ + async connectFromSubgraphOutput( + targetNode: NodeReference, + targetSlotIndex: number, + sourceOutputName?: string + ): Promise { + const sourceSlot = await this.getSubgraphOutputSlot(sourceOutputName) + const targetSlot = await targetNode.getInput(targetSlotIndex) + + const sourcePosition = sourceOutputName + ? await sourceSlot.getPosition() // Connect from existing slot + : await sourceSlot.getOpenSlotPosition() // Create new slot + + await this.dragAndDrop(sourcePosition, await targetSlot.getPosition()) + await this.nextFrame() + } + + /** + * Add a visual marker at a position for debugging + */ + async debugAddMarker( + position: Position, + id: string = 'debug-marker' + ): Promise { + await this.page.evaluate( + ([pos, markerId]) => { + // Remove existing marker if present + const existing = document.getElementById(markerId) + if (existing) existing.remove() + + // Create marker + const marker = document.createElement('div') + marker.id = markerId + marker.style.position = 'fixed' + marker.style.left = `${pos.x - 10}px` + marker.style.top = `${pos.y - 10}px` + marker.style.width = '20px' + marker.style.height = '20px' + marker.style.border = '2px solid red' + marker.style.borderRadius = '50%' + marker.style.backgroundColor = 'rgba(255, 0, 0, 0.3)' + marker.style.pointerEvents = 'none' + marker.style.zIndex = '10000' + document.body.appendChild(marker) + }, + [position, id] as const + ) + } + + /** + * Remove debug markers + */ + async debugRemoveMarkers(): Promise { + await this.page.evaluate(() => { + document + .querySelectorAll('[id^="debug-marker"]') + .forEach((el) => el.remove()) + }) + } + + /** + * Take a screenshot and attach it to the test report for debugging + * This is a convenience method that combines screenshot capture and test attachment + * + * @param testInfo The Playwright TestInfo object (from test parameters) + * @param name Name for the attachment + * @param options Optional screenshot options (defaults to page screenshot) + */ + async debugAttachScreenshot( + testInfo: any, + name: string, + options?: { + fullPage?: boolean + element?: 'canvas' | 'page' + markers?: Array<{ position: Position; id?: string }> + } + ): Promise { + // Add markers if requested + if (options?.markers) { + for (const marker of options.markers) { + await this.debugAddMarker(marker.position, marker.id) + } + } + + // Take screenshot - default to page if not specified + let screenshot: Buffer + const targetElement = options?.element || 'page' + + if (targetElement === 'canvas') { + screenshot = await this.canvas.screenshot() + } else if (options?.fullPage) { + screenshot = await this.page.screenshot({ fullPage: true }) + } else { + screenshot = await this.page.screenshot() + } + + // Attach to test report + await testInfo.attach(name, { + body: screenshot, + contentType: 'image/png' + }) + + // Clean up markers if we added any + if (options?.markers) { + await this.debugRemoveMarkers() + } + } + async doubleClickCanvas() { await this.page.mouse.dblclick(10, 10, { delay: 5 }) await this.nextFrame() } + /** + * Capture the canvas as a PNG and save it for debugging + */ + async debugSaveCanvasScreenshot(filename: string): Promise { + await this.page.evaluate(async (filename) => { + const canvas = document.getElementById( + 'graph-canvas' + ) as HTMLCanvasElement + if (!canvas) { + throw new Error('Canvas not found') + } + + // Convert canvas to blob + return new Promise((resolve) => { + canvas.toBlob(async (blob) => { + if (!blob) { + throw new Error('Failed to create blob from canvas') + } + + // Create a download link and trigger it + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + resolve() + }, 'image/png') + }) + }, filename) + + // Wait a bit for the download to process + await this.page.waitForTimeout(500) + } + + /** + * Capture canvas as base64 data URL for inspection + */ + async debugGetCanvasDataURL(): Promise { + return await this.page.evaluate(() => { + const canvas = document.getElementById( + 'graph-canvas' + ) as HTMLCanvasElement + if (!canvas) { + throw new Error('Canvas not found') + } + return canvas.toDataURL('image/png') + }) + } + + /** + * Create an overlay div with the canvas image for easier Playwright screenshot + */ + async debugShowCanvasOverlay(): Promise { + await this.page.evaluate(() => { + const canvas = document.getElementById( + 'graph-canvas' + ) as HTMLCanvasElement + if (!canvas) { + throw new Error('Canvas not found') + } + + // Remove existing overlay if present + const existingOverlay = document.getElementById('debug-canvas-overlay') + if (existingOverlay) { + existingOverlay.remove() + } + + // Create overlay div + const overlay = document.createElement('div') + overlay.id = 'debug-canvas-overlay' + overlay.style.position = 'fixed' + overlay.style.top = '0' + overlay.style.left = '0' + overlay.style.zIndex = '9999' + overlay.style.backgroundColor = 'white' + overlay.style.padding = '10px' + overlay.style.border = '2px solid red' + + // Create image from canvas + const img = document.createElement('img') + img.src = canvas.toDataURL('image/png') + img.style.maxWidth = '800px' + img.style.maxHeight = '600px' + overlay.appendChild(img) + + document.body.appendChild(overlay) + }) + } + + /** + * Remove the debug canvas overlay + */ + async debugHideCanvasOverlay(): Promise { + await this.page.evaluate(() => { + const overlay = document.getElementById('debug-canvas-overlay') + if (overlay) { + overlay.remove() + } + }) + } + async clickEmptyLatentNode() { await this.canvas.click({ position: { diff --git a/browser_tests/fixtures/utils/litegraphUtils.ts b/browser_tests/fixtures/utils/litegraphUtils.ts index 94bfd059d..8a52d8b66 100644 --- a/browser_tests/fixtures/utils/litegraphUtils.ts +++ b/browser_tests/fixtures/utils/litegraphUtils.ts @@ -12,6 +12,128 @@ export const getMiddlePoint = (pos1: Position, pos2: Position) => { } } +export class SubgraphSlotReference { + constructor( + readonly type: 'input' | 'output', + readonly slotName: string, + readonly comfyPage: ComfyPage + ) {} + + async getPosition(): Promise { + const pos: [number, number] = await this.comfyPage.page.evaluate( + ([type, slotName]) => { + const currentGraph = window['app'].canvas.graph + + // Check if we're in a subgraph + if (currentGraph.constructor.name !== 'Subgraph') { + throw new Error( + 'Not in a subgraph - this method only works inside subgraphs' + ) + } + + const slots = + type === 'input' ? currentGraph.inputs : currentGraph.outputs + if (!slots || slots.length === 0) { + throw new Error(`No ${type} slots found in subgraph`) + } + + // Find the specific slot or use the first one if no name specified + const slot = slotName + ? slots.find((s) => s.name === slotName) + : slots[0] + + if (!slot) { + throw new Error(`${type} slot '${slotName}' not found`) + } + + if (!slot.pos) { + throw new Error(`${type} slot '${slotName}' has no position`) + } + + // Convert from offset to canvas coordinates + const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([ + slot.pos[0], + slot.pos[1] + ]) + return canvasPos + }, + [this.type, this.slotName] as const + ) + + return { + x: pos[0], + y: pos[1] + } + } + + async getOpenSlotPosition(): Promise { + const pos: [number, number] = await this.comfyPage.page.evaluate( + ([type]) => { + const currentGraph = window['app'].canvas.graph + + if (currentGraph.constructor.name !== 'Subgraph') { + throw new Error( + 'Not in a subgraph - this method only works inside subgraphs' + ) + } + + const node = + type === 'input' ? currentGraph.inputNode : currentGraph.outputNode + const slots = + type === 'input' ? currentGraph.inputs : currentGraph.outputs + + if (!node) { + throw new Error(`No ${type} node found in subgraph`) + } + + // Calculate position for next available slot + // const nextSlotIndex = slots?.length || 0 + // const slotHeight = 20 + // const slotY = node.pos[1] + 30 + nextSlotIndex * slotHeight + + // Find last slot position + const lastSlot = slots.at(-1) + let slotX: number + let slotY: number + + if (lastSlot) { + // If there are existing slots, position the new one below the last one + const gapHeight = 20 + slotX = lastSlot.pos[0] + slotY = lastSlot.pos[1] + gapHeight + } else { + // No existing slots - use slotAnchorX if available, otherwise calculate from node position + if (currentGraph.slotAnchorX !== undefined) { + // The actual slot X position seems to be slotAnchorX - 10 + slotX = currentGraph.slotAnchorX - 10 + } else { + // Fallback: calculate from node edge + slotX = + type === 'input' + ? node.pos[0] + node.size[0] - 10 // Right edge for input node + : node.pos[0] + 10 // Left edge for output node + } + // For Y position when no slots exist, use middle of node + slotY = node.pos[1] + node.size[1] / 2 + } + + // Convert from offset to canvas coordinates + const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([ + slotX, + slotY + ]) + return canvasPos + }, + [this.type] as const + ) + + return { + x: pos[0], + y: pos[1] + } + } +} + export class NodeSlotReference { constructor( readonly type: 'input' | 'output', @@ -21,11 +143,27 @@ export class NodeSlotReference { async getPosition() { const pos: [number, number] = await this.node.comfyPage.page.evaluate( ([type, id, index]) => { - const node = window['app'].graph.getNodeById(id) + // Use canvas.graph to get the current graph (works in both main graph and subgraphs) + const node = window['app'].canvas.graph.getNodeById(id) if (!node) throw new Error(`Node ${id} not found.`) - return window['app'].canvas.ds.convertOffsetToCanvas( - node.getConnectionPos(type === 'input', index) + + const rawPos = node.getConnectionPos(type === 'input', index) + const convertedPos = + window['app'].canvas.ds.convertOffsetToCanvas(rawPos) + + // Debug logging - convert Float32Arrays to regular arrays for visibility + console.log( + `NodeSlotReference debug for ${type} slot ${index} on node ${id}:`, + { + nodePos: [node.pos[0], node.pos[1]], + nodeSize: [node.size[0], node.size[1]], + rawConnectionPos: [rawPos[0], rawPos[1]], + convertedPos: [convertedPos[0], convertedPos[1]], + currentGraphType: window['app'].canvas.graph.constructor.name + } ) + + return convertedPos }, [this.type, this.node.id, this.index] as const ) @@ -37,7 +175,7 @@ export class NodeSlotReference { async getLinkCount() { return await this.node.comfyPage.page.evaluate( ([type, id, index]) => { - const node = window['app'].graph.getNodeById(id) + const node = window['app'].canvas.graph.getNodeById(id) if (!node) throw new Error(`Node ${id} not found.`) if (type === 'input') { return node.inputs[index].link == null ? 0 : 1 @@ -50,7 +188,7 @@ export class NodeSlotReference { async removeLinks() { await this.node.comfyPage.page.evaluate( ([type, id, index]) => { - const node = window['app'].graph.getNodeById(id) + const node = window['app'].canvas.graph.getNodeById(id) if (!node) throw new Error(`Node ${id} not found.`) if (type === 'input') { node.disconnectInput(index) @@ -75,7 +213,7 @@ export class NodeWidgetReference { async getPosition(): Promise { const pos: [number, number] = await this.node.comfyPage.page.evaluate( ([id, index]) => { - const node = window['app'].graph.getNodeById(id) + const node = window['app'].canvas.graph.getNodeById(id) if (!node) throw new Error(`Node ${id} not found.`) const widget = node.widgets[index] if (!widget) throw new Error(`Widget ${index} not found.`) @@ -134,7 +272,7 @@ export class NodeWidgetReference { const pos = await this.getPosition() const canvas = this.node.comfyPage.canvas const canvasPos = (await canvas.boundingBox())! - this.node.comfyPage.dragAndDrop( + await this.node.comfyPage.dragAndDrop( { x: canvasPos.x + pos.x, y: canvasPos.y + pos.y @@ -166,7 +304,7 @@ export class NodeReference { ) {} async exists(): Promise { return await this.comfyPage.page.evaluate((id) => { - const node = window['app'].graph.getNodeById(id) + const node = window['app'].canvas.graph.getNodeById(id) return !!node }, this.id) } @@ -185,7 +323,7 @@ export class NodeReference { async getBounding(): Promise { const [x, y, width, height]: [number, number, number, number] = await this.comfyPage.page.evaluate((id) => { - const node = window['app'].graph.getNodeById(id) + const node = window['app'].canvas.graph.getNodeById(id) if (!node) throw new Error('Node not found') return node.getBounding() }, this.id) @@ -218,7 +356,7 @@ export class NodeReference { async getProperty(prop: string): Promise { return await this.comfyPage.page.evaluate( ([id, prop]) => { - const node = window['app'].graph.getNodeById(id) + const node = window['app'].canvas.graph.getNodeById(id) if (!node) throw new Error('Node not found') return node[prop] }, @@ -259,7 +397,8 @@ export class NodeReference { await this.comfyPage.canvas.click({ ...options, - position: clickPos + position: clickPos, + force: true }) await this.comfyPage.nextFrame() if (moveMouseToEmptyArea) { @@ -319,6 +458,18 @@ export class NodeReference { } return nodes[0] } + async convertToSubgraph() { + await this.clickContextMenuOption('Convert to Subgraph') + await this.comfyPage.nextFrame() + await this.comfyPage.page.waitForTimeout(256) + const nodes = await this.comfyPage.getNodeRefsByTitle('New Subgraph') + if (nodes.length !== 1) { + throw new Error( + `Did not find single subgraph node (found=${nodes.length})` + ) + } + return nodes[0] + } async manageGroupNode() { await this.clickContextMenuOption('Manage Group Node') await this.comfyPage.nextFrame() @@ -327,4 +478,58 @@ export class NodeReference { this.comfyPage.page.locator('.comfy-group-manage') ) } + async navigateIntoSubgraph() { + const titleHeight = await this.comfyPage.page.evaluate(() => { + return window['LiteGraph']['NODE_TITLE_HEIGHT'] + }) + const nodePos = await this.getPosition() + const nodeSize = await this.getSize() + + // Try multiple positions to avoid DOM widget interference + const clickPositions = [ + { x: nodePos.x + nodeSize.width / 2, y: nodePos.y + titleHeight + 5 }, + { x: nodePos.x + nodeSize.width / 2, y: nodePos.y + nodeSize.height / 2 }, + { x: nodePos.x + 20, y: nodePos.y + titleHeight + 5 } + ] + + let isInSubgraph = false + let attempts = 0 + const maxAttempts = 3 + + while (!isInSubgraph && attempts < maxAttempts) { + attempts++ + + for (const position of clickPositions) { + // Clear any selection first + await this.comfyPage.canvas.click({ + position: { x: 50, y: 50 }, + force: true + }) + await this.comfyPage.nextFrame() + + // Double-click to enter subgraph + await this.comfyPage.canvas.dblclick({ position, force: true }) + await this.comfyPage.nextFrame() + await this.comfyPage.page.waitForTimeout(500) + + // Check if we successfully entered the subgraph + isInSubgraph = await this.comfyPage.page.evaluate(() => { + const graph = window['app'].canvas.graph + return graph?.constructor?.name === 'Subgraph' + }) + + if (isInSubgraph) break + } + + if (!isInSubgraph && attempts < maxAttempts) { + await this.comfyPage.page.waitForTimeout(500) + } + } + + if (!isInSubgraph) { + throw new Error( + 'Failed to navigate into subgraph after ' + attempts + ' attempts' + ) + } + } } diff --git a/browser_tests/tests/domWidgetPromotion.spec.ts b/browser_tests/tests/domWidgetPromotion.spec.ts deleted file mode 100644 index df4aeee93..000000000 --- a/browser_tests/tests/domWidgetPromotion.spec.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { expect } from '@playwright/test' - -import { comfyPageFixture as test } from '../fixtures/ComfyPage' -import type { ComfyPage } from '../fixtures/ComfyPage' -import type { NodeReference } from '../fixtures/litegraph' - -/** - * Helper to navigate into a subgraph with retry logic - */ -async function navigateIntoSubgraph( - comfyPage: ComfyPage, - subgraphNode: NodeReference -) { - const nodePos = await subgraphNode.getPosition() - const nodeSize = await subgraphNode.getSize() - - // Use simple navigation for tests without promoted widgets blocking - await comfyPage.canvas.dblclick({ - position: { - x: nodePos.x + nodeSize.width / 2, - y: nodePos.y + 10 // Click below the title - } - }) - await comfyPage.nextFrame() - await comfyPage.page.waitForTimeout(100) -} - -/** - * Helper to navigate into a subgraph when DOM widgets might interfere - * Uses retry logic with different click positions - */ -async function navigateIntoSubgraphWithRetry( - comfyPage: ComfyPage, - subgraphNode: NodeReference -) { - const nodePos = await subgraphNode.getPosition() - const nodeSize = await subgraphNode.getSize() - - let attempts = 0 - const maxAttempts = 3 - let isInSubgraph = false - - while (attempts < maxAttempts && !isInSubgraph) { - attempts++ - - // Clear any existing selection that might interfere - await comfyPage.canvas.click({ - position: { x: 50, y: 50 } - }) - await comfyPage.nextFrame() - - // Try different click positions to avoid DOM widget interference - const clickPositions = [ - { x: nodePos.x + nodeSize.width / 2, y: nodePos.y + 15 }, // Near top - { x: nodePos.x + nodeSize.width / 2, y: nodePos.y + nodeSize.height / 2 }, // Center - { x: nodePos.x + 20, y: nodePos.y + nodeSize.height / 2 } // Left side - ] - - const position = - clickPositions[Math.min(attempts - 1, clickPositions.length - 1)] - - await comfyPage.canvas.dblclick({ position }) - await comfyPage.nextFrame() - await comfyPage.page.waitForTimeout(300) - - // Check if we're now in the subgraph - isInSubgraph = await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph - return graph?.constructor?.name === 'Subgraph' - }) - - if (isInSubgraph) { - break - } - } - - if (!isInSubgraph) { - throw new Error( - `Failed to navigate into subgraph after ${maxAttempts} attempts` - ) - } -} - -test.describe.skip('DOM Widget Promotion', () => { - test('DOM widget visibility persists through subgraph navigation', async ({ - comfyPage - }) => { - // Load workflow with promoted text widget - await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget') - await comfyPage.nextFrame() - - // Check that the promoted widget's DOM element is visible in parent graph - const parentTextarea = await comfyPage.page.locator( - '.comfy-multiline-input' - ) - await expect(parentTextarea).toBeVisible() - await expect(parentTextarea).toHaveCount(1) - - // Get subgraph node - const subgraphNode = await comfyPage.getNodeRefById('11') - if (!(await subgraphNode.exists())) { - throw new Error('Subgraph node with ID 11 not found') - } - - // Navigate into the subgraph - await navigateIntoSubgraph(comfyPage, subgraphNode) - - // Check that the original widget's DOM element is visible in subgraph - const subgraphTextarea = await comfyPage.page.locator( - '.comfy-multiline-input' - ) - await expect(subgraphTextarea).toBeVisible() - await expect(subgraphTextarea).toHaveCount(1) - - // Navigate back to parent graph - await comfyPage.page.keyboard.press('Escape') - await comfyPage.nextFrame() - - // Check that the promoted widget's DOM element is still visible - const backToParentTextarea = await comfyPage.page.locator( - '.comfy-multiline-input' - ) - await expect(backToParentTextarea).toBeVisible() - await expect(backToParentTextarea).toHaveCount(1) - }) - - test('DOM widget content is preserved through navigation', async ({ - comfyPage - }) => { - await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget') - - // Type some text in the promoted widget - const textarea = await comfyPage.page.locator('.comfy-multiline-input') - await textarea.fill('Test content that should persist') - - // Get subgraph node - const subgraphNode = await comfyPage.getNodeRefById('11') - - // Navigate into subgraph - await navigateIntoSubgraph(comfyPage, subgraphNode) - - // Verify content is still there - const subgraphTextarea = await comfyPage.page.locator( - '.comfy-multiline-input' - ) - await expect(subgraphTextarea).toHaveValue( - 'Test content that should persist' - ) - - // Navigate back - await comfyPage.page.keyboard.press('Escape') - await comfyPage.nextFrame() - - // Verify content persisted - const parentTextarea = await comfyPage.page.locator( - '.comfy-multiline-input' - ) - await expect(parentTextarea).toHaveValue('Test content that should persist') - }) - - test('DOM elements are cleaned up when subgraph node is removed', async ({ - comfyPage - }) => { - await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget') - - // Count initial DOM elements - const initialCount = await comfyPage.page - .locator('.comfy-multiline-input') - .count() - expect(initialCount).toBe(1) - - // Get subgraph node - const subgraphNode = await comfyPage.getNodeRefById('11') - - // Select and delete the subgraph node - await subgraphNode.click('title') - await comfyPage.page.keyboard.press('Delete') - await comfyPage.nextFrame() - - // Verify DOM elements are cleaned up - const finalCount = await comfyPage.page - .locator('.comfy-multiline-input') - .count() - expect(finalCount).toBe(0) - }) - - test('DOM elements are cleaned up when widget is disconnected from I/O', async ({ - comfyPage - }) => { - await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget') - - // Verify initial state - promoted widget exists - const textareaCount = await comfyPage.page - .locator('.comfy-multiline-input') - .count() - expect(textareaCount).toBe(1) - - // Get subgraph node - const subgraphNode = await comfyPage.getNodeRefById('11') - - // Navigate into subgraph with retry logic (DOM widget might interfere) - await navigateIntoSubgraphWithRetry(comfyPage, subgraphNode) - - // Count DOM widgets before removing the slot - const beforeRemovalCount = await comfyPage.page - .locator('.comfy-multiline-input') - .count() - - // Right-click on the "text" input slot (the one connected to the DOM widget) - await comfyPage.rightClickSubgraphInputSlot('text') - - // Click "Remove Slot" in the litegraph context menu - await comfyPage.clickLitegraphContextMenuItem('Remove Slot') - - await comfyPage.page.waitForTimeout(200) - - // Navigate back to parent - await comfyPage.page.keyboard.press('Escape') - await comfyPage.nextFrame() - - await comfyPage.page.waitForTimeout(200) - - // Verify the promoted widget is actually removed from the subgraph node - const widgetRemoved = await comfyPage.page.evaluate(() => { - const subgraphNode = window['app'].canvas.graph.getNodeById(11) - if (!subgraphNode) { - throw new Error('Subgraph node not found') - } - - // Check if the subgraph node still has any promoted widgets - const hasPromotedWidgets = - subgraphNode.widgets && subgraphNode.widgets.length > 0 - - // Also check the subgraph's inputs to see if the text input was actually removed - const hasTextInput = subgraphNode.subgraph?.inputs?.some( - (input) => input.name === 'text' - ) - - return { - nodeWidgetCount: subgraphNode.widgets?.length || 0, - hasTextInput: !!hasTextInput, - inputCount: subgraphNode.subgraph?.inputs?.length || 0 - } - }) - - // The subgraph node should no longer have any promoted widgets - expect(widgetRemoved.nodeWidgetCount).toBe(0) - - // The text input should be removed from the subgraph - expect(widgetRemoved.hasTextInput).toBe(false) - }) - - test('Multiple promoted widgets are handled correctly', async ({ - comfyPage - }) => { - await comfyPage.loadWorkflow('subgraph-with-multiple-promoted-widgets') - - // Count widgets in parent view - const parentCount = await comfyPage.page - .locator('.comfy-multiline-input') - .count() - expect(parentCount).toBeGreaterThan(1) // Should have multiple widgets - - // Get subgraph node - const subgraphNode = await comfyPage.getNodeRefById('11') - - // Navigate into subgraph - await navigateIntoSubgraph(comfyPage, subgraphNode) - - // Count should be the same in subgraph - const subgraphCount = await comfyPage.page - .locator('.comfy-multiline-input') - .count() - expect(subgraphCount).toBe(parentCount) - - // Navigate back - await comfyPage.page.keyboard.press('Escape') - await comfyPage.nextFrame() - - // Count should still be the same - const finalCount = await comfyPage.page - .locator('.comfy-multiline-input') - .count() - expect(finalCount).toBe(parentCount) - }) -}) diff --git a/browser_tests/tests/subgraph.spec.ts b/browser_tests/tests/subgraph.spec.ts new file mode 100644 index 000000000..cbd4e2033 --- /dev/null +++ b/browser_tests/tests/subgraph.spec.ts @@ -0,0 +1,463 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../fixtures/ComfyPage' + +// Constants +const RENAMED_INPUT_NAME = 'renamed_input' +const NEW_SUBGRAPH_TITLE = 'New Subgraph' +const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title' +const TEST_WIDGET_CONTENT = 'Test content that should persist' + +// Common selectors +const SELECTORS = { + breadcrumb: '.subgraph-breadcrumb', + promptDialog: '.graphdialog input', + nodeSearchContainer: '.node-search-container', + domWidget: '.comfy-multiline-input' +} as const + +test.describe('Subgraph Operations', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') + }) + + // Helper to get subgraph slot count + async function getSubgraphSlotCount( + comfyPage: typeof test.prototype.comfyPage, + type: 'inputs' | 'outputs' + ): Promise { + return await comfyPage.page.evaluate((slotType) => { + return window['app'].canvas.graph[slotType]?.length || 0 + }, type) + } + + // Helper to get current graph node count + async function getGraphNodeCount( + comfyPage: typeof test.prototype.comfyPage + ): Promise { + return await comfyPage.page.evaluate(() => { + return window['app'].canvas.graph.nodes?.length || 0 + }) + } + + // Helper to verify we're in a subgraph + async function isInSubgraph( + comfyPage: typeof test.prototype.comfyPage + ): Promise { + return await comfyPage.page.evaluate(() => { + const graph = window['app'].canvas.graph + return graph?.constructor?.name === 'Subgraph' + }) + } + + test.describe('I/O Slot Management', () => { + test('Can add input slots to subgraph', async ({ comfyPage }) => { + await comfyPage.loadWorkflow('basic-subgraph') + + const subgraphNode = await comfyPage.getNodeRefById('2') + await subgraphNode.navigateIntoSubgraph() + + const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs') + const vaeEncodeNode = await comfyPage.getNodeRefById('2') + + await comfyPage.connectFromSubgraphInput(vaeEncodeNode, 0) + await comfyPage.nextFrame() + + const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs') + expect(finalCount).toBe(initialCount + 1) + }) + + test('Can add output slots to subgraph', async ({ comfyPage }) => { + await comfyPage.loadWorkflow('basic-subgraph') + + const subgraphNode = await comfyPage.getNodeRefById('2') + await subgraphNode.navigateIntoSubgraph() + + const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs') + const vaeEncodeNode = await comfyPage.getNodeRefById('2') + + await comfyPage.connectToSubgraphOutput(vaeEncodeNode, 0) + await comfyPage.nextFrame() + + const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs') + expect(finalCount).toBe(initialCount + 1) + }) + + test('Can remove input slots from subgraph', async ({ comfyPage }) => { + await comfyPage.loadWorkflow('basic-subgraph') + + const subgraphNode = await comfyPage.getNodeRefById('2') + await subgraphNode.navigateIntoSubgraph() + + const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs') + expect(initialCount).toBeGreaterThan(0) + + await comfyPage.rightClickSubgraphInputSlot() + await comfyPage.clickLitegraphContextMenuItem('Remove Slot') + + // Force re-render + await comfyPage.canvas.click({ position: { x: 100, y: 100 } }) + await comfyPage.nextFrame() + + const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs') + expect(finalCount).toBe(initialCount - 1) + }) + + test('Can remove output slots from subgraph', async ({ comfyPage }) => { + await comfyPage.loadWorkflow('basic-subgraph') + + const subgraphNode = await comfyPage.getNodeRefById('2') + await subgraphNode.navigateIntoSubgraph() + + const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs') + expect(initialCount).toBeGreaterThan(0) + + await comfyPage.rightClickSubgraphOutputSlot() + await comfyPage.clickLitegraphContextMenuItem('Remove Slot') + + // Force re-render + await comfyPage.canvas.click({ position: { x: 100, y: 100 } }) + await comfyPage.nextFrame() + + const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs') + expect(finalCount).toBe(initialCount - 1) + }) + + test('Can rename I/O slots', async ({ comfyPage }) => { + await comfyPage.loadWorkflow('basic-subgraph') + + const subgraphNode = await comfyPage.getNodeRefById('2') + await subgraphNode.navigateIntoSubgraph() + + const initialInputLabel = await comfyPage.page.evaluate(() => { + const graph = window['app'].canvas.graph + return graph.inputs?.[0]?.label || null + }) + + await comfyPage.rightClickSubgraphInputSlot(initialInputLabel) + await comfyPage.clickLitegraphContextMenuItem('Rename Slot') + + await comfyPage.page.waitForSelector(SELECTORS.promptDialog, { + state: 'visible' + }) + await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME) + await comfyPage.page.keyboard.press('Enter') + + // Force re-render + await comfyPage.canvas.click({ position: { x: 100, y: 100 } }) + await comfyPage.nextFrame() + + const newInputName = await comfyPage.page.evaluate(() => { + const graph = window['app'].canvas.graph + return graph.inputs?.[0]?.label || null + }) + + expect(newInputName).toBe(RENAMED_INPUT_NAME) + expect(newInputName).not.toBe(initialInputLabel) + }) + }) + + test.describe('Subgraph Creation and Deletion', () => { + test('Can create subgraph from selected nodes', async ({ comfyPage }) => { + await comfyPage.loadWorkflow('default') + + const initialNodeCount = await getGraphNodeCount(comfyPage) + + await comfyPage.ctrlA() + await comfyPage.nextFrame() + + const node = await comfyPage.getNodeRefById('5') + await node.convertToSubgraph() + await comfyPage.nextFrame() + + const subgraphNodes = + await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE) + expect(subgraphNodes.length).toBe(1) + + const finalNodeCount = await getGraphNodeCount(comfyPage) + expect(finalNodeCount).toBe(1) + }) + + test('Can delete subgraph node', async ({ comfyPage }) => { + await comfyPage.loadWorkflow('basic-subgraph') + + const subgraphNode = await comfyPage.getNodeRefById('2') + expect(await subgraphNode.exists()).toBe(true) + + const initialNodeCount = await getGraphNodeCount(comfyPage) + + await subgraphNode.click('title') + await comfyPage.page.keyboard.press('Delete') + await comfyPage.nextFrame() + + const finalNodeCount = await getGraphNodeCount(comfyPage) + expect(finalNodeCount).toBe(initialNodeCount - 1) + + const deletedNode = await comfyPage.getNodeRefById('2') + expect(await deletedNode.exists()).toBe(false) + }) + }) + + test.describe('Operations Inside Subgraphs', () => { + test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => { + await comfyPage.loadWorkflow('basic-subgraph') + + const subgraphNode = await comfyPage.getNodeRefById('2') + await subgraphNode.navigateIntoSubgraph() + + const initialNodeCount = await getGraphNodeCount(comfyPage) + + const nodesInSubgraph = await comfyPage.page.evaluate(() => { + const nodes = window['app'].canvas.graph.nodes + return nodes?.[0]?.id || null + }) + + expect(nodesInSubgraph).not.toBeNull() + + const nodeToClone = await comfyPage.getNodeRefById( + String(nodesInSubgraph) + ) + await nodeToClone.click('title') + await comfyPage.nextFrame() + + await comfyPage.page.keyboard.press('Control+c') + await comfyPage.nextFrame() + + await comfyPage.page.keyboard.press('Control+v') + await comfyPage.nextFrame() + + const finalNodeCount = await getGraphNodeCount(comfyPage) + expect(finalNodeCount).toBe(initialNodeCount + 1) + }) + + test('Can undo and redo operations in subgraph', async ({ comfyPage }) => { + await comfyPage.loadWorkflow('basic-subgraph') + + const subgraphNode = await comfyPage.getNodeRefById('2') + await subgraphNode.navigateIntoSubgraph() + + // Add a node + await comfyPage.doubleClickCanvas() + await comfyPage.searchBox.fillAndSelectFirstNode('Note') + await comfyPage.nextFrame() + + // Get initial node count + const initialCount = await getGraphNodeCount(comfyPage) + + // Undo + await comfyPage.ctrlZ() + await comfyPage.nextFrame() + + const afterUndoCount = await getGraphNodeCount(comfyPage) + expect(afterUndoCount).toBe(initialCount - 1) + + // Redo + await comfyPage.ctrlY() + await comfyPage.nextFrame() + + const afterRedoCount = await getGraphNodeCount(comfyPage) + expect(afterRedoCount).toBe(initialCount) + }) + }) + + test.describe('Subgraph Navigation and UI', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') + }) + + test('Breadcrumb updates when subgraph node title is changed', async ({ + comfyPage + }) => { + await comfyPage.loadWorkflow('nested-subgraph') + await comfyPage.nextFrame() + + const subgraphNode = await comfyPage.getNodeRefById('10') + const nodePos = await subgraphNode.getPosition() + const nodeSize = await subgraphNode.getSize() + + // Navigate into subgraph + await subgraphNode.navigateIntoSubgraph() + + await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, { + state: 'visible', + timeout: 20000 + }) + + const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb) + const initialBreadcrumbText = await breadcrumb.textContent() + + // Go back and edit title + await comfyPage.page.keyboard.press('Escape') + await comfyPage.nextFrame() + + await comfyPage.canvas.dblclick({ + position: { + x: nodePos.x + nodeSize.width / 2, + y: nodePos.y - 10 + }, + delay: 5 + }) + + await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible() + + await comfyPage.page.keyboard.press('Control+a') + await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE) + await comfyPage.page.keyboard.press('Enter') + await comfyPage.nextFrame() + + // Navigate back into subgraph + await subgraphNode.navigateIntoSubgraph() + + await comfyPage.page.waitForSelector(SELECTORS.breadcrumb) + + const updatedBreadcrumbText = await breadcrumb.textContent() + expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE) + expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText) + }) + }) + + test.describe('DOM Widget Promotion', () => { + test('DOM widget visibility persists through subgraph navigation', async ({ + comfyPage + }) => { + await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget') + await comfyPage.nextFrame() + + // Verify promoted widget is visible in parent graph + const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget) + await expect(parentTextarea).toBeVisible() + await expect(parentTextarea).toHaveCount(1) + + const subgraphNode = await comfyPage.getNodeRefById('11') + expect(await subgraphNode.exists()).toBe(true) + + await subgraphNode.navigateIntoSubgraph() + + // Verify widget is visible in subgraph + const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget) + await expect(subgraphTextarea).toBeVisible() + await expect(subgraphTextarea).toHaveCount(1) + + // Navigate back + await comfyPage.page.keyboard.press('Escape') + await comfyPage.nextFrame() + + // Verify widget is still visible + const backToParentTextarea = comfyPage.page.locator(SELECTORS.domWidget) + await expect(backToParentTextarea).toBeVisible() + await expect(backToParentTextarea).toHaveCount(1) + }) + + test('DOM widget content is preserved through navigation', async ({ + comfyPage + }) => { + await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget') + + const textarea = comfyPage.page.locator(SELECTORS.domWidget) + await textarea.fill(TEST_WIDGET_CONTENT) + + const subgraphNode = await comfyPage.getNodeRefById('11') + await subgraphNode.navigateIntoSubgraph() + + const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget) + await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT) + + await comfyPage.page.keyboard.press('Escape') + await comfyPage.nextFrame() + + const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget) + await expect(parentTextarea).toHaveValue(TEST_WIDGET_CONTENT) + }) + + test('DOM elements are cleaned up when subgraph node is removed', async ({ + comfyPage + }) => { + await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget') + + const initialCount = await comfyPage.page + .locator(SELECTORS.domWidget) + .count() + expect(initialCount).toBe(1) + + const subgraphNode = await comfyPage.getNodeRefById('11') + + await subgraphNode.click('title') + await comfyPage.page.keyboard.press('Delete') + await comfyPage.nextFrame() + + const finalCount = await comfyPage.page + .locator(SELECTORS.domWidget) + .count() + expect(finalCount).toBe(0) + }) + + test('DOM elements are cleaned up when widget is disconnected from I/O', async ({ + comfyPage + }) => { + // Enable new menu for breadcrumb navigation + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') + + await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget') + + const textareaCount = await comfyPage.page + .locator(SELECTORS.domWidget) + .count() + expect(textareaCount).toBe(1) + + const subgraphNode = await comfyPage.getNodeRefById('11') + + // Navigate into subgraph (method now handles retries internally) + await subgraphNode.navigateIntoSubgraph() + + await comfyPage.rightClickSubgraphInputSlot('text') + await comfyPage.clickLitegraphContextMenuItem('Remove Slot') + await comfyPage.page.waitForTimeout(200) + + // Wait for breadcrumb to be visible + await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, { + state: 'visible', + timeout: 5000 + }) + + // Click breadcrumb to navigate back to parent graph + const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb).first() + await breadcrumb.click() + await comfyPage.nextFrame() + await comfyPage.page.waitForTimeout(300) + + // Check that the subgraph node has no widgets after removing the text slot + const widgetCount = await comfyPage.page.evaluate(() => { + return window['app'].canvas.graph.nodes[0].widgets?.length || 0 + }) + + expect(widgetCount).toBe(0) + }) + + test('Multiple promoted widgets are handled correctly', async ({ + comfyPage + }) => { + await comfyPage.loadWorkflow('subgraph-with-multiple-promoted-widgets') + + const parentCount = await comfyPage.page + .locator(SELECTORS.domWidget) + .count() + expect(parentCount).toBeGreaterThan(1) + + const subgraphNode = await comfyPage.getNodeRefById('11') + await subgraphNode.navigateIntoSubgraph() + + const subgraphCount = await comfyPage.page + .locator(SELECTORS.domWidget) + .count() + expect(subgraphCount).toBe(parentCount) + + await comfyPage.page.keyboard.press('Escape') + await comfyPage.nextFrame() + + const finalCount = await comfyPage.page + .locator(SELECTORS.domWidget) + .count() + expect(finalCount).toBe(parentCount) + }) + }) +}) diff --git a/browser_tests/tests/subgraphBreadcrumb.spec.ts b/browser_tests/tests/subgraphBreadcrumb.spec.ts deleted file mode 100644 index f28f7067f..000000000 --- a/browser_tests/tests/subgraphBreadcrumb.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { expect } from '@playwright/test' - -import { comfyPageFixture as test } from '../fixtures/ComfyPage' - -test.describe.skip('Subgraph Breadcrumb Title Sync', () => { - test.beforeEach(async ({ comfyPage }) => { - await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') - }) - - test('Breadcrumb updates when subgraph node title is changed', async ({ - comfyPage - }) => { - // Load a workflow with subgraphs - await comfyPage.loadWorkflow('nested-subgraph') - await comfyPage.nextFrame() - - // Get the subgraph node by ID (node 10 is the subgraph) - const subgraphNode = await comfyPage.getNodeRefById('10') - - // Get node position and double-click on it to enter the subgraph - const nodePos = await subgraphNode.getPosition() - const nodeSize = await subgraphNode.getSize() - await comfyPage.canvas.dblclick({ - position: { - x: nodePos.x + nodeSize.width / 2, - y: nodePos.y + nodeSize.height / 2 + 10 - } - }) - await comfyPage.nextFrame() - - // Wait for breadcrumb to appear - await comfyPage.page.waitForSelector('.subgraph-breadcrumb', { - state: 'visible', - timeout: 20000 - }) - - // Get initial breadcrumb text - const breadcrumb = comfyPage.page.locator('.subgraph-breadcrumb') - const initialBreadcrumbText = await breadcrumb.textContent() - - // Go back to main graph - await comfyPage.page.keyboard.press('Escape') - - // Double-click on the title area of the subgraph node to edit - await comfyPage.canvas.dblclick({ - position: { - x: nodePos.x + nodeSize.width / 2, - y: nodePos.y - 10 // Title area is above the node body - }, - delay: 5 - }) - - // Wait for title editor to appear - await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible() - - // Clear existing text and type new title - await comfyPage.page.keyboard.press('Control+a') - const newTitle = 'Updated Subgraph Title' - await comfyPage.page.keyboard.type(newTitle) - await comfyPage.page.keyboard.press('Enter') - - // Wait a frame for the update to complete - await comfyPage.nextFrame() - - // Enter the subgraph again - await comfyPage.canvas.dblclick({ - position: { - x: nodePos.x + nodeSize.width / 2, - y: nodePos.y + nodeSize.height / 2 - }, - delay: 5 - }) - - // Wait for breadcrumb - await comfyPage.page.waitForSelector('.subgraph-breadcrumb') - - // Check that breadcrumb now shows the new title - const updatedBreadcrumbText = await breadcrumb.textContent() - expect(updatedBreadcrumbText).toContain(newTitle) - expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText) - }) -}) diff --git a/tests-ui/tests/domWidgetStore.test.ts b/tests-ui/tests/domWidgetStore.test.ts new file mode 100644 index 000000000..21a83f7c6 --- /dev/null +++ b/tests-ui/tests/domWidgetStore.test.ts @@ -0,0 +1,151 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useDomWidgetStore } from '@/stores/domWidgetStore' + +// Mock DOM widget for testing +const createMockDOMWidget = (id: string) => { + const element = document.createElement('input') + return { + id, + element, + node: { + id: 'node-1', + title: 'Test Node', + pos: [0, 0], + size: [200, 100] + } as any, + name: 'test_widget', + type: 'text', + value: 'test', + options: {}, + y: 0, + margin: 10, + isVisible: () => true, + containerNode: undefined as any + } +} + +describe('domWidgetStore', () => { + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + store = useDomWidgetStore() + }) + + describe('widget registration', () => { + it('should register a widget with default state', () => { + const widget = createMockDOMWidget('widget-1') + + store.registerWidget(widget) + + expect(store.widgetStates.has('widget-1')).toBe(true) + const state = store.widgetStates.get('widget-1') + expect(state).toBeDefined() + expect(state!.widget).toBe(widget) + expect(state!.visible).toBe(true) + expect(state!.active).toBe(true) + expect(state!.readonly).toBe(false) + expect(state!.zIndex).toBe(0) + expect(state!.pos).toEqual([0, 0]) + expect(state!.size).toEqual([0, 0]) + }) + + it('should not register the same widget twice', () => { + const widget = createMockDOMWidget('widget-1') + + store.registerWidget(widget) + store.registerWidget(widget) + + // Should still only have one entry + const states = Array.from(store.widgetStates.values()) + expect(states.length).toBe(1) + }) + }) + + describe('widget unregistration', () => { + it('should unregister a widget by id', () => { + const widget = createMockDOMWidget('widget-1') + + store.registerWidget(widget) + expect(store.widgetStates.has('widget-1')).toBe(true) + + store.unregisterWidget('widget-1') + expect(store.widgetStates.has('widget-1')).toBe(false) + }) + + it('should handle unregistering non-existent widget gracefully', () => { + // Should not throw + expect(() => { + store.unregisterWidget('non-existent') + }).not.toThrow() + }) + }) + + describe('widget state management', () => { + it('should activate a widget', () => { + const widget = createMockDOMWidget('widget-1') + store.registerWidget(widget) + + // Set to inactive first + const state = store.widgetStates.get('widget-1')! + state.active = false + + store.activateWidget('widget-1') + expect(state.active).toBe(true) + }) + + it('should deactivate a widget', () => { + const widget = createMockDOMWidget('widget-1') + store.registerWidget(widget) + + store.deactivateWidget('widget-1') + const state = store.widgetStates.get('widget-1') + expect(state!.active).toBe(false) + }) + + it('should handle activating non-existent widget gracefully', () => { + expect(() => { + store.activateWidget('non-existent') + }).not.toThrow() + }) + }) + + describe('computed states', () => { + it('should separate active and inactive widget states', () => { + const widget1 = createMockDOMWidget('widget-1') + const widget2 = createMockDOMWidget('widget-2') + + store.registerWidget(widget1) + store.registerWidget(widget2) + + // Deactivate widget2 + store.deactivateWidget('widget-2') + + expect(store.activeWidgetStates.length).toBe(1) + expect(store.activeWidgetStates[0].widget.id).toBe('widget-1') + + expect(store.inactiveWidgetStates.length).toBe(1) + expect(store.inactiveWidgetStates[0].widget.id).toBe('widget-2') + }) + }) + + describe('clear functionality', () => { + it('should clear all widget states', () => { + const widget1 = createMockDOMWidget('widget-1') + const widget2 = createMockDOMWidget('widget-2') + + store.registerWidget(widget1) + store.registerWidget(widget2) + + expect(store.widgetStates.size).toBe(2) + + store.clear() + + expect(store.widgetStates.size).toBe(0) + expect(store.activeWidgetStates.length).toBe(0) + expect(store.inactiveWidgetStates.length).toBe(0) + }) + }) +})