Merge branch 'feat/331-collections-pr2' into feat/331-collections-pr3
This commit is contained in:
@@ -27,6 +27,15 @@ concurrency:
|
||||
# coverage shards 7m34s/6m04s/5m22s (tests 6m38s/4m53s/4m04s), merge 54s, firefox 7m57s/5m21s (tests
|
||||
# 7m08s/4m34s), webkit 8m16s/5m03s (tests 7m26s/3m58s); a cache miss adds up to 5m to npm ci, which
|
||||
# the 20-minute limits absorb.
|
||||
# Hangs (#425): a job killed at its limit keeps no log and skips its upload steps, so each browser
|
||||
# test step has its own timeout-minutes (18) below the job's 20. At the step limit, SIGTERM makes
|
||||
# tools/vendor/running-tests-reporter.mjs print the tests still running and hand over to Playwright's
|
||||
# interrupt, which writes the blob report; the always() uploads then run.
|
||||
# Measured on main (runs 40725289094, 40719375442, 40700439399): setup before the test step <= 52s,
|
||||
# uploads <= 35s; test steps up to 14m42s (webkit shard 1/2) and 13m05s for coverage (shard 1/3:
|
||||
# 12m53s, node units and the viewer suite before 11.8m of workflows), so coverage is the tight one
|
||||
# in its 18 minutes. This holds only when the npm cache hits: a cold cache adds up to 5m to npm ci,
|
||||
# and then the job limit can still end the job before the step limit does.
|
||||
jobs:
|
||||
frontend-coverage:
|
||||
name: frontend coverage (shard ${{ matrix.shard }}/3)
|
||||
@@ -79,6 +88,7 @@ jobs:
|
||||
- if: matrix.shard == 3
|
||||
run: npm run --prefix tools/vendor test
|
||||
- name: Page workflows with coverage, shard ${{ matrix.shard }}/3
|
||||
timeout-minutes: 18
|
||||
run: npm run --prefix tools/vendor test:coverage:shard -- ${{ matrix.shard }}/3
|
||||
- name: Upload raw coverage
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -87,6 +97,7 @@ jobs:
|
||||
path: coverage/js/raw/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
# Traces and the blob report (test-results/blob/) of a failed, timed-out or cancelled step.
|
||||
- name: Upload Chromium and touch traces
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -211,7 +222,9 @@ jobs:
|
||||
key: npm-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('tools/vendor/package-lock.json') }}
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- name: Page workflows in ${{ matrix.browser }}, shard ${{ matrix.shard }}/2
|
||||
timeout-minutes: 18
|
||||
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }} --shard ${{ matrix.shard }}/2
|
||||
# Traces and the blob report (test-results/blob/) of a failed, timed-out or cancelled step.
|
||||
- name: Upload browser traces
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -312,6 +312,12 @@ Both nodes support the same metadata fields:
|
||||
|
||||
Both nodes will automatically save prompts to the database and link any generated images to the prompt.
|
||||
|
||||
- **Save to library** (`save_to_library`, on by default; "Save to library" in the Focused, Split and Pair editors):
|
||||
turn it off while iterating and PromptManager ignores that node's runs. The prompt is not saved, the run is not
|
||||
counted and its images are not linked (a scan skips them too), while the node's output is unchanged. Workflows
|
||||
saved before this switch existed load with it on. If any PromptManager node feeding a sampler's positive input
|
||||
has it off, none of that run's images are linked, because the image cannot show which sampler produced it.
|
||||
|
||||
### 🖼️ Using the Output-Wide Gallery
|
||||
|
||||
The comprehensive gallery system provides complete access to all your ComfyUI output:
|
||||
|
||||
+25
@@ -262,6 +262,31 @@ watchdog dump, is a failure like any other: read the dump, find the blocking
|
||||
call, fix it. Never raise a limit or rerun to get green; a limit changes only
|
||||
with new measurements written next to it.
|
||||
|
||||
Browser jobs (#425): GitHub keeps no log of a job killed at its limit and skips
|
||||
its upload steps, so the test step in each browser job in `viewerjs-check.yml`
|
||||
has its own `timeout-minutes` (18, below the job's 20). At the step limit
|
||||
GitHub sends SIGTERM, which would end Playwright without any report; under `CI`
|
||||
`tools/vendor/running-tests-reporter.mjs` catches the first one, prints
|
||||
`Still running when the run ended (SIGTERM): N` and one line per unfinished
|
||||
test (path relative to the suite's `rootDir`), then sends Playwright its own
|
||||
SIGINT, so the workers stop, the run ends interrupted and the blob report is
|
||||
written to `tools/vendor/test-results/blob/`. In the coverage job npm signals
|
||||
only `frontend-coverage.mjs`, which forwards SIGTERM and SIGINT to the child it
|
||||
is running (started in its own process group, so a group-wide signal still
|
||||
arrives once). Delivery is proven locally in the pinned image for a SIGTERM to
|
||||
the whole process group (`timeout -s TERM`). A SIGTERM to npm alone
|
||||
(`timeout --foreground`) reaches nothing: npm forwards it to the `sh -c` that
|
||||
runs the script, and that shell exits without passing it on. Delivery in CI,
|
||||
where the step runs through `docker exec`, is not yet observed; the next real
|
||||
step timeout will show it. That directory and the traces
|
||||
upload with `if: always()`; `npx playwright merge-reports` opens the blob.
|
||||
Each test keeps the 30 s default and there is no `globalTimeout`. Two limits
|
||||
of this: it assumes the npm cache hits (a cold cache adds up to 5 minutes to
|
||||
`npm ci`, and the job limit can then still win), and coverage shard 1 (12m53s
|
||||
measured) is the tight one in its 18-minute step. Local runs keep the `list`
|
||||
reporter only; reproduce with `CI=1 timeout -s TERM 40 npm run --prefix
|
||||
tools/vendor test:workflows -- --project=firefox`.
|
||||
|
||||
### After a push
|
||||
|
||||
Follow the run for the commit you pushed, never an older one:
|
||||
|
||||
+13
-6
@@ -26,13 +26,13 @@ except ImportError:
|
||||
InputTypeDict = dict
|
||||
|
||||
try:
|
||||
from .prompt_manager_base import PromptManagerBase
|
||||
from .prompt_manager_base import SAVE_TO_LIBRARY_INPUT, PromptManagerBase
|
||||
except ImportError:
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from prompt_manager_base import PromptManagerBase
|
||||
from prompt_manager_base import SAVE_TO_LIBRARY_INPUT, PromptManagerBase
|
||||
|
||||
|
||||
class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
@@ -115,6 +115,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
)
|
||||
},
|
||||
),
|
||||
"save_to_library": SAVE_TO_LIBRARY_INPUT,
|
||||
},
|
||||
"hidden": {
|
||||
# The queued graph and this node's id: used to tell the positive
|
||||
@@ -152,6 +153,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
append_text: str = "",
|
||||
prompt: Optional[Dict[str, Any]] = None,
|
||||
unique_id: Optional[str] = None,
|
||||
save_to_library: bool = True,
|
||||
) -> Tuple[Any]:
|
||||
"""
|
||||
Encode the text prompt and save it to the database.
|
||||
@@ -164,6 +166,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
search_text: Text to search for in past prompts
|
||||
prepend_text: Text to prepend to the main prompt
|
||||
append_text: Text to append to the main prompt
|
||||
save_to_library: Off: nothing is saved, counted or tracked (#90)
|
||||
|
||||
Returns:
|
||||
Tuple containing the conditioning for the diffusion model and the final
|
||||
@@ -205,7 +208,9 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
# Save prompt to database and set execution context for gallery tracking
|
||||
prompt_id = None
|
||||
extended_tags = []
|
||||
if storage_text and storage_text.strip():
|
||||
if not save_to_library:
|
||||
self._skip_not_saved(prompt, unique_id)
|
||||
elif storage_text and storage_text.strip():
|
||||
self.logger.debug(f"Processing prompt text: {storage_text[:100]}...")
|
||||
|
||||
# Add prepend/append info to tags if they exist
|
||||
@@ -249,6 +254,11 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
tokens = clip.tokenize(encoding_text)
|
||||
conditioning = clip.encode_from_tokens_scheduled(tokens)
|
||||
|
||||
self.logger.info(f"CLIP encoding completed, text: {repr(encoding_text)[:80]}")
|
||||
result = (conditioning, encoding_text)
|
||||
if not save_to_library:
|
||||
return result # #90: no metadata registration, no history text
|
||||
|
||||
# Register with ComfyUI integration for standard metadata compatibility
|
||||
node_id = f"promptmanager_{int(time.time() * 1000)}"
|
||||
self.comfyui_integration.register_prompt(
|
||||
@@ -262,9 +272,6 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
},
|
||||
)
|
||||
|
||||
self.logger.info(f"CLIP encoding completed, text: {repr(encoding_text)[:80]}")
|
||||
result = (conditioning, encoding_text)
|
||||
record_runtime_context(
|
||||
type(self).__name__, text, prepend_text, append_text, encoding_text
|
||||
)
|
||||
|
||||
+38
-2
@@ -23,7 +23,7 @@ try:
|
||||
from .utils.image_monitor import get_image_monitor
|
||||
from .utils.prompt_tracker import get_prompt_tracker
|
||||
from .utils.hashing import generate_prompt_hash
|
||||
from .utils.prompt_graph import resolve_text, run_prompt_nodes
|
||||
from .utils.prompt_graph import resolve_text, run_prompt_nodes, save_setting
|
||||
from .utils.run_keys import executing_run_id, prompt_node_key
|
||||
from .utils.usage_tracking import PENDING_FIRST_USE
|
||||
from .utils.cached_runs import (
|
||||
@@ -42,7 +42,7 @@ except ImportError:
|
||||
from utils.image_monitor import get_image_monitor
|
||||
from utils.prompt_tracker import get_prompt_tracker
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.prompt_graph import resolve_text, run_prompt_nodes
|
||||
from utils.prompt_graph import resolve_text, run_prompt_nodes, save_setting
|
||||
from utils.run_keys import executing_run_id, prompt_node_key
|
||||
from utils.usage_tracking import PENDING_FIRST_USE
|
||||
from utils.cached_runs import (
|
||||
@@ -54,6 +54,22 @@ except ImportError:
|
||||
)
|
||||
|
||||
|
||||
# The optional input every PromptManager node declares last (#90): declared
|
||||
# last so no widget value of a workflow saved before it shifts.
|
||||
SAVE_TO_LIBRARY_INPUT = (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"label_on": "save",
|
||||
"label_off": "skip",
|
||||
"tooltip": (
|
||||
"Save this node's prompt to the library, count its runs and link its "
|
||||
"images. Off: PromptManager ignores the run; the output is unchanged."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PromptManagerBase:
|
||||
"""Mixin providing shared prompt management logic for ComfyUI nodes.
|
||||
|
||||
@@ -156,7 +172,9 @@ class PromptManagerBase:
|
||||
resolved = resolve_text(prompt_graph, node_id)
|
||||
return {
|
||||
"is_run_prompt": node_id in run_prompt_nodes(prompt_graph),
|
||||
# The hook skips a switch it cannot read (#90): the node counts
|
||||
"seen_at_queue": resolved is not None
|
||||
and save_setting(prompt_graph, node_id) is True
|
||||
and generate_prompt_hash(resolved) == generate_prompt_hash(text),
|
||||
"known": True,
|
||||
}
|
||||
@@ -245,6 +263,24 @@ class PromptManagerBase:
|
||||
"run_id": executing_run_id(),
|
||||
}
|
||||
|
||||
def _skip_not_saved(self, prompt_graph: Any = None, unique_id: Any = None) -> None:
|
||||
"""``save_to_library`` is off: nothing is saved, counted or tracked (#90).
|
||||
|
||||
A literal off makes the node no run prompt (``utils.prompt_graph``), so
|
||||
no image looks up its run key. A switch wired from a node the graph
|
||||
cannot read keeps the node a run prompt: its run key is marked like an
|
||||
empty editor's, so an earlier run's entry of that key never links this
|
||||
run's images.
|
||||
"""
|
||||
message = (
|
||||
"Save to library is off for this node: its prompt is not saved, the "
|
||||
"run is not counted and its images are not linked"
|
||||
)
|
||||
if isinstance(prompt_graph, dict) and unique_id is not None:
|
||||
self._mark_untracked(prompt_graph, unique_id, message)
|
||||
else:
|
||||
self.logger.info(message)
|
||||
|
||||
def _skip_empty_prompt(self, prompt_graph: Any, unique_id: Any) -> None:
|
||||
"""The ``text`` editor is empty: save and track nothing (#342).
|
||||
|
||||
|
||||
+23
-4
@@ -22,7 +22,11 @@ try:
|
||||
)
|
||||
from .utils.hashing import generate_prompt_hash
|
||||
from .utils.pair_execution import record_pair_execution
|
||||
from .utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components
|
||||
from .utils.prompt_graph import (
|
||||
intrinsic_pair_nodes,
|
||||
run_prompt_components,
|
||||
save_setting,
|
||||
)
|
||||
from .utils.prompt_pair_resolver import resolve_text_input
|
||||
from .utils.usage_tracking import (
|
||||
PENDING_FIRST_USE,
|
||||
@@ -41,7 +45,11 @@ except ImportError:
|
||||
)
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.pair_execution import record_pair_execution
|
||||
from utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components
|
||||
from utils.prompt_graph import (
|
||||
intrinsic_pair_nodes,
|
||||
run_prompt_components,
|
||||
save_setting,
|
||||
)
|
||||
from utils.prompt_pair_resolver import resolve_text_input
|
||||
from utils.usage_tracking import (
|
||||
PENDING_FIRST_USE,
|
||||
@@ -90,8 +98,13 @@ class PromptManagerPair(PromptManagerFocused):
|
||||
prompt: Optional[Dict[str, Any]] = None,
|
||||
unique_id: Optional[str] = None,
|
||||
extra_pnginfo: Optional[Dict[str, Any]] = None,
|
||||
save_to_library: bool = True,
|
||||
) -> Tuple[Any, Any, str, str]:
|
||||
"""Encode both sides once; database and tracking failures never block CLIP."""
|
||||
"""Encode both sides once; database and tracking failures never block CLIP.
|
||||
|
||||
With ``save_to_library`` off nothing is saved, counted, tracked or
|
||||
recorded (#90); the four outputs are the same.
|
||||
"""
|
||||
if clip is None:
|
||||
raise RuntimeError("ERROR: clip input is invalid: None")
|
||||
if not isinstance(text, str) or not isinstance(negative_text, str):
|
||||
@@ -109,6 +122,10 @@ class PromptManagerPair(PromptManagerFocused):
|
||||
negative_conditioning = clip.encode_from_tokens_scheduled(
|
||||
clip.tokenize(negative_text)
|
||||
)
|
||||
result = positive_conditioning, negative_conditioning, positive, negative_text
|
||||
if not save_to_library:
|
||||
self._skip_not_saved(prompt, unique_id)
|
||||
return result
|
||||
record_pair_execution(
|
||||
extra_pnginfo,
|
||||
unique_id,
|
||||
@@ -169,7 +186,6 @@ class PromptManagerPair(PromptManagerFocused):
|
||||
)
|
||||
except Exception as error:
|
||||
self.logger.warning(f"Could not register paired metadata: {error}")
|
||||
result = positive_conditioning, negative_conditioning, positive, negative_text
|
||||
record_runtime_context(
|
||||
type(self).__name__,
|
||||
text,
|
||||
@@ -219,6 +235,9 @@ class PromptManagerPair(PromptManagerFocused):
|
||||
resolved_negative = (
|
||||
resolve_text_input(prompt, node_id, "negative_text") if known else None
|
||||
)
|
||||
if known and save_setting(prompt, node_id) is not True:
|
||||
# The hook skips a switch it cannot read (#90): the node counts
|
||||
resolved_positive = resolved_negative = None
|
||||
positive_seen = resolved_positive is not None and generate_prompt_hash(
|
||||
resolved_positive
|
||||
) == generate_prompt_hash(text)
|
||||
|
||||
+13
-6
@@ -26,13 +26,13 @@ except ImportError:
|
||||
InputTypeDict = dict
|
||||
|
||||
try:
|
||||
from .prompt_manager_base import PromptManagerBase
|
||||
from .prompt_manager_base import SAVE_TO_LIBRARY_INPUT, PromptManagerBase
|
||||
except ImportError:
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from prompt_manager_base import PromptManagerBase
|
||||
from prompt_manager_base import SAVE_TO_LIBRARY_INPUT, PromptManagerBase
|
||||
|
||||
|
||||
class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
@@ -115,6 +115,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
),
|
||||
},
|
||||
),
|
||||
"save_to_library": SAVE_TO_LIBRARY_INPUT,
|
||||
},
|
||||
"hidden": {
|
||||
# The queued graph and this node's id: used to tell the positive
|
||||
@@ -149,6 +150,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
append_text: str = "",
|
||||
prompt: Optional[Dict[str, Any]] = None,
|
||||
unique_id: Optional[str] = None,
|
||||
save_to_library: bool = True,
|
||||
) -> Tuple[str]:
|
||||
"""
|
||||
Process the text prompt and save it to the database.
|
||||
@@ -160,6 +162,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
search_text: Text to search for in past prompts
|
||||
prepend_text: Text to prepend to the main prompt
|
||||
append_text: Text to append to the main prompt
|
||||
save_to_library: Off: nothing is saved, counted or tracked (#90)
|
||||
|
||||
Returns:
|
||||
Tuple containing the final processed text string
|
||||
@@ -184,7 +187,9 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
# Save prompt to database and set execution context for gallery tracking
|
||||
prompt_id = None
|
||||
extended_tags = []
|
||||
if storage_text and storage_text.strip():
|
||||
if not save_to_library:
|
||||
self._skip_not_saved(prompt, unique_id)
|
||||
elif storage_text and storage_text.strip():
|
||||
self.logger.debug(f"Processing prompt text: {storage_text[:100]}...")
|
||||
|
||||
# Add prepend/append info to tags if they exist
|
||||
@@ -220,6 +225,11 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
else:
|
||||
self._skip_empty_prompt(prompt, unique_id)
|
||||
|
||||
self.logger.debug(f"Text processing completed: {final_text[:100]}...")
|
||||
result = (final_text,)
|
||||
if not save_to_library:
|
||||
return result # #90: no metadata registration, no history text
|
||||
|
||||
# Register with ComfyUI integration for standard metadata compatibility
|
||||
node_id = f"promptmanagertext_{int(time.time() * 1000)}"
|
||||
self.comfyui_integration.register_prompt(
|
||||
@@ -233,9 +243,6 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
},
|
||||
)
|
||||
|
||||
self.logger.debug(f"Text processing completed: {final_text[:100]}...")
|
||||
result = (final_text,)
|
||||
record_runtime_context(
|
||||
type(self).__name__, text, prepend_text, append_text, final_text
|
||||
)
|
||||
|
||||
@@ -52,6 +52,7 @@ try:
|
||||
extract_generation_params,
|
||||
parse_page_json,
|
||||
)
|
||||
from ...utils.prompt_graph import metadata_opted_out, side_opted_out
|
||||
from ...utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
|
||||
from ...utils.review_capture import reviewable
|
||||
except ImportError:
|
||||
@@ -67,6 +68,7 @@ except ImportError:
|
||||
extract_generation_params,
|
||||
parse_page_json,
|
||||
)
|
||||
from utils.prompt_graph import metadata_opted_out, side_opted_out
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
|
||||
from utils.review_capture import reviewable
|
||||
|
||||
@@ -260,6 +262,11 @@ SCAN_DETAILS = {
|
||||
"failed": "Error: the prompt could not be stored",
|
||||
"error": "Error: the file could not be processed (see the server log)",
|
||||
"found": "Metadata found, but no prompt text to store",
|
||||
"not_saved": "Skipped · its PromptManager node does not save to the library",
|
||||
"not_saved_wired": (
|
||||
"Skipped · its Save to library switch is wired from another node, so only "
|
||||
"the run itself can link it"
|
||||
),
|
||||
None: "No prompt metadata in the file",
|
||||
}
|
||||
|
||||
@@ -1720,10 +1727,34 @@ class AdminRoutesMixin:
|
||||
except (ValueError, TypeError):
|
||||
# Preserve malformed metadata for conservative resolver rejection.
|
||||
pass
|
||||
if metadata_opted_out(canonical):
|
||||
# Save to library was off for the run's prompt: no prompt, link,
|
||||
# recorded-run fallback or Needs review item (#90)
|
||||
return "not_saved"
|
||||
if metadata_opted_out(canonical, unreadable=True):
|
||||
# The switch is wired from a node the file cannot show: only the
|
||||
# live run (the node saw the value) can link it, never a scan
|
||||
return "not_saved_wired"
|
||||
if not canonical.get("prompt") and not canonical.get("parameters"):
|
||||
return self._link_recorded_run(media_file, canonical, detailed, history)
|
||||
_with_file_size(canonical, media_file)
|
||||
resolution = resolve_prompt_pair(canonical)
|
||||
if resolution["state"] == "resolved" and side_opted_out(
|
||||
canonical.get("prompt"), resolution["negative"]
|
||||
):
|
||||
# Only the negative's node is off: store the positive alone (#90)
|
||||
prompt_text = (
|
||||
resolution["positive"]
|
||||
.get("storage_text", resolution["positive"]["text"])
|
||||
.strip()
|
||||
)
|
||||
status = self._store_scanned_prompt(media_file, canonical, prompt_text)
|
||||
if not detailed or status not in SCAN_TITLED_DETAILS:
|
||||
return status
|
||||
return {
|
||||
"status": status,
|
||||
"title": self._scan_title(media_file, prompt_text),
|
||||
}
|
||||
if blank_positive(resolution):
|
||||
# Only prepend/append text: no stored prompt can own it (#342)
|
||||
resolution = {**resolution, "state": "unresolved"}
|
||||
|
||||
@@ -19,7 +19,7 @@ test.beforeEach(async ({page, browserName}) => {
|
||||
await import('/extensions/promptmanager/prompt_manager.js');
|
||||
class Node {
|
||||
constructor(){
|
||||
this.widgets=['text','category','tags','search_text'].map(name=>({name,value:'',inputEl:document.createElement('textarea')}));
|
||||
this.widgets=[...['text','category','tags','search_text'].map(name=>({name,value:'',inputEl:document.createElement('textarea')})),{name:'save_to_library',type:'toggle',value:true}];
|
||||
this.widgets_values=[]; this.inputs=[];
|
||||
}
|
||||
addDOMWidget(name,type,element){document.body.append(element);return {element};}
|
||||
@@ -36,6 +36,14 @@ test.afterEach(async ({page,browserName}) => {
|
||||
expect(page.classicErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('Classic loads an older workflow with Save to library on and keeps a saved off (#90)',async({page})=>{
|
||||
// A workflow saved before the switch held the search UI's slot ('') at the switch's index
|
||||
const loaded=await page.evaluate(()=>{const widget=node.widgets.find(w=>w.name==='save_to_library');widget.value='';node.configure({});return widget.value;});
|
||||
expect(loaded).toBe(true);
|
||||
const off=await page.evaluate(()=>{const widget=node.widgets.find(w=>w.name==='save_to_library');widget.value=false;node.configure({});return [widget.value,node.serialize().widgets_values[4]];});
|
||||
expect(off).toEqual([false,false]);
|
||||
});
|
||||
|
||||
test('Classic keeps immediate selection, search query and metadata serialization',async({page})=>{
|
||||
await page.route('**/prompt_manager/search?**', route=>route.fulfill({json:{success:true,results:[{text:'A new portrait',category:'Portrait',tags:['film grain'],rating:5}]}}));
|
||||
await page.evaluate(()=>{node.widgets.find(w=>w.name==='search_text').value='tag:"film grain" portrait';});
|
||||
|
||||
@@ -26,7 +26,7 @@ test.beforeEach(async({page,browserName})=>{
|
||||
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings:new EventTarget()},graph:{setDirtyCanvas(){}},canvas:{}};
|
||||
await import('/extensions/promptmanager/prompt_manager.js');
|
||||
class Node {
|
||||
constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=[...['clip','prepend_text','append_text'].map(name=>({name,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}}))];this.properties={};this.size=[550,700];}
|
||||
constructor(){this.widgets=[...['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')})),{name:'save_to_library',type:'toggle',value:true,options:{}}];this.inputs=[...['clip','prepend_text','append_text'].map(name=>({name,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}})),{name:'save_to_library',type:'BOOLEAN',link:null,widget:{name:'save_to_library'}}];this.properties={};this.size=[550,700];}
|
||||
findInputByType(type){const index=this.inputs.findIndex(input=>input.type===type&&input.link==null);return index<0?undefined:{index,slot:this.inputs[index]};}
|
||||
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
|
||||
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.height='650px';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
|
||||
@@ -41,6 +41,32 @@ test.beforeEach(async({page,browserName})=>{
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
|
||||
|
||||
test(`${variant} Save to library switch saves its value and loads older workflows on (#90)`, async ({page}) => {
|
||||
const toggle=page.getByRole('checkbox',{name:'Save to library',exact:true});
|
||||
await expect(toggle).toBeChecked();
|
||||
expect(await page.evaluate(()=>node.widgets.map(widget=>widget.name))).toEqual(['text','category','tags','search_text','prepend_text','append_text','save_to_library']);
|
||||
// The native switch is drawn in the toolbar only: the host keeps it hidden and zero-height, like the text fallbacks
|
||||
expect(await page.evaluate(()=>{const widget=node.widgets.find(item=>item.name==='save_to_library');return {type:widget.type,hidden:widget.hidden,size:widget.computeSize()};})).toEqual({type:'hidden',hidden:true,size:[0,-4]});
|
||||
expect(await page.evaluate(()=>node.widgets.find(item=>item.name==='text').computeSize())).toEqual([0,-4]);
|
||||
await toggle.uncheck();
|
||||
await expect(page.locator('.pm-focused > .pm-editor-status[role=status]')).toHaveText('Save to library is off: runs of this node are not added to the library.');
|
||||
const saved=await page.evaluate(()=>node.serialize());
|
||||
expect(saved.widgets_values[6]).toBe(false);
|
||||
await page.evaluate(saved=>node.configure(saved),saved);
|
||||
await expect(toggle).not.toBeChecked();
|
||||
await page.getByLabel('Main prompt',{exact:true}).fill('Typed while off');
|
||||
await expect(page.locator('.pm-focused > .pm-editor-status[role=status]')).toHaveText('Save to library is off: runs of this node are not added to the library.');
|
||||
// A workflow saved before the switch held the editor's own slot ('') at its index
|
||||
await page.evaluate(()=>node.configure({widgets_values:['Old text','','','','','',''],properties:{}}));
|
||||
await expect(toggle).toBeChecked();
|
||||
expect(await page.evaluate(()=>node.widgets.find(widget=>widget.name==='save_to_library').value)).toBe(true);
|
||||
await toggle.check();
|
||||
await page.getByLabel('Main prompt',{exact:true}).fill('Typed while on');
|
||||
await expect(page.locator('.pm-focused > .pm-editor-status[role=status]')).toHaveText('Main prompt is saved when this node runs.');
|
||||
await page.evaluate(()=>{node.inputs.find(input=>input.name==='save_to_library').link=9;node.onConnectionsChange?.();});
|
||||
await expect(toggle).toBeDisabled();
|
||||
});
|
||||
|
||||
test(`${variant} library autocompletes filters and searches without changing editor metadata`, async ({page}) => {
|
||||
const requests=[];
|
||||
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait']}}));
|
||||
@@ -257,7 +283,7 @@ test(`${variant} restores workflow values and disclosures and protects connected
|
||||
test(`${variant} offers text as a link socket, keeps typed text while linked and through a reload (#343)`,async({page})=>{
|
||||
const sockets=()=>page.evaluate(()=>Object.fromEntries(node.inputs.map(input=>[input.name,input.widget?.name??null])));
|
||||
// The hidden text widget no longer owns the input, so the host draws it as a socket; the other widget inputs are unchanged.
|
||||
expect(await sockets()).toEqual({clip:null,prepend_text:null,append_text:null,text:null,category:'category',tags:'tags',search_text:'search_text'});
|
||||
expect(await sockets()).toEqual({clip:null,prepend_text:null,append_text:null,text:null,category:'category',tags:'tags',search_text:'search_text',save_to_library:'save_to_library'});
|
||||
expect(await page.evaluate(()=>node.findInputByType('STRING').slot.name)).toBe('text');
|
||||
expect(await page.evaluate(()=>node.findInputByType('CLIP').slot.name)).toBe('clip');
|
||||
const main=page.getByRole('textbox',{name:'Main prompt',exact:true});
|
||||
|
||||
@@ -17,7 +17,7 @@ test.beforeEach(async({page,browserName})=>{
|
||||
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings},graph:{_nodes:[],setDirtyCanvas(){}},canvas:{},graphToPrompt:async()=>({})};
|
||||
await import('/extensions/promptmanager/prompt_manager.js');
|
||||
class Node {
|
||||
constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=['text','negative_text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=[...['clip','prepend_text','append_text'].map(name=>({name,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','negative_text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}}))];this.properties={};this.size=[1100,700];}
|
||||
constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=[...['text','negative_text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')})),{name:'save_to_library',type:'toggle',value:true,options:{}}];this.inputs=[...['clip','prepend_text','append_text'].map(name=>({name,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','negative_text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}})),{name:'save_to_library',type:'BOOLEAN',link:null,widget:{name:'save_to_library'}}];this.properties={};this.size=[1100,700];}
|
||||
findInputByType(type){const index=this.inputs.findIndex(input=>input.type===type&&input.link==null);return index<0?undefined:{index,slot:this.inputs[index]};}
|
||||
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
|
||||
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.cssText='height:700px;width:1100px;max-width:100%';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
|
||||
@@ -34,6 +34,18 @@ test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);
|
||||
async function selectPair(page){await page.getByRole('button',{name:'Recent',exact:true}).click();await page.locator('.pm-editor-result').click();}
|
||||
async function draft(page,positive='Draft positive',negative='Draft negative'){await page.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(positive);await page.getByRole('textbox',{name:'Negative prompt',exact:true}).fill(negative);}
|
||||
|
||||
test('paired editor Save to library switch keeps the widget order and loads older workflows on (#90)',async({page})=>{
|
||||
await expect(page.locator('.pm-pair')).toBeVisible();
|
||||
const toggle=page.getByRole('checkbox',{name:'Save to library',exact:true});
|
||||
await expect(toggle).toBeChecked();
|
||||
expect(await page.evaluate(()=>node.widgets.map(widget=>widget.name))).toEqual(['text','negative_text','category','tags','search_text','prepend_text','append_text','save_to_library']);
|
||||
await toggle.uncheck();
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values[7])).toBe(false);
|
||||
await expect(page.locator('.pm-pair > .pm-editor-status[role=status]').last()).toHaveText('Save to library is off: runs of this node are not added to the library.');
|
||||
await page.evaluate(()=>node.configure({widgets_values:['P','N','','','','','',''],properties:{}}));
|
||||
await expect(toggle).toBeChecked();
|
||||
});
|
||||
|
||||
test('paired editor previews both texts before explicitly replacing both',async({page})=>{
|
||||
await expect(page.locator('.pm-pair')).toBeVisible();
|
||||
await draft(page);
|
||||
|
||||
@@ -16,8 +16,8 @@ installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptManagerFocu
|
||||
// `primitive`: a core string node the queue-time resolver follows. `legacy`: the frontend's virtual Primitive node,
|
||||
// whose value is queued as literal text. `preview`: PrimitiveString -> PreviewAny, the shape of the maintainer's
|
||||
// TextGenerate -> PreviewAny chain, whose text is only known when the node runs.
|
||||
function build(page, { kind, positive, negative, prefix }) {
|
||||
return page.evaluate(async ({ kind, positive, negative, prefix }) => {
|
||||
function build(page, { kind, positive, negative, prefix, saveToLibrary }) {
|
||||
return page.evaluate(async ({ kind, positive, negative, prefix, saveToLibrary }) => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph.clear();
|
||||
const add = (type, x, y) => { const node = LiteGraph.createNode(type); app.graph.add(node); node.pos = [x, y]; return node; };
|
||||
@@ -28,6 +28,7 @@ function build(page, { kind, positive, negative, prefix }) {
|
||||
const set = (name, value) => { const widget = prompt.widgets.find(item => item.name === name); widget.value = value; if (widget.inputEl) widget.inputEl.value = value; };
|
||||
set('text', positive.typed);
|
||||
if (negative) set('negative_text', negative.typed);
|
||||
if (saveToLibrary !== undefined) prompt.widgets.find(item => item.name === 'save_to_library').value = saveToLibrary;
|
||||
const drive = (name, side, y) => {
|
||||
if (!side.via) return;
|
||||
if (side.via === 'legacy') {
|
||||
@@ -73,7 +74,7 @@ function build(page, { kind, positive, negative, prefix }) {
|
||||
await prompt._pmTextSerializerReady;
|
||||
const { output, workflow } = await app.graphToPrompt();
|
||||
return { output, workflow, promptNode: String(prompt.id) };
|
||||
}, { kind, positive, negative, prefix });
|
||||
}, { kind, positive, negative, prefix, saveToLibrary });
|
||||
}
|
||||
|
||||
async function run(execution, page, testCase, built) {
|
||||
@@ -195,6 +196,47 @@ for (const [label, linked, negativeVia] of [['positive', ['text'], 'primitive'],
|
||||
});
|
||||
}
|
||||
|
||||
// #90: with Save to library off the run still produces its image, but PromptManager saves, counts and links nothing,
|
||||
// and the run's history records no prompt text for it.
|
||||
for (const kind of ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']) {
|
||||
test(`${kind} with Save to library off saves, counts and links nothing (#90)`, async ({ execution, page }) => {
|
||||
test.setTimeout(90000);
|
||||
const text = unique(`Not saved ${kind}`);
|
||||
const negative = kind === 'PromptManagerPair' ? { typed: unique('Not saved negative') } : undefined;
|
||||
const prefix = `notsaved/${randomUUID()}`;
|
||||
const { output, workflow, promptNode } = await build(page, { kind, positive: { typed: text }, negative, prefix, saveToLibrary: false });
|
||||
expect(output[promptNode].inputs.save_to_library).toBe(false);
|
||||
const id = randomUUID();
|
||||
const response = await execution.request.post('/prompt', { data: { prompt: output, prompt_id: id, extra_data: { extra_pnginfo: { workflow } } } });
|
||||
expect(response.ok(), await response.text()).toBe(true);
|
||||
let item;
|
||||
await expect.poll(async () => {
|
||||
item = (await (await execution.request.get(`/history/${id}`)).json())[id];
|
||||
return item?.status?.status_str ?? null;
|
||||
}, { timeout: 20000 }).toBe('success');
|
||||
let result;
|
||||
await expect.poll(async () => {
|
||||
result = await readExecutionRuns(execution.databasePath, id);
|
||||
const run = result.rows[0];
|
||||
return Boolean(run && result.captures[run.run_id]);
|
||||
}, { timeout: 10000, message: 'the run reaches SQLite' }).toBe(true);
|
||||
const run = result.rows[0];
|
||||
for (const context of result.captures[run.run_id].contexts || []) {
|
||||
expect(context.positive_prompt_id ?? null).toBeNull();
|
||||
expect(context.pair_id ?? null).toBeNull();
|
||||
}
|
||||
expect((result.usage[run.run_id] || []).every(entry => entry.run_count === 0)).toBe(true);
|
||||
const files = Object.values(item.outputs).flatMap(value => value.images || []).map(image => image.filename);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
await scan(execution);
|
||||
expect(Object.values(await linkedPrompts(execution, files))).toEqual(files.map(() => null));
|
||||
for (const query of [text, negative?.typed].filter(Boolean)) {
|
||||
const found = await (await execution.request.get(`/prompt_manager/search?text=${encodeURIComponent(query)}`)).json();
|
||||
expect(found.results ?? [], query).toEqual([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// #347: a cached PromptManager node counts every job, as an executing one does. Jobs after the first change only
|
||||
// the sampler seed, so ComfyUI serves the PromptManager node (and its PreviewAny source) from its output cache.
|
||||
const COUNTS = `import json, sqlite3, sys, pathlib
|
||||
|
||||
@@ -370,6 +370,7 @@ test('workflows saved before #343 load unchanged and their linked text keeps wor
|
||||
const nodes = Object.fromEntries(app.graph._nodes.map(node => [node.id, node]));
|
||||
const reloaded = app.graph.serialize();
|
||||
return {
|
||||
saves: Object.fromEntries(Object.entries(output).filter(([, node]) => node.class_type.startsWith('PromptManager')).map(([id, node]) => [id, node.inputs.save_to_library])),
|
||||
values: Object.fromEntries(reloaded.nodes.map(node => [node.id, node.widgets_values])),
|
||||
links: Object.fromEntries(Object.entries(output).map(([id, node]) => [id, { text: node.inputs.text, negative_text: node.inputs.negative_text }])),
|
||||
widgetBound: Object.fromEntries(Object.values(nodes).filter(node => node.type.startsWith('PromptManager')).map(node => [node.id, node.inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).map(input => input.name)])),
|
||||
@@ -377,8 +378,16 @@ test('workflows saved before #343 load unchanged and their linked text keeps wor
|
||||
}, workflow);
|
||||
for (const node of workflow.nodes) {
|
||||
// Every saved widget value comes back as it was saved, except the typed text main lost under a link (null).
|
||||
if (node.widgets_values?.[0] !== null) expect(result.values[node.id], `${node.type} ${node.id}`).toEqual(node.widgets_values);
|
||||
// #90: the last saved value is the editor's own slot (''), where the Save to library switch is now: it loads on.
|
||||
if (node.widgets_values?.[0] === null) continue;
|
||||
if (!node.type.startsWith('PromptManager')) { expect(result.values[node.id], `${node.type} ${node.id}`).toEqual(node.widgets_values); continue; }
|
||||
const saved = node.widgets_values.length - 1;
|
||||
expect(result.values[node.id].slice(0, saved), `${node.type} ${node.id}`).toEqual(node.widgets_values.slice(0, saved));
|
||||
expect(result.values[node.id][saved], `${node.type} ${node.id} save_to_library`).toBe(true);
|
||||
}
|
||||
// Every PromptManager node of the old workflow queues with Save to library on.
|
||||
for (const save of Object.values(result.saves)) expect(save).toBe(true);
|
||||
expect(Object.keys(result.saves).length).toBeGreaterThan(0);
|
||||
expect(result.links).toMatchObject({
|
||||
1: { text: 'Saved PromptManager text' }, 2: { text: 'Saved PromptManagerText text' }, 3: { text: 'Saved PromptManagerFocused text' },
|
||||
4: { text: 'Saved PromptManagerSplit text' }, 5: { text: 'Saved PromptManagerPair text', negative_text: 'Saved pair negative' },
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// #90: the node's save_to_library switch (web/js/save-to-library.js).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/save-to-library.js');
|
||||
|
||||
function classicNode(saved) {
|
||||
const names = ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text', 'save_to_library', 'prompt_manager_search_ui'];
|
||||
const widgets = names.map(name => ({name, value: name === 'save_to_library' ? true : ''}));
|
||||
const node = {widgets, widgets_values: saved ? [...saved] : undefined};
|
||||
// What a host configure does: each saved value goes to the widget at its index.
|
||||
(saved || []).forEach((value, index) => { if (widgets[index]) widgets[index].value = value; });
|
||||
return node;
|
||||
}
|
||||
|
||||
test('an older workflow stored the editor slot where the switch is now: it loads on', async () => {
|
||||
const {normalizeSaveWidget, savesToLibrary} = await source;
|
||||
const node = classicNode(['a cat', 'c', 't', '', '', '', '']);
|
||||
assert.equal(node.widgets[6].value, '', 'the host put the old DOM slot value into the switch');
|
||||
assert.equal(normalizeSaveWidget(node), true);
|
||||
assert.equal(node.widgets[6].value, true);
|
||||
assert.equal(node.widgets_values[6], true);
|
||||
assert.equal(savesToLibrary(node), true);
|
||||
assert.deepEqual(node.widgets_values.slice(0, 6), ['a cat', 'c', 't', '', '', ''], 'no earlier value moved');
|
||||
});
|
||||
|
||||
test('null, undefined and missing slots load on; saved booleans are kept', async () => {
|
||||
const {normalizeSaveWidget, savesToLibrary} = await source;
|
||||
for (const value of [null, undefined, 0, 'false']) {
|
||||
const node = classicNode(['a', '', '', '', '', '', value]);
|
||||
normalizeSaveWidget(node);
|
||||
assert.equal(savesToLibrary(node), true, String(value));
|
||||
}
|
||||
const short = classicNode(['a', '', '', '', '', '']);
|
||||
assert.equal(normalizeSaveWidget(short), false);
|
||||
assert.equal(savesToLibrary(short), true);
|
||||
const off = classicNode(['a', '', '', '', '', '', false]);
|
||||
assert.equal(normalizeSaveWidget(off), false);
|
||||
assert.equal(savesToLibrary(off), false);
|
||||
});
|
||||
|
||||
test('a node without the widget (an older backend) saves', async () => {
|
||||
const {normalizeSaveWidget, savesToLibrary} = await source;
|
||||
assert.equal(savesToLibrary({widgets: [{name: 'text', value: ''}]}), true);
|
||||
assert.equal(normalizeSaveWidget({widgets: []}), false);
|
||||
assert.equal(savesToLibrary(undefined), true);
|
||||
});
|
||||
|
||||
test('the switch moves after the editor fallbacks so every frontend has one order', async () => {
|
||||
const {placeSaveWidgetLast} = await source;
|
||||
const widgets = ['text', 'category', 'tags', 'search_text', 'save_to_library', 'prepend_text', 'append_text'].map(name => ({name}));
|
||||
const node = {widgets};
|
||||
assert.equal(placeSaveWidgetLast(node), true);
|
||||
assert.deepEqual(node.widgets.map(widget => widget.name), ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text', 'save_to_library']);
|
||||
assert.equal(placeSaveWidgetLast(node), false, 'already last');
|
||||
assert.equal(placeSaveWidgetLast({widgets: [{name: 'text'}]}), false);
|
||||
});
|
||||
|
||||
test('setting the switch updates the widget, its saved value and notifies the host', async () => {
|
||||
const {setSaveToLibrary, savesToLibrary} = await source;
|
||||
const calls = [];
|
||||
const node = classicNode(['a', '', '', '', '', '', true]);
|
||||
node.widgets[6].callback = value => calls.push(value);
|
||||
setSaveToLibrary(node, false);
|
||||
assert.equal(savesToLibrary(node), false);
|
||||
assert.equal(node.widgets_values[6], false);
|
||||
assert.deepEqual(calls, [false]);
|
||||
setSaveToLibrary({widgets: []}, false);
|
||||
});
|
||||
@@ -210,13 +210,24 @@ class JobCase(unittest.TestCase):
|
||||
node = self.node(kind)
|
||||
self.context = (self.context[0], index)
|
||||
clip = mock.Mock()
|
||||
# The node receives its switch as the queued graph holds it (#90)
|
||||
save = g[node_id]["inputs"].get("save_to_library", True)
|
||||
if kind == "PromptManagerText":
|
||||
return node.process_text(text, prompt=g, unique_id=node_id)
|
||||
return node.process_text(
|
||||
text, prompt=g, unique_id=node_id, save_to_library=save
|
||||
)
|
||||
if kind == "PromptManagerPair":
|
||||
return node.encode_prompt(
|
||||
clip, text, negative_text=negative, prompt=g, unique_id=node_id
|
||||
clip,
|
||||
text,
|
||||
negative_text=negative,
|
||||
prompt=g,
|
||||
unique_id=node_id,
|
||||
save_to_library=save,
|
||||
)
|
||||
return node.encode_prompt(clip, text, prompt=g, unique_id=node_id)
|
||||
return node.encode_prompt(
|
||||
clip, text, prompt=g, unique_id=node_id, save_to_library=save
|
||||
)
|
||||
|
||||
def cache(self, node_id, *results):
|
||||
"""Store executed results (one per list index) as ComfyUI's cache entry."""
|
||||
@@ -313,6 +324,35 @@ class TestCachedJobsCount(JobCase):
|
||||
self.job(g, cached=["pm"])
|
||||
self.assertEqual(self.db.search_prompts(), [])
|
||||
|
||||
def test_disabled_node_then_cached_jobs_counts_0(self):
|
||||
# #90 x #347: a node with Save to library off records no receipt and is
|
||||
# no run prompt, so its cached jobs replay nothing. The prompt exists
|
||||
# (an enabled run saved it), so any replayed count would show.
|
||||
for kind in (
|
||||
"PromptManager",
|
||||
"PromptManagerFocused",
|
||||
"PromptManagerSplit",
|
||||
"PromptManagerText",
|
||||
"PromptManagerPair",
|
||||
):
|
||||
with self.subTest(kind=kind):
|
||||
text = f"{LLM} not saved by {kind}"
|
||||
negative = "blurry" if kind == "PromptManagerPair" else ""
|
||||
self.first(graph(kind, negative_text=negative), text, negative)
|
||||
before = (self.count(text), self.pair_count(text, negative))
|
||||
|
||||
def off(seed):
|
||||
g = graph(kind, negative_text=negative, seed=seed)
|
||||
g["pm"]["inputs"]["save_to_library"] = False
|
||||
return g
|
||||
|
||||
self.first(off(1), text, negative)
|
||||
self.job(off(2), cached=["pm"])
|
||||
self.job(off(3), cached=["pm"])
|
||||
after = (self.count(text), self.pair_count(text, negative))
|
||||
self.assertEqual(before[0], 1)
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_alternating_upstream_values_each_count_their_own_prompt(self):
|
||||
g = graph()
|
||||
self.first(g, text="value A")
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
"""#90 item 3: a node's ``save_to_library`` switch.
|
||||
|
||||
Off: PromptManager ignores the run completely (no prompt saved, no run count,
|
||||
no image link), while the node still returns its normal outputs. On (the
|
||||
default, and every workflow or image made before the switch existed): as before.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from tests.test_prompt_attribution import MonitorCase, NodeCase
|
||||
from tests.test_execution_contexts import load_module
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from test_prompt_attribution import MonitorCase, NodeCase
|
||||
from test_execution_contexts import load_module
|
||||
|
||||
from prompt_manager import PromptManager, PromptManagerFocused, PromptManagerSplit
|
||||
from prompt_manager_pair import PromptManagerPair
|
||||
from prompt_manager_text import PromptManagerText
|
||||
from prompt_search_list import PromptSearchList
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.prompt_graph import (
|
||||
intrinsic_pair_nodes,
|
||||
positive_opt_outs,
|
||||
run_prompt_components,
|
||||
run_prompt_nodes,
|
||||
save_setting,
|
||||
)
|
||||
from utils.run_keys import prompt_node_key
|
||||
from utils.usage_tracking import (
|
||||
PendingFirstUse,
|
||||
QueuedRuns,
|
||||
handle_queued_prompt,
|
||||
on_dequeue,
|
||||
queued_pair_runs,
|
||||
queued_prompt_runs,
|
||||
)
|
||||
|
||||
POSITIVE = "a lighthouse at dusk"
|
||||
NEGATIVE = "blurry"
|
||||
NODE_CLASSES = (
|
||||
PromptManager,
|
||||
PromptManagerText,
|
||||
PromptManagerFocused,
|
||||
PromptManagerSplit,
|
||||
PromptManagerPair,
|
||||
)
|
||||
|
||||
|
||||
def run_graph(save=None, kind="PromptManager", negative_save=None):
|
||||
"""A PromptManager positive feeding a KSampler; ``save`` None leaves it out."""
|
||||
inputs = {"text": POSITIVE, "clip": ["4", 1]}
|
||||
if save is not None:
|
||||
inputs["save_to_library"] = save
|
||||
negative_inputs = {"text": NEGATIVE, "clip": ["4", 1]}
|
||||
if negative_save is not None:
|
||||
negative_inputs["save_to_library"] = negative_save
|
||||
return {
|
||||
"1": {"class_type": kind, "inputs": inputs},
|
||||
"2": {"class_type": "PromptManager", "inputs": negative_inputs},
|
||||
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["1", 0],
|
||||
"negative": ["2", 0],
|
||||
"latent_image": ["6", 0],
|
||||
},
|
||||
},
|
||||
"6": {"class_type": "EmptyLatentImage", "inputs": {"batch_size": 1}},
|
||||
}
|
||||
|
||||
|
||||
def pair_graph(save=None):
|
||||
inputs = {"text": POSITIVE, "negative_text": NEGATIVE, "clip": ["4", 1]}
|
||||
if save is not None:
|
||||
inputs["save_to_library"] = save
|
||||
return {
|
||||
"1": {"class_type": "PromptManagerPair", "inputs": inputs},
|
||||
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["1", 0],
|
||||
"negative": ["1", 1],
|
||||
"latent_image": ["6", 0],
|
||||
},
|
||||
},
|
||||
"6": {"class_type": "EmptyLatentImage", "inputs": {"batch_size": 1}},
|
||||
}
|
||||
|
||||
|
||||
class TestInputDeclaration(unittest.TestCase):
|
||||
def test_every_prompt_manager_node_declares_a_boolean_defaulting_on(self):
|
||||
for cls in NODE_CLASSES:
|
||||
optional = cls.INPUT_TYPES()["optional"]
|
||||
kind, options = optional["save_to_library"]
|
||||
self.assertEqual(kind, "BOOLEAN", cls.__name__)
|
||||
self.assertIs(options["default"], True, cls.__name__)
|
||||
# Declared last: no saved widget value of an older workflow shifts
|
||||
self.assertEqual(list(optional)[-1], "save_to_library", cls.__name__)
|
||||
|
||||
def test_search_list_has_no_switch(self):
|
||||
inputs = PromptSearchList.INPUT_TYPES()
|
||||
self.assertNotIn("save_to_library", inputs.get("optional", {}))
|
||||
self.assertNotIn("save_to_library", inputs.get("required", {}))
|
||||
|
||||
|
||||
class TestSaveSetting(unittest.TestCase):
|
||||
def test_missing_input_is_on(self):
|
||||
self.assertIs(save_setting(run_graph(), "1"), True)
|
||||
|
||||
def test_literals_follow_comfyui_boolean_coercion(self):
|
||||
for value, expected in (
|
||||
(True, True),
|
||||
(False, False),
|
||||
("", False),
|
||||
(0, False),
|
||||
({"__value__": False}, False),
|
||||
({"__value__": True}, True),
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
self.assertIs(save_setting(run_graph(save=value), "1"), expected)
|
||||
|
||||
def test_link_to_primitive_boolean_is_read(self):
|
||||
for value in (True, False):
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": value}}
|
||||
self.assertIs(save_setting(graph, "1"), value)
|
||||
|
||||
def test_link_to_an_unknown_node_is_not_knowable(self):
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
self.assertIsNone(save_setting(graph, "1"))
|
||||
self.assertIsNone(save_setting(run_graph(save=["missing", 0]), "1"))
|
||||
|
||||
|
||||
class TestGraphRoles(unittest.TestCase):
|
||||
def test_opted_out_positive_is_not_a_run_prompt(self):
|
||||
graph = run_graph(save=False)
|
||||
self.assertEqual(run_prompt_nodes(graph), [])
|
||||
self.assertEqual(run_prompt_components(graph), [])
|
||||
self.assertEqual(positive_opt_outs(graph), ["1"])
|
||||
|
||||
def test_enabled_positive_is_unchanged(self):
|
||||
for save in (None, True):
|
||||
graph = run_graph(save=save)
|
||||
self.assertEqual(run_prompt_nodes(graph), ["1"])
|
||||
self.assertEqual(positive_opt_outs(graph), [])
|
||||
|
||||
def test_opted_out_negative_is_no_positive_opt_out(self):
|
||||
graph = run_graph(negative_save=False)
|
||||
self.assertEqual(run_prompt_nodes(graph), ["1"])
|
||||
self.assertEqual(positive_opt_outs(graph), [])
|
||||
|
||||
def test_walk_stops_at_an_opted_out_node(self):
|
||||
graph = run_graph(save=False)
|
||||
graph["1"]["inputs"]["text"] = ["7", 0]
|
||||
graph["7"] = {"class_type": "PromptManagerText", "inputs": {"text": "x"}}
|
||||
self.assertEqual(run_prompt_nodes(graph), [])
|
||||
|
||||
def test_sampler_less_fallback_skips_opted_out_nodes(self):
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "a", "save_to_library": False},
|
||||
},
|
||||
"2": {"class_type": "PromptManagerText", "inputs": {"text": "b"}},
|
||||
}
|
||||
self.assertEqual(run_prompt_nodes(graph), ["2"])
|
||||
self.assertEqual(run_prompt_components(graph), [("2", "text")])
|
||||
self.assertEqual(positive_opt_outs(graph), ["1"])
|
||||
|
||||
def test_opted_out_pair_is_not_an_intrinsic_pair(self):
|
||||
self.assertEqual(intrinsic_pair_nodes(pair_graph()), ["1"])
|
||||
self.assertEqual(intrinsic_pair_nodes(pair_graph(save=False)), [])
|
||||
|
||||
|
||||
class TestQueueHook(unittest.TestCase):
|
||||
def test_opted_out_node_is_never_stashed(self):
|
||||
self.assertEqual(queued_prompt_runs(run_graph(save=False)), [])
|
||||
self.assertEqual(queued_pair_runs(pair_graph(save=False)), [])
|
||||
|
||||
def test_unknown_setting_is_left_to_the_node(self):
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
self.assertEqual(queued_prompt_runs(graph), [])
|
||||
pair = pair_graph(save=["9", 0])
|
||||
pair["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
self.assertEqual(queued_pair_runs(pair), [])
|
||||
|
||||
def test_primitive_boolean_on_still_counts(self):
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": True}}
|
||||
self.assertEqual(
|
||||
queued_prompt_runs(graph), [(generate_prompt_hash(POSITIVE), POSITIVE)]
|
||||
)
|
||||
|
||||
def test_dequeue_counts_nothing_for_a_cached_opted_out_node(self):
|
||||
"""A cached node never executes: only the queued graph can say it is off."""
|
||||
db = mock.Mock()
|
||||
db.get_prompt_by_hash.return_value = {"id": 5}
|
||||
queued, pending = QueuedRuns(), PendingFirstUse()
|
||||
request = {"prompt": run_graph(save=False), "prompt_id": "job-1"}
|
||||
handle_queued_prompt(request, queued, pair_queued=QueuedRuns())
|
||||
on_dequeue("job-1", queued, lambda: db, pending, pair_queued=QueuedRuns())
|
||||
db.record_prompt_use.assert_not_called()
|
||||
self.assertEqual(pending.consume(generate_prompt_hash(POSITIVE)), 0)
|
||||
|
||||
|
||||
class TestNodesSaveNothing(NodeCase):
|
||||
def run_node(self, cls, graph, save):
|
||||
node = self.node(cls)
|
||||
node.record_pair = mock.Mock()
|
||||
kwargs = {"prompt": graph, "unique_id": "1", "save_to_library": save}
|
||||
if cls is PromptManagerText:
|
||||
return node, node.process_text(POSITIVE, **kwargs)
|
||||
if cls is PromptManagerPair:
|
||||
return node, node.encode_prompt(
|
||||
self.clip(), POSITIVE, negative_text=NEGATIVE, **kwargs
|
||||
)
|
||||
return node, node.encode_prompt(self.clip(), POSITIVE, **kwargs)
|
||||
|
||||
def test_off_saves_counts_and_tracks_nothing_but_returns_outputs(self):
|
||||
for cls in NODE_CLASSES:
|
||||
with self.subTest(node=cls.__name__):
|
||||
graph = (
|
||||
pair_graph(save=False)
|
||||
if cls is PromptManagerPair
|
||||
else run_graph(save=False, kind=cls.__name__)
|
||||
)
|
||||
node, result = self.run_node(cls, graph, False)
|
||||
self.assertEqual(self.count_rows("prompts"), 0)
|
||||
self.assertEqual(self.count_rows("prompt_pairs"), 0)
|
||||
self.assertEqual(self.tracker.keyed_snapshot(), {})
|
||||
self.assertEqual(self.tracker._prompt_queue, [])
|
||||
node.comfyui_integration.register_prompt.assert_not_called()
|
||||
if cls is PromptManagerText:
|
||||
self.assertEqual(result, (POSITIVE,))
|
||||
elif cls is PromptManagerPair:
|
||||
self.assertEqual(result[0], ("cond", POSITIVE))
|
||||
self.assertEqual(result[1], ("cond", NEGATIVE))
|
||||
self.assertEqual(result[2:], (POSITIVE, NEGATIVE))
|
||||
else:
|
||||
self.assertEqual(result, (("cond", POSITIVE), POSITIVE))
|
||||
|
||||
def test_off_without_graph_context_still_saves_nothing(self):
|
||||
node = self.node(PromptManager)
|
||||
node.encode_prompt(self.clip(), POSITIVE, save_to_library=False)
|
||||
self.assertEqual(self.count_rows("prompts"), 0)
|
||||
self.assertEqual(self.tracker._prompt_queue, [])
|
||||
|
||||
def test_off_leaves_an_existing_prompt_uncounted(self):
|
||||
prompt_id = self.save(POSITIVE)
|
||||
self.run_node(PromptManager, run_graph(save=False), False)
|
||||
row = self.db.get_prompt_by_id(prompt_id)
|
||||
self.assertFalse(row.get("run_count") or 0)
|
||||
|
||||
def test_on_is_unchanged(self):
|
||||
self.run_node(PromptManager, run_graph(save=True), True)
|
||||
self.assertIsNotNone(self.db.get_prompt_by_hash(generate_prompt_hash(POSITIVE)))
|
||||
self.assertIn(
|
||||
prompt_node_key(run_graph(save=True), "1"), self.tracker.keyed_snapshot()
|
||||
)
|
||||
|
||||
def test_unknown_setting_on_at_run_time_counts_at_execution(self):
|
||||
"""The hook could not read a wired switch, so the node counts its run."""
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
prompt_id = self.save(POSITIVE)
|
||||
self.run_node(PromptManager, graph, True)
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt_id)["run_count"], 1)
|
||||
|
||||
|
||||
class TestWiredSwitchOffMarksItsRun(NodeCase):
|
||||
"""Review finding: a wired switch off at run time keeps the node a run prompt."""
|
||||
|
||||
def wired_graph(self):
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
return graph
|
||||
|
||||
def test_an_earlier_entry_of_the_key_never_stands_in(self):
|
||||
graph = self.wired_graph()
|
||||
key = prompt_node_key(graph, "1")
|
||||
node = self.node(PromptManager)
|
||||
node.encode_prompt(
|
||||
self.clip(), POSITIVE, prompt=graph, unique_id="1", save_to_library=True
|
||||
)
|
||||
node.encode_prompt(
|
||||
self.clip(), POSITIVE, prompt=graph, unique_id="1", save_to_library=False
|
||||
)
|
||||
latest, _run, _count = self.tracker.keyed_snapshot()[key]
|
||||
self.assertTrue(latest.get("placeholder"))
|
||||
|
||||
def test_literal_off_marks_nothing(self):
|
||||
node = self.node(PromptManager)
|
||||
node.encode_prompt(
|
||||
self.clip(),
|
||||
POSITIVE,
|
||||
prompt=run_graph(save=False),
|
||||
unique_id="1",
|
||||
save_to_library=False,
|
||||
)
|
||||
self.assertEqual(self.tracker.keyed_snapshot(), {})
|
||||
|
||||
|
||||
class TestMonitorLeavesOptedOutFilesUnlinked(MonitorCase):
|
||||
def test_existing_prompt_is_not_linked_from_metadata(self):
|
||||
self.save(POSITIVE)
|
||||
self.assertIsNone(self.process({"prompt": run_graph(save=False)}))
|
||||
|
||||
def test_find_prompt_reports_opted_out(self):
|
||||
self.save(POSITIVE)
|
||||
state, prompt = self.handler._find_prompt_from_metadata(
|
||||
{"prompt": run_graph(save=False)}
|
||||
)
|
||||
self.assertEqual((state, prompt), ("opted_out", None))
|
||||
|
||||
def test_mixed_positive_settings_leave_the_file_unlinked(self):
|
||||
self.save(POSITIVE)
|
||||
graph = run_graph(save=True)
|
||||
graph["8"] = {
|
||||
"class_type": "PromptManager",
|
||||
"inputs": {"text": "other", "save_to_library": False},
|
||||
}
|
||||
graph["5"] = {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["8", 0],
|
||||
"negative": ["2", 0],
|
||||
"latent_image": ["6", 0],
|
||||
},
|
||||
}
|
||||
self.assertIsNone(self.process({"prompt": graph}))
|
||||
|
||||
def test_workflow_only_metadata_with_switch_off_is_opted_out(self):
|
||||
self.save(POSITIVE)
|
||||
metadata = {
|
||||
"text_encoder_nodes": [
|
||||
{
|
||||
"type": "PromptManager",
|
||||
"inputs": [],
|
||||
"widgets_values": [POSITIVE, "", "", "", "", "", False],
|
||||
}
|
||||
]
|
||||
}
|
||||
state, _prompt = self.handler._find_prompt_from_metadata(metadata)
|
||||
self.assertEqual(state, "opted_out")
|
||||
|
||||
def test_workflow_only_ignores_muted_nodes_and_reads_subgraphs(self):
|
||||
from utils.prompt_graph import metadata_opted_out
|
||||
|
||||
node = {
|
||||
"type": "PromptManager",
|
||||
"widgets_values": [POSITIVE, "", "", "", "", "", False],
|
||||
}
|
||||
self.assertFalse(
|
||||
metadata_opted_out({"workflow": {"nodes": [{**node, "mode": 4}]}})
|
||||
)
|
||||
self.assertTrue(
|
||||
metadata_opted_out(
|
||||
{
|
||||
"workflow": {
|
||||
"nodes": [],
|
||||
"definitions": {"subgraphs": [{"nodes": [node]}]},
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_unknown_setting_is_left_to_the_run(self):
|
||||
self.save(POSITIVE)
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
state, _prompt = self.handler._find_prompt_from_metadata({"prompt": graph})
|
||||
self.assertEqual(state, "unknown")
|
||||
|
||||
def test_enabled_positive_still_links(self):
|
||||
prompt_id = self.save(POSITIVE)
|
||||
self.assertEqual(self.process({"prompt": run_graph(save=True)}), prompt_id)
|
||||
|
||||
|
||||
class TestLinkKeepsOptedOutNegativeOut(MonitorCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# The real link, not the recording stub of MonitorCase
|
||||
del self.handler.link_image_to_prompt
|
||||
|
||||
def test_opted_out_negative_is_never_stored_as_a_pair(self):
|
||||
prompt_id = self.save(POSITIVE)
|
||||
metadata = {"prompt": run_graph(save=True, negative_save=False)}
|
||||
self.handler.link_image_to_prompt(self.image, {"id": prompt_id}, metadata)
|
||||
self.assertIsNone(self.db.get_prompt_by_hash(generate_prompt_hash(NEGATIVE)))
|
||||
self.assertEqual(self.count_pairs(), 0)
|
||||
linked = (
|
||||
self.db.model.get_connection()
|
||||
.execute("SELECT prompt_id FROM generated_images")
|
||||
.fetchall()
|
||||
)
|
||||
self.assertEqual([row[0] for row in linked], [prompt_id])
|
||||
|
||||
def test_opted_out_positive_links_nothing(self):
|
||||
prompt_id = self.save(POSITIVE)
|
||||
metadata = {"prompt": run_graph(save=False)}
|
||||
self.handler.link_image_to_prompt(self.image, {"id": prompt_id}, metadata)
|
||||
count = (
|
||||
self.db.model.get_connection()
|
||||
.execute("SELECT COUNT(*) FROM generated_images")
|
||||
.fetchone()[0]
|
||||
)
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def count_pairs(self):
|
||||
conn = self.db.model.get_connection()
|
||||
return conn.execute("SELECT COUNT(*) FROM prompt_pairs").fetchone()[0]
|
||||
|
||||
|
||||
class TestScanSkipsOptedOutFiles(NodeCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from py.api import admin
|
||||
|
||||
self.mixin = admin.AdminRoutesMixin.__new__(admin.AdminRoutesMixin)
|
||||
self.mixin.db = self.db
|
||||
self.mixin.logger = logging.getLogger("test.save_to_library.scan")
|
||||
self.file = Path(self.tmp.name) / "out.png"
|
||||
|
||||
def test_opted_out_file_stores_nothing(self):
|
||||
status = self.mixin._ingest_scanned_file(
|
||||
self.file, {"prompt": run_graph(save=False)}
|
||||
)
|
||||
self.assertEqual(status, "not_saved")
|
||||
self.assertEqual(self.count_rows("prompts"), 0)
|
||||
self.assertEqual(self.count_rows("generated_images"), 0)
|
||||
|
||||
def test_opted_out_file_is_never_queued_for_review(self):
|
||||
ambiguous = []
|
||||
graph = run_graph(save=False)
|
||||
graph["5"] = dict(graph["3"]) # a second sampler: ambiguous otherwise
|
||||
graph["5"]["inputs"] = {**graph["3"]["inputs"], "positive": ["2", 0]}
|
||||
status = self.mixin._ingest_scanned_file(
|
||||
self.file, {"prompt": graph}, ambiguous=ambiguous
|
||||
)
|
||||
self.assertEqual(status, "not_saved")
|
||||
self.assertEqual(ambiguous, [])
|
||||
|
||||
def test_opted_out_negative_stores_the_positive_only(self):
|
||||
status = self.mixin._ingest_scanned_file(
|
||||
self.file, {"prompt": run_graph(save=True, negative_save=False)}
|
||||
)
|
||||
self.assertEqual(status, "added")
|
||||
self.assertIsNotNone(self.db.get_prompt_by_hash(generate_prompt_hash(POSITIVE)))
|
||||
self.assertIsNone(self.db.get_prompt_by_hash(generate_prompt_hash(NEGATIVE)))
|
||||
self.assertEqual(self.count_rows("prompt_pairs"), 0)
|
||||
|
||||
def test_wired_switch_is_left_to_the_live_run(self):
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
status = self.mixin._ingest_scanned_file(self.file, {"prompt": graph})
|
||||
self.assertEqual(status, "not_saved_wired")
|
||||
self.assertEqual(self.count_rows("prompts"), 0)
|
||||
|
||||
def test_status_has_a_scan_detail(self):
|
||||
from py.api import admin
|
||||
|
||||
group, row, _line = admin._scan_outcome(self.file, [], "not_saved")
|
||||
self.assertEqual(group, "no_metadata")
|
||||
self.assertIn("does not save", row["detail"])
|
||||
|
||||
|
||||
class TestHistoryRecordsNoText(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.m = load_module("execution_context_graph")
|
||||
|
||||
def test_opted_out_role_has_no_sources_and_no_text(self):
|
||||
recipe = self.m.project_contexts(run_graph(save=False), None).recipes[0]
|
||||
self.assertEqual(recipe.positive.sources, ())
|
||||
self.assertIsNone(recipe.positive.inferred)
|
||||
self.assertEqual(recipe.positive.reason, "not_saved")
|
||||
# The enabled negative is recorded as before
|
||||
self.assertEqual(recipe.negative.inferred.raw.value, NEGATIVE)
|
||||
|
||||
def test_unknown_setting_records_no_text_either(self):
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
recipe = self.m.project_contexts(graph, None).recipes[0]
|
||||
self.assertIsNone(recipe.positive.inferred)
|
||||
self.assertEqual(recipe.positive.reason, "not_saved")
|
||||
|
||||
def test_enabled_role_is_unchanged(self):
|
||||
recipe = self.m.project_contexts(run_graph(save=True), None).recipes[0]
|
||||
self.assertEqual(recipe.positive.inferred.raw.value, POSITIVE)
|
||||
self.assertEqual(len(recipe.positive.sources), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+58
-2
@@ -2,12 +2,14 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:net';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { startBrowserServer, createServerSupervisor, attachServerStderr, forwardPrefixedLines, SLOW_RESET_PREFIX } from '../../tools/vendor/start-browser-server.mjs';
|
||||
import { workflowWorkers } from '../../tools/vendor/test-workers.mjs';
|
||||
import { reporters, workflowWorkers } from '../../tools/vendor/test-workers.mjs';
|
||||
import RunningTestsReporter from '../../tools/vendor/running-tests-reporter.mjs';
|
||||
|
||||
const fakeServer = fileURLToPath(new URL('./fake-browser-server.mjs', import.meta.url));
|
||||
const fake = (...extra) => ({ command: process.execPath, args: port => [fakeServer, '--port', String(port), ...extra] });
|
||||
@@ -255,3 +257,57 @@ test('worker count follows PM_TEST_WORKERS, then one in CI, then a quarter of th
|
||||
assert.throws(() => workflowWorkers({ env: { PM_TEST_WORKERS: 'many' }, cpus: 8 }), /PM_TEST_WORKERS must be a positive integer/);
|
||||
assert.throws(() => workflowWorkers({ env: { PM_TEST_WORKERS: '0' }, cpus: 8 }), /PM_TEST_WORKERS must be a positive integer/);
|
||||
});
|
||||
|
||||
test('CI adds the running-tests reporter and a blob report under test-results/blob/, which the trace upload covers; local runs keep list', () => {
|
||||
assert.equal(reporters({ env: {}, name: 'workflows' }), 'list');
|
||||
const [list, running, blob] = reporters({ env: { CI: '1' }, name: 'workflows' });
|
||||
assert.deepEqual(list, ['list']);
|
||||
// Absolute: a config imported from another directory (workflow-artifacts.test.mjs) still finds it.
|
||||
assert.ok(isAbsolute(running[0]) && running[0].endsWith('running-tests-reporter.mjs'), running[0]);
|
||||
assert.deepEqual(blob, ['blob', { outputDir: './test-results/blob/workflows' }]);
|
||||
});
|
||||
|
||||
function fakeHost() {
|
||||
const host = new EventEmitter();
|
||||
Object.assign(host, { pid: 4242, signals: [], cwd: () => '/repo/tools/vendor' });
|
||||
host.kill = (pid, signal) => host.signals.push([pid, signal]);
|
||||
return host;
|
||||
}
|
||||
|
||||
const spec = (id, title, line) => ({ id, title, location: { file: '/repo/tests/browser/a.spec.mjs', line },
|
||||
parent: { project: () => ({ name: 'firefox' }) } });
|
||||
|
||||
test('the running-tests reporter names only the tests that never ended, relative to rootDir', () => {
|
||||
let clock = 1_000;
|
||||
const output = [];
|
||||
const host = fakeHost();
|
||||
const reporter = new RunningTestsReporter({ now: () => clock, write: text => output.push(text), host });
|
||||
reporter.onBegin({ rootDir: '/repo/tests/browser' });
|
||||
const done = spec('1', 'finishes', 10);
|
||||
const stuck = spec('2', 'hangs on a fetch', 20);
|
||||
reporter.onTestBegin(done, { retry: 0 });
|
||||
reporter.onTestBegin(stuck, { retry: 0 });
|
||||
clock += 500;
|
||||
reporter.onTestEnd(done, { retry: 0 });
|
||||
clock += 41_500;
|
||||
reporter.onEnd({ status: 'interrupted' });
|
||||
assert.equal(output.join(''), '\nStill running when the run ended (interrupted): 1\n'
|
||||
+ ' [firefox] › a.spec.mjs:20 › hangs on a fetch (running 42.0s)\n');
|
||||
assert.equal(host.listenerCount('SIGTERM'), 0, 'the run is over: SIGTERM gets its default again');
|
||||
output.length = 0;
|
||||
reporter.onTestEnd(stuck, { retry: 0 });
|
||||
reporter.onEnd({ status: 'passed' });
|
||||
assert.deepEqual(output, [], 'a run where every test ended prints nothing');
|
||||
});
|
||||
|
||||
test('on the first SIGTERM the reporter prints the running tests and hands over to Playwright\'s SIGINT', () => {
|
||||
const output = [];
|
||||
const host = fakeHost();
|
||||
const reporter = new RunningTestsReporter({ now: () => 0, write: text => output.push(text), host });
|
||||
reporter.onBegin({ rootDir: '/repo/tests/browser' });
|
||||
reporter.onTestBegin(spec('2', 'hangs on a fetch', 20), { retry: 0 });
|
||||
host.emit('SIGTERM');
|
||||
assert.match(output.join(''), /Still running when the run ended \(SIGTERM\): 1\n {2}\[firefox\] › a\.spec\.mjs:20 › hangs on a fetch/);
|
||||
assert.deepEqual(host.signals, [[4242, 'SIGINT']], 'Playwright\'s own interrupt ends the run and writes the blob');
|
||||
assert.equal(host.listenerCount('SIGTERM'), 0, 'a second SIGTERM kills the process as usual');
|
||||
});
|
||||
|
||||
Vendored
+20
@@ -247,6 +247,26 @@ test('browser job timeouts keep a real margin over measured runs', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// A hang (#425) must end inside the test step, before the job limit, so the log and the always()
|
||||
// uploads survive: the step's timeout-minutes < the job's.
|
||||
const BROWSER_TEST_STEP = /npm run --prefix tools\/vendor test:(workflows|coverage)/;
|
||||
|
||||
test('each browser test step stops itself before its job limit and its traces upload anyway', () => {
|
||||
for (const id of browserJobIds) {
|
||||
const body = allJobs.get(id);
|
||||
const jobMinutes = Number(/^ {4}timeout-minutes: (\d+)$/m.exec(body)[1]);
|
||||
const jobSteps = steps(body);
|
||||
const testAt = jobSteps.findIndex(step => BROWSER_TEST_STEP.test(step));
|
||||
const testStep = jobSteps[testAt];
|
||||
const stepMinutes = Number(/^ {8}timeout-minutes: (\d+)$/m.exec(testStep)?.[1]);
|
||||
assert.ok(stepMinutes > 0, `${id}: the test step sets its own timeout-minutes`);
|
||||
assert.ok(jobMinutes - stepMinutes >= 2, `${id}: the step limit leaves time for setup and the uploads`);
|
||||
const upload = jobSteps.slice(testAt + 1).find(step => /^ {10}path: tools\/vendor\/test-results\/$/m.test(step));
|
||||
assert.ok(upload, `${id} uploads tools/vendor/test-results/ (traces and blob report) after the tests`);
|
||||
assert.match(upload, /^ {8}if: always\(\)$/m, `${id} uploads them when the step failed or timed out`);
|
||||
}
|
||||
});
|
||||
|
||||
// Every job is bounded (#350): a hang ends at a measured limit instead of GitHub's 6-hour default.
|
||||
const GATING_WORKFLOWS = ['test.yml', 'code-quality.yml', 'viewerjs-check.yml'];
|
||||
const BOUNDED_WORKFLOWS = [...GATING_WORKFLOWS, 'portability.yml'];
|
||||
|
||||
Vendored
+4
-3
@@ -35,15 +35,16 @@ test('execution script discovers only the new matrix and preserves all 78 origin
|
||||
assert.match(executions, /\[nodes2-start\]/);
|
||||
// + the fresh-host library location case, + #342 image attribution, + #343 linked text,
|
||||
// + #332 image category routes on a generated output (image-attribution.spec.mjs, 2 projects),
|
||||
// + #347 cached runtime-linked runs: five node kinds and eight one-renderer cases (execution-text-link.spec.mjs, 2 projects)
|
||||
assert.match(executions, /Total: 118 tests in 5 files/);
|
||||
// + #347 cached runtime-linked runs: five node kinds and eight one-renderer cases (execution-text-link.spec.mjs, 2 projects),
|
||||
// + #90 Save to library off (five node types)
|
||||
assert.match(executions, /Total: 128 tests in 5 files/);
|
||||
for (const project of ['classic-start', 'nodes2-start']) {
|
||||
const cases = executions.split('\n').filter(line => line.includes(`[${project}]`));
|
||||
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 15);
|
||||
assert.equal(cases.filter(line => line.includes('execution-outputs.spec.mjs')).length, 8);
|
||||
assert.equal(cases.filter(line => line.includes('library-location.spec.mjs')).length, 1);
|
||||
assert.equal(cases.filter(line => line.includes('image-attribution.spec.mjs')).length, 3);
|
||||
assert.equal(cases.filter(line => line.includes('execution-text-link.spec.mjs')).length, 32);
|
||||
assert.equal(cases.filter(line => line.includes('execution-text-link.spec.mjs')).length, 37);
|
||||
}
|
||||
assert.doesNotMatch(executions, /prompt-manager.spec/);
|
||||
});
|
||||
|
||||
+47
-2
@@ -1,9 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { plan, runCoverage } from '../../tools/vendor/frontend-coverage.mjs';
|
||||
import { plan, runCoverage, runNode } from '../../tools/vendor/frontend-coverage.mjs';
|
||||
|
||||
const steps = argv => plan(argv).map(({ step, ...options }) => (Object.keys(options).length ? { step, ...options } : step));
|
||||
|
||||
@@ -91,3 +93,46 @@ test('shard 1 runs the node units into the shared raw dir, then the viewer suite
|
||||
assert.ok(calls[1].args.includes(join(paths.tools, 'playwright.config.mjs')));
|
||||
assert.deepEqual(calls[2].args.slice(-2), ['--shard', '1/3']);
|
||||
});
|
||||
|
||||
// #425: npm and a CI step timeout signal only frontend-coverage.mjs; its running child must get it.
|
||||
function hostWithSignals() {
|
||||
return new EventEmitter();
|
||||
}
|
||||
|
||||
test('runNode resolves on exit 0, rejects with the exit code otherwise and leaves no signal listener', async () => {
|
||||
const host = hostWithSignals();
|
||||
await runNode(['-e', 'process.exit(0)'], {}, { host });
|
||||
await assert.rejects(runNode(['-e', 'process.exit(3)'], {}, { host }), /Command failed \(3\): -e process\.exit\(3\)/);
|
||||
assert.equal(host.listenerCount('SIGTERM') + host.listenerCount('SIGINT'), 0);
|
||||
});
|
||||
|
||||
test('runNode forwards SIGTERM to the running child, which can report before it exits', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'pm-coverage-signal-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const ready = join(root, 'ready');
|
||||
const seen = join(root, 'seen');
|
||||
const child = `const fs = require('node:fs');
|
||||
process.on('SIGTERM', () => { fs.writeFileSync(${JSON.stringify(seen)}, 'SIGTERM'); process.exit(7); });
|
||||
fs.writeFileSync(${JSON.stringify(ready)}, ''); setInterval(() => {}, 1000);`;
|
||||
const host = hostWithSignals();
|
||||
const running = runNode(['-e', child], {}, { host });
|
||||
while (!existsSync(ready)) await delay(20);
|
||||
host.emit('SIGTERM');
|
||||
await assert.rejects(running, /Command failed \(7\)/);
|
||||
assert.equal(readFileSync(seen, 'utf8'), 'SIGTERM', 'the child handled the forwarded signal');
|
||||
assert.equal(host.listenerCount('SIGTERM'), 0);
|
||||
});
|
||||
|
||||
test('runNode forwards SIGINT and starts the child in its own process group', async () => {
|
||||
const host = hostWithSignals();
|
||||
const kills = [];
|
||||
let options;
|
||||
const fake = new EventEmitter();
|
||||
fake.kill = signal => { kills.push(signal); queueMicrotask(() => fake.emit('exit', null, signal)); };
|
||||
const running = runNode(['x.mjs'], {}, { host, spawnChild: (command, args, spawnOptions) => { options = spawnOptions; return fake; } });
|
||||
assert.equal(options.detached, true, 'a group-wide signal reaches the child only through forwarding, once');
|
||||
host.emit('SIGINT');
|
||||
await assert.rejects(running, /Command failed \(null\): x\.mjs/);
|
||||
assert.deepEqual(kills, ['SIGINT']);
|
||||
assert.equal(host.listenerCount('SIGINT'), 0);
|
||||
});
|
||||
|
||||
@@ -107,6 +107,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"save-to-library.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"scan-progress.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
|
||||
Vendored
+26
-9
@@ -1,7 +1,7 @@
|
||||
import { existsSync, readFileSync, readdirSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { repositoryRoot } from './vendor-viewerjs.mjs';
|
||||
|
||||
import { discoverSources, checkCoverage } from '@comfyassets/toolkit/testing/coverage';
|
||||
@@ -10,10 +10,27 @@ const metrics = ['lines', 'branches', 'functions', 'statements'];
|
||||
const SHARD = /^([1-9]\d*)\/([1-9]\d*)$/;
|
||||
const USAGE = 'usage: frontend-coverage.mjs [--update-baseline] | --shard I/N | --merge-only [--update-baseline]';
|
||||
|
||||
function runNode(args, env = {}) {
|
||||
const result = spawnSync(process.execPath, args, { cwd: repositoryRoot, stdio: 'inherit',
|
||||
env: { ...process.env, ...env } });
|
||||
if (result.status !== 0) throw new Error(`Command failed (${result.status}): ${args.join(' ')}`);
|
||||
const FORWARDED_SIGNALS = ['SIGTERM', 'SIGINT'];
|
||||
|
||||
// Runs one step's node child and waits for it. npm and a CI step timeout signal only this process,
|
||||
// so SIGTERM and SIGINT are forwarded to the running child (#425): Playwright then reports the tests
|
||||
// still running and writes its blob. The child gets its own process group, so a signal sent to the
|
||||
// whole group (Ctrl-C, `timeout`) still reaches it exactly once, through this forwarding.
|
||||
export function runNode(args, env = {}, { spawnChild = spawn, host = process } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawnChild(process.execPath, args, { cwd: repositoryRoot, stdio: 'inherit',
|
||||
env: { ...process.env, ...env }, detached: true });
|
||||
const forward = signal => () => child.kill(signal);
|
||||
const handlers = FORWARDED_SIGNALS.map(signal => [signal, forward(signal)]);
|
||||
for (const [signal, handler] of handlers) host.on(signal, handler);
|
||||
const done = () => { for (const [signal, handler] of handlers) host.removeListener(signal, handler); };
|
||||
child.once('error', error => { done(); reject(error); });
|
||||
child.once('exit', code => {
|
||||
done();
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Command failed (${code}): ${args.join(' ')}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function defaultPaths() {
|
||||
@@ -112,17 +129,17 @@ export async function runCoverage({ argv = process.argv.slice(2), run = runNode,
|
||||
'node-units'() {
|
||||
const tests = readdirSync(join(root, 'tests/js')).filter(file => file.endsWith('.test.js'))
|
||||
.map(file => `tests/js/${file}`);
|
||||
run(['--test', ...tests], { NODE_V8_COVERAGE: raw });
|
||||
return run(['--test', ...tests], { NODE_V8_COVERAGE: raw });
|
||||
},
|
||||
viewer() {
|
||||
run([playwright, 'test', '--config', join(tools, 'playwright.config.mjs')]);
|
||||
return run([playwright, 'test', '--config', join(tools, 'playwright.config.mjs')]);
|
||||
},
|
||||
workflows({ shard }) {
|
||||
run([playwright, 'test', '--config', join(tools, 'playwright.workflows.config.mjs'),
|
||||
return run([playwright, 'test', '--config', join(tools, 'playwright.workflows.config.mjs'),
|
||||
'--project', 'chromium', '--project', 'touch', ...(shard ? ['--shard', shard] : [])], { PM_JS_COVERAGE: raw });
|
||||
},
|
||||
report() {
|
||||
run([join(tools, 'node_modules/c8/bin/c8.js'), 'report', '--all', '--src', sourceRoot,
|
||||
return run([join(tools, 'node_modules/c8/bin/c8.js'), 'report', '--all', '--src', sourceRoot,
|
||||
'--include', 'web/js/**/*.js', '--include', 'web/js/**/*.mjs', '--temp-directory', raw, '--reports-dir', reportRoot,
|
||||
'--reporter', 'text', '--reporter', 'json-summary', '--reporter', 'html']);
|
||||
},
|
||||
|
||||
Vendored
+2
-1
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
import { reporters } from './test-workers.mjs';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
@@ -8,7 +9,7 @@ export default defineConfig({
|
||||
workers: 2,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: 'list',
|
||||
reporter: reporters({ name: 'viewer' }),
|
||||
use: { browserName: 'chromium', headless: true, trace: 'retain-on-failure' },
|
||||
projects: [
|
||||
{ name: 'desktop', use: { viewport: { width: 1280, height: 800 } } },
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { workflowWorkers } from './test-workers.mjs';
|
||||
import { reporters, workflowWorkers } from './test-workers.mjs';
|
||||
|
||||
// No global webServer: tests/browser/fixtures.mjs starts one server per worker
|
||||
// on an OS-chosen port. Files run in parallel; tests inside a file keep their order.
|
||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
fullyParallel: false,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
reporter: reporters({ name: 'workflows' }),
|
||||
use: { trace: 'retain-on-failure', screenshot: 'only-on-failure' },
|
||||
projects: [
|
||||
{ name: 'chromium', outputDir: './test-results/workflows/chromium', use: { browserName: 'chromium' } },
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { writeSync } from 'node:fs';
|
||||
import { relative } from 'node:path';
|
||||
|
||||
// Names the tests still running when a run is cut off (#425). A browser test step that reaches its
|
||||
// timeout-minutes gets SIGTERM, which ends Playwright at once: no reporter's onEnd runs, so neither
|
||||
// `list` nor the blob report says what was running. On the first SIGTERM this reporter prints every
|
||||
// started test that has not ended, then turns the signal into SIGINT, Playwright's own interrupt,
|
||||
// which stops the workers, ends the run as interrupted (non-zero exit) and writes the blob report.
|
||||
// A second SIGTERM gets the default behaviour: the process dies.
|
||||
export default class RunningTestsReporter {
|
||||
constructor({ now = () => Date.now(), write = text => writeSync(1, text), host = process } = {}) {
|
||||
this.now = now;
|
||||
this.write = write;
|
||||
this.host = host;
|
||||
this.rootDir = host.cwd();
|
||||
this.running = new Map();
|
||||
this.onSigterm = () => {
|
||||
this.host.removeListener('SIGTERM', this.onSigterm);
|
||||
this.report('SIGTERM');
|
||||
this.host.kill(this.host.pid, 'SIGINT');
|
||||
};
|
||||
}
|
||||
|
||||
printsToStdio() {
|
||||
return false;
|
||||
}
|
||||
|
||||
onBegin(config) {
|
||||
this.rootDir = config.rootDir;
|
||||
this.host.on('SIGTERM', this.onSigterm);
|
||||
}
|
||||
|
||||
onTestBegin(test, result) {
|
||||
this.running.set(`${test.id}#${result.retry}`, { test, startedAt: this.now() });
|
||||
}
|
||||
|
||||
onTestEnd(test, result) {
|
||||
this.running.delete(`${test.id}#${result.retry}`);
|
||||
}
|
||||
|
||||
describe({ test, startedAt }) {
|
||||
const project = test.parent?.project()?.name;
|
||||
const where = `${relative(this.rootDir, test.location.file)}:${test.location.line}`;
|
||||
const seconds = ((this.now() - startedAt) / 1000).toFixed(1);
|
||||
return `${project ? `[${project}] › ` : ''}${where} › ${test.title} (running ${seconds}s)`;
|
||||
}
|
||||
|
||||
report(reason) {
|
||||
if (!this.running.size) return;
|
||||
const lines = [...this.running.values()].map(entry => ` ${this.describe(entry)}`);
|
||||
this.write(`\nStill running when the run ended (${reason}): ${this.running.size}\n${lines.join('\n')}\n`);
|
||||
}
|
||||
|
||||
onEnd(result) {
|
||||
this.host.removeListener('SIGTERM', this.onSigterm);
|
||||
this.report(result.status);
|
||||
}
|
||||
}
|
||||
Vendored
+11
@@ -1,4 +1,8 @@
|
||||
import { availableParallelism } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Absolute, so a config that imports this one from another directory still finds it.
|
||||
const RUNNING_TESTS_REPORTER = fileURLToPath(new URL('./running-tests-reporter.mjs', import.meta.url));
|
||||
|
||||
const MAX_LOCAL_WORKERS = 8;
|
||||
const CPUS_PER_WORKER = 4;
|
||||
@@ -16,3 +20,10 @@ export function workflowWorkers({ env = process.env, cpus = availableParallelism
|
||||
if (env.CI) return 1;
|
||||
return Math.max(1, Math.min(MAX_LOCAL_WORKERS, Math.floor(cpus / CPUS_PER_WORKER)));
|
||||
}
|
||||
|
||||
// Under CI, running-tests-reporter.mjs names the tests a step timeout's signal cut off (`list`
|
||||
// cannot: they never end), and a blob report beside the traces is uploaded with them.
|
||||
export function reporters({ env = process.env, name } = {}) {
|
||||
if (!env.CI) return 'list';
|
||||
return [['list'], [RUNNING_TESTS_REPORTER], ['blob', { outputDir: `./test-results/blob/${name}` }]];
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import weakref
|
||||
|
||||
from .execution_context_budget import ContextBudget, PayloadLease, SealedContext
|
||||
from .execution_context_graph import (
|
||||
NOT_SAVED,
|
||||
GraphProjection,
|
||||
GraphRole,
|
||||
SourceRef,
|
||||
@@ -325,6 +326,8 @@ class ContextCapture:
|
||||
nonlocal ambiguous
|
||||
if role.reason == "source-transformed":
|
||||
return _unknown("unsupported")
|
||||
if role.reason == NOT_SAVED:
|
||||
return _unknown("unavailable")
|
||||
if not role.sources:
|
||||
return role.inferred or _unknown("unsupported")
|
||||
values = [
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from .execution_contexts import PromptSnapshot, TextEvidence, text_size
|
||||
from .prompt_graph import save_setting
|
||||
from .prompt_pair_resolver import _DEFAULT_ADAPTERS
|
||||
|
||||
# A role whose PromptManager source has save_to_library off, or wired from a
|
||||
# node the graph cannot read: no text is recorded for it (#90)
|
||||
NOT_SAVED = "not_saved"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceRef:
|
||||
@@ -332,6 +337,11 @@ class _Projector:
|
||||
self.transformed = False
|
||||
# Only the capture-source-to-consumer path determines transforms.
|
||||
sources = self.discover_sources(value, "conditioning")
|
||||
if any(
|
||||
save_setting(self.graph, source.node_id) is not True
|
||||
for source in sources
|
||||
):
|
||||
return GraphRole((), None, NOT_SAVED)
|
||||
snapshot, _ = self.text(value, "conditioning")
|
||||
return GraphRole(
|
||||
sources, snapshot, "source-transformed" if self.transformed else ""
|
||||
|
||||
+36
-3
@@ -37,7 +37,13 @@ from .hashing import generate_prompt_hash
|
||||
from .metadata_extractor import ComfyUIMetadataExtractor
|
||||
from .parallel import note_refusal, refused_at_shutdown
|
||||
from .logging_config import get_logger
|
||||
from .prompt_graph import resolve_text, run_prompt_nodes
|
||||
from .prompt_graph import (
|
||||
metadata_opted_out,
|
||||
resolve_text,
|
||||
run_prompt_nodes,
|
||||
save_setting,
|
||||
side_opted_out,
|
||||
)
|
||||
from .prompt_pair_resolver import resolve_prompt_pair
|
||||
from .run_keys import image_run_keys
|
||||
from .usage_tracking import survive_switch
|
||||
@@ -450,6 +456,12 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
# that produced this image, independent of node caching and of any
|
||||
# negative-prompt PromptManager nodes in the workflow.
|
||||
state, current_prompt = self._find_prompt_from_metadata(metadata)
|
||||
if state == "opted_out":
|
||||
self.logger.info(
|
||||
"Save to library is off for this run's prompt, skipping image: "
|
||||
f"{image_path}"
|
||||
)
|
||||
return
|
||||
if state == "foreign":
|
||||
# Another workflow saved this image: it belongs to no prompt of
|
||||
# ours, and must not consume a batch queue entry.
|
||||
@@ -715,16 +727,27 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
``("linked", prompt)`` with a context dict holding 'id' and 'text';
|
||||
``("foreign", None)`` when the workflow has no positive PromptManager
|
||||
node, so the image belongs to no prompt of ours;
|
||||
``("opted_out", None)`` when a PromptManager node that would be the
|
||||
run's prompt has its ``save_to_library`` switch off (#90): the image
|
||||
is linked to no prompt, by no fallback;
|
||||
``("unknown", None)`` when metadata can't decide (missing, a batch
|
||||
item whose text came from another node, or a lookup failure) and the
|
||||
caller's queue and tracker fallbacks apply.
|
||||
"""
|
||||
if not metadata:
|
||||
return "unknown", None
|
||||
if metadata_opted_out(metadata):
|
||||
return "opted_out", None
|
||||
graph = metadata.get("prompt")
|
||||
if isinstance(graph, dict) and any(
|
||||
save_setting(graph, node_id) is None for node_id in run_prompt_nodes(graph)
|
||||
):
|
||||
# A switch wired from a node the graph cannot read: only the node's
|
||||
# own run (it saw the real value) can link the image (#90)
|
||||
return "unknown", None
|
||||
|
||||
prompt_text = None
|
||||
resolution = resolve_prompt_pair(metadata)
|
||||
graph = metadata.get("prompt")
|
||||
if resolution["state"] == "resolved" and isinstance(graph, dict):
|
||||
positive = resolution["positive"]
|
||||
path = positive["provenance"].get("path", [])
|
||||
@@ -865,13 +888,23 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
metadata: Extracted metadata from the image file (workflow, parameters,
|
||||
etc.)
|
||||
"""
|
||||
if metadata_opted_out(metadata):
|
||||
# Reached from a run or history: the file's own graph says no (#90)
|
||||
self.logger.info(
|
||||
f"Save to library is off for {os.path.basename(image_path)}; "
|
||||
"left unlinked"
|
||||
)
|
||||
return
|
||||
try:
|
||||
resolution = resolve_prompt_pair(metadata)
|
||||
graph = metadata.get("prompt") if isinstance(metadata, dict) else None
|
||||
# An opted-out negative is never stored: link the positive alone
|
||||
pair_allowed = not side_opted_out(graph, resolution.get("negative"))
|
||||
|
||||
def link():
|
||||
# One write; run again on the moved library when a Move
|
||||
# library switched while it waited (#249 S6b).
|
||||
if resolution["state"] == "resolved":
|
||||
if resolution["state"] == "resolved" and pair_allowed:
|
||||
positive = resolution["positive"]
|
||||
expected = self.db_manager.get_prompt_by_hash(
|
||||
generate_prompt_hash(
|
||||
|
||||
+184
-18
@@ -21,6 +21,11 @@ PROMPT_MANAGER_TYPES = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# A node's switch (#90): False means PromptManager ignores that node's runs
|
||||
SAVE_INPUT = "save_to_library"
|
||||
# Core nodes whose output is a known boolean: (class_type) -> input holding it
|
||||
_BOOLEAN_SOURCES = {"PrimitiveBoolean": "value"}
|
||||
|
||||
# Upper bound on nodes expanded per graph; queued and embedded graphs are untrusted
|
||||
MAX_VISITS = 500
|
||||
# Longest text resolve_text will build; StringConcatenate chains can double
|
||||
@@ -59,6 +64,116 @@ def _inputs(node: Any) -> Dict[str, Any]:
|
||||
return inputs if isinstance(inputs, dict) else {}
|
||||
|
||||
|
||||
def _as_boolean(value: Any) -> bool:
|
||||
"""ComfyUI's coercion of a BOOLEAN input before the node runs."""
|
||||
if isinstance(value, dict) and "__value__" in value:
|
||||
value = value["__value__"]
|
||||
return bool(value)
|
||||
|
||||
|
||||
def save_setting(graph: Any, node_id: Any) -> Optional[bool]:
|
||||
"""A PromptManager node's ``save_to_library`` as the node will receive it.
|
||||
|
||||
True when the input is missing (every workflow and image from before the
|
||||
switch), the literal coerced like ComfyUI does, the value of a linked core
|
||||
``PrimitiveBoolean``, and None when the switch is wired from a node whose
|
||||
output cannot be known from the graph.
|
||||
"""
|
||||
node = graph.get(str(node_id)) if isinstance(graph, dict) else None
|
||||
inputs = _inputs(node)
|
||||
if SAVE_INPUT not in inputs:
|
||||
return True
|
||||
value = inputs[SAVE_INPUT]
|
||||
if not _is_link(value):
|
||||
return _as_boolean(value)
|
||||
source = graph.get(str(value[0]))
|
||||
if not isinstance(source, dict) or value[1] != 0:
|
||||
return None
|
||||
key = _BOOLEAN_SOURCES.get(str(source.get("class_type", "")))
|
||||
source_inputs = _inputs(source)
|
||||
if key is None or key not in source_inputs or _is_link(source_inputs[key]):
|
||||
return None
|
||||
return _as_boolean(source_inputs[key])
|
||||
|
||||
|
||||
def opted_out(graph: Any, node_id: Any) -> bool:
|
||||
"""Whether a PromptManager node's switch is off: PromptManager ignores it."""
|
||||
return save_setting(graph, node_id) is False
|
||||
|
||||
|
||||
def side_opted_out(graph: Any, side: Any) -> bool:
|
||||
"""Whether a resolved side's path passes a PromptManager node that is off.
|
||||
|
||||
``side`` is a resolver side (``positive`` or ``negative`` of a resolved
|
||||
pair): its text must not be stored when any PromptManager node on its
|
||||
provenance path has its switch off or wired from an unreadable node (#90).
|
||||
"""
|
||||
if not isinstance(graph, dict) or not isinstance(side, dict):
|
||||
return False
|
||||
provenance = side.get("provenance")
|
||||
path = provenance.get("path") if isinstance(provenance, dict) else None
|
||||
for step in path if isinstance(path, list) else []:
|
||||
node_id = step.get("node_id") if isinstance(step, dict) else None
|
||||
node = graph.get(str(node_id)) if node_id is not None else None
|
||||
if (
|
||||
isinstance(node, dict)
|
||||
and node.get("class_type") in PROMPT_MANAGER_TYPES
|
||||
and save_setting(graph, node_id) is not True
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _workflow_node_opted_out(node: Any) -> bool:
|
||||
"""A saved-workflow PromptManager node whose widget values hold ``False``.
|
||||
|
||||
No other PromptManager widget holds a boolean, so the value is the switch.
|
||||
"""
|
||||
if not isinstance(node, dict):
|
||||
return False
|
||||
if (node.get("type") or node.get("class_type")) not in PROMPT_MANAGER_TYPES:
|
||||
return False
|
||||
if node.get("mode") in (2, 4):
|
||||
return False # muted or bypassed: it did not run
|
||||
values = node.get("widgets_values")
|
||||
return isinstance(values, list) and any(value is False for value in values)
|
||||
|
||||
|
||||
def metadata_opted_out(metadata: Any, *, unreadable: bool = False) -> bool:
|
||||
"""Whether a file's metadata says its run's prompt does not save (#90).
|
||||
|
||||
The executed graph (``prompt``) decides when present: a node of
|
||||
``positive_opt_outs``, or with ``unreadable`` also a run prompt whose
|
||||
switch is wired from a node the graph cannot read. Without one, the
|
||||
saved workflow (``workflow`` nodes or the extractor's
|
||||
``text_encoder_nodes``) is opted out when a PromptManager node stores
|
||||
``False``.
|
||||
"""
|
||||
if not isinstance(metadata, dict):
|
||||
return False
|
||||
graph = metadata.get("prompt")
|
||||
if isinstance(graph, dict):
|
||||
if positive_opt_outs(graph):
|
||||
return True
|
||||
return unreadable and any(
|
||||
save_setting(graph, node_id) is None for node_id in run_prompt_nodes(graph)
|
||||
)
|
||||
workflow = metadata.get("workflow")
|
||||
nodes = list(metadata.get("text_encoder_nodes") or [])
|
||||
if isinstance(workflow, dict) and isinstance(workflow.get("nodes"), list):
|
||||
nodes.extend(workflow["nodes"])
|
||||
definitions = workflow.get("definitions")
|
||||
subgraphs = (
|
||||
definitions.get("subgraphs") if isinstance(definitions, dict) else None
|
||||
)
|
||||
for subgraph in subgraphs if isinstance(subgraphs, list) else []:
|
||||
inner = subgraph.get("nodes") if isinstance(subgraph, dict) else None
|
||||
nodes.extend(inner if isinstance(inner, list) else [])
|
||||
# Without the executed graph the node's role is unknown: any opted-out
|
||||
# PromptManager node opts the file out (never a guessed link).
|
||||
return any(_workflow_node_opted_out(node) for node in nodes[:MAX_VISITS])
|
||||
|
||||
|
||||
def _positive_roots(graph: Dict[str, Any]) -> List[Any]:
|
||||
"""Links into samplers' positive inputs (KSampler, CFGGuider, BasicGuider, ...).
|
||||
|
||||
@@ -100,11 +215,21 @@ def has_sampler(graph: Any) -> bool:
|
||||
|
||||
|
||||
def positive_prompt_components(graph: Any) -> List[Tuple[str, str]]:
|
||||
"""Node/input identities reaching actual sampler positive wires."""
|
||||
"""Node/input identities reaching actual sampler positive wires.
|
||||
|
||||
A node whose switch is off is not one, and nothing upstream of it is
|
||||
followed (#90): see ``positive_opt_outs``.
|
||||
"""
|
||||
return _walk_positive(graph)[0]
|
||||
|
||||
|
||||
def _walk_positive(graph: Any) -> Tuple[List[Tuple[str, str]], List[str]]:
|
||||
"""(components of enabled nodes, opted-out nodes) on sampler positive wires."""
|
||||
if not isinstance(graph, dict):
|
||||
return []
|
||||
return [], []
|
||||
|
||||
found: List[Tuple[str, str]] = []
|
||||
skipped: List[str] = []
|
||||
seen = set()
|
||||
stack = list(reversed(_positive_roots(graph)))
|
||||
while stack and len(seen) < MAX_VISITS:
|
||||
@@ -117,6 +242,10 @@ def positive_prompt_components(graph: Any) -> List[Tuple[str, str]]:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if node.get("class_type") in PROMPT_MANAGER_TYPES:
|
||||
if opted_out(graph, node_id):
|
||||
if node_id not in skipped:
|
||||
skipped.append(node_id)
|
||||
continue
|
||||
field = "text"
|
||||
if node.get("class_type") == "PromptManagerPair":
|
||||
field = {
|
||||
@@ -132,7 +261,33 @@ def positive_prompt_components(graph: Any) -> List[Tuple[str, str]]:
|
||||
found.append(identity)
|
||||
continue
|
||||
stack.extend(reversed(_upstream_links(_inputs(node))))
|
||||
return found
|
||||
return found, skipped
|
||||
|
||||
|
||||
def _sampler_less_nodes(graph: Dict[str, Any]) -> List[str]:
|
||||
"""Every PromptManager node of a graph without a recognisable sampler."""
|
||||
return [
|
||||
str(node_id)
|
||||
for node_id, node in graph.items()
|
||||
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
|
||||
]
|
||||
|
||||
|
||||
def positive_opt_outs(graph: Any) -> List[str]:
|
||||
"""PromptManager nodes with their switch off that would be a run's prompt.
|
||||
|
||||
Those on a sampler's positive wires, or, in a graph without a recognisable
|
||||
sampler, any. A run with one of them links no file to any prompt (#90):
|
||||
the file's graph cannot always say which sampler wrote it.
|
||||
"""
|
||||
if not isinstance(graph, dict):
|
||||
return []
|
||||
found, skipped = _walk_positive(graph)
|
||||
if found or skipped or has_sampler(graph):
|
||||
return skipped
|
||||
return [
|
||||
node_id for node_id in _sampler_less_nodes(graph) if opted_out(graph, node_id)
|
||||
]
|
||||
|
||||
|
||||
def positive_prompt_nodes(graph: Any) -> List[str]:
|
||||
@@ -148,13 +303,13 @@ def positive_prompt_nodes(graph: Any) -> List[str]:
|
||||
|
||||
def run_prompt_components(graph: Any) -> List[Tuple[str, str]]:
|
||||
"""Actual positive components, retaining standalone main-editor fallback."""
|
||||
components = positive_prompt_components(graph)
|
||||
if components or not isinstance(graph, dict) or has_sampler(graph):
|
||||
components, skipped = _walk_positive(graph)
|
||||
if components or skipped or not isinstance(graph, dict) or has_sampler(graph):
|
||||
return components
|
||||
return [
|
||||
(str(node_id), "text")
|
||||
for node_id, node in graph.items()
|
||||
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
|
||||
(node_id, "text")
|
||||
for node_id in _sampler_less_nodes(graph)
|
||||
if not opted_out(graph, node_id)
|
||||
]
|
||||
|
||||
|
||||
@@ -165,16 +320,16 @@ def run_prompt_nodes(graph: Any) -> List[str]:
|
||||
say), every PromptManager node counts, as before 3.2.4, so an exotic workflow
|
||||
never silently loses usage counting or image linking. When a sampler exists
|
||||
and no PromptManager node feeds its positive input, nothing counts: the only
|
||||
PromptManager nodes are negative prompts.
|
||||
PromptManager nodes are negative prompts. A node whose switch is off never
|
||||
counts (#90).
|
||||
"""
|
||||
positive = positive_prompt_nodes(graph)
|
||||
if positive or not isinstance(graph, dict) or has_sampler(graph):
|
||||
return positive
|
||||
return [
|
||||
str(node_id)
|
||||
for node_id, node in graph.items()
|
||||
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
|
||||
]
|
||||
return list(
|
||||
dict.fromkeys(
|
||||
node_id
|
||||
for node_id, field in run_prompt_components(graph)
|
||||
if field == "text"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _join_text_inputs(
|
||||
@@ -270,7 +425,18 @@ def resolve_text(graph: Any, node_id: str) -> Optional[str]:
|
||||
|
||||
|
||||
def intrinsic_pair_nodes(graph: Any) -> List[str]:
|
||||
"""Paired nodes whose matching outputs feed the same sampler, or standalone."""
|
||||
"""Paired nodes whose matching outputs feed the same sampler, or standalone.
|
||||
|
||||
A pair whose switch is off is never one (#90).
|
||||
"""
|
||||
return [
|
||||
node_id
|
||||
for node_id in _intrinsic_pair_nodes(graph)
|
||||
if not opted_out(graph, node_id)
|
||||
]
|
||||
|
||||
|
||||
def _intrinsic_pair_nodes(graph: Any) -> List[str]:
|
||||
if not isinstance(graph, dict) or len(graph) > MAX_VISITS:
|
||||
return []
|
||||
if not has_sampler(graph):
|
||||
|
||||
+13
-1
@@ -30,7 +30,12 @@ from typing import Any, Callable, List, Optional, Tuple
|
||||
try:
|
||||
from .hashing import generate_prompt_hash
|
||||
from .logging_config import get_logger
|
||||
from .prompt_graph import intrinsic_pair_nodes, resolve_text, run_prompt_components
|
||||
from .prompt_graph import (
|
||||
intrinsic_pair_nodes,
|
||||
resolve_text,
|
||||
run_prompt_components,
|
||||
save_setting,
|
||||
)
|
||||
from .prompt_pair_resolver import resolve_text_input
|
||||
from .pair_execution import clear_pair_execution
|
||||
from .queue_observers import subscribe_dequeue
|
||||
@@ -41,6 +46,7 @@ except ImportError:
|
||||
intrinsic_pair_nodes,
|
||||
resolve_text,
|
||||
run_prompt_components,
|
||||
save_setting,
|
||||
)
|
||||
from utils.prompt_pair_resolver import resolve_text_input
|
||||
from utils.pair_execution import clear_pair_execution
|
||||
@@ -127,10 +133,14 @@ def queued_prompt_runs(graph: Any) -> List[Run]:
|
||||
"""(hash, text) of each positive PromptManager node whose text is knowable.
|
||||
|
||||
Each hash once: the same text in two positive nodes is one run of the prompt.
|
||||
A node whose switch is off is never counted; one whose switch is wired from
|
||||
a node the graph cannot read counts itself when it executes (#90).
|
||||
"""
|
||||
runs: List[Run] = []
|
||||
seen = set()
|
||||
for node_id, field in run_prompt_components(graph):
|
||||
if save_setting(graph, node_id) is not True:
|
||||
continue
|
||||
node = graph.get(node_id, {}) if isinstance(graph, dict) else {}
|
||||
text = (
|
||||
resolve_text_input(graph, node_id, field)
|
||||
@@ -156,6 +166,8 @@ def queued_pair_runs(graph: Any) -> List[Tuple[str, str, str]]:
|
||||
"""Resolve raw paired recipes only when both outputs form the used pair."""
|
||||
found, seen = [], set()
|
||||
for node_id in intrinsic_pair_nodes(graph):
|
||||
if save_setting(graph, node_id) is not True:
|
||||
continue # wired switch: the node counts itself (#90)
|
||||
positive = resolve_text_input(graph, node_id, "text")
|
||||
negative = resolve_text_input(graph, node_id, "negative_text")
|
||||
if positive is None or not positive.strip() or negative is None:
|
||||
|
||||
@@ -6,6 +6,7 @@ const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/
|
||||
const { installFocusedNode, installSplitNode, installPairNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js'));
|
||||
const { installPromptBridge } = await import(api.fileURL('/prompt_manager/js/prompt-bridge-host.js'));
|
||||
const { collapseLinkedWidget, isLinked, linkNoteText, primitiveTextValue, storedWidgetValue } = await import(api.fileURL('/prompt_manager/js/text-links.js'));
|
||||
const { normalizeSaveWidget } = await import(api.fileURL('/prompt_manager/js/save-to-library.js'));
|
||||
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']);
|
||||
|
||||
// ============================================================================
|
||||
@@ -525,6 +526,8 @@ app.registerExtension({
|
||||
if (originalConfigure) {
|
||||
originalConfigure.call(this, data);
|
||||
}
|
||||
// #90: an older workflow loads with Save to library on
|
||||
normalizeSaveWidget(this);
|
||||
if (data._userHasResized) {
|
||||
this._userHasResized = data._userHasResized;
|
||||
}
|
||||
|
||||
+31
-4
@@ -2,9 +2,13 @@ import {createScope, createButton, createField, mountTheme} from './shared/toolk
|
||||
import {createPromptLibrary} from './prompt-library.js';
|
||||
import {attachMetadataAutocomplete} from './metadata-autocomplete.js';
|
||||
import {exposeTextSockets, linkNoteText, preferTextInputs, textInputAtPoint} from './text-links.js';
|
||||
import {SAVE_WIDGET, normalizeSaveWidget, placeSaveWidgetLast, saveWidget, savesToLibrary, setSaveToLibrary} from './save-to-library.js';
|
||||
|
||||
const names = ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text'];
|
||||
let nextEditorId = 0;
|
||||
// The editor draws these widgets itself; the host keeps them as hidden, zero-height values.
|
||||
function hiddenWidgetSize() { return [0, -4]; }
|
||||
function hideNativeWidget(widget) { widget.hidden = true; widget.type = 'hidden'; widget.computeSize = hiddenWidgetSize; }
|
||||
|
||||
function loadStyles() {
|
||||
const href = new URL('./prompt-editor.css', import.meta.url).href;
|
||||
@@ -43,10 +47,15 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
for (const name of pair ? [...names,'negative_text'] : names) {
|
||||
let widget = this.widgets?.find(item => item.name === name);
|
||||
if (!widget) widget = this.addWidget('text', name, '', () => {}, {});
|
||||
widget.hidden = true; widget.type = 'hidden'; widget.computeSize = () => [0, -4];
|
||||
hideNativeWidget(widget);
|
||||
widgets.set(name, widget);
|
||||
}
|
||||
exposeTextSockets(this, textInputs);
|
||||
// #90: the switch follows the hidden fallbacks above (one widget order on every frontend) and is drawn in the
|
||||
// toolbar, like the other hidden widgets are drawn by the editor.
|
||||
placeSaveWidgetLast(this);
|
||||
const nativeSave = saveWidget(this);
|
||||
if (nativeSave) hideNativeWidget(nativeSave);
|
||||
const element = document.createElement('div'); element.className = `ca-tools pm-focused${split ? ' pm-split' : ''}${pair ? ' pm-pair' : ''}`;
|
||||
element.setAttribute('aria-label',pair ? 'Paired prompt editor' : split ? 'Split prompt editor' : 'Focused prompt editor');
|
||||
loadStyles(); mountTheme(element, app.ui?.settings, scope);
|
||||
@@ -58,6 +67,11 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
toolbar.append(web);
|
||||
const save = pair ? createButton('Save pair','ca-primary') : null;
|
||||
if (save) toolbar.append(save);
|
||||
const saveSwitch = document.createElement('input'); saveSwitch.type = 'checkbox'; saveSwitch.dataset.saveToLibrary = '';
|
||||
const saveLabel = document.createElement('label'); saveLabel.className = 'pm-editor-save';
|
||||
saveLabel.title = 'Off: runs of this node are not saved, counted or linked to their images. The output is unchanged.';
|
||||
saveLabel.append(saveSwitch, document.createTextNode('Save to library'));
|
||||
if (nativeSave) toolbar.append(saveLabel);
|
||||
let saving = false;
|
||||
const workspace = document.createElement('div'); workspace.className = 'pm-editor-workspace';
|
||||
const editor = document.createElement('div'); editor.className = 'pm-editor-compose';
|
||||
@@ -138,7 +152,11 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
}
|
||||
const composed = disclosure('preview','Composed text preview');
|
||||
const preview = document.createElement('p'); preview.className = 'pm-editor-preview'; preview.dataset.composedPreview = ''; composed.append(preview);
|
||||
const footer = document.createElement('p'); footer.className = 'pm-editor-status'; footer.setAttribute('role','status'); footer.textContent = pair ? 'The prompt pair is saved when this node runs, or use Save pair.' : 'Main prompt is saved when this node runs.';
|
||||
const footer = document.createElement('p'); footer.className = 'pm-editor-status'; footer.setAttribute('role','status');
|
||||
const SAVE_OFF = 'Save to library is off: runs of this node are not added to the library.';
|
||||
const SAVE_LINKED = 'Save to library comes from a link: runs are added to the library only while it is on.';
|
||||
function runNote(saved) { return isConnected(SAVE_WIDGET) ? SAVE_LINKED : savesToLibrary(thisNode) ? saved : SAVE_OFF; }
|
||||
footer.textContent = runNote(pair ? 'The prompt pair is saved when this node runs, or use Save pair.' : 'Main prompt is saved when this node runs.');
|
||||
const saveConnectionNotice = pair ? document.createElement('p') : null;
|
||||
if (saveConnectionNotice) {
|
||||
saveConnectionNotice.className = 'pm-editor-status'; saveConnectionNotice.setAttribute('role','status');
|
||||
@@ -166,6 +184,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
mainField.hidden = Boolean(isConnected('text')); connected.hidden = !isConnected('text');
|
||||
if (negative) { negative.field.hidden = Boolean(isConnected('negative_text')); negative.note.hidden = !isConnected('negative_text'); }
|
||||
if (save) { save.disabled = saving || hasConnectedPairValues(); saveConnectionNotice.hidden = !hasConnectedPairValues(); }
|
||||
saveSwitch.checked = savesToLibrary(thisNode); saveSwitch.disabled = isConnected(SAVE_WIDGET);
|
||||
controls.forEach((input,name) => {
|
||||
input.value = value(name);
|
||||
if (name !== 'text' && name !== 'negative_text') {
|
||||
@@ -185,7 +204,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
write('category',prompt.category || '');
|
||||
write('tags',Array.isArray(prompt.tags) ? prompt.tags.join(', ') : prompt.tags || '');
|
||||
}
|
||||
render(); footer.setAttribute('role','status'); footer.textContent = pair ? 'Loaded pair. Saved when this node runs.' : 'Loaded prompt. Saved when this node runs.'; main.focus();
|
||||
render(); footer.setAttribute('role','status'); footer.textContent = runNote(pair ? 'Loaded pair. Saved when this node runs.' : 'Loaded prompt. Saved when this node runs.'); main.focus();
|
||||
}});
|
||||
workspace.append(library.element); element.append(footer);
|
||||
function snapshot() {
|
||||
@@ -197,7 +216,13 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
library.element.hidden = !split && !state.libraryOpen; browse.textContent = state.libraryOpen ? 'Hide library' : 'Browse library'; browse.setAttribute('aria-expanded', String(Boolean(state.libraryOpen)));
|
||||
library.setQuery(value('search_text')); library.restoreFilters(state.libraryFilters); render();
|
||||
}
|
||||
controls.forEach((input,name) => scope.listen(input,'input',() => {write(name,input.value);render();footer.setAttribute('role','status');footer.textContent=pair?'The pair is saved when this node runs.':'Main prompt is saved when this node runs.';}));
|
||||
controls.forEach((input,name) => scope.listen(input,'input',() => {write(name,input.value);render();footer.setAttribute('role','status');footer.textContent=runNote(pair?'The pair is saved when this node runs.':'Main prompt is saved when this node runs.');}));
|
||||
scope.listen(saveSwitch,'change',() => {
|
||||
if (isConnected(SAVE_WIDGET)) return;
|
||||
setSaveToLibrary(thisNode, saveSwitch.checked); render();
|
||||
footer.setAttribute('role','status'); footer.textContent = runNote(pair ? 'The pair is saved when this node runs.' : 'Main prompt is saved when this node runs.');
|
||||
thisNode.setDirtyCanvas(true,true); app.graph?.setDirtyCanvas(true,true);
|
||||
});
|
||||
scope.listen(browse,'click',() => {
|
||||
library.element.hidden = !library.element.hidden;
|
||||
library.closeCompletions();
|
||||
@@ -248,6 +273,8 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
const result = configure?.apply(this,args);
|
||||
// A saved workflow restores its inputs with their widget bindings; keep the text inputs as sockets.
|
||||
exposeTextSockets(this, textInputs);
|
||||
// #90: an older workflow loads with Save to library on
|
||||
normalizeSaveWidget(this);
|
||||
this._focusedView?.restore();
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
.pm-focused [hidden] { display: none !important; }
|
||||
.pm-editor-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.pm-editor-toolbar > strong { margin-inline-end: auto; font-weight: 500; }
|
||||
.pm-editor-save { display: inline-flex; align-items: center; gap: 6px; color: var(--ca-muted); cursor: pointer; }
|
||||
.pm-editor-save:has(input:checked) { color: inherit; }
|
||||
.pm-focused .ca-field-label { align-self: flex-start; margin-inline: 0; text-align: start; }
|
||||
.pm-editor-workspace, .pm-editor-compose { display: contents; }
|
||||
.pm-focused .pm-editor-main { display: flex; flex: 1; min-block-size: 180px; flex-direction: column; align-items: stretch; gap: 5px; text-align: start; }
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// #90: a PromptManager node's `save_to_library` switch. Off: PromptManager ignores the node's runs (nothing saved,
|
||||
// counted or linked); the node's output is unchanged.
|
||||
//
|
||||
// The input is declared last, so it takes the widget index an older workflow used for the editor's own
|
||||
// (non-serialized) DOM widget, where it stored `''` or nothing. ComfyUI coerces a BOOLEAN with `bool(value)`, so that
|
||||
// `''` would turn saving off: after a workflow loads, anything that is not a boolean is the default, on.
|
||||
|
||||
export const SAVE_WIDGET = 'save_to_library';
|
||||
|
||||
export function saveWidget(node) {
|
||||
return node?.widgets?.find(widget => widget?.name === SAVE_WIDGET);
|
||||
}
|
||||
|
||||
/** Whether the node saves its runs (a missing widget, as on an older backend, is on). */
|
||||
export function savesToLibrary(node) {
|
||||
return saveWidget(node)?.value !== false;
|
||||
}
|
||||
|
||||
/** Set a non-boolean switch value (from an older workflow) to on. Returns whether it changed. */
|
||||
export function normalizeSaveWidget(node) {
|
||||
const widget = saveWidget(node);
|
||||
if (!widget || typeof widget.value === 'boolean') return false;
|
||||
widget.value = true;
|
||||
const index = node.widgets.indexOf(widget);
|
||||
if (Array.isArray(node.widgets_values) && index >= 0) node.widgets_values[index] = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Move the switch after every other widget the node has now, so its index is the same on every frontend. */
|
||||
export function placeSaveWidgetLast(node) {
|
||||
const widget = saveWidget(node);
|
||||
const widgets = node?.widgets;
|
||||
if (!widget || widgets.at(-1) === widget) return false;
|
||||
widgets.splice(widgets.indexOf(widget), 1);
|
||||
widgets.push(widget);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Turn the switch on or off the way a native widget edit does. */
|
||||
export function setSaveToLibrary(node, value) {
|
||||
const widget = saveWidget(node);
|
||||
if (!widget) return;
|
||||
widget.value = Boolean(value);
|
||||
const index = node.widgets.indexOf(widget);
|
||||
if (Array.isArray(node.widgets_values) && index >= 0) node.widgets_values[index] = widget.value;
|
||||
widget.callback?.(widget.value, undefined, node);
|
||||
}
|
||||
Reference in New Issue
Block a user