Compare commits

...
Author SHA1 Message Date
Vito Sansevero d7827679de test(ui): use consistent unittest imports (#186) 2026-10-17 06:36:07 -07:00
Vito Sansevero 13c70e849e feat(ui): add Split Prompt Manager workspace (#186) 2026-10-17 05:51:19 -07:00
Vito a52ff4e052 feat(ui): add Focused Prompt Manager with shared autocomplete (#191)
* feat(ui): add Focused Prompt Manager node (#186)

* feat(ui): autocomplete saved Focused metadata with shared controls (#186)

* feat(ui): autocomplete Focused library filters (#186)
2026-10-17 05:31:24 -07:00
Vito 035e816db6 feat(ui): adopt shared toolkit for Prompt Manager Classic (#190)
* feat(ui): adopt shared toolkit for Prompt Manager Classic (#186)

* test(ui): retain Text node Web UI coverage alongside Classic

* fix(tooling): reject lookalike toolkit download URLs (#186)
2026-10-16 22:18:18 -07:00
Vito f98f6fa08d test(ci): validate shared toolkit in real PromptManager hosts (#189)
* test(ci): prove shared toolkit with real PromptManager host coverage

* test(ci): match KikoTools Python 3.13 compatibility coverage
2026-10-16 20:56:50 -07:00
Vito ce82dfb912 fix: quiet routine console logging and prevent duplicate output (#183) 2026-10-16 09:58:05 -07:00
Vito 5ded3189c1 test: cover backup restore and destructive browser workflows (#185) 2026-10-16 07:56:15 -07:00
78 changed files with 3411 additions and 61 deletions
+5 -1
View File
@@ -5,16 +5,20 @@
"py3.10 on ubuntu-latest",
"py3.11 on ubuntu-latest",
"py3.12 on ubuntu-latest",
"py3.13 on ubuntu-latest",
"py3.10 on windows-latest",
"py3.11 on windows-latest",
"py3.12 on windows-latest",
"py3.13 on windows-latest",
"py3.10 on macos-latest",
"py3.11 on macos-latest",
"py3.12 on macos-latest",
"py3.13 on macos-latest",
"coverage gate (85%)",
"black and flake8",
"frontend modules",
"viewerjs"
"viewerjs",
"real ComfyUI compatibility"
]
},
"enforce_admins": true,
+68 -2
View File
@@ -17,7 +17,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v7
@@ -37,6 +37,62 @@ jobs:
- name: Run tests
run: python -m unittest discover -s tests -v
comfyui:
name: real ComfyUI compatibility
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
cache-dependency-path: tools/vendor/package-lock.json
- name: Read the reviewed runtime pins
id: runtime
run: |
node --input-type=module <<'JS' >> "$GITHUB_OUTPUT"
import runtime from './tools/vendor/comfyui-runtime.json' with { type: 'json' };
for (const [key, value] of Object.entries(runtime)) console.log(`${key}=${value}`);
JS
- name: Check out pinned ComfyUI
uses: actions/checkout@v7
with:
repository: ${{ steps.runtime.outputs.repository }}
ref: ${{ steps.runtime.outputs.revision }}
path: .ci-comfyui
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: ${{ steps.runtime.outputs.pythonVersion }}
cache: pip
cache-dependency-path: |
requirements-dev.txt
.ci-comfyui/requirements.txt
tools/vendor/comfyui-runtime.json
- name: Install CPU runtime and test dependencies
run: |
python -m pip install --upgrade pip
pip install "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}" --index-url https://download.pytorch.org/whl/cpu
pip install -r .ci-comfyui/requirements.txt -r requirements-dev.txt "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}"
- run: npm ci --prefix tools/vendor --ignore-scripts
- run: npx --prefix tools/vendor playwright install --with-deps chromium
- name: Classic and Nodes 2.0 startup and compatibility
env:
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
PM_TEST_PYTHON: python
run: npm run --prefix tools/vendor test:comfyui
- name: Upload real-host reports and startup logs
if: always()
uses: actions/upload-artifact@v7
with:
name: real-comfyui-reports
path: tools/vendor/test-results/
if-no-files-found: ignore
retention-days: 14
coverage:
name: coverage gate (85%)
runs-on: ubuntu-latest
@@ -78,6 +134,16 @@ jobs:
steps:
- uses: actions/checkout@v7
# Pure-JS frontend modules, tested with the runner's preinstalled Node
- uses: actions/setup-node@v7
with:
node-version: "24"
- name: Check shipped extension and page modules parse
run: |
shopt -s globstar nullglob
for f in web/comfy/**/*.js web/comfy/**/*.mjs web/js/**/*.js web/js/**/*.mjs; do
node --input-type=module --check < "$f" || exit 1
done
- name: Run JS tests
run: node --test tests/js/*.test.js
+3
View File
@@ -208,3 +208,6 @@ playwright-report/
# Combined frontend coverage reports
coverage/
# Disposable upstream checkout for real-host CI
.ci-comfyui/
+26
View File
@@ -142,6 +142,32 @@ _Powered by [ViewerJS](https://github.com/fengyuanchen/viewerjs) for professiona
Viewer.js is vendored locally. See [Viewer.js maintenance](tools/vendor/README.md)
for reproducible updates, verification, and automated update PRs.
Prompt Manager appears as **Prompt Manager (Classic)** in the Add Node menu.
It keeps the existing layout and behavior while using shared
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit) controls
that follow ComfyUI's light/dark theme. Existing workflows keep the same internal
node identifier and sockets. The reusable architecture is inspired by
[Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma); no implementation or assets
were copied. See the [toolkit maintenance notes](tools/vendor/README.md#shared-comfyassets-browser-controls).
**Prompt Manager (Focused)** adds an editor-first layout with collapsible
composition and metadata fields. Open **Browse library** to search saved prompts,
filter by saved category and comma-separated tags, preview a selection, then choose
**Use prompt**; **Load metadata** controls whether
category and tags come with it. Metadata fields suggest saved categories and tags;
choose with the mouse or arrow keys and Enter. Tag selections append `, ` for the
next tag; category remains a single value. New values can still be typed freely.
Prepend and append STRING sockets remain visible
when the fields are collapsed, so LoRA trigger outputs can connect directly.
Encoding, storage, and CONDITIONING/text outputs match Classic. Both layouts
support ComfyUI Classic and Nodes 2.0.
**Prompt Manager (Split)** keeps the same editor and library visible together.
Wide nodes place the library beside the editor; narrow nodes stack the panels.
It shares Focused's category/tag autocomplete, search filters, preview-before-use,
workflow persistence, and connectable prepend/append inputs. Encoding and storage
match Classic and Focused.
### 🔬 ComfyUI Workflow Analysis
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
+53 -6
View File
@@ -10,11 +10,17 @@ reviewed. Add a regression test for each bug; prove it fails before the fix.
| Python tests | Domain logic, SQLite, API handlers, filesystem checks | ComfyUI host and ML runtimes where needed |
| JavaScript tests | Frontend modules and controller methods | DOM/network fixtures for focused edge cases |
| Viewer checks | Production Viewer.js and frontend methods in Chromium desktop/touch | Controlled images and page fixtures |
| Real ComfyUI | Pinned CPU host, actual extension, node definitions, widgets, routes and SQLite | Inference is not queued; browser startup settings and host preference writes are isolated |
| Page workflows | Production HTML, startup, click handlers, HTTP routes, SQLite, PNG files, thumbnails | ComfyUI host services and the tagging model |
Page workflows cover save/edit/reload, duplicate-edit rejection, gallery image
identity, Tags selection and viewer cleanup, delayed responses, auto-tag review
and persistence, and infinite scrolling without replacing existing cards.
Data safety workflows download and upload actual SQLite backups, verify restored
prompts/tags/image links, reject corrupt uploads without changing live data, and
exercise cancellation and network-failure recovery for restores and deletion.
Duplicate cleanup verifies only the selected file disappears; prompt deletion
verifies the other prompt and original image files survive.
They run in Chromium, Firefox, WebKit, and Chromium touch emulation. No GPU,
model downloads, running ComfyUI instance, or personal configuration is required.
The server binds to localhost and creates disposable directories and a database;
@@ -39,11 +45,49 @@ or the port is occupied. Test servers are never reused. On unsupported Linux
distributions, browser system dependencies may need manual installation; CI uses
Ubuntu. For a quick browser run, use `test:workflows -- --project chromium`.
## Managed real ComfyUI
The shared engine comes from an exact commit of
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit), recorded
in `tools/vendor/package.json` and its integrity-checked lockfile. The local
adapter sets PromptManager's database/configuration and monitoring policy;
source discovery, coverage mapping/gates and process lifecycle are shared code.
KikoTools remains the reference and is unchanged by this integration.
Use a **dedicated disposable** ComfyUI checkout at the revision in
`tools/vendor/comfyui-runtime.json`. With a separate Python environment, install
that checkout's requirements, this repo's `requirements-dev.txt`, and the exact
CPU torch/torchvision pins from the JSON (using PyTorch's CPU wheel index).
Install Chromium using the Playwright command above, then run:
```sh
PM_COMFY_ROOT=/path/to/disposable/ComfyUI \
PM_TEST_PYTHON=/path/to/cpu-env/bin/python \
npm run --prefix tools/vendor test:comfyui
```
CI performs this setup automatically. `PM_COMFY_URL` can select another
`http://127.0.0.1:PORT/` address (default port 8189). This is an owned managed
server: a running host is never reused. Do not point it at your live checkout.
The launcher verifies the upstream SHA and clean tracked files, snapshots only
tracked PromptManager files, and creates temporary user/input/output/database/
configuration/log directories. Personal untracked files are excluded. It runs
CPU/offline with only PromptManager enabled and removes owned state on shutdown.
The upstream checkout must also be disposable: ComfyUI may initialize local
runtime files there. Browser host-settings writes are intercepted; actual
PromptManager routes use the disposable host and database.
`real-comfyui-reports` retains startup logs, version attachments, screenshots and
traces for 14 days. Update upstream/frontend/CPU pins together after reviewing
and running both startup projects; never replace the SHA with a moving branch.
Update the toolkit via an exact reviewed commit archive and commit both npm
manifest and lockfile. No toolkit or npm installation is needed by end users.
## Coverage and failures
Python CI enforces 85% combined statement/branch coverage. Frontend coverage merges
Node V8 unit coverage with Chromium page-workflow coverage through c8. Every
`web/js/*.js` file appears, including untouched files at zero coverage. The
`web/js/**/*.js` and `web/js/**/*.mjs` file appears, including untouched files at zero coverage. The
controller tests provide V8 filenames so their executed code is counted too.
`tools/vendor/frontend-coverage-baseline.json` holds a floor for every file and
@@ -59,14 +103,17 @@ The `viewerjs` check runs on every PR, including backend-only changes. GitHub
artifacts named `frontend-test-reports` contain HTML coverage, a JSON summary,
and failure screenshots/traces. Open traces with Playwright's `show-trace` tool.
Live checks remain useful for ComfyUI host compatibility, real inference/GPU or
model dependency changes, and visual design changes. The automated fixtures do
not certify those environments or every existing product feature.
The real-host suite covers both Classic and Nodes 2.0 startup, switching both
ways, actual database prompt selection, execution-text serialization, workflow
reload, widget/style cleanup, node schemas and standalone-page opening. It does
not queue inference or certify every existing feature, arbitrary host versions,
GPU/model changes, cross-pack installation or visual design changes. Those still
need targeted validation.
## Required merge checks
`.github/branch-protection.json` records the required checks: all nine Python/OS
combinations, Python coverage, formatting, frontend modules, and `viewerjs`.
`.github/branch-protection.json` records the required checks: all twelve Python/OS
combinations, Python coverage, formatting, frontend modules, `viewerjs`, and `real ComfyUI compatibility`.
The branch must be current with main, and administrators follow the same checks.
It does not require an additional human approval.
+14 -4
View File
@@ -3,12 +3,14 @@ ComfyUI_PromptManager: A comprehensive ComfyUI custom node that extends text enc
with persistent prompt storage, advanced search capabilities, and an automatic image
gallery system.
This module provides two main node types:
This module provides prompt node types:
- PromptManager: CLIP encoding node that outputs CONDITIONING
- PromptManagerFocused: Editor-first layout using the same encoding contract
- PromptManagerSplit: Side-by-side editor and library using the same contract
- PromptManagerText: Text-only node that outputs STRING with prepend/append
functionality
Both nodes share the same database backend for persistent prompt storage and include
The nodes share the same database backend for persistent prompt storage and include
an automatic image gallery system that monitors ComfyUI output directories and links
generated images to their source prompts.
@@ -38,18 +40,26 @@ def get_version():
return "unknown"
from .prompt_manager import PromptManager # noqa: E402
from .prompt_manager import ( # noqa: E402
PromptManager,
PromptManagerFocused,
PromptManagerSplit,
)
from .prompt_manager_text import PromptManagerText # noqa: E402
from .prompt_search_list import PromptSearchList # noqa: E402
NODE_CLASS_MAPPINGS = {
"PromptManager": PromptManager,
"PromptManagerFocused": PromptManagerFocused,
"PromptManagerSplit": PromptManagerSplit,
"PromptManagerText": PromptManagerText,
"PromptSearchList": PromptSearchList,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptManager": "Prompt Manager",
"PromptManager": "Prompt Manager (Classic)",
"PromptManagerFocused": "Prompt Manager (Focused)",
"PromptManagerSplit": "Prompt Manager (Split)",
"PromptManagerText": "Prompt Manager Text",
"PromptSearchList": "Prompt Search List",
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

+71
View File
@@ -0,0 +1,71 @@
# Shared ComfyAssets UI adoption
Status: approved direction; toolkit/testing foundation in progress.
Product UI brainstorming comes before node changes.
Tracking: [PromptManager #186](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/186)
and [KikoTools #81](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues/81).
KikoTools is the validated model. The
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit) is the
shared source; PromptManager proves the package boundaries before a later
KikoTools adoption PR. Other custom nodes can become additional consumers.
## Approved sequence
1. Extract reusable controls and test infrastructure from the working KikoTools
reference into the toolkit. Keep routes, node schemas and controllers local.
2. Bring PromptManager's automated tests up to parity using the pinned toolkit.
3. Brainstorm PromptManager node layouts, controls and behavior with the maintainer
before changing its production UI. Select one bounded first consumer together.
4. Validate shared distribution, themes, cleanup and compatibility through that
consumer, then migrate incrementally. Test both packs together as appropriate.
5. Backport toolkit adoption to KikoTools after PromptManager proves it, preserving
the existing KikoTools appearance, behavior and workflow compatibility.
KikoTools is not being migrated again during this phase. The original
[foundation mockup](https://github.com/ComfyAssets/ComfyUI-KikoTools/pull/82)
records the approved visual direction and inspiration.
## PromptManager requirements
- Preserve existing node names, inputs/outputs, API routes, saved workflows,
gallery behavior, and existing test/coverage gates.
- Use shared control factories, editor slots, settings/help/window behavior and
the ComfyUI adapter where applicable. Keep PromptManager controllers and HTTP
behavior in this repository.
- Both projects must look identical: one shared set of controls, typography,
spacing, colors and interaction states. Follow ComfyUI light/dark mode with
our own styling, a blue primary accent and strong red errors/destructive actions.
Use compact buttons, pill switches, segmented selections and responsive rows.
Review the pilot mockup before production styling. Existing `--pm-*` tokens
are migration context, not an approved design to retain.
- Theme standalone pages explicitly: `window.open` pages do not inherit the
host's CSS variables. Test the agreed palette policy, live changes, fallbacks
and persistence, including errors when host settings are unavailable.
- Ship a pinned, reproducible browser distribution. Installation must work
without npm or KikoTools, including when both packs ship different core versions.
- Attribute [Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma) as architectural
inspiration. Do not copy/vendor his code, styles, or assets.
## First migration
With the maintainer, inventory the PromptManager nodes and gallery dialogs and
choose one bounded first UI consumer after brainstorming. Record its current behavior with tests before changing it.
Verify keyboard opening/closing, focus restoration, accessible labels, confirm
and cancel behavior, network failures, and light/dark appearances. Keep the
existing gallery and destructive-action browser workflows passing.
Build shared-source extraction and distribution checks before importing the core.
Test both packs alone and together, scoped styling, isolated settings/storage,
and independent cleanup. ComfyUI-facing changes require the real-host Classic,
Nodes 2.0 and live-switching matrix from the foundation proposal. Standalone
dialogs additionally run through PromptManager's existing browser harness.
## Delivery
Use small issue-linked PRs and TDD: verify each new behavioral test fails before
implementing it, then run relevant suites and required CI checks. Include visual
evidence for appearance changes. Migrate additional nodes/dialogs/editors only after
the first consumer validates the package boundaries; do not rewrite all pages
or change backend schemas as part of this initiative.
+19
View File
@@ -281,3 +281,22 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
combined = f"{text}|{prepend_text}|{append_text}|{category}|{tags}"
return hashlib.sha256(combined.encode()).hexdigest()
class PromptManagerFocused(PromptManager):
"""The editor-first UI uses the existing encoding and storage contract."""
@classmethod
def INPUT_TYPES(cls) -> InputTypeDict:
inputs = super().INPUT_TYPES()
for name in ("prepend_text", "append_text"):
kind, options = inputs["optional"][name]
inputs["optional"][name] = (
kind,
{**options, "default": "", "forceInput": True},
)
return inputs
class PromptManagerSplit(PromptManagerFocused):
"""The persistent library layout shares Focused encoding and storage."""
+150
View File
@@ -0,0 +1,150 @@
import { readFileSync } from 'node:fs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
test.beforeEach(async ({page, browserName}) => {
await startCoverage(page, browserName);
page.classicErrors = [];
page.on('pageerror', error => page.classicErrors.push(error.message));
await page.route('**/scripts/app.js', route => route.fulfill({contentType:'text/javascript', body:'export const app = window.fixtureApp;'}));
await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => path, queuePrompt: async (...args) => args};'}));
await page.route('**/extensions/promptmanager/prompt_manager.js', route => route.fulfill({contentType:'text/javascript', body:readFileSync(new URL('../../web/comfy/prompt_manager.js', import.meta.url),'utf8')}));
await page.goto('/prompt_manager/test');
await page.evaluate(() => {
document.body.replaceChildren();
document.body.style.setProperty('--bg-color','#222');
window.fixtureApp={extensions:[],registerExtension(extension){this.extensions.push(extension);},ui:{settings:new EventTarget()},canvas:{},graph:{setDirtyCanvas(){}}};
});
await page.evaluate(async () => {
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_values=[]; this.inputs=[];
}
addDOMWidget(name,type,element){document.body.append(element);return {element};}
setSize(){} setDirtyCanvas(){} serialize(){return {properties:{...this.properties}};}
}
await fixtureApp.extensions[0].beforeRegisterNodeDef(Node,{name:'PromptManager'},fixtureApp);
window.node=new Node();node.onNodeCreated();
});
await expect(page.locator('.pm-classic')).toBeVisible();
});
test.afterEach(async ({page,browserName}) => {
await saveCoverage(page,browserName);
expect(page.classicErrors).toEqual([]);
});
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';});
const response=page.waitForRequest('**/prompt_manager/search?**');
await page.getByRole('button',{name:'🔍 Search',exact:true}).click();
expect(new URL((await response).url()).searchParams.get('tags')).toBe('film grain');
const result=page.getByRole('button',{name:/A new portrait/});
await result.focus();await result.press('Enter');
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,3))).toEqual(['A new portrait','Portrait','film grain']);
await expect(result).toHaveAttribute('aria-pressed','true');
const released=await page.evaluate(()=>{
const previous=node.resultsSection.querySelector('button');
node.displayResults([{text:'Replacement',tags:[]}]);
previous.click();
return node.widgets.find(widget=>widget.name==='text').value;
});
expect(released).toBe('A new portrait');
await expect(page.getByRole('button',{name:'🌐 Web UI',exact:true})).toBeVisible();
});
test('Classic detaches native text synchronization on removal',async({page})=>{
await page.waitForTimeout(60);
const values=await page.evaluate(()=>{
const widget=node.widgets.find(item=>item.name==='text');
widget.inputEl.value='Before removal';widget.inputEl.dispatchEvent(new Event('input'));
const before=widget.value;node.onRemoved();
widget.inputEl.value='After removal';widget.inputEl.dispatchEvent(new Event('input'));
return [before,widget.value];
});
expect(values).toEqual(['Before removal','Before removal']);
});
test('Classic follows host themes, keeps restore behavior and disposes resources',async({page})=>{
const panel=page.locator('.pm-classic');
await expect(panel).toHaveAttribute('data-ca-theme','dark');
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#fff'));
await expect(panel).toHaveAttribute('data-ca-theme','light');
await page.evaluate(()=>{node.properties.resultTimeout=0;node.displayResults([{text:'Example',tags:[]}]);node.hideResults();});
await page.getByRole('button',{name:/Results auto-hidden/}).click();
await expect(page.getByRole('button',{name:/Example/})).toBeVisible();
const state=await page.evaluate(()=>{
node.showNotification('Failure','error');node.onRemoved();
const disposed=node._classicScope.signal.aborted;
node.showNotification('Late response','info');
return {disposed,notifications:document.querySelectorAll('.pm-classic-notification').length,style:node._styleSheet.isConnected};
});
expect(state).toEqual({disposed:true,notifications:0,style:false});
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#222'));
await expect(panel).toHaveAttribute('data-ca-theme','light');
});
test('Classic handles empty queries, empty results, failed requests and late responses',async({page})=>{
await page.getByRole('button',{name:'🔍 Search',exact:true}).click();
await expect(page.locator('.pm-classic-results')).toContainText('Enter search text');
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[]}}));
await page.getByRole('button',{name:'📋 Recent',exact:true}).click();
await expect(page.locator('.pm-classic-results')).toContainText('No prompts found');
await page.evaluate(async()=>{
node.callNodeMethod=async()=>{throw new Error('<img src=x onerror=alert(1)>');};
await node.loadRecentPrompts();
});
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('<img src=x');
await expect(page.locator('.pm-classic-results img')).toHaveCount(0);
const late=await page.evaluate(async()=>{
let finish; node.callNodeMethod=()=>new Promise(resolve=>{finish=resolve;});
const loading=node.loadRecentPrompts();node.onRemoved();finish({results:[{text:'Late'}]});await loading;
return {notifications:node._classicNotifications.size,late:node.resultsSection.textContent.includes('Late')};
});
expect(late).toEqual({notifications:0,late:false});
});
test('shipped shared factories keep keyboard behavior and theme isolation across mounts',async({page})=>{
await page.evaluate(async()=>{
const ui=await import('/prompt_manager/js/shared/toolkit/core.js');
const scope=ui.createScope();const panel=document.createElement('div');panel.className='ca-tools';panel.id='shared-contract';
window.shared={ui,scope,panel,events:[]};
document.body.append(panel);
ui.mountTheme(panel,null,scope);
const save=ui.createButton('Save contract','ca-primary');scope.listen(save,'click',()=>shared.events.push('save'));
panel.append(save,ui.createSlider('Size',{id:'contract-size',min:0,max:100,step:1,value:50,unit:'px'},scope,value=>shared.events.push(value)),ui.createSwitch('Enabled','','contract-enabled'),ui.createField('Order',ui.createSelect([['name','Name'],['date','Date']],''),'contract-order'),ui.createSegments('View',[['grid','Grid'],['list','List']],'contract-view',scope,value=>shared.events.push(value)));
});
await page.getByRole('button',{name:'Save contract',exact:true}).press('Enter');
await page.getByRole('slider',{name:'Size'}).press('ArrowRight');
await expect(page.locator('#shared-contract output')).toHaveText('51 px');
await page.getByRole('switch',{name:'Enabled'}).uncheck();
await page.getByLabel('Order').selectOption('date');
await page.getByRole('radio',{name:'List',exact:true}).check();
expect(await page.evaluate(()=>shared.events)).toEqual(['save',51,'list']);
await page.evaluate(()=>{document.body.style.removeProperty('--bg-color');fixtureApp.ui.settings.dispatchEvent(new Event('Comfy.ColorPalette.change'));});
await page.emulateMedia({colorScheme:'light'});
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','light');
await page.emulateMedia({colorScheme:'dark'});
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','dark');
await page.evaluate(()=>{shared.scope.dispose();document.body.style.setProperty('--bg-color','#fff');});
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','dark');
await expect(page.locator('.pm-classic')).toHaveAttribute('data-ca-theme','light');
});
test('Classic test button reports success and failure and releases expired notifications',async({page})=>{
await page.clock.install();
await page.evaluate(()=>{node.properties.showTestButton=true;node.createSearchUI(node._classicContainer);});
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
await expect(page.locator('.pm-classic-results')).toContainText('API Connection Successful');
await page.route('**/prompt_manager/test',route=>route.fulfill({status:503,body:'unavailable'}));
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('503');
await page.route('**/prompt_manager/test',route=>route.fulfill({json:{success:false,message:'Not ready'}}));
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('Not ready');
await page.clock.fastForward(3001);
await expect(page.locator('.pm-classic-notification')).toHaveCount(0);
});
+489
View File
@@ -0,0 +1,489 @@
import {readFileSync} from 'node:fs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
for (const variant of ['Focused', 'Split']) {
test.describe(variant, () => {
const isSplit = variant === 'Split';
const uiProperty = isSplit ? 'splitUI' : 'focusedUI';
async function openLibrary(page) {
if (isSplit) {
await expect(page.locator('.pm-editor-library')).toBeVisible();
await expect(page.getByRole('button',{name:'Browse library',exact:true})).toHaveCount(0);
} else await page.getByRole('button',{name:'Browse library',exact:true}).click();
}
test.beforeEach(async({page,browserName})=>{
await page.addInitScript(()=>Object.defineProperty(Crypto.prototype,'randomUUID',{value:undefined,configurable:true}));
await startCoverage(page,browserName);
page.focusedErrors=[];page.on('pageerror',error=>page.focusedErrors.push(error.message));
await page.route('**/scripts/app.js',route=>route.fulfill({contentType:'text/javascript',body:'export const app=window.fixtureApp;'}));
await page.route('**/scripts/api.js',route=>route.fulfill({contentType:'text/javascript',body:'export const api={fileURL:path=>path,queuePrompt:async(...args)=>args};'}));
await page.route('**/extensions/promptmanager/prompt_manager.js',route=>route.fulfill({contentType:'text/javascript',body:readFileSync(new URL('../../web/comfy/prompt_manager.js',import.meta.url),'utf8')}));
await page.goto('/prompt_manager/test');
await page.evaluate(async variant=>{
document.body.replaceChildren();document.body.style.setProperty('--bg-color','#222');
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,link:null}));this.properties={};this.size=[550,700];}
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};}
setSize(size){this.size=size;}setDirtyCanvas(){}
serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
configure(data){this.widgets.forEach((widget,index)=>{widget.value=data.widgets_values[index];});this.properties={...data.properties};}
}
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:`PromptManager${variant}`},fixtureApp);
window.node=new Node();node.onNodeCreated();
},variant);
await expect(page.locator('.pm-focused')).toBeVisible();
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
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']}}));
await page.route('**/prompt_manager/tags/suggest?**',route=>route.fulfill({json:{success:true,suggestions:[{name:'studio'}]}}));
await page.route('**/prompt_manager/search?**',route=>{
requests.push(new URL(route.request().url()));
return route.fulfill({json:{success:true,results:[{text:'Filtered portrait',category:'Portrait',tags:['studio']}]}});
});
await page.getByLabel('Main prompt',{exact:true}).fill('My draft');
await openLibrary(page);
const category=page.getByLabel('Filter category',{exact:true});
await category.fill('por');
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
await category.press('ArrowDown');
await category.press('Enter');
expect(requests).toHaveLength(0);
const tags=page.getByLabel('Filter tags',{exact:true});
await tags.fill('stu');
await page.getByRole('option',{name:'studio',exact:true}).click();
await expect(tags).toHaveValue('studio, ');
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('portrait tag:studio tag:"film grain"');
await page.getByRole('button',{name:'Search',exact:true}).click();
await expect(page.locator('.pm-editor-result')).toContainText('Filtered portrait');
expect(requests[0].searchParams.get('text')).toBe('portrait');
expect(requests[0].searchParams.get('category')).toBe('Portrait');
expect(requests[0].searchParams.get('tags')).toBe('studio,film grain');
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,3))).toEqual(['My draft','','']);
await page.locator('.pm-editor-result').click();
await category.fill('Landscape');
await expect(page.locator('.pm-editor-selection button')).toBeDisabled();
const saved=await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);return saved;});
expect(saved.properties[uiProperty].libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
await expect(category).toHaveValue('Landscape');
await expect(tags).toHaveValue('studio, ');
let recentRequest;
await page.route('**/prompt_manager/recent?**',route=>{recentRequest=new URL(route.request().url());return route.fulfill({json:{success:true,results:[]}});});
await page.getByRole('button',{name:'Recent',exact:true}).click();
await expect.poll(()=>Boolean(recentRequest)).toBe(true);
expect(recentRequest.searchParams.has('category')).toBe(false);
expect(recentRequest.searchParams.has('tags')).toBe(false);
if (isSplit) await page.getByRole('searchbox',{name:'Search saved prompts'}).focus();
else await page.getByRole('button',{name:'Hide library',exact:true}).click();
await expect(page.getByRole('listbox')).toBeHidden();
});
test(`${variant} library can search by filters alone and Enter searches after autocomplete closes`, async ({page}) => {
const requests=[];
await page.route('**/prompt_manager/search?**',route=>{requests.push(new URL(route.request().url()));return route.fulfill({json:{success:true,results:[]}});});
await openLibrary(page);
await page.getByLabel('Filter category',{exact:true}).fill('New category');
await page.getByLabel('Filter category',{exact:true}).press('Enter');
await expect.poll(()=>requests.length).toBe(1);
expect(requests[0].searchParams.get('category')).toBe('New category');
await page.getByLabel('Filter category',{exact:true}).fill('');
await page.getByLabel('Filter tags',{exact:true}).fill('custom, ');
await page.getByLabel('Filter tags',{exact:true}).press('Enter');
await expect.poll(()=>requests.length).toBe(2);
expect(requests[1].searchParams.get('tags')).toBe('custom');
});
test(`${variant} completes saved tags with keyboard and pointer and appends commas`, async ({page}) => {
await page.route('**/prompt_manager/tags/suggest?**', route => {
const q = new URL(route.request().url()).searchParams.get('q');
return route.fulfill({json:{success:true,suggestions:(q === 'bl' ? ['blue eyes','blue sky'] : ['portrait','blue eyes']).map(name => ({name}))}});
});
await page.getByText('Metadata · category / tags',{exact:true}).click();
const tags = page.getByLabel('Tags',{exact:true});
await tags.fill('portrait, bl');
await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible();
await tags.press('ArrowDown');
await tags.press('Enter');
await expect(tags).toHaveValue('portrait, blue eyes, ');
expect(await page.evaluate(()=>node.serialize().widgets_values[2])).toBe('portrait, blue eyes, ');
await tags.fill('bl');
await page.getByRole('option',{name:'blue sky',exact:true}).click();
await expect(tags).toHaveValue('blue sky, ');
await tags.fill('portrait, ');
await expect(page.getByRole('option',{name:'portrait',exact:true})).toHaveCount(0);
await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible();
await tags.press('Escape');
await expect(page.getByRole('listbox')).toBeHidden();
await tags.fill('bl');
await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible();
await page.locator('.pm-focused').dispatchEvent('scroll');
await expect(page.getByRole('listbox')).toBeHidden();
await tags.fill('A brand new tag');
await tags.press('Tab');
await expect(tags).toHaveValue('A brand new tag');
});
test(`${variant} metadata autocomplete ignores stale responses, recovers from errors and cleans up`, async ({page}) => {
let release;
let mode='delayed';
await page.route('**/prompt_manager/tags/suggest?**', async route => {
const q = new URL(route.request().url()).searchParams.get('q');
if(q === 'old') await new Promise(resolve=>{release=resolve;});
if(mode === 'error') return route.fulfill({status:503,body:'Unavailable'});
await route.fulfill({json:{success:true,suggestions:[{name:q+' result'}]}}).catch(()=>{});
});
await page.getByText('Metadata · category / tags',{exact:true}).click();
const tags=page.getByLabel('Tags',{exact:true});
await tags.fill('old');
await expect.poll(()=>Boolean(release)).toBe(true);
await tags.fill('new');
await expect(page.getByRole('option',{name:'new result',exact:true})).toBeVisible();
release();
await expect(page.getByRole('option',{name:'old result',exact:true})).toHaveCount(0);
mode='error';
await tags.fill('unavailable');
await expect(page.getByRole('listbox')).toBeHidden();
await expect(tags).toHaveValue('unavailable');
mode='ok';
await tags.fill('retry');
await expect(page.getByRole('option',{name:'retry result',exact:true})).toBeVisible();
await page.evaluate(()=>node.onRemoved());
await expect(page.getByRole('listbox')).toHaveCount(0);
});
test(`${variant} completes one category, allows new values and dismisses suggestions on blur`, async ({page}) => {
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait','Landscape','Portrait studio']}}));
await page.getByText('Metadata · category / tags',{exact:true}).click();
const category=page.getByLabel('Category',{exact:true});
await category.fill('por');
await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeVisible();
await category.press('ArrowUp');
await expect(page.getByRole('option',{name:'Portrait studio',exact:true})).toHaveAttribute('aria-selected','true');
await category.press('Enter');
await expect(category).toHaveValue('Portrait studio');
expect(await page.evaluate(()=>node.serialize().widgets_values[1])).toBe('Portrait studio');
await category.fill('por');
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
await category.dispatchEvent('keydown',{key:'ArrowDown',isComposing:true});
await expect(category).not.toHaveAttribute('aria-activedescendant',/.+/);
await page.getByLabel('Tags',{exact:true}).focus();
await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeHidden();
await category.fill('New category');
await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeHidden();
await expect(category).toHaveValue('New category');
});
test(`${variant} shares suggestion controls safely with another bundled toolkit copy`, async ({page}) => {
await page.evaluate(async()=>{
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
const first=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=first');
const second=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=second');
window.completionFixture={scope:createScope(),selected:[],inserted:[]};
const one=first.createSuggestionView(completionFixture.scope,null);
const two=second.createSuggestionView(completionFixture.scope,null,{label:'Other pack'});
completionFixture.views=[one,two];
one.hide();
const items=[{name:'plain'},{name:'tag',type:'tag'},{name:'model',type:'lora'},{name:'nested',type:'embedding',fullPath:'folder/nested'},{name:'flat',type:'embedding',fullPath:'flat'}];
one.render(items,index=>{completionFixture.selected.push(index);one.select(index);},item=>completionFixture.inserted.push(item.name));
one.select(-1);
one.position({left:innerWidth-5,bottom:innerHeight,top:innerHeight-20});
two.render([{name:'Other pack suggestion'}],()=>{},()=>{});
two.position({left:0,bottom:5,top:0});
});
const lists=page.getByRole('listbox');
const ids=await lists.evaluateAll(elements=>elements.map(element=>element.id));
expect(new Set(ids).size).toBe(ids.length);
await page.getByRole('option',{name:'[LORA] model'}).hover();
await expect(page.getByRole('option',{name:'[LORA] model'})).toHaveAttribute('aria-selected','true');
await page.getByRole('option',{name:'[EMB] folder/nested'}).click();
expect(await page.evaluate(()=>completionFixture.inserted)).toEqual(['nested']);
await page.evaluate(()=>completionFixture.scope.dispose());
await expect(lists).toHaveCount(0);
});
test(`${variant} previews before loading, keeps metadata optional and composes text`,async({page})=>{
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:1,text:'Library portrait',category:'Portrait',tags:['studio']}]}}));
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('My draft');
await openLibrary(page);
await page.getByRole('button',{name:'Recent',exact:true}).click();
await page.getByRole('button',{name:/Library portrait/}).click();
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('My draft');
await page.getByRole('switch',{name:'Load metadata'}).uncheck();
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Library portrait');
await page.getByText('Composition · prepend / append',{exact:true}).click();
await page.getByLabel('Prepend',{exact:true}).fill('trigger');
await page.getByLabel('Append',{exact:true}).fill('detailed');
await page.getByText('Composed text preview',{exact:true}).click();
await expect(page.locator('[data-composed-preview]')).toHaveText('trigger Library portrait detailed');
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,6))).toEqual(['Library portrait','','','','trigger','detailed']);
});
test(`${variant} restores workflow values and disclosures and protects connected text`,async({page})=>{
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Saved draft');
await page.getByText('Metadata · category / tags',{exact:true}).click();
await page.getByLabel('Category',{exact:true}).fill('Saved category');
await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);});
await expect(page.getByLabel('Category',{exact:true})).toBeVisible();
await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Saved category');
await page.evaluate(()=>{node.inputs.push({name:'text',link:9});node.onConnectionsChange();});
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toBeHidden();
await expect(page.getByText('Text is supplied by an upstream connection.',{exact:true})).toBeVisible();
await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=null;node.onConnectionsChange();});
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Saved draft');
});
test(`${variant} searches quoted tags with Enter and explicitly loads metadata`, async ({page}) => {
let requested;
await page.route('**/prompt_manager/search?**', async route => {
requested = new URL(route.request().url());
await route.fulfill({json:{success:true,results:[{id:7,text:'Selected fox',category:'Animals',tags:['film grain','fox']}]}});
});
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Preserved draft');
await openLibrary(page);
const query=page.getByRole('searchbox',{name:'Search saved prompts'});
await query.fill('fox tag:"film grain"');
await query.press('Enter');
await page.getByRole('button',{name:/Selected fox/}).click();
expect(requested.searchParams.get('text')).toBe('fox');
expect(requested.searchParams.get('tags')).toBe('film grain');
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Preserved draft');
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
await page.getByText('Metadata · category / tags',{exact:true}).click();
await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Animals');
await expect(page.getByLabel('Tags',{exact:true})).toHaveValue('film grain, fox');
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,4))).toEqual(['Selected fox','Animals','film grain, fox','fox tag:"film grain"']);
});
test(`${variant} reports empty queries, no results, and safely renders backend and HTTP errors`, async ({page}) => {
let requests=0;
let mode='empty';
const malicious='<img src=x onerror="window.focusedInjected=true">';
await page.route('**/prompt_manager/search?**', async route => {
requests++;
if(mode==='http') return route.fulfill({status:503,body:'Unavailable'});
await route.fulfill({json:mode==='error'?{success:false,error:malicious}:{success:true,results:[]}});
});
await openLibrary(page);
const search=page.getByRole('button',{name:'Search',exact:true});
await search.click();
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
expect(requests).toBe(0);
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
await search.click();
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
await expect(page.locator('.pm-editor-result')).toHaveCount(0);
mode='error';
await search.click();
await expect(page.getByRole('alert')).toHaveText(`Could not load prompts: ${malicious}`);
await expect(page.locator('.pm-editor-library img')).toHaveCount(0);
expect(await page.evaluate(()=>window.focusedInjected)).toBeUndefined();
mode='http';
await search.click();
await expect(page.getByRole('alert')).toContainText('HTTP 503');
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('');
await search.click();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
mode='empty';
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
await search.click();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
});
test(`${variant} treats saved prompt content and metadata as text`, async ({page}) => {
const malicious='<img src=x onerror="window.focusedInjected=true">';
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:8,text:malicious,category:'<script>bad()</script>',tags:['<svg onload=bad()>']}]}}));
await openLibrary(page);
await page.getByRole('button',{name:'Recent',exact:true}).click();
await page.locator('.pm-editor-result').click();
await expect(page.locator('.pm-editor-selection .pm-editor-preview')).toHaveText(malicious);
await expect(page.locator('.pm-editor-library img, .pm-editor-library svg, .pm-editor-library script')).toHaveCount(0);
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(malicious);
expect(await page.evaluate(()=>window.focusedInjected)).toBeUndefined();
});
async function deferLibraryRequests(page) {
await page.evaluate(()=>{
const originalFetch=window.fetch;
window.libraryRequests=[];
window.fetch=(url,options)=>{
if(!/\/prompt_manager\/(search|recent)\?/.test(String(url))) return originalFetch(url,options);
return new Promise(resolve=>window.libraryRequests.push({url:String(url),signal:options.signal,resolve}));
};
window.completeLibraryRequest=(index,text)=>libraryRequests[index].resolve(new Response(JSON.stringify({success:true,results:[{id:index,text}]}),{status:200,headers:{'Content-Type':'application/json'}}));
});
}
test(`${variant} keeps the latest result when searches and recent requests overlap`, async ({page}) => {
await deferLibraryRequests(page);
await openLibrary(page);
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('older search');
await page.getByRole('button',{name:'Search',exact:true}).click();
await page.getByRole('button',{name:'Recent',exact:true}).click();
expect(await page.evaluate(()=>libraryRequests[0].signal.aborted)).toBe(true);
await page.evaluate(()=>completeLibraryRequest(1,'Latest recent'));
await expect(page.locator('.pm-editor-result')).toHaveText('Latest recent');
await page.evaluate(()=>completeLibraryRequest(0,'Stale search'));
await expect(page.locator('.pm-editor-result')).toHaveText('Latest recent');
await page.getByRole('button',{name:'Recent',exact:true}).click();
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('latest search');
await page.getByRole('button',{name:'Search',exact:true}).click();
expect(await page.evaluate(()=>libraryRequests[2].signal.aborted)).toBe(true);
await page.evaluate(()=>completeLibraryRequest(3,'Latest search'));
await expect(page.locator('.pm-editor-result')).toHaveText('Latest search');
await page.evaluate(()=>completeLibraryRequest(2,'Stale recent'));
await expect(page.locator('.pm-editor-result')).toHaveText('Latest search');
});
test(`${variant} removal aborts work and stops theme and control listeners`, async ({page}) => {
await deferLibraryRequests(page);
await openLibrary(page);
await page.getByRole('button',{name:'Recent',exact:true}).click();
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
const removed=await page.evaluate(()=>{
node.onRemoved();
document.body.style.setProperty('--bg-color','#ffffff');
fixtureApp.ui.settings.dispatchEvent(new Event('Comfy.ColorPalette.change'));
return {requestAborted:libraryRequests[0].signal.aborted,scopeAborted:node._focusedScope.signal.aborted};
});
expect(removed).toEqual({requestAborted:true,scopeAborted:true});
await page.evaluate(()=>completeLibraryRequest(0,'Late result after removal'));
await expect(page.locator('.pm-editor-result')).toHaveCount(0);
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
if (isSplit) {
const count=await page.evaluate(()=>libraryRequests.length);
await page.getByRole('button',{name:'Recent',exact:true}).click();
expect(await page.evaluate(()=>libraryRequests.length)).toBe(count);
await expect(page.locator('.pm-editor-library')).toBeVisible();
} else {
await page.getByRole('button',{name:'Hide library',exact:true}).click();
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible();
}
});
test(`${variant} fields reflow with node width and follow live host themes`, async ({page}) => {
await page.setViewportSize({width:1000,height:1000});
await page.getByText('Composition · prepend / append',{exact:true}).click();
await page.evaluate(()=>{node._focusedView.element.style.width='700px';node._focusedView.element.style.height='850px';});
const prepend=page.getByLabel('Prepend',{exact:true});
const append=page.getByLabel('Append',{exact:true});
let first=await prepend.boundingBox(),second=await append.boundingBox();
expect(Math.abs(first.y-second.y)).toBeLessThan(2);
expect(second.x).toBeGreaterThan(first.x+first.width);
await page.evaluate(()=>{node._focusedView.element.style.width='300px';});
await expect.poll(async()=>{const a=await prepend.boundingBox(),b=await append.boundingBox();return b.y>a.y+a.height;}).toBe(true);
expect(await page.locator('.pm-focused').evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
const dark=await prepend.evaluate(element=>getComputedStyle(element).backgroundColor);
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#ffffff'));
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','light');
await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).not.toBe(dark);
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#222222'));
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).toBe(dark);
});
test(`${variant} composed preview preserves main prompt whitespace like queued execution`, async ({page}) => {
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill(' draft ');
await page.getByText('Composition · prepend / append',{exact:true}).click();
await page.getByLabel('Prepend',{exact:true}).fill(' trigger ');
await page.getByLabel('Append',{exact:true}).fill(' detail ');
await page.getByText('Composed text preview',{exact:true}).click();
expect(await page.locator('[data-composed-preview]').textContent()).toBe('trigger draft detail');
});
test(`${variant} exposes the visible text element for autocomplete and host extension listeners`, async ({page}) => {
expect(await page.evaluate(()=>node.widgets.find(widget=>widget.name==='text').inputEl===document.querySelector('.pm-focused textarea'))).toBe(true);
});
test(`${variant} expands its main editor when the node gets taller`,async({page})=>{
await page.locator('.pm-focused').evaluate((element,isSplit)=>{element.style.width=isSplit?'900px':'550px';element.style.height='800px';},isSplit);
const editor=await page.getByRole('textbox',{name:'Main prompt',exact:true}).boundingBox();
expect(editor.height).toBeGreaterThan(400);
});
test(`${variant} respects the existing Web UI display preference`,async({page})=>{
await page.route('**/prompt_manager/settings',route=>route.fulfill({json:{success:true,settings:{webui_display_mode:'newtab'}}}));
await page.evaluate(()=>{
const Node=node.constructor;node.onRemoved();node._focusedView.element.remove();node=new Node();node.onNodeCreated();
window.open=(...args)=>{window.openArgs=args;return {};};
});
await page.evaluate(()=>node._focusedSettingsReady);
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']);
});
test(`${variant} reports blocked Web UI popups without leaving the workflow`,async({page})=>{
await page.evaluate(()=>{window.open=()=>null;});
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
await expect(page.locator('.pm-focused [role="alert"]')).toContainText('Allow popups');
await expect(page.locator('.pm-focused')).toBeVisible();
});
if (isSplit) test('Split keeps the library beside the editor when wide and stacks it when narrow', async ({page}) => {
await page.setViewportSize({width:1200,height:1000});
const root=page.locator('.pm-split');
await expect(root).toHaveAttribute('aria-label','Split prompt editor');
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toHaveCount(0);
await root.evaluate(element=>{element.style.width='900px';element.style.height='800px';});
const compose=root.locator('.pm-editor-workspace > .pm-editor-compose');
const library=root.locator('.pm-editor-workspace > .pm-editor-library');
await expect(compose.locator('.pm-editor-main')).toBeVisible();
let a=await compose.boundingBox(),b=await library.boundingBox();
expect(Math.abs(a.y-b.y)).toBeLessThan(2);
expect(b.x).toBeGreaterThanOrEqual(a.x+a.width);
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{text:'Library example'}]}}));
await page.getByRole('button',{name:'Recent',exact:true}).click();
await expect(page.locator('.pm-editor-result')).toContainText('Library example');
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Editing beside the library');
await expect(library).toBeVisible();
await expect(page.locator('.pm-editor-result')).toContainText('Library example');
expect(await page.evaluate(()=>node.serialize().widgets_values[0])).toBe('Editing beside the library');
await root.evaluate(element=>{element.style.width='340px';});
await expect.poll(async()=>{const a=await compose.boundingBox(),b=await library.boundingBox();return b.y>=a.y+a.height;}).toBe(true);
expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Editing beside the library');
await expect(library).toBeVisible();
});
if (isSplit) test('Split bounds long recent results and selected previews at the default node size', async ({page}) => {
await page.setViewportSize({width:1200,height:1000});
const root=page.locator('.pm-split');
await root.evaluate(element=>{element.style.width='900px';element.style.height='650px';});
const longPrompt='Long saved prompt '+('detailed portrait in a studio with natural light '.repeat(110));
const prompts=Array.from({length:20},(_,index)=>({id:index+1,text:index===0?longPrompt:`Saved prompt ${index+1}`,category:'Portrait',tags:['studio']}));
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:prompts}}));
await page.getByRole('button',{name:'Recent',exact:true}).click();
const results=root.locator('.pm-editor-results');
await expect(results.locator('.pm-editor-result')).toHaveCount(20);
const rowHeights=await results.locator('.pm-editor-result > span:first-child').evaluateAll(elements=>elements.map(element=>element.getBoundingClientRect().height));
expect(rowHeights.every(height=>height>0)).toBe(true);
const resultSize=await results.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight}));
expect.soft(resultSize.height).toBeLessThanOrEqual(300);
expect(resultSize.scroll).toBeGreaterThan(resultSize.client);
await results.locator('.pm-editor-result').first().click();
const preview=root.locator('.pm-editor-selection > .pm-editor-preview');
await expect(preview).toHaveText(longPrompt);
const previewSize=await preview.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight}));
expect.soft(previewSize.height).toBeLessThanOrEqual(180);
expect.soft(previewSize.scroll).toBeGreaterThan(previewSize.client);
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(longPrompt);
const bounds=await root.boundingBox();
for (const selector of ['.pm-editor-compose','.pm-editor-library']) {
const pane=await root.locator(selector).boundingBox();
expect(pane.y).toBeGreaterThanOrEqual(bounds.y);
expect(pane.y+pane.height).toBeLessThanOrEqual(bounds.y+bounds.height);
}
expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
});
});
}
+203
View File
@@ -139,3 +139,206 @@ test('gallery infinite scroll appends a real backend page while retaining existi
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(110);
await expect(first).toHaveAttribute('data-preserved', 'yes');
});
async function promptSnapshot(request) {
const response = await request.get('/prompt_manager/recent?limit=100');
await expect(response).toBeOK();
return (await response.json()).results.map(({ id, text, tags, images }) => ({
id, text, tags: [...tags].sort(),
images: images.map(image => image.image_path).sort(),
})).sort((a, b) => a.id - b.id);
}
async function downloadBackup(page) {
const downloading = page.waitForEvent('download');
await page.locator('#backupBtn').click();
const download = await downloading;
expect(download.suggestedFilename()).toMatch(/^prompts_backup_.*\.db$/);
expect(await download.failure()).toBeNull();
const chunks = [];
for await (const chunk of await download.createReadStream()) chunks.push(chunk);
const buffer = Buffer.concat(chunks);
expect(buffer.subarray(0, 16).toString()).toBe('SQLite format 3\0');
return { name: download.suggestedFilename(), mimeType: 'application/octet-stream', buffer };
}
async function chooseRestore(page, file) {
await page.locator('#restoreBtn').click();
await expect(page.locator('#restoreModal')).toBeVisible();
await page.locator('#restoreFileInput').setInputFiles(file);
}
function confirmNextDialog(page, accept, message) {
page.once('dialog', async dialog => {
expect(dialog.type()).toBe('confirm');
expect(dialog.message()).toContain(message);
await (accept ? dialog.accept() : dialog.dismiss());
});
}
async function deletePromptButton(page, text) {
const prompt = page.locator('.prompt-text').filter({ hasText: text });
await expect(prompt).toBeVisible();
const id = await prompt.getAttribute('data-id');
return page.locator(`[data-action="delete"][data-prompt-id="${id}"]`);
}
test('downloaded backup restores deleted prompts, tags, and image links through the actual upload form', async ({ page, request }) => {
await page.goto('/prompt_manager/admin');
await expect(page.locator('.prompt-text')).toHaveCount(2);
const before = await promptSnapshot(request);
expect(before.map(prompt => prompt.tags)).toEqual([['cats'], ['dogs']]);
expect(before.every(prompt => prompt.images.length === 1)).toBe(true);
const backup = await downloadBackup(page);
const button = await deletePromptButton(page, 'Cat portrait');
confirmNextDialog(page, true, 'delete this prompt');
await button.click();
await expect(page.locator('.prompt-text')).toHaveCount(1);
await expect(await request.post('/prompt_manager/save', { data: { text: 'Added after backup', tags: ['temporary'] } })).toBeOK();
expect(await promptSnapshot(request)).not.toEqual(before);
await chooseRestore(page, backup);
confirmNextDialog(page, true, backup.name);
const restored = page.waitForResponse(response => response.url().endsWith('/prompt_manager/restore'));
await page.locator('#confirmRestore').click();
const response = await restored;
expect(response.ok()).toBe(true);
expect(await response.json()).toMatchObject({ success: true, prompt_count: 2, backup_created: expect.any(String) });
await expect(page.locator('#restoreModal')).toBeHidden();
await expect(page.locator('.prompt-text')).toHaveCount(2);
await page.reload();
await expect(page.locator('.prompt-text').filter({ hasText: 'Cat portrait' })).toBeVisible();
await expect(page.locator('.prompt-text').filter({ hasText: 'Added after backup' })).toHaveCount(0);
expect(await promptSnapshot(request)).toEqual(before);
await page.goto('/prompt_manager/admin#/tags');
await page.locator('[data-tag-name="cats"]').click();
await expect(page.locator('#tagPromptsGrid')).toContainText('Cat portrait');
await page.locator('#tagPromptsGrid img').first().click();
await expect(page.locator('.viewer-canvas img')).toBeVisible();
await expect.poll(() => page.locator('.viewer-canvas img').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
});
test('restore validation and cancellation preserve live data and allow a valid retry', async ({ page, request }) => {
await page.goto('/prompt_manager/admin');
const before = await promptSnapshot(request);
const backup = await downloadBackup(page);
let uploads = 0;
page.on('request', request => { if (request.url().endsWith('/prompt_manager/restore')) uploads++; });
await page.locator('#restoreBtn').click();
await page.locator('#confirmRestore').click();
await expect(page.getByText('Please select a database file to restore', { exact: true })).toBeVisible();
await page.locator('#restoreFileInput').setInputFiles({ ...backup, name: 'backup.txt' });
await page.locator('#confirmRestore').click();
await expect(page.getByText('Please select a valid .db file', { exact: true })).toBeVisible();
expect(uploads).toBe(0);
await page.locator('#restoreFileInput').setInputFiles(backup);
confirmNextDialog(page, false, 'replace your current database');
await page.locator('#confirmRestore').click();
await expect(page.locator('#restoreModal')).toBeVisible();
expect(uploads).toBe(0);
await page.locator('#restoreFileInput').setInputFiles({ name: 'corrupt.db', mimeType: 'application/octet-stream', buffer: Buffer.from('not a SQLite database') });
confirmNextDialog(page, true, 'corrupt.db');
const rejected = page.waitForResponse(response => response.url().endsWith('/prompt_manager/restore'));
await page.locator('#confirmRestore').click();
expect((await rejected).status()).toBe(400);
await expect(page.getByText(/Restore failed:/)).toBeVisible();
await expect(page.locator('#restoreModal')).toBeVisible();
await expect(page.locator('#confirmRestore')).toBeEnabled();
expect(await promptSnapshot(request)).toEqual(before);
await page.locator('#restoreFileInput').setInputFiles(backup);
confirmNextDialog(page, true, backup.name);
await page.locator('#confirmRestore').click();
await expect(page.locator('#restoreModal')).toBeHidden();
expect(await promptSnapshot(request)).toEqual(before);
});
test('backup and restore recover from interrupted network requests without losing data', async ({ page, request }) => {
await page.goto('/prompt_manager/admin');
const before = await promptSnapshot(request);
await page.route('**/prompt_manager/backup', route => route.abort('failed'), { times: 1 });
await page.locator('#backupBtn').click();
await expect(page.getByText(/Backup failed:/)).toBeVisible();
const backup = await downloadBackup(page);
await chooseRestore(page, backup);
await page.route('**/prompt_manager/restore', route => route.abort('failed'), { times: 1 });
confirmNextDialog(page, true, backup.name);
await page.locator('#confirmRestore').click();
await expect(page.getByText(/Restore failed:/)).toBeVisible();
await expect(page.locator('#confirmRestore')).toBeEnabled();
await expect(page.locator('#restoreModal')).toBeVisible();
expect(await promptSnapshot(request)).toEqual(before);
confirmNextDialog(page, true, backup.name);
await page.locator('#confirmRestore').click();
await expect(page.locator('#restoreModal')).toBeHidden();
expect(await promptSnapshot(request)).toEqual(before);
});
test('prompt deletion requires confirmation and can retry after a network failure', async ({ page, request }) => {
await page.goto('/prompt_manager/admin');
const before = await promptSnapshot(request);
const button = await deletePromptButton(page, 'Cat portrait');
let deletes = 0;
page.on('request', request => { if (request.method() === 'DELETE') deletes++; });
confirmNextDialog(page, false, 'delete this prompt');
await button.click();
expect(deletes).toBe(0);
expect(await promptSnapshot(request)).toEqual(before);
await page.route('**/prompt_manager/delete/*', route => route.abort('failed'), { times: 1 });
confirmNextDialog(page, true, 'delete this prompt');
await button.click();
await expect(page.getByText('Failed to delete prompt', { exact: true })).toBeVisible();
expect(await promptSnapshot(request)).toEqual(before);
confirmNextDialog(page, true, 'delete this prompt');
await button.click();
await expect(page.locator('.prompt-text')).toHaveCount(1);
await page.reload();
await expect(page.locator('.prompt-text')).toHaveCount(1);
expect(await promptSnapshot(request)).toEqual(before.filter(prompt => prompt.text !== 'Cat portrait'));
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
for (const url of await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original))) {
await expect(await request.get(url)).toBeOK();
}
});
test('duplicate cleanup cancels safely and deletes only the selected file after a retry', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 2 } })).toBeOK();
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(4);
const originals = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
await page.locator('#settingsBtn').click();
await page.locator('#scanDuplicatesBtn').click();
await expect(page.locator('#duplicatesCount')).toHaveText('1');
const candidates = page.locator('.duplicate-checkbox');
await expect(candidates).toHaveCount(2);
const selected = await candidates.first().getAttribute('data-image-path');
const retained = await candidates.last().getAttribute('data-image-path');
const serve = path => `/prompt_manager/images/serve/${path.split(/[\\/]/).map(encodeURIComponent).join('/')}`;
await candidates.first().check();
let deletes = 0;
page.on('request', request => { if (request.url().endsWith('/delete_duplicate_images')) deletes++; });
confirmNextDialog(page, false, 'permanently delete 1 duplicate image files');
await page.locator('#removeDuplicatesBtn').click();
expect(deletes).toBe(0);
await expect(await request.get(serve(selected))).toBeOK();
await page.route('**/prompt_manager/delete_duplicate_images', route => route.abort('failed'), { times: 1 });
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
await page.locator('#removeDuplicatesBtn').click();
await expect(page.getByText('Failed to delete duplicate images', { exact: true })).toBeVisible();
await expect(page.locator('#removeDuplicatesBtn')).toBeEnabled();
await expect(await request.get(serve(selected))).toBeOK();
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images'));
await page.locator('#removeDuplicatesBtn').click();
expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 });
await expect(page.locator('#duplicatesModal')).toBeHidden();
expect((await request.get(serve(selected))).status()).toBe(404);
await expect(await request.get(serve(retained))).toBeOK();
await page.reload();
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(3);
const remaining = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
expect(remaining).toHaveLength(originals.length - 1);
for (const url of remaining) {
expect(originals).toContain(url);
await expect(await request.get(url)).toBeOK();
}
});
+441
View File
@@ -0,0 +1,441 @@
import { writeFile } from 'node:fs/promises';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { installHostHooks } from './host-fixture.mjs';
installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptSearchList', 'PromptManagerFocused']);
async function addEditor(page, type) {
await page.waitForFunction(type => Boolean(globalThis.LiteGraph?.registered_node_types?.[type]), type);
return page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(type);
if (!node) throw new Error(`${type} must be registered by the real host`);
app.graph.add(node);
node.setSize([type === 'PromptManagerSplit' ? 1100 : 680, 1050]);
app.canvas.ds.scale = .8;
app.canvas.centerOnNode(node);
return node.id;
}, type);
}
async function model(page, id) {
return page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
return Object.fromEntries(app.graph.getNodeById(id).widgets.map(widget => [widget.name, widget.value]));
}, id);
}
async function seed(request, text, category = 'Focused host tests', tags = ['focused-host']) {
const response = await request.post('/prompt_manager/save', { data: { text, category, tags } });
expect(response.ok()).toBe(true);
expect((await response.json()).success).toBe(true);
}
async function showLibrary(page) {
const button = page.locator('.pm-focused').getByRole('button', { name: 'Browse library', exact: true });
if (await button.count()) await button.click();
}
async function browse(page, text) {
const root = page.locator('.pm-focused');
await showLibrary(page);
await root.getByRole('button', { name: 'Recent', exact: true }).click();
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
return root;
}
for (const [label, type] of [['Focused', 'PromptManagerFocused'], ['Split', 'PromptManagerSplit']]) {
test.describe(label, () => {
test(`${label} definition preserves Classic encoding and exposes composition STRING sockets`, async ({ request }) => {
const definitions = await (await request.get('/object_info')).json();
const focused = definitions[type];
expect(focused).toBeDefined();
expect(focused.display_name).toBe(`Prompt Manager (${label})`);
expect(focused.output).toEqual(definitions.PromptManager.output);
expect(focused.input.required).toEqual(definitions.PromptManager.input.required);
for (const name of ['prepend_text', 'append_text']) {
expect(focused.input.optional[name][0]).toBe('STRING');
expect(focused.input.optional[name][1]).toMatchObject({ forceInput: true, default: '' });
}
});
test(`${label} preview, explicit load, composition, renderer changes and workflow reload preserve state`, async ({ page, request }, testInfo) => {
const text = `Focused selected ${testInfo.project.name}`;
await seed(request, text);
const id = await addEditor(page, type);
const root = page.locator('.pm-focused');
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
await expect(root).toBeVisible();
expect(await editor.evaluate(async (element, id) => {
const { app } = await import('/scripts/app.js');
return app.graph.getNodeById(id).widgets.find(widget => widget.name === 'text').inputEl === element;
}, id)).toBe(true);
await editor.fill('Original editing text');
await browse(page, text);
await expect(editor).toHaveValue('Original editing text');
expect((await model(page, id)).text).toBe('Original editing text');
await root.getByRole('button', { name: 'Use prompt', exact: true }).click();
await expect(editor).toHaveValue(text);
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host' });
await root.getByText('Composition · prepend / append', { exact: true }).click();
await root.getByRole('textbox', { name: 'Prepend', exact: true }).fill('LoRA trigger');
await root.getByRole('textbox', { name: 'Append', exact: true }).fill('high detail');
await root.getByText('Composed text preview', { exact: true }).click();
await expect(root).toContainText(`LoRA trigger ${text} high detail`);
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(root).toHaveCount(1);
await expect(editor).toHaveValue(text);
}
const queued = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const prompt = await app.graphToPrompt();
await app.loadGraphData(app.graph.serialize());
return prompt.output[String(id)].inputs;
}, id);
expect(queued).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host', prepend_text: 'LoRA trigger', append_text: 'high detail' });
await expect(editor).toHaveValue(text);
await expect.poll(() => model(page, id)).toMatchObject({ text, prepend_text: 'LoRA trigger', append_text: 'high detail' });
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-reloaded.png`) });
await page.evaluate(async id => { const { app } = await import('/scripts/app.js'); app.graph.remove(app.graph.getNodeById(id)); }, id);
await expect(root).toHaveCount(0);
});
test(`${label} search preview respects metadata opt-out`, async ({ page, request }, testInfo) => {
const text = `Focused search ${testInfo.project.name}`;
await seed(request, text, 'Incoming category', ['incoming-tag']);
const id = await addEditor(page, type);
const root = page.locator('.pm-focused');
await root.getByText('Metadata · category / tags', { exact: true }).click();
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Keep category');
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('keep-tag');
await showLibrary(page);
await root.getByRole('searchbox', { name: 'Search saved prompts', exact: true }).fill(text);
await root.getByRole('button', { name: 'Search', exact: true }).click();
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
await root.getByRole('switch', { name: 'Load metadata', exact: true }).uncheck();
await root.getByRole('button', { name: 'Use prompt', exact: true }).click();
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Keep category', tags: 'keep-tag' });
});
test(`${label} connected STRING inputs remain linked through UI actions and reload`, async ({ page, request }, testInfo) => {
const text = `Connected library ${testInfo.project.name}`;
await seed(request, text);
const id = await addEditor(page, type);
const root = page.locator('.pm-focused');
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Local main');
await root.getByText('Composition · prepend / append', { exact: true }).click();
await root.getByRole('textbox', { name: 'Prepend', exact: true }).fill('Local prepend');
await root.getByRole('textbox', { name: 'Append', exact: true }).fill('Local append');
await root.getByText('Composition · prepend / append', { exact: true }).click();
const links = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const { convertToInput } = await import('/extensions/core/widgetInputs.js');
const target = app.graph.getNodeById(id);
convertToInput(target, target.widgets.find(widget => widget.name === 'text'));
const result = {};
for (const name of ['text', 'prepend_text', 'append_text']) {
const source = LiteGraph.createNode('PrimitiveString');
app.graph.add(source);
source.pos = [target.pos[0] - 360, target.pos[1] + Object.keys(result).length * 150];
source.widgets.find(widget => widget.name === 'value').value = `Connected ${name}`;
const slot = target.inputs.findIndex(input => input.name === name);
if (slot < 0) throw new Error(`Missing ${name} input socket`);
source.connect(0, target, slot);
result[name] = [String(source.id), 0];
}
app.canvas.centerOnNode(target);
return result;
}, id);
await browse(page, text);
await expect(root.getByRole('textbox', { name: 'Main prompt', exact: true })).toBeHidden();
await expect(root.getByRole('button', { name: 'Use prompt', exact: true })).toBeDisabled();
await root.getByText('Composition · prepend / append', { exact: true }).click();
for (const name of ['Prepend', 'Append']) await expect(root.getByRole('textbox', { name, exact: true })).toBeHidden();
const guardedValues = await model(page, id);
await root.locator('textarea, input').evaluateAll(inputs => {
for (const input of inputs) {
if (!input.hidden && !input.closest('[hidden]')) continue;
input.value = 'Attempt to replace upstream text';
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
expect(await model(page, id)).toMatchObject(guardedValues);
for (let reload = 0; reload < 2; reload++) {
const inputs = await page.evaluate(async ({ id, reload }) => {
const { app } = await import('/scripts/app.js');
if (reload) await app.loadGraphData(app.graph.serialize());
return (await app.graphToPrompt()).output[String(id)].inputs;
}, { id, reload });
expect(inputs).toMatchObject(links);
}
await expect(root.getByRole('button', { name: 'Use prompt', exact: true, includeHidden: true })).toBeDisabled();
const disconnected = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
for (const name of ['text', 'prepend_text', 'append_text']) node.disconnectInput(node.inputs.findIndex(input => input.name === name));
return (await app.graphToPrompt()).output[String(id)].inputs;
}, id);
expect(disconnected).toMatchObject({ text: 'Local main', prepend_text: 'Local prepend', append_text: 'Local append' });
for (const [name, value] of [['Main prompt', 'Local main'], ['Prepend', 'Local prepend'], ['Append', 'Local append']]) {
const input = page.getByRole('textbox', { name, exact: true });
await expect(input).toHaveCount(1);
await expect(input).toBeEditable();
await expect(input).toHaveValue(value);
}
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-disconnected.png`) });
});
test(`${label} follows actual ComfyUI light and dark palettes`, async ({ page }, testInfo) => {
await addEditor(page, type);
for (const palette of ['light', 'dark']) {
await page.evaluate(async palette => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
}, palette);
const root = page.locator('.pm-focused');
await expect(root).toHaveAttribute('data-ca-theme', palette);
if (label === 'Split') {
await expect(root.getByRole('button', { name: /^(Browse|Hide) library$/ })).toHaveCount(0);
await expect(root.getByRole('button', { name: 'Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
} else {
await expect(root.getByRole('button', { name: 'Browse library', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
}
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-${palette}.png`) });
}
});
test(`${label} Web UI button opens actual standalone pages`, async ({ page }) => {
await addEditor(page, type);
const popupPromise = page.waitForEvent('popup');
await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
await expect(popup.locator('body')).toContainText('Prompt');
await expect(page.locator('.pm-focused').getByRole('status').filter({ hasText: 'Web UI opened.' })).toHaveText('Web UI opened.');
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
await expect(popup.locator('#addPromptBtn')).toBeVisible();
await popup.close();
});
test(`${label} metadata completion uses saved categories and tags across renderers and reload`, async ({ page, request }, testInfo) => {
const category = `Host completion category ${testInfo.project.name}`;
const tag = `host-completion-${testInfo.project.name}`;
await seed(request, `Saved autocomplete ${testInfo.project.name}`, category, [tag]);
const id = await addEditor(page, type);
const root = page.locator('.pm-focused');
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Autocomplete workflow');
await root.getByText('Metadata · category / tags', { exact: true }).click();
const categoryInput = root.getByRole('combobox', { name: 'Category', exact: true });
const tagInput = root.getByRole('combobox', { name: 'Tags', exact: true });
await categoryInput.fill(category.slice(0, -3));
const categories = page.getByRole('listbox', { name: 'Saved categories', exact: true });
await expect(categories.getByRole('option', { name: category, exact: true })).toBeVisible();
await categoryInput.press('ArrowDown');
await expect(categoryInput).toHaveAttribute('aria-activedescendant', /.+/);
await categoryInput.press('Enter');
await expect(categoryInput).toHaveValue(category);
await expect(categoryInput).toHaveAttribute('aria-expanded', 'false');
for (const palette of ['light', 'dark']) {
await page.evaluate(async palette => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
}, palette);
await tagInput.fill(`keep, ${tag.slice(0, -3)}`);
const tags = page.getByRole('listbox', { name: 'Saved tags', exact: true });
const option = tags.getByRole('option', { name: tag, exact: true });
await expect(option).toBeVisible();
await expect(tags).toHaveAttribute('data-ca-theme', palette);
const bounds = await tags.boundingBox();
const viewport = page.viewportSize();
expect(bounds.x).toBeGreaterThanOrEqual(0);
expect(bounds.y).toBeGreaterThanOrEqual(0);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height);
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-metadata-${palette}.png`) });
await option.click();
await expect(tagInput).toHaveValue(`keep, ${tag}, `);
}
const expected = { text: 'Autocomplete workflow', category, tags: `keep, ${tag}, ` };
await expect.poll(() => model(page, id)).toMatchObject(expected);
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(categoryInput).toHaveValue(category);
await expect(tagInput).toHaveValue(expected.tags);
}
const queued = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const prompt = await app.graphToPrompt();
await app.loadGraphData(app.graph.serialize());
return prompt.output[String(id)].inputs;
}, id);
expect(queued).toMatchObject(expected);
await expect(categoryInput).toHaveValue(category);
await expect(tagInput).toHaveValue(expected.tags);
await categoryInput.fill(category.slice(0, -3));
await expect(categories).toBeVisible();
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
app.graph.remove(app.graph.getNodeById(id));
}, id);
await expect(page.getByRole('listbox', { name: 'Saved categories', includeHidden: true })).toHaveCount(0);
await expect(page.getByRole('listbox', { name: 'Saved tags', includeHidden: true })).toHaveCount(0);
});
test(`${label} library autocomplete filters saved prompts without editing draft metadata`, async ({ page, request }, testInfo) => {
const category = `Library category ${testInfo.project.name}`;
const tag = `library-filter-${testInfo.project.name}`;
const matchingText = `Matching library prompt ${testInfo.project.name}`;
await seed(request, matchingText, category, [tag]);
await seed(request, `Wrong category ${testInfo.project.name}`, `Other category ${testInfo.project.name}`, [tag]);
await seed(request, `Wrong tag ${testInfo.project.name}`, category, [`other-filter-${testInfo.project.name}`]);
const id = await addEditor(page, type);
const root = page.locator('.pm-focused');
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Keep my draft');
await root.getByText('Metadata · category / tags', { exact: true }).click();
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Draft category');
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('draft-tag');
await showLibrary(page);
const filterCategory = root.getByRole('combobox', { name: 'Filter category', exact: true });
const filterTags = root.getByRole('combobox', { name: 'Filter tags', exact: true });
await filterCategory.fill(category.slice(0, -3));
await expect(page.getByRole('listbox', { name: 'Saved categories', exact: true }).getByRole('option', { name: category, exact: true })).toBeVisible();
await filterCategory.press('ArrowDown');
await filterCategory.press('Enter');
await expect(filterCategory).toHaveValue(category);
for (const palette of ['light', 'dark']) {
await page.evaluate(async palette => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
}, palette);
await filterTags.fill(tag.slice(0, -3));
const option = page.getByRole('listbox', { name: 'Saved tags', exact: true }).getByRole('option', { name: tag, exact: true });
await expect(option).toBeVisible();
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-library-filters-${palette}.png`) });
await option.click();
await expect(filterTags).toHaveValue(`${tag}, `);
}
const draft = { text: 'Keep my draft', category: 'Draft category', tags: 'draft-tag' };
await root.getByRole('button', { name: 'Search', exact: true }).click();
const results = root.locator('.pm-editor-results').getByRole('button');
await expect(results).toHaveCount(1);
await expect(results.first()).toContainText(matchingText);
await results.first().click();
await expect.poll(() => model(page, id)).toMatchObject(draft);
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(filterCategory).toHaveValue(category);
await expect(filterTags).toHaveValue(`${tag}, `);
await expect.poll(() => model(page, id)).toMatchObject(draft);
}
const queued = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const prompt = await app.graphToPrompt();
await app.loadGraphData(app.graph.serialize());
return prompt.output[String(id)].inputs;
}, id);
expect(queued).toMatchObject(draft);
await expect(filterCategory).toHaveValue(category);
await expect(filterTags).toHaveValue(`${tag}, `);
await root.getByRole('button', { name: 'Search', exact: true }).click();
await expect(results).toHaveCount(1);
await expect(results.first()).toContainText(matchingText);
await expect.poll(() => model(page, id)).toMatchObject(draft);
});
if (label === 'Split') {
test('Split keeps its library visible beside the editor and stacks narrow nodes', async ({ page, request }, testInfo) => {
for (let index = 0; index < 21; index++) {
await seed(request, `Split layout recent ${testInfo.project.name} ${index}`);
}
const id = await addEditor(page, type);
const root = page.locator('.pm-split');
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
node.setSize([900, 650]);
app.canvas.centerOnNode(node);
}, id);
await expect(root).toHaveAttribute('aria-label', 'Split prompt editor');
const savedProperties = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
return app.graph.getNodeById(id).serialize().properties;
}, id);
expect(savedProperties).toHaveProperty('splitUI');
expect(savedProperties).not.toHaveProperty('focusedUI');
await expect(root.getByRole('button', { name: /^(Browse|Hide) library$/ })).toHaveCount(0);
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
const library = root.getByRole('region', { name: 'Prompt library', exact: true });
await expect(library).toBeVisible();
const captureGeometry = async phase => {
const geometry = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
const root = node._focusedView.element;
const bounds = selector => root.querySelector(selector)?.getBoundingClientRect().toJSON();
const ancestors = [];
for (let element = root; element && element !== document.body; element = element.parentElement) {
const style = getComputedStyle(element);
ancestors.push({ tag: element.tagName, className: element.className, bounds: element.getBoundingClientRect().toJSON(), inlineStyle: element.style.cssText, computed: Object.fromEntries(['height', 'min-height', 'max-height', 'display', 'overflow', 'flex', 'contain', 'position'].map(name => [name, style.getPropertyValue(name)])) });
}
return { nodeSize: [...node.size], ancestors, root: root.getBoundingClientRect().toJSON(), library: bounds('.pm-editor-library'), results: bounds('.pm-editor-results'), use: bounds('.pm-editor-selection button'), footer: bounds(':scope > .pm-editor-status') };
}, id);
const path = testInfo.outputPath(`split-${phase}-geometry.json`);
await writeFile(path, JSON.stringify(geometry, null, 2));
await testInfo.attach(`split-${phase}-geometry.json`, { path, contentType: 'application/json' });
return geometry;
};
const beforeRecent = await captureGeometry('before-recent');
await root.getByRole('button', { name: 'Recent', exact: true }).click();
const results = library.locator('.pm-editor-results').getByRole('button');
await expect(results).toHaveCount(20);
const promptText = results.first().locator('span').first();
const selectedText = await promptText.innerText();
await results.first().click();
const use = root.getByRole('button', { name: 'Use prompt', exact: true });
const afterSelection = await captureGeometry('after-selection');
await page.screenshot({ path: testInfo.outputPath('split-default-selected.png') });
expect.soft((await promptText.boundingBox())?.height || 0).toBeGreaterThan(10);
expect.soft(afterSelection.root.height).toBeLessThanOrEqual(beforeRecent.root.height + 1);
await use.click();
await expect(editor).toHaveValue(selectedText);
await expect.poll(async () => {
const main = await editor.boundingBox();
const browser = await library.boundingBox();
return browser.x >= main.x + main.width;
}).toBe(true);
await page.screenshot({ path: testInfo.outputPath('split-wide.png') });
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
node.setSize([480, 1050]);
app.canvas.centerOnNode(node);
}, id);
await expect.poll(async () => {
const main = await editor.boundingBox();
const browser = await library.boundingBox();
return browser.y >= main.y + main.height;
}).toBe(true);
await expect(library).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('split-narrow.png') });
});
}
});
}
+43
View File
@@ -0,0 +1,43 @@
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
export function installHostHooks(test, expectedTypes = ['PromptManager', 'PromptManagerText', 'PromptSearchList']) {
test.beforeEach(async ({ page, context, request }, testInfo) => {
const response = await request.get('/system_stats');
expect(response.ok()).toBe(true);
const info = (await response.json()).system;
expect(info.comfyui_version).toBe(runtime.serverVersion);
expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
expect(info.required_frontend_version).toBe(runtime.frontendVersion);
expect(info.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe(runtime.frontendVersion);
await testInfo.attach('host-versions.json', { body: JSON.stringify(info, null, 2), contentType: 'application/json' });
page.hostErrors = [];
page.on('pageerror', error => page.hostErrors.push(error.message));
await context.route('**/*', async route => {
const request = route.request();
const path = new URL(request.url()).pathname.replace(/^\/api\//, '/');
if (path === '/settings' && request.method() === 'GET') {
const response = await route.fetch();
return route.fulfill({ json: { ...await response.json(),
'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer,
'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false,
'Comfy.Notification.ShowVersionUpdates': false } });
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && !path.startsWith('/prompt_manager/')) return route.fulfill({ json: {} });
return route.continue();
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
await page.waitForFunction(async expectedTypes => {
const { app } = await import('/scripts/app.js');
return !!app.graph && expectedTypes.every(name => globalThis.LiteGraph?.registered_node_types?.[name]);
}, expectedTypes);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); app.graph.clear(); });
});
test.afterEach(async ({ page }) => {
expect(page.hostErrors).toEqual([]);
});
}
+123
View File
@@ -0,0 +1,123 @@
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { installHostHooks } from './host-fixture.mjs';
installHostHooks(test);
for (const type of ['PromptManager', 'PromptManagerText']) {
test(`${type} selects real database prompts and preserves text across renderers and reload`, async ({ page, request }, testInfo) => {
const text = `Host test ${type} ${testInfo.project.name}`;
const saved = await request.post('/prompt_manager/save', { data: { text, category: 'Host tests', tags: ['host-test'] } });
expect(saved.ok()).toBe(true);
expect((await saved.json()).success).toBe(true);
const id = await page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(type);
app.graph.add(node);
node.setSize([650, 800]);
app.canvas.centerOnNode(node);
return node.id;
}, type);
const recent = page.getByRole('button', { name: '📋 Recent', exact: true });
await expect(recent).toBeVisible();
if (type === 'PromptManager') {
await expect(page.locator('.pm-classic')).toBeVisible();
await expect(page.getByRole('button', { name: '🔍 Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
}
await recent.click();
await page.getByText(text, { exact: true }).click();
const state = () => page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
return Object.fromEntries(node.widgets.filter(widget => ['text', 'category', 'tags'].includes(widget.name)).map(widget => [widget.name, widget.value]));
}, id);
await expect.poll(state).toMatchObject({ text, category: 'Host tests', tags: 'host-test' });
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.ds.scale = .8;
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(recent).toHaveCount(1);
await expect(recent).toBeVisible();
expect(await state()).toMatchObject({ text });
}
const output = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const prompt = await app.graphToPrompt();
const workflow = app.graph.serialize();
await app.loadGraphData(workflow);
return prompt.output[String(id)].inputs;
}, id);
expect(output.text).toBe(text);
await expect.poll(state).toMatchObject({ text, category: 'Host tests', tags: 'host-test' });
await expect(recent).toHaveCount(1);
await page.screenshot({ path: testInfo.outputPath(`${type}-reloaded.png`) });
const removedStyle = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
const style = node._styleSheet;
app.graph.remove(node);
return !style.isConnected;
}, id);
expect(removedStyle).toBe(true);
await expect(recent).toHaveCount(0);
});
}
test('all node schemas and extension entrypoints load from the real host', async ({ page, request }) => {
const definitions = await (await request.get('/object_info')).json();
expect(definitions.PromptManager.display_name).toBe('Prompt Manager (Classic)');
for (const name of ['PromptManager', 'PromptManagerText', 'PromptSearchList']) expect(definitions[name]?.name).toBe(name);
const extensions = await (await request.get('/extensions')).json();
expect(extensions.filter(path => path.startsWith('/extensions/promptmanager/'))).toEqual(['/extensions/promptmanager/prompt_manager.js']);
const result = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('PromptSearchList');
app.graph.add(node);
const workflow = app.graph.serialize();
await app.loadGraphData(workflow);
return { type: app.graph._nodes[0].type, extension: app.extensions.some(item => item.name === 'PromptManager.UI') };
});
expect(result).toEqual({ type: 'PromptSearchList', extension: true });
});
for (const type of ['PromptManager', 'PromptManagerText']) {
test(`${type} Web UI button opens actual standalone pages`, async ({ page }) => {
await page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(type);
app.graph.add(node);
app.canvas.centerOnNode(node);
}, type);
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: '🌐 Web UI', exact: true }).click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
await expect(popup.locator('body')).toContainText('Prompt');
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
await expect(popup.locator('#addPromptBtn')).toBeVisible();
await popup.close();
});
}
test('Classic controls follow actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('PromptManager');
app.graph.add(node);
node.setSize([650, 800]);
app.canvas.centerOnNode(node);
});
for (const palette of ['light', 'dark']) {
await page.evaluate(async palette => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
}, palette);
await expect(page.locator('.pm-classic')).toHaveAttribute('data-ca-theme', palette);
await expect(page.getByRole('button', { name: '🔍 Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
await page.screenshot({ path: testInfo.outputPath(`classic-${palette}.png`) });
}
});
+67
View File
@@ -327,3 +327,70 @@ test("missing or empty metadata yields placeholders", () => {
assert.equal(r.checkpoint, "Unknown");
assert.equal(extractGenerationParams(null).seed, "Unknown");
});
test("Focused API metadata composes linked positive text and preserves negative role", () => {
const prompt = {
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
2: { class_type: "PromptManagerFocused", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } },
3: { class_type: "PromptManagerFocused", inputs: { text: "blurry", prepend_text: "avoid" } },
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } },
};
const result = extractGenerationParams({ prompt });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry");
});
test("Focused workflow-only metadata recovers serialized fields and connected composition", () => {
const workflow = {
nodes: [
{ id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] },
{ id: 2, type: "PromptManagerFocused", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] },
{ id: 3, type: "PromptManagerFocused", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] },
{ id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] },
{ id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] },
],
links: [
[1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"],
[3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"],
],
};
const result = extractGenerationParams({ workflow });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry watermark");
assert.equal(result.checkpoint, "model.safetensors");
});
test("Split API metadata composes linked positive text and preserves negative role", () => {
const prompt = {
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
2: { class_type: "PromptManagerSplit", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } },
3: { class_type: "PromptManagerSplit", inputs: { text: "blurry", prepend_text: "avoid" } },
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } },
};
const result = extractGenerationParams({ prompt });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry");
});
test("Split workflow-only metadata recovers serialized fields and connected composition", () => {
const workflow = {
nodes: [
{ id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] },
{ id: 2, type: "PromptManagerSplit", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] },
{ id: 3, type: "PromptManagerSplit", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] },
{ id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] },
{ id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] },
],
links: [
[1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"],
[3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"],
],
};
const result = extractGenerationParams({ workflow });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry watermark");
assert.equal(result.checkpoint, "model.safetensors");
});
+42
View File
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createScope, themeFromColor } from '../../web/js/shared/toolkit/core.js';
test('host background luminance determines mode without assuming palette names', () => {
for (const color of ['#fff', '#F4F4F6', 'rgb(240, 240, 240)']) assert.equal(themeFromColor(color), 'light');
for (const color of ['#222', '#202020', 'rgb(30, 32, 34)']) assert.equal(themeFromColor(color), 'dark');
assert.equal(themeFromColor(''), null);
assert.equal(themeFromColor('transparent'), null);
});
test('scope cancels requests and callbacks, removes listeners and disposes once', () => {
const scope = createScope();
const target = new EventTarget();
let events = 0;
let cleanups = 0;
scope.listen(target, 'change', () => events++);
scope.cleanup(() => cleanups++);
target.dispatchEvent(new Event('change'));
assert.equal(events, 1);
scope.dispose();
scope.dispose();
target.dispatchEvent(new Event('change'));
assert.equal(events, 1);
assert.equal(cleanups, 1);
assert.equal(scope.signal.aborted, true);
});
test('debounced work runs once with the latest value and stops after disposal', async () => {
const scope = createScope();
const values = [];
const change = scope.debounce(value => values.push(value), 1);
change('old');
change('new');
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(values, ['new']);
change('cancelled');
scope.dispose();
change('after removal');
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(values, ['new']);
});
+1 -1
View File
@@ -1867,7 +1867,7 @@ class TestScanReadsVideos(AdminAPITestCase):
(self.output_dir / "bad.png").write_bytes(b"\x89PNG not really")
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
with self.assertLogs(level="WARNING") as cm:
with self.assertLogs("prompt_manager", level="WARNING") as cm:
body = await (
await self.client.request("POST", "/prompt_manager/scan")
).text()
+61
View File
@@ -0,0 +1,61 @@
"""Focused shares encoding and tracking while exposing composition sockets."""
import logging
import unittest
from unittest import mock
from prompt_manager import PromptManager, PromptManagerFocused
from utils.prompt_graph import positive_prompt_nodes, run_prompt_nodes
class TestFocusedNode(unittest.TestCase):
def test_focused_keeps_the_execution_contract_and_visible_string_inputs(self):
self.assertTrue(issubclass(PromptManagerFocused, PromptManager))
self.assertEqual(PromptManagerFocused.RETURN_TYPES, PromptManager.RETURN_TYPES)
self.assertEqual(PromptManagerFocused.FUNCTION, PromptManager.FUNCTION)
focused = PromptManagerFocused.INPUT_TYPES()
for name in ("prepend_text", "append_text"):
self.assertTrue(focused["optional"][name][1]["forceInput"])
self.assertNotIn(
"forceInput", PromptManager.INPUT_TYPES()["optional"][name][1]
)
self.assertEqual(focused["hidden"]["unique_id"], "UNIQUE_ID")
def test_focused_encodes_composition_and_tracks_main_text_and_graph(self):
node = PromptManagerFocused.__new__(PromptManagerFocused)
node.logger = logging.getLogger("test.focused")
node.comfyui_integration = mock.Mock()
node._inject_lora_trigger_words = lambda text: text
node._track_prompt_execution = mock.Mock(return_value=42)
clip = mock.Mock()
graph = {"1": {"class_type": "PromptManagerFocused", "inputs": {"text": "cat"}}}
conditioning, text = node.encode_prompt(
clip,
"cat",
prepend_text="trigger",
append_text="detailed",
prompt=graph,
unique_id="1",
)
self.assertEqual(text, "trigger cat detailed")
clip.tokenize.assert_called_once_with(text)
self.assertIs(conditioning, clip.encode_from_tokens_scheduled.return_value)
tracked = node._track_prompt_execution.call_args.kwargs
self.assertEqual(tracked["text"], "cat")
self.assertEqual(tracked["prompt_graph"], graph)
def test_focused_is_traced_as_positive_and_not_as_the_negative_run(self):
graph = {
"1": {"class_type": "PromptManagerFocused", "inputs": {"text": "cat"}},
"2": {"class_type": "PromptManagerFocused", "inputs": {"text": "blur"}},
"3": {
"class_type": "KSampler",
"inputs": {
"model": ["4", 0],
"positive": ["1", 0],
"negative": ["2", 0],
},
},
}
self.assertEqual(positive_prompt_nodes(graph), ["1"])
self.assertEqual(run_prompt_nodes(graph), ["1"])
+17 -1
View File
@@ -135,9 +135,25 @@ class TestPackageWiring(PackageTestCase):
def test_nodes_are_registered(self):
self.assertEqual(
sorted(self.package.NODE_CLASS_MAPPINGS),
["PromptManager", "PromptManagerText", "PromptSearchList"],
[
"PromptManager",
"PromptManagerFocused",
"PromptManagerSplit",
"PromptManagerText",
"PromptSearchList",
],
)
def test_split_display_name_and_execution_contract_are_registered(self):
node = self.package.NODE_CLASS_MAPPINGS["PromptManagerSplit"]
self.assertEqual(
self.package.NODE_DISPLAY_NAME_MAPPINGS["PromptManagerSplit"],
"Prompt Manager (Split)",
)
focused = self.package.NODE_CLASS_MAPPINGS["PromptManagerFocused"]
self.assertTrue(issubclass(node, focused))
self.assertEqual(node.INPUT_TYPES(), focused.INPUT_TYPES())
def test_db_factory_returns_the_shared_database(self):
self.assertIsNotNone(self.package._global_db)
self.assertIs(self.package._db_factory(), self.package._global_db)
+26
View File
@@ -54,6 +54,32 @@ class TestPositivePromptNodes(unittest.TestCase):
}
self.assertEqual(positive_prompt_nodes(graph), ["134"])
def test_focused_nodes_preserve_positive_roles_and_samplerless_fallback(self):
graph = {
"1": {"class_type": "PromptManagerFocused", "inputs": {"text": "cat"}},
"2": {"class_type": "PromptManagerFocused", "inputs": {"text": "blur"}},
"5": ksampler("1", "2"),
}
self.assertEqual(positive_prompt_nodes(graph), ["1"])
self.assertEqual(run_prompt_nodes(graph), ["1"])
graph["1"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "cat"}}
self.assertEqual(run_prompt_nodes(graph), [])
del graph["5"]
self.assertEqual(run_prompt_nodes(graph), ["2"])
def test_split_nodes_preserve_positive_roles_and_samplerless_fallback(self):
graph = {
"1": {"class_type": "PromptManagerSplit", "inputs": {"text": "cat"}},
"2": {"class_type": "PromptManagerSplit", "inputs": {"text": "blur"}},
"5": ksampler("1", "2"),
}
self.assertEqual(positive_prompt_nodes(graph), ["1"])
self.assertEqual(run_prompt_nodes(graph), ["1"])
graph["1"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "cat"}}
self.assertEqual(run_prompt_nodes(graph), [])
del graph["5"]
self.assertEqual(run_prompt_nodes(graph), ["2"])
def test_negative_only_prompt_manager_is_never_positive(self):
graph = {
"176": pm("blurry"),
+54
View File
@@ -0,0 +1,54 @@
"""Split changes presentation while inheriting Focused execution and storage."""
import logging
import unittest
import unittest.mock
from prompt_manager import PromptManagerFocused, PromptManagerSplit
class TestSplitNode(unittest.TestCase):
def test_split_preserves_focused_schema_encoding_and_change_detection(self):
self.assertTrue(issubclass(PromptManagerSplit, PromptManagerFocused))
self.assertEqual(
PromptManagerSplit.INPUT_TYPES(), PromptManagerFocused.INPUT_TYPES()
)
self.assertEqual(
PromptManagerSplit.RETURN_TYPES, PromptManagerFocused.RETURN_TYPES
)
self.assertEqual(PromptManagerSplit.FUNCTION, PromptManagerFocused.FUNCTION)
self.assertIs(
PromptManagerSplit.encode_prompt, PromptManagerFocused.encode_prompt
)
self.assertEqual(
PromptManagerSplit.IS_CHANGED(None, "cat", category="Animals"),
PromptManagerFocused.IS_CHANGED(None, "cat", category="Animals"),
)
def test_split_encodes_composition_and_persists_main_text_with_metadata(self):
node = PromptManagerSplit.__new__(PromptManagerSplit)
node.logger = logging.getLogger("test.split")
node.comfyui_integration = unittest.mock.Mock()
node._inject_lora_trigger_words = lambda text: text
node._track_prompt_execution = unittest.mock.Mock(return_value=42)
clip = unittest.mock.Mock()
graph = {"1": {"class_type": "PromptManagerSplit", "inputs": {"text": "cat"}}}
conditioning, text = node.encode_prompt(
clip,
"cat",
category="Animals",
tags="studio",
prepend_text="trigger",
append_text="detailed",
prompt=graph,
unique_id="1",
)
self.assertEqual(text, "trigger cat detailed")
clip.tokenize.assert_called_once_with(text)
self.assertIs(conditioning, clip.encode_from_tokens_scheduled.return_value)
tracked = node._track_prompt_execution.call_args.kwargs
self.assertEqual(tracked["text"], "cat")
self.assertEqual(tracked["category"], "Animals")
self.assertIn("studio", tracked["tags"])
self.assertEqual(tracked["prompt_graph"], graph)
self.assertEqual(tracked["unique_id"], "1")
+20
View File
@@ -126,6 +126,26 @@ class TestQueueHook(DbTestCase):
self.assertEqual(self._runs(positive), 1)
self.assertEqual(self._runs(negative), 0)
def test_focused_queued_runs_count_only_positive_even_when_cached(self):
positive, negative = self._save("a cat"), self._save("blurry")
graph = workflow()
for node_id in ("134", "176"):
graph[node_id]["class_type"] = "PromptManagerFocused"
self._queue(graph)
self._queue(graph)
self.assertEqual(self._runs(positive), 2)
self.assertEqual(self._runs(negative), 0)
def test_split_queued_runs_count_only_positive_even_when_cached(self):
positive, negative = self._save("a cat"), self._save("blurry")
graph = workflow()
for node_id in ("134", "176"):
graph[node_id]["class_type"] = "PromptManagerSplit"
self._queue(graph)
self._queue(graph)
self.assertEqual(self._runs(positive), 2)
self.assertEqual(self._runs(negative), 0)
def test_requeue_counts_again_even_when_comfyui_skips_the_cached_node(self):
positive = self._save("a cat")
self._queue(workflow())
+30
View File
@@ -263,5 +263,35 @@ class TestConfiguration(IsolatedLoggerTestCase):
self.manager.update_config({"console_logging": False})
class TestConsoleNoise(IsolatedLoggerTestCase):
"""ComfyUI's console only sees warnings unless the level is DEBUG."""
def _console_handler(self) -> logging.StreamHandler:
return next(
h
for h in logging.getLogger("prompt_manager").handlers
if type(h) is logging.StreamHandler
)
def test_records_do_not_propagate_to_comfyui_root_logger(self) -> None:
self.assertFalse(logging.getLogger("prompt_manager").propagate)
def test_console_hides_info_at_the_default_level(self) -> None:
self.manager.update_config({"console_logging": True})
self.assertEqual(self._console_handler().level, logging.WARNING)
self.logger.info("startup chatter")
self.assertEqual(self.manager.get_recent_logs(limit=1)[0]["level"], "INFO")
log_file = pathlib.Path(self.log_dir) / "prompt_manager.log"
self.assertIn("startup chatter", log_file.read_text())
self.manager.update_config({"console_logging": False})
def test_debug_level_opens_the_console(self) -> None:
self.manager.update_config({"console_logging": True, "level": "DEBUG"})
self.assertEqual(self._console_handler().level, logging.DEBUG)
self.manager.update_config({"level": "INFO"})
self.assertEqual(self._console_handler().level, logging.WARNING)
self.manager.update_config({"console_logging": False})
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
test('browser coverage maps nested JS/MJS and excludes unrelated and traversal paths', async t => {
const directory = mkdtempSync(join(tmpdir(), 'pm-browser-coverage-'));
const previous = process.env.PM_JS_COVERAGE;
process.env.PM_JS_COVERAGE = directory;
t.after(() => {
if (previous === undefined) delete process.env.PM_JS_COVERAGE;
else process.env.PM_JS_COVERAGE = previous;
rmSync(directory, { recursive: true, force: true });
});
const urls = [
'/prompt_manager/js/gallery.js',
'/prompt_manager/js/shared/controls/button.mjs?v=1',
'/prompt_manager/js/shared/panel.js',
'/scripts/app.js', '/prompt_manager/js/../private.js',
'/prompt_manager/js/shared/theme.css',
];
await saveCoverage({ coverage: { stopJSCoverage: async () => urls.map(url => ({ url: `http://127.0.0.1:8765${url}`, functions: [] })) } }, 'chromium');
const report = JSON.parse(readFileSync(join(directory, readdirSync(directory)[0]), 'utf8'));
const root = fileURLToPath(new URL('../../web/js/', import.meta.url));
assert.deepEqual(report.result.map(entry => fileURLToPath(entry.url)), [join(root, 'gallery.js'), join(root, 'shared/controls/button.mjs'), join(root, 'shared/panel.js')]);
});
+15
View File
@@ -0,0 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../../', import.meta.url));
test('real-ComfyUI suite registers Classic and Nodes 2.0 startup projects', () => {
const output = execFileSync(process.execPath, ['tools/vendor/node_modules/@playwright/test/cli.js',
'test', '--config', 'tools/vendor/comfyui.config.mjs', '--list'], {
cwd: root, encoding: 'utf8', env: { ...process.env,
PM_COMFY_ROOT: '/unused-pinned-checkout', PM_COMFY_URL: 'http://127.0.0.1:8199/' },
});
assert.match(output, /\[classic-start\]/);
assert.match(output, /\[nodes2-start\]/);
});
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtemp, writeFile, mkdir, readFile, realpath, rm, access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { once } from 'node:events';
import { launchComfyHost } from '../../tools/vendor/comfyui-host.mjs';
async function fixture(t, program) {
const directory = await mkdtemp(join(tmpdir(), 'pm-host-test-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const checkout = join(directory, 'host');
const repository = join(directory, 'plugin');
await mkdir(checkout);
await mkdir(repository);
await writeFile(join(repository, '__init__.py'), '');
execFileSync('git', ['init', '--quiet', repository]);
execFileSync('git', ['-C', repository, 'add', '__init__.py']);
await writeFile(join(repository, 'config.json'), 'private-user-config');
await writeFile(join(repository, 'prompts.db'), 'private-user-database');
await writeFile(join(checkout, 'main.py'), program);
const git = args => execFileSync('git', ['-C', checkout, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
git(['init', '--quiet']);
git(['add', 'main.py']);
git(['-c', 'user.name=PromptManager Tests', '-c', 'user.email=tests@example.invalid', 'commit', '--quiet', '-m', 'fixture']);
return { checkout, repository, python: process.execPath, port: 8199,
pins: { revision: git(['rev-parse', 'HEAD']) }, logPath: join(directory, 'host.log'), forwardOutput: false };
}
const idle = `console.log('ready'); setInterval(() => {}, 1000);`;
test('PromptManager adapter isolates database, configuration and monitored directories', async t => {
const options = await fixture(t, idle);
const host = await launchComfyHost(options);
t.after(() => host.stop());
await once(host.child.stdout, 'data');
const args = host.child.spawnargs;
for (const flag of ['--cpu', '--offline', '--disable-all-custom-nodes']) assert.ok(args.includes(flag));
assert.equal(args[args.indexOf('--base-directory') + 1], host.stateDirectory);
assert.equal(args[args.indexOf('--listen') + 1], '127.0.0.1');
assert.equal(args[args.indexOf('--whitelist-custom-nodes') + 1], 'promptmanager');
const installed = join(host.stateDirectory, 'custom_nodes/promptmanager');
assert.notEqual(await realpath(installed), await realpath(options.repository));
assert.equal(await readFile(join(installed, '__init__.py'), 'utf8'), '');
await assert.rejects(access(join(installed, 'prompts.db')), { code: 'ENOENT' });
assert.equal(JSON.parse(await readFile(join(installed, 'config.json'), 'utf8')).database.default_path, join(host.stateDirectory, 'prompts.db'));
await writeFile(join(installed, 'prompts.db'), 'test data');
assert.equal(await readFile(join(options.repository, 'prompts.db'), 'utf8'), 'private-user-database');
assert.equal(await readFile(join(options.repository, 'config.json'), 'utf8'), 'private-user-config');
const config = JSON.parse(await readFile(join(installed, 'config.json'), 'utf8'));
assert.equal(config.gallery.monitoring.enabled, false);
assert.deepEqual(config.gallery.monitoring.directories, [join(host.stateDirectory, 'output')]);
assert.equal(config.web_ui.result_timeout, 0);
await access(join(host.stateDirectory, 'user'));
await host.stop();
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
await access(join(options.checkout, 'main.py'));
await access(join(options.repository, '__init__.py'));
});
+12 -1
View File
@@ -1,8 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const sourceRoot = join(process.cwd(), 'web/js');
import { checkCoverage } from '../../tools/vendor/frontend-coverage.mjs';
import { checkCoverage, discoverSources } from '../../tools/vendor/frontend-coverage.mjs';
const metrics = value => Object.fromEntries(['lines', 'branches', 'functions', 'statements'].map(key => [key, { pct: value }]));
const summary = { total: metrics(90), [join(sourceRoot, 'a.js')]: metrics(85) };
const baseline = { 'a.js': { lines: 80, branches: 80, functions: 80, statements: 80 } };
@@ -20,3 +22,12 @@ test('coverage gate rejects invalid baselines and missing metrics', () => {
assert.throws(() => checkCoverage(summary, { 'a.js': { lines: -1 } }, sourceRoot, ['a.js']), /baseline/i);
assert.match(checkCoverage({ total: metrics(100), [join(sourceRoot, 'a.js')]: {} }, baseline, sourceRoot, ['a.js']).join('\n'), /lines/);
});
test('coverage discovers nested JS and MJS while excluding non-source files', t => {
const root = mkdtempSync(join(tmpdir(), 'pm-coverage-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'shared/controls'), { recursive: true });
for (const file of ['entry.js', 'shared/core.mjs', 'shared/controls/button.js', 'shared/theme.css', 'shared/notes.txt']) writeFileSync(join(root, file), '');
assert.deepEqual(discoverSources(root), ['entry.js', 'shared/controls/button.js', 'shared/core.mjs']);
assert.match(checkCoverage({}, {}, root, discoverSources(root)).join('\n'), /baseline.*shared\/controls\/button.js/i);
});
+62
View File
@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { syncToolkit } from '../../tools/vendor/vendor-toolkit.mjs';
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'pm-toolkit-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const tooling = join(root, 'tools/vendor');
const dependency = join(tooling, 'node_modules/@comfyassets/toolkit');
mkdirSync(join(dependency, 'ui'), { recursive: true });
const resolved = 'https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/' + 'a'.repeat(40);
writeFileSync(join(tooling, 'package.json'), JSON.stringify({ devDependencies: { '@comfyassets/toolkit': resolved } }));
writeFileSync(join(tooling, 'package-lock.json'), JSON.stringify({ packages: { 'node_modules/@comfyassets/toolkit': { version: '0.1.0', resolved, integrity: 'sha512-fixture' } } }));
writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: '@comfyassets/toolkit', version: '0.1.0', license: 'MIT' }));
for (const file of ['ui/core.js', 'ui/suggestions.js', 'ui/controls.css', 'LICENSE', 'SOURCE.md']) writeFileSync(join(dependency, file), file);
return { root, tooling, dependency, destination: join(root, 'web/js/shared/toolkit') };
}
test('toolkit browser assets ship unchanged with deterministic locked provenance', t => {
const f = fixture(t);
syncToolkit({ root: f.root });
for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'suggestions.js': 'ui/suggestions.js', 'controls.css': 'ui/controls.css', LICENSE: 'LICENSE', 'SOURCE.md': 'SOURCE.md' })) assert.deepEqual(readFileSync(join(f.destination, name)), readFileSync(join(f.dependency, source)));
const first = readFileSync(join(f.destination, 'provenance.json'), 'utf8');
assert.equal(JSON.parse(first).integrity, 'sha512-fixture');
syncToolkit({ root: f.root });
assert.equal(readFileSync(join(f.destination, 'provenance.json'), 'utf8'), first);
syncToolkit({ root: f.root, check: true });
writeFileSync(join(f.destination, 'core.js'), 'local edit');
assert.throws(() => syncToolkit({ root: f.root, check: true }), /drift/);
assert.equal(readFileSync(join(f.destination, 'core.js'), 'utf8'), 'local edit');
});
test('toolkit sync rejects moving pins, identity mismatches and missing sources before writing', t => {
const f = fixture(t);
writeFileSync(join(f.tooling, 'package.json'), JSON.stringify({ devDependencies: { '@comfyassets/toolkit': 'main' } }));
assert.throws(() => syncToolkit({ root: f.root }), /pin/);
const second = fixture(t);
writeFileSync(join(second.dependency, 'package.json'), JSON.stringify({ name: '@comfyassets/toolkit', version: '0.2.0', license: 'MIT' }));
assert.throws(() => syncToolkit({ root: second.root }), /identity/);
const third = fixture(t);
rmSync(join(third.dependency, 'ui/controls.css'));
assert.throws(() => syncToolkit({ root: third.root }), /ENOENT/);
assert.throws(() => readFileSync(join(third.destination, 'core.js')), /ENOENT/);
});
test('toolkit sync rejects lookalike download hosts and archive paths before writing', t => {
for (const prefix of [
'https://codeloadXgithub.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/',
'https://codeload.githubXcom/ComfyAssets/ComfyAssets-Toolkit/tar.gz/',
'https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tarXgz/',
]) {
const f = fixture(t);
const resolved = prefix + 'a'.repeat(40);
writeFileSync(join(f.tooling, 'package.json'), JSON.stringify({ devDependencies: { '@comfyassets/toolkit': resolved } }));
writeFileSync(join(f.tooling, 'package-lock.json'), JSON.stringify({ packages: { 'node_modules/@comfyassets/toolkit': { version: '0.1.0', resolved, integrity: 'sha512-fixture' } } }));
assert.throws(() => syncToolkit({ root: f.root }), /immutable commit pin/);
assert.throws(() => readFileSync(join(f.destination, 'core.js')), /ENOENT/);
}
});
+28
View File
@@ -78,3 +78,31 @@ windowing is not a replacement for bounding the supplied image list: native
slideshow playback can still preload every supplied original. Review new
preloading defaults, gesture behavior, CSS changes, and any adapter use of
Viewer.js internal methods before adopting another release.
## Shared ComfyAssets test tooling
The exact toolkit commit archive in `package.json` / `package-lock.json` supplies
host orchestration and coverage helpers. `comfyui-host.mjs` is PromptManager's
configuration adapter; keep reusable lifecycle changes in the toolkit source.
The existing npm scripts remain the public maintainer entry points. See
[TESTING.md](../../TESTING.md) for the managed real-host matrix and update policy.
## Shared ComfyAssets browser controls
Prompt Manager (Classic) consumes the same pinned toolkit's browser controls.
The node identifier remains `PromptManager`; existing workflows, sockets, and
immediate result selection are preserved. PromptManager Text is unchanged.
Committed assets in `web/js/shared/toolkit/` work without npm or KikoTools installed.
Maintain common controls in [ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit),
then update the exact archive pin and lockfile here. Regenerate using
`npm run --prefix tools/vendor sync:toolkit`; `npm run --prefix tools/vendor check`
verifies both Viewer.js and the toolkit without rewriting files. The toolkit
distribution includes its license, source history, archive integrity and per-file
hashes. Do not edit its copied files directly. Classic's controller and layout
remain in `web/js/classic-node.js` and `web/js/classic-node.css`.
Run the frontend coverage/browser suites and managed Classic/Nodes 2.0 matrix
after updates. Shared modules participate in the frontend coverage gate.
Pixaroma's reusable framework inspired this architecture; no Pixaroma code,
styles or assets are included.
+2 -6
View File
@@ -1,6 +1,6 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { mapBrowserCoverage } from '@comfyassets/toolkit/testing/coverage';
import { randomUUID } from 'node:crypto';
import { repositoryRoot } from './vendor-viewerjs.mjs';
@@ -12,11 +12,7 @@ export async function startCoverage(page, browserName) {
export async function saveCoverage(page, browserName) {
if (!process.env.PM_JS_COVERAGE || browserName !== 'chromium') return;
const entries = await page.coverage.stopJSCoverage();
const result = [];
for (const entry of entries) {
const match = entry.url.match(/\/prompt_manager\/js\/([a-z0-9-]+\.js)(?:\?.*)?$/i);
if (match) result.push({ ...entry, url: pathToFileURL(join(repositoryRoot, 'web/js', match[1])).href });
}
const result = mapBrowserCoverage(entries, { urlPrefix: '/prompt_manager/js/', sourceRoot: join(repositoryRoot, 'web/js') });
mkdirSync(process.env.PM_JS_COVERAGE, { recursive: true });
writeFileSync(join(process.env.PM_JS_COVERAGE, `browser-${randomUUID()}.json`), JSON.stringify({ result }));
}
+39
View File
@@ -0,0 +1,39 @@
import { writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { launchComfyHost as launchSharedHost } from '@comfyassets/toolkit/testing/host';
import runtime from './comfyui-runtime.json' with { type: 'json' };
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
export function launchComfyHost({ checkout, repository = repositoryRoot,
python = process.env.PM_TEST_PYTHON || 'python', port = 8189, pins = runtime,
logPath = join(repositoryRoot, 'tools/vendor/test-results/comfyui-host.log'), forwardOutput = true }) {
return launchSharedHost({ checkout, repository, python, port, pins, logPath, forwardOutput,
pluginName: 'promptmanager',
async preparePlugin({ installed, stateDirectory }) {
const configPath = join(installed, 'config.json');
await writeFile(configPath, JSON.stringify({
database: { default_path: join(stateDirectory, 'prompts.db') },
gallery: { monitoring: { enabled: false, directories: [join(stateDirectory, 'output')] } },
web_ui: { result_timeout: 0 },
}), { mode: 0o600 });
return { PROMPT_MANAGER_CONFIG_PATH: configPath, PROMPT_MANAGER_EXTRA_GALLERY_ROOTS: '' };
},
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const host = await launchComfyHost({ checkout: process.env.PM_COMFY_ROOT,
port: Number(process.env.PM_COMFY_PORT || 8189) });
const stop = () => { host.stop().catch(error => { console.error(error.message); process.exitCode = 1; }); };
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
const result = await host.finished;
process.removeListener('SIGINT', stop);
process.removeListener('SIGTERM', stop);
if (result.code) console.error(`ComfyUI host exited with status ${result.code}; see tools/vendor/test-results/comfyui-host.log`);
process.exitCode = result.code;
} catch (error) { console.error(error.message); process.exitCode = 1; }
}
+9
View File
@@ -0,0 +1,9 @@
{
"repository": "Comfy-Org/ComfyUI",
"revision": "e8665ca8e4089ab917567cdb491308e1397663d4",
"serverVersion": "0.39.0",
"frontendVersion": "1.55.14",
"pythonVersion": "3.12",
"torchVersion": "2.11.0",
"torchvisionVersion": "0.26.0"
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from './node_modules/@playwright/test/index.mjs';
import { fileURLToPath } from 'node:url';
if (!process.env.PM_COMFY_ROOT) throw new Error('Set PM_COMFY_ROOT to a pinned disposable ComfyUI checkout');
const host = new URL(process.env.PM_COMFY_URL || 'http://127.0.0.1:8189/');
if (host.protocol !== 'http:' || host.hostname !== '127.0.0.1' || !host.port) throw new Error('A managed host needs an HTTP 127.0.0.1 URL with an explicit port');
const servers = [{
command: 'node tools/vendor/comfyui-host.mjs',
cwd: fileURLToPath(new URL('../../', import.meta.url)),
url: new URL('system_stats', host).href, timeout: 120000, reuseExistingServer: false,
env: { PM_COMFY_PORT: host.port },
gracefulShutdown: { signal: 'SIGTERM', timeout: 10000 },
}];
export default defineConfig({
testDir: '../../tests/comfyui',
outputDir: './test-results/comfyui',
timeout: 60000,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: 0,
use: { baseURL: host.href, browserName: 'chromium', viewport: { width: 1600, height: 1100 }, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
webServer: servers,
projects: [
{ name: 'classic-start', metadata: { initialRenderer: false } },
{ name: 'nodes2-start', metadata: { initialRenderer: true } },
],
});
+40 -4
View File
@@ -1,9 +1,9 @@
{
"admin.js": {
"lines": 24,
"branches": 53,
"functions": 20,
"statements": 24
"lines": 28,
"branches": 58,
"functions": 23,
"statements": 28
},
"api-client.js": {
"lines": 98,
@@ -100,5 +100,41 @@
"branches": 98,
"functions": 98,
"statements": 98
},
"classic-node.js": {
"lines": 98,
"branches": 79,
"functions": 98,
"statements": 98
},
"shared/toolkit/core.js": {
"lines": 98,
"branches": 96,
"functions": 98,
"statements": 98
},
"focused-node.js": {
"lines": 95,
"branches": 73,
"functions": 87,
"statements": 95
},
"prompt-library.js": {
"lines": 98,
"branches": 86,
"functions": 98,
"statements": 98
},
"metadata-autocomplete.js": {
"lines": 98,
"branches": 81,
"functions": 85,
"statements": 98
},
"shared/toolkit/suggestions.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
}
}
+4 -20
View File
@@ -4,25 +4,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';
import { repositoryRoot } from './vendor-viewerjs.mjs';
import { discoverSources, checkCoverage } from '@comfyassets/toolkit/testing/coverage';
export { discoverSources, checkCoverage };
const metrics = ['lines', 'branches', 'functions', 'statements'];
export function checkCoverage(summary, baseline, sourceRoot, files) {
const failures = [];
for (const file of files) {
const floor = baseline[file];
if (!floor) { failures.push(`Missing baseline for ${file}`); continue; }
const measured = summary[join(sourceRoot, file)];
if (!measured) { failures.push(`Missing coverage for ${file}`); continue; }
for (const metric of metrics) {
if (!Number.isFinite(floor[metric]) || floor[metric] < 0 || floor[metric] > 100) {
throw new Error(`Invalid baseline for ${file}: ${metric}`);
}
if (!Number.isFinite(measured[metric]?.pct) || measured[metric].pct < floor[metric]) {
failures.push(`${file} ${metric}: ${measured[metric]?.pct ?? 'missing'} < ${floor[metric]}`);
}
}
}
return failures;
}
function run(args, env = {}) {
const result = spawnSync(process.execPath, args, { cwd: repositoryRoot, stdio: 'inherit',
@@ -35,7 +19,7 @@ async function main() {
const reportRoot = join(repositoryRoot, 'coverage/js');
const raw = join(reportRoot, 'raw');
const sourceRoot = join(repositoryRoot, 'web/js');
const files = readdirSync(sourceRoot).filter(file => file.endsWith('.js')).sort();
const files = discoverSources(sourceRoot);
rmSync(reportRoot, { recursive: true, force: true });
mkdirSync(raw, { recursive: true });
const tests = readdirSync(join(repositoryRoot, 'tests/js')).filter(file => file.endsWith('.test.js')).map(file => `tests/js/${file}`);
@@ -45,7 +29,7 @@ async function main() {
run([playwright, 'test', '--config', join(tools, 'playwright.workflows.config.mjs'),
'--project', 'chromium', '--project', 'touch'], { PM_JS_COVERAGE: raw });
run([join(tools, 'node_modules/c8/bin/c8.js'), 'report', '--all', '--src', sourceRoot,
'--include', 'web/js/*.js', '--temp-directory', raw, '--reports-dir', reportRoot,
'--include', 'web/js/**/*.js', '--include', 'web/js/**/*.mjs', '--temp-directory', raw, '--reports-dir', reportRoot,
'--reporter', 'text', '--reporter', 'json-summary', '--reporter', 'html']);
const summary = JSON.parse(readFileSync(join(reportRoot, 'coverage-summary.json'), 'utf8'));
const baselinePath = join(tools, 'frontend-coverage-baseline.json');
+11
View File
@@ -8,6 +8,7 @@
"name": "promptmanager-vendor-tools",
"version": "1.0.0",
"devDependencies": {
"@comfyassets/toolkit": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/b5a34b1d12336cd1bdfd6b890b80db63cbc3c389",
"@playwright/test": "1.64.1",
"c8": "12.0.0",
"viewerjs": "1.15.2"
@@ -23,6 +24,16 @@
"node": ">=18"
}
},
"node_modules/@comfyassets/toolkit": {
"version": "0.1.0",
"resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/b5a34b1d12336cd1bdfd6b890b80db63cbc3c389",
"integrity": "sha512-rhI9xDH0U1WPBq7zuAN4rE6bLWx7ujTM7IIBNXINexN/z4FoQK4jnGXPMtNhW1XbbEpoZPZdExnrJpcRRPZXUw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=24"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
+4 -1
View File
@@ -5,15 +5,18 @@
"description": "Maintainer-only tools for reproducible browser dependencies",
"scripts": {
"sync": "node vendor-viewerjs.mjs",
"check": "node vendor-viewerjs.mjs --check",
"check": "node vendor-viewerjs.mjs --check && node vendor-toolkit.mjs --check",
"sync:toolkit": "node vendor-toolkit.mjs",
"update": "node update-viewerjs.mjs",
"test": "node --test ../../tests/vendor/*.test.mjs",
"test:browser": "playwright test --config playwright.config.mjs",
"test:workflows": "playwright test --config playwright.workflows.config.mjs",
"test:comfyui": "playwright test --config comfyui.config.mjs",
"test:coverage": "node frontend-coverage.mjs",
"coverage:update": "node frontend-coverage.mjs --update-baseline"
},
"devDependencies": {
"@comfyassets/toolkit": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/b5a34b1d12336cd1bdfd6b890b80db63cbc3c389",
"@playwright/test": "1.64.1",
"c8": "12.0.0",
"viewerjs": "1.15.2"
+43
View File
@@ -0,0 +1,43 @@
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { repositoryRoot } from './vendor-viewerjs.mjs';
export function syncToolkit({ root = repositoryRoot, check = false } = {}) {
const tooling = join(root, 'tools/vendor');
const readJson = path => JSON.parse(readFileSync(path, 'utf8'));
const pin = readJson(join(tooling, 'package.json')).devDependencies['@comfyassets/toolkit'];
if (!/^https:\/\/codeload\.github\.com\/ComfyAssets\/ComfyAssets-Toolkit\/tar\.gz\/[a-f0-9]{40}$/.test(pin)) throw new Error('Toolkit needs an immutable commit pin');
const locked = readJson(join(tooling, 'package-lock.json')).packages['node_modules/@comfyassets/toolkit'];
const installed = join(tooling, 'node_modules/@comfyassets/toolkit');
const info = readJson(join(installed, 'package.json'));
if (locked?.resolved !== pin || !locked.integrity || locked.version !== info.version || info.name !== '@comfyassets/toolkit' || info.license !== 'MIT') throw new Error('Toolkit package identity or lock mismatch; run npm ci --ignore-scripts');
const output = new Map();
const files = {};
for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'suggestions.js': 'ui/suggestions.js', 'controls.css': 'ui/controls.css', LICENSE: 'LICENSE', 'SOURCE.md': 'SOURCE.md' })) {
const content = readFileSync(join(installed, source));
output.set(name, content);
files[name] = { source, sha256: createHash('sha256').update(content).digest('hex') };
}
output.set('provenance.json', Buffer.from(JSON.stringify({ name: info.name, version: info.version, resolved: pin, integrity: locked.integrity, files }, null, 2) + '\n'));
const destination = join(root, 'web/js/shared/toolkit');
if (check) {
const drift = [...output].filter(([name, content]) => {
try { return !readFileSync(join(destination, name)).equals(content); }
catch (error) { if (error.code === 'ENOENT') return true; throw error; }
}).map(([name]) => name);
if (drift.length) throw new Error(`Toolkit browser distribution drift: ${drift.join(', ')}`);
} else {
mkdirSync(destination, { recursive: true });
for (const [name, content] of output) writeFileSync(join(destination, name), content);
}
return info.version;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
if (process.argv.slice(2).some(arg => arg !== '--check')) throw new Error('Usage: vendor-toolkit.mjs [--check]');
console.log(`Toolkit ${syncToolkit({ check: process.argv.includes('--check') })}: verified`);
} catch (error) { console.error(error.message); process.exitCode = 1; }
}
+16
View File
@@ -85,6 +85,8 @@ class PromptManagerLogger:
# Main logger
self.logger = logging.getLogger("prompt_manager")
self.logger.setLevel(getattr(logging, self.config["level"]))
# ComfyUI's root handler would print every record a second time.
self.logger.propagate = False
# Clear existing handlers
for handler in self.logger.handlers[:]:
@@ -100,6 +102,7 @@ class PromptManagerLogger:
# Console handler
if self.config["console_logging"]:
console_handler = logging.StreamHandler()
console_handler.setLevel(self._console_level())
console_handler.setFormatter(formatter)
self.logger.addHandler(console_handler)
@@ -124,6 +127,15 @@ class PromptManagerLogger:
# Component-specific loggers
self._setup_component_loggers()
def _console_level(self) -> int:
"""Console threshold: everything at DEBUG, otherwise warnings only.
INFO still reaches the log file and the web log viewer.
"""
if str(self.config["level"]).upper() == "DEBUG":
return logging.DEBUG
return logging.WARNING
def _setup_component_loggers(self):
"""Set up loggers for specific components.
@@ -363,6 +375,10 @@ class PromptManagerLogger:
logger = logging.getLogger(logger_name)
logger.setLevel(level)
for handler in self.logger.handlers:
if type(handler) is logging.StreamHandler:
handler.setLevel(self._console_level())
# Re-setup loggers if handlers changed
if any(
key in new_config
+4 -2
View File
@@ -11,7 +11,9 @@ link-tracing rules of web/js/comfy-metadata.js.
import re
from typing import Any, Dict, List, Optional
PROMPT_MANAGER_TYPES = frozenset({"PromptManager", "PromptManagerText"})
PROMPT_MANAGER_TYPES = frozenset(
{"PromptManager", "PromptManagerText", "PromptManagerFocused", "PromptManagerSplit"}
)
# Upper bound on nodes expanded per graph; queued and embedded graphs are untrusted
MAX_VISITS = 500
@@ -92,7 +94,7 @@ def has_sampler(graph: Any) -> bool:
def positive_prompt_nodes(graph: Any) -> List[str]:
"""Ids of PromptManager/PromptManagerText nodes that feed a positive input.
"""Ids of PromptManager family nodes that feed a positive input.
Ordered by discovery, each id once. Malformed graphs yield an empty list.
"""
+28 -12
View File
@@ -2,6 +2,9 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js'));
const { installFocusedNode, installSplitNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js'));
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit']);
// ============================================================================
// Top Bar Button Integration (similar to rgthree-comfy approach)
@@ -196,7 +199,7 @@ function syncPromptManagerWidgets() {
const nodes = graph._nodes || graph.nodes || [];
for (const node of nodes) {
if (node.type === "PromptManager" || node.type === "PromptManagerText") {
if (promptManagerTypes.has(node.type)) {
if (node.widgets && node.widgets.length > 0) {
if (!node.widgets_values) {
node.widgets_values = [];
@@ -256,7 +259,7 @@ app.registerExtension({
if (graph && output) {
const nodes = graph._nodes || graph.nodes || [];
for (const node of nodes) {
if ((node.type === "PromptManager" || node.type === "PromptManagerText") && output[node.id]) {
if (promptManagerTypes.has(node.type) && output[node.id]) {
const outputNode = output[node.id];
// Skip if text input is connected to another node (value is a link reference array)
@@ -289,6 +292,11 @@ app.registerExtension({
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === 'PromptManagerFocused' || nodeData.name === 'PromptManagerSplit') {
const install = nodeData.name === 'PromptManagerSplit' ? installSplitNode : installFocusedNode;
install(nodeType, app, api, parseSearchQuery);
return;
}
if (nodeData.name === "PromptManager" || nodeData.name === "PromptManagerText") {
// Store original methods
const onNodeCreated = nodeType.prototype.onNodeCreated;
@@ -302,7 +310,8 @@ app.registerExtension({
// CRITICAL FIX: Ensure text widget always returns current value during serialization
// This fixes the issue where graphToPrompt reads stale cached values
const self = this;
setTimeout(() => {
this._textSyncTimer = setTimeout(() => {
if (self._classicScope?.signal.aborted) return;
const textWidget = self.widgets?.find((w) => w.name === "text");
if (textWidget) {
// Store reference to the widget for the closure
@@ -334,13 +343,16 @@ app.registerExtension({
};
// Sync on every keystroke
textWidget.inputEl.addEventListener('input', syncValue);
const listen = self._classicScope
? (event) => self._classicScope.listen(textWidget.inputEl, event, syncValue)
: (event) => textWidget.inputEl.addEventListener(event, syncValue);
listen('input');
// Sync on change (for paste, autocomplete, etc.)
textWidget.inputEl.addEventListener('change', syncValue);
listen('change');
// Sync when focus leaves the field
textWidget.inputEl.addEventListener('blur', syncValue);
listen('blur');
// Sync on keyup as backup
textWidget.inputEl.addEventListener('keyup', syncValue);
listen('keyup');
// Initial sync
syncValue();
@@ -377,7 +389,7 @@ app.registerExtension({
width: 100%;
height: 100%;
padding: 8px;
background-color: #2a2a2a;
background-color: ${nodeData.name === 'PromptManager' ? 'var(--ca-surface)' : '#2a2a2a'};
border-radius: 4px;
box-sizing: border-box;
display: flex;
@@ -983,6 +995,7 @@ app.registerExtension({
throw new Error(`Unknown method: ${method}`);
}
if (this._classicScope) options.signal = this._classicScope.signal;
const response = await fetch(api.fileURL(url), options);
if (!response.ok) {
@@ -997,10 +1010,12 @@ app.registerExtension({
return { results: data.results || [] };
} catch (error) {
console.error(
`[PromptManager] API call failed for ${method}:`,
error,
);
if (!this._classicScope?.signal.aborted) {
console.error(
`[PromptManager] API call failed for ${method}:`,
error,
);
}
throw error;
}
};
@@ -1174,6 +1189,7 @@ app.registerExtension({
document.body.removeChild(notification);
}, 3000);
};
if (nodeData.name === 'PromptManager') installClassicControls(nodeType, app, parseSearchQuery, api);
}
},
});
+14
View File
@@ -0,0 +1,14 @@
.pm-classic .pm-classic-workspace { min-block-size: 0; }
.pm-classic .pm-classic-toolbar { display: flex; flex-wrap: wrap; gap: 8px; flex-shrink: 0; }
.pm-classic-toolbar > button { flex: 1; }
.pm-classic .pm-classic-results { flex: 1; min-block-size: 100px; overflow: auto; padding: 8px; border: 1px solid var(--ca-line); border-radius: 5px; background: var(--ca-field); }
.pm-classic-message { color: var(--ca-muted); text-align: center; padding: 12px; margin: 0; overflow-wrap: anywhere; }
.pm-classic-error { color: var(--ca-error-text); background: var(--ca-error-bg); }
.pm-classic-count { color: var(--ca-text); text-align: center; margin: 0 0 10px; }
.pm-classic button.pm-classic-result { display: flex; flex-direction: column; align-items: stretch; text-align: start; inline-size: 100%; white-space: normal; margin-block-end: 5px; }
.pm-classic-result[aria-pressed="true"] { border-color: var(--ca-accent); box-shadow: inset 3px 0 var(--ca-accent); }
.pm-classic-preview { overflow-wrap: anywhere; }
.pm-classic-metadata { color: var(--ca-muted); font-size: 11px; overflow-wrap: anywhere; }
.pm-classic-restore { white-space: normal !important; }
.pm-classic-notification { position: fixed; inset-block-start: 20px; inset-inline-end: 20px; padding: 10px 15px; border: 1px solid var(--ca-border); border-radius: 5px; z-index: 10000; max-inline-size: min(300px, calc(100vw - 40px)); overflow-wrap: anywhere; }
.pm-classic-notification[data-kind="error"] { color: var(--ca-error-text); background: var(--ca-error-bg); border-color: var(--ca-error); }
+156
View File
@@ -0,0 +1,156 @@
import { createScope, createButton, mountTheme } from './shared/toolkit/core.js';
function loadStyles() {
const href = new URL('./classic-node.css', import.meta.url).href;
if ([...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === href)) return;
const link = document.createElement('link');
Object.assign(link, { rel: 'stylesheet', href });
document.head.append(link);
}
function message(node, text, error = false) {
node._classicResultsScope?.dispose();
const element = document.createElement('p');
element.className = error ? 'pm-classic-message pm-classic-error' : 'pm-classic-message';
element.textContent = text;
element.setAttribute('role', error ? 'alert' : 'status');
node.resultsSection.replaceChildren(element);
}
export function installClassicControls(nodeType, app, parseSearchQuery, api) {
const prototype = nodeType.prototype;
const onCreated = prototype.onNodeCreated;
const onRemoved = prototype.onRemoved;
prototype.onNodeCreated = function (...args) {
this._classicScope = createScope();
this._classicNotifications = new Set();
this._classicScope.cleanup(() => {
clearTimeout(this._textSyncTimer);
clearTimeout(this.resultHideTimer);
this._classicResultsScope?.dispose();
for (const item of this._classicNotifications) { clearTimeout(item.timer); item.element.remove(); }
this._classicNotifications.clear();
});
onCreated.apply(this, args);
};
prototype.onRemoved = function (...args) {
this._classicScope?.dispose();
return onRemoved?.apply(this, args);
};
prototype.createSearchUI = function (container) {
loadStyles();
container.classList.add('ca-tools', 'pm-classic');
mountTheme(container, app.ui?.settings, this._classicScope);
this._classicContainer = container;
const wrapper = document.createElement('div');
wrapper.className = 'pm-classic-workspace';
const buttons = document.createElement('div');
buttons.className = 'pm-classic-toolbar';
for (const [label, action, primary] of [
['🔍 Search', () => this.performSearch(), true],
['📋 Recent', () => this.loadRecentPrompts()],
['🌐 Web UI', () => this.openWebInterface()],
]) {
const button = createButton(label, primary ? 'ca-primary' : '');
this._classicScope.listen(button, 'click', action);
buttons.append(button);
}
wrapper.append(buttons);
if (this.properties.showTestButton) {
const test = createButton('🔧 Test API');
this._classicScope.listen(test, 'click', () => this.testApiConnection());
wrapper.append(test);
}
this.resultsSection = document.createElement('div');
this.resultsSection.className = 'pm-classic-results';
wrapper.append(this.resultsSection);
container.replaceChildren(wrapper);
message(this, '💡 Click Search or Recent to find prompts');
};
prototype.performSearch = async function () {
const { text, tags } = parseSearchQuery(this.widgets?.find(widget => widget.name === 'search_text')?.value || '');
if (!text && !tags.length) { message(this, '⚠️ Enter search text or tag:name'); return; }
await this._classicQuery('search_prompts', { search_text: text, tags }, '🔍 Searching database...');
};
prototype.loadRecentPrompts = async function () {
await this._classicQuery('get_recent_prompts', { limit: 20 }, '📋 Loading recent prompts...');
};
prototype._classicQuery = async function (method, params, pending) {
message(this, pending);
try {
const response = await this.callNodeMethod(method, params);
if (this._classicScope.signal.aborted) return;
this.displayResults(response.results || []);
this.showNotification(response.results?.length ? `Found ${response.results.length} prompts` : 'No recent prompts found');
} catch (error) {
if (this._classicScope.signal.aborted) return;
message(this, `❌ ${error.message}`, true);
this.showNotification(error.message, 'error');
}
};
prototype.displayResults = function (results) {
if (this._classicScope.signal.aborted) return;
this._searchResults = results || [];
this._selectedPromptIndex = -1;
if (!results?.length) { message(this, '📭 No prompts found'); return; }
this._classicResultsScope?.dispose();
this._classicResultsScope = createScope();
const header = document.createElement('p');
header.className = 'pm-classic-count';
header.textContent = `📚 Found ${results.length} prompt${results.length === 1 ? '' : 's'}`;
this.resultsSection.replaceChildren(header);
results.forEach((result, index) => {
const button = createButton('', 'pm-classic-result');
button.setAttribute('aria-pressed', 'false');
const preview = document.createElement('span');
preview.className = 'pm-classic-preview';
preview.textContent = result.text.length > 80 ? result.text.substring(0, 80) + '...' : result.text;
const metadata = document.createElement('span');
metadata.className = 'pm-classic-metadata';
metadata.textContent = `${result.category || 'No category'} · ${result.rating ? '⭐' + result.rating : 'No rating'} · ${result.created_at ? new Date(result.created_at).toLocaleDateString() : 'Unknown'} · ${Array.isArray(result.tags) ? result.tags.join(', ') : 'No tags'}`;
button.append(preview, metadata);
this._classicResultsScope.listen(button, 'click', () => this.usePrompt(result, index));
this.resultsSection.append(button);
});
this.startAutoHideTimer();
};
prototype.highlightSelectedResult = function (index) {
this._selectedPromptIndex = index;
this.resultsSection.querySelectorAll('.pm-classic-result').forEach((element, i) => element.setAttribute('aria-pressed', String(i === index)));
};
prototype.testApiConnection = async function () {
message(this, '🔧 Testing API connection...');
try {
const response = await fetch(api.fileURL('/prompt_manager/test'), { signal: this._classicScope.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const data = await response.json();
if (!data.success) throw new Error(data.message || 'Test failed');
if (this._classicScope.signal.aborted) return;
message(this, `✅ API Connection Successful! ${data.message || ''} ${data.timestamp || ''}`);
this.showNotification('API connection test passed!', 'success');
} catch (error) {
if (this._classicScope.signal.aborted) return;
message(this, `❌ API Connection Failed: ${error.message}`, true);
this.showNotification(`API test failed: ${error.message}`, 'error');
}
};
prototype.hideResults = function () {
if (this._classicScope.signal.aborted || this.restoreButton) return;
this.resultsSection.style.display = 'none';
this.restoreButton = createButton('🔄 Results auto-hidden · Click to restore', 'pm-classic-restore');
this._classicScope.listen(this.restoreButton, 'click', () => this.showResults());
this.resultsSection.after(this.restoreButton);
};
prototype.showNotification = function (text, type = 'info') {
if (this._classicScope.signal.aborted) return;
const element = document.createElement('div');
element.className = 'ca-tools pm-classic-notification';
element.dataset.caTheme = this._classicContainer.dataset.caTheme;
element.dataset.kind = type;
element.setAttribute('role', type === 'error' ? 'alert' : 'status');
element.textContent = text;
document.body.append(element);
const item = { element, timer: setTimeout(() => { element.remove(); this._classicNotifications.delete(item); }, 3000) };
this._classicNotifications.add(item);
};
}
+2
View File
@@ -235,6 +235,8 @@
CLIPTextEncode: ["text"],
PromptManager: PROMPT_MANAGER_WIDGETS,
PromptManagerText: PROMPT_MANAGER_WIDGETS,
PromptManagerFocused: PROMPT_MANAGER_WIDGETS,
PromptManagerSplit: PROMPT_MANAGER_WIDGETS,
CheckpointLoader: ["config_name", "ckpt_name"],
UNETLoader: ["unet_name", "weight_dtype"],
};
+171
View File
@@ -0,0 +1,171 @@
import {createScope, createButton, createField, mountTheme} from './shared/toolkit/core.js';
import {createPromptLibrary} from './prompt-library.js';
import {attachMetadataAutocomplete} from './metadata-autocomplete.js';
const names = ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text'];
let nextEditorId = 0;
function loadStyles() {
const href = new URL('./prompt-editor.css', import.meta.url).href;
if ([...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === href)) return;
const link = document.createElement('link'); Object.assign(link, {rel:'stylesheet', href}); document.head.append(link);
}
export function installSplitNode(nodeType, app, api, parseQuery) {
installFocusedNode(nodeType, app, api, parseQuery, {layout:'split'});
}
export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'focused'} = {}) {
const split = layout === 'split';
const uiKey = split ? 'splitUI' : 'focusedUI';
const prototype = nodeType.prototype;
const created = prototype.onNodeCreated;
const removed = prototype.onRemoved;
const configure = prototype.configure;
const serialize = prototype.serialize;
const connections = prototype.onConnectionsChange;
prototype.onNodeCreated = function (...args) {
created?.apply(this, args);
const scope = createScope(); this._focusedScope = scope;
const editorId = `pm-focused-${++nextEditorId}`;
this.properties ||= {};
const widgets = new Map();
for (const name of 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];
widgets.set(name, widget);
}
const element = document.createElement('div'); element.className = `ca-tools pm-focused${split ? ' pm-split' : ''}`;
element.setAttribute('aria-label',split ? 'Split prompt editor' : 'Focused prompt editor');
loadStyles(); mountTheme(element, app.ui?.settings, scope);
const toolbar = document.createElement('div'); toolbar.className = 'pm-editor-row pm-editor-toolbar';
const title = document.createElement('strong'); title.textContent = 'Prompt Manager';
const browse = createButton('Browse library','ca-primary'); browse.setAttribute('aria-expanded','false');
const web = createButton('Web UI ↗'); toolbar.append(title);
if (!split) toolbar.append(browse);
toolbar.append(web);
const workspace = document.createElement('div'); workspace.className = 'pm-editor-workspace';
const editor = document.createElement('div'); editor.className = 'pm-editor-compose';
workspace.append(editor);
let webDisplayMode = 'popup';
this._focusedSettingsReady = fetch(api.fileURL('/prompt_manager/settings'),{signal:scope.signal})
.then(response => response.ok ? response.json() : null)
.then(data => {
const mode = data?.settings?.webui_display_mode;
if (!scope.signal.aborted && data?.success && ['popup','newtab'].includes(mode)) webDisplayMode = mode;
}).catch(() => {});
const textWidget = widgets.get('text');
const nativeInput = textWidget.inputEl;
const inputDescriptor = Object.getOwnPropertyDescriptor(textWidget,'inputEl');
const main = document.createElement('textarea'); main.rows = 6; main.spellcheck = nativeInput?.spellcheck ?? false;
// ComfyUI owns widget.element; inputEl exposes our visible editor to extensions.
Object.defineProperty(textWidget,'inputEl',{configurable:true,writable:true,value:main});
scope.cleanup(() => {
if (inputDescriptor) Object.defineProperty(textWidget,'inputEl',inputDescriptor);
else delete textWidget.inputEl;
});
const mainField = createField('Main prompt', main, `${editorId}-text`);
mainField.classList.add('pm-editor-main');
const connected = document.createElement('p'); connected.className = 'pm-editor-status'; connected.textContent = 'Text is supplied by an upstream connection.';
const controls = new Map([['text', main]]);
const details = new Map();
const completions = new Map();
element.append(toolbar, workspace);
editor.append(mainField, connected);
function disclosure(key, label) {
const section = document.createElement('details'); section.className = 'pm-editor-details';
const summary = document.createElement('summary'); summary.textContent = label; section.append(summary); editor.append(section); details.set(key,section);
scope.listen(section, 'toggle', () => {
if (!section.open) completions.forEach(completion => completion.close());
if (!scope.signal.aborted) thisNode.properties[uiKey] = snapshot();
});
return section;
}
const thisNode = this;
const composition = disclosure('composition','Composition · prepend / append');
const metadata = disclosure('metadata','Metadata · category / tags');
for (const [section, fields] of [[composition,[['prepend_text','Prepend'],['append_text','Append']]],[metadata,[['category','Category'],['tags','Tags']]]]) {
const grid = document.createElement('div'); grid.className = 'pm-editor-fields';
for (const [name,label] of fields) {
const input = document.createElement('input'); input.type = 'text'; input.setAttribute('aria-label',label); controls.set(name,input);
const field = createField(label,input,`${editorId}-${name}`);
const note = document.createElement('span'); note.className = 'pm-editor-connection'; note.textContent = 'Supplied by an upstream connection.'; note.hidden = true;
field.append(note); input._connectionNote = note; grid.append(field);
if (name === 'category' || name === 'tags') completions.set(name, attachMetadataAutocomplete(input, {
scope,api,settings:app.ui?.settings,multiple:name === 'tags',available:() => !isConnected(name) && input.getClientRects().length > 0,
}));
}
section.append(grid);
}
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 = 'Main prompt is saved when this node runs.';
function isConnected(name) { return thisNode.inputs?.some(input => input.name === name && input.link != null); }
function value(name) { return String(widgets.get(name)?.value ?? ''); }
function write(name, text) {
if (isConnected(name)) return;
const widget = widgets.get(name); widget.value = text;
if (widget.inputEl) widget.inputEl.value = text;
const index = thisNode.widgets.indexOf(widget);
if (thisNode.widgets_values) thisNode.widgets_values[index] = text;
widget.callback?.(text, app.canvas, thisNode);
thisNode.setDirtyCanvas(true,true); app.graph?.setDirtyCanvas(true,true);
}
function render() {
mainField.hidden = Boolean(isConnected('text')); connected.hidden = !isConnected('text');
controls.forEach((input,name) => {
input.value = value(name);
if (name !== 'text') {
if (isConnected(name)) completions.get(name)?.close();
input.hidden = Boolean(isConnected(name));
input._connectionNote.hidden = !isConnected(name);
}
});
preview.textContent = ['prepend_text','text','append_text'].map(name => isConnected(name) ? `[${name} supplied by connection]` : name === 'text' ? value(name) : value(name).trim()).filter(Boolean).join(' ');
library.refreshAvailability();
}
const library = createPromptLibrary({scope,api,settings:app.ui?.settings,parseQuery,isConnected:() => isConnected('text'), onQuery:text => write('search_text',text), onFilters:() => {this.properties[uiKey] = snapshot();}, onUse:(prompt,includeMetadata) => {
if (isConnected('text')) return;
write('text',prompt.text);
if (includeMetadata) {
write('category',prompt.category || '');
write('tags',Array.isArray(prompt.tags) ? prompt.tags.join(', ') : prompt.tags || '');
}
render(); footer.setAttribute('role','status'); footer.textContent = 'Loaded prompt. Saved when this node runs.'; main.focus();
}});
workspace.append(library.element); element.append(footer);
function snapshot() {
return {libraryOpen:!library.element.hidden,libraryFilters:library.filters(),...Object.fromEntries([...details].map(([name,section]) => [name,section.open]))};
}
function restore() {
const state = thisNode.properties[uiKey] || {};
details.forEach((section,name) => {section.open = Boolean(state[name]);});
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='Main prompt is saved when this node runs.';}));
scope.listen(browse,'click',() => {
library.element.hidden = !library.element.hidden;
library.closeCompletions();
browse.textContent = library.element.hidden ? 'Browse library' : 'Hide library'; browse.setAttribute('aria-expanded',String(!library.element.hidden));
this.properties[uiKey] = snapshot(); if (!library.element.hidden) library.focus();
});
scope.listen(web,'click',() => {
const url = api.fileURL('/prompt_manager/web');
const opened = webDisplayMode === 'newtab' ? window.open(url,'_blank') : window.open(url,'_blank','width=1200,height=800,scrollbars=yes,resizable=yes');
footer.setAttribute('role',opened ? 'status' : 'alert');
footer.textContent = opened ? 'Web UI opened.' : 'Allow popups to open Web UI, then try again.';
});
this._focusedView = {element,render,restore,snapshot};
this.addDOMWidget('prompt_manager_focused_ui','div',element,{serialize:false, hideOnZoom:false, getMinHeight:() => 360});
this.size = [split ? 900 : 550,650]; this.setSize(this.size); restore();
};
prototype.configure = function (...args) { const result = configure?.apply(this,args); this._focusedView?.restore(); return result; };
prototype.serialize = function (...args) {
if (this._focusedView) this.properties[uiKey] = this._focusedView.snapshot();
return serialize?.apply(this,args);
};
prototype.onConnectionsChange = function (...args) { const result = connections?.apply(this,args); this._focusedView?.render(); return result; };
prototype.onRemoved = function (...args) { this._focusedScope?.dispose(); return removed?.apply(this,args); };
}
+85
View File
@@ -0,0 +1,85 @@
import {createScope} from './shared/toolkit/core.js';
import {createSuggestionView} from './shared/toolkit/suggestions.js';
export function attachMetadataAutocomplete(input, {scope: owner, api, settings, multiple = false, available = () => true}) {
const scope = createScope();
owner.cleanup(() => scope.dispose());
const view = createSuggestionView(scope, settings, {label: multiple ? 'Saved tags' : 'Saved categories'});
const attributes = ['role','aria-autocomplete','aria-controls','aria-expanded','aria-activedescendant','autocomplete'];
const original = new Map(attributes.map(name => [name,input.getAttribute(name)]));
input.setAttribute('role','combobox');
input.setAttribute('aria-autocomplete','list');
input.setAttribute('aria-controls',view.element.id);
input.setAttribute('aria-expanded','false');
input.autocomplete = 'off';
let timer, request, sequence = 0, items = [], selected = -1;
function close() {
clearTimeout(timer); request?.abort(); sequence++;
items = []; selected = -1; view.hide();
input.setAttribute('aria-expanded','false');
input.removeAttribute('aria-activedescendant');
}
scope.cleanup(() => {
close();
original.forEach((value,name) => value === null ? input.removeAttribute(name) : input.setAttribute(name,value));
});
function choose(index) {
selected = index;
input.setAttribute('aria-activedescendant',view.select(index));
}
function insert(item) {
if (!available()) return close();
const parts = input.value.split(',');
parts.pop();
input.value = multiple ? [...parts.map(part => part.trim()).filter(Boolean),item.name].join(', ') + ', ' : item.name;
input.dispatchEvent(new Event('input',{bubbles:true}));
close(); input.focus();
}
async function load() {
const current = ++sequence;
request = new AbortController();
const parts = input.value.split(',');
const query = (multiple ? parts.pop() : input.value).trim();
const committed = new Set(parts.map(part => part.trim().toLowerCase()));
const url = multiple ? `/prompt_manager/tags/suggest?${new URLSearchParams({q:query,limit:'20'})}` : '/prompt_manager/categories';
try {
const response = await fetch(api.fileURL(url),{signal:request.signal});
if (scope.signal.aborted || current !== sequence) return;
if (!response.ok) return close();
const data = await response.json();
if (scope.signal.aborted || current !== sequence || document.activeElement !== input || !available()) return;
if (!data.success) return close();
items = (multiple ? data.suggestions : data.categories).map(item => ({name:typeof item === 'string' ? item : item.name}))
.filter(item => item.name.toLowerCase().startsWith(query.toLowerCase()) && (!multiple || !committed.has(item.name.toLowerCase()))).slice(0,15);
if (!items.length) return close();
selected = -1;
view.render(items,choose,insert); view.position(input.getBoundingClientRect());
input.setAttribute('aria-expanded','true');
} catch {
if (current === sequence && !scope.signal.aborted) close();
}
}
function schedule() {
close();
if (available()) timer = setTimeout(load,150);
}
scope.listen(input,'input',schedule);
scope.listen(input,'focus',schedule);
scope.listen(input,'blur',close);
scope.listen(input,'keydown',event => {
if (event.isComposing) return;
if (event.key === 'Escape' || event.key === 'Tab') {close();return;}
if (!items.length) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault(); event.stopPropagation();
choose(selected < 0 ? (event.key === 'ArrowDown' ? 0 : items.length - 1) : (selected + (event.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length);
} else if (event.key === 'Enter' && selected >= 0) {
event.preventDefault(); event.stopPropagation(); insert(items[selected]);
}
});
scope.listen(window,'resize',close);
document.addEventListener('scroll',event => {
if (!view.element.hidden && !view.element.contains(event.target)) close();
},{capture:true,signal:scope.signal});
return {close};
}
+40
View File
@@ -0,0 +1,40 @@
.pm-focused { box-sizing: border-box; inline-size: 100%; block-size: 100%; min-block-size: 0; padding: 12px; display: flex; flex-direction: column; gap: 10px; overflow: auto; container: pm-editor / inline-size; border-radius: 5px; }
.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-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; }
.pm-focused textarea { flex: 1; inline-size: 100%; min-block-size: 160px; resize: vertical; line-height: 1.65; }
.pm-editor-details { border-block-end: 1px solid var(--ca-line); padding-block: 5px; }
.pm-editor-details > summary { color: var(--ca-muted); cursor: pointer; }
.pm-editor-fields { display: grid; grid-template-columns: minmax(0,1fr); gap: 10px; padding-block: 10px; }
.pm-editor-fields .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 5px; text-align: start; }
.pm-editor-fields input { inline-size: 100%; }
.pm-editor-preview { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 10px; border-radius: 5px; background: var(--ca-field); }
.pm-editor-status, .pm-editor-connection { color: var(--ca-muted); font-size: 12px; margin: 0; overflow-wrap: anywhere; }
.pm-editor-error { color: var(--ca-error-text); background: var(--ca-error-bg); padding: 8px; border-radius: 5px; }
.pm-editor-library { display: grid; gap: 10px; border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; }
.pm-editor-library h3 { font: inherit; font-weight: 500; margin: 0; }
.pm-editor-query { display: flex; gap: 6px; flex-wrap: wrap; }
.pm-editor-query input { flex: 1; inline-size: 100%; min-inline-size: 100px; }
.pm-editor-results { display: grid; grid-auto-rows: max-content; align-content: start; gap: 6px; max-block-size: 300px; overflow: auto; }
.pm-focused button.pm-editor-result { flex-direction: column; align-items: stretch; text-align: start; white-space: normal; overflow-wrap: anywhere; }
.pm-editor-result[aria-pressed="true"] { border-color: var(--ca-accent); box-shadow: inset 3px 0 var(--ca-accent); }
.pm-editor-result-meta { color: var(--ca-muted); font-size: 11px; }
.pm-editor-result > span:first-child { flex-shrink: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-clamp: 2; overflow: hidden; }
.pm-editor-selection { display: grid; gap: 10px; }
.pm-editor-selection .pm-editor-row { justify-content: space-between; }
@container pm-editor (inline-size > 420px) { .pm-editor-fields { grid-template-columns: repeat(2,minmax(0,1fr)); } }
/* Contain intrinsic sizing so Nodes 2.0 keeps the widget within the node. */
.pm-split { contain: size; }
.pm-split .pm-editor-workspace { display: grid; grid-template-columns: minmax(0,1fr); gap: 12px; flex: 1; min-inline-size: 0; }
.pm-split .pm-editor-compose { display: flex; flex-direction: column; gap: 10px; min-inline-size: 0; min-block-size: 300px; }
.pm-split .pm-editor-library { min-inline-size: 0; align-content: start; }
.pm-split .pm-editor-preview { box-sizing: border-box; max-block-size: 180px; overflow: auto; }
@container pm-editor (inline-size > 720px) {
.pm-split .pm-editor-workspace { grid-template-columns: minmax(0,1.3fr) minmax(0,1fr); grid-template-rows: minmax(0,1fr); min-block-size: 0; }
.pm-split .pm-editor-compose, .pm-split .pm-editor-library { min-block-size: 0; overflow: auto; }
.pm-split .pm-editor-library { border-block-start: 0; border-inline-start: 1px solid var(--ca-line); padding-block-start: 0; padding-inline-start: 12px; }
.pm-split .pm-editor-fields { grid-template-columns: minmax(0,1fr); }
}
+107
View File
@@ -0,0 +1,107 @@
import {createScope, createButton, createSwitch, createField} from './shared/toolkit/core.js';
import {attachMetadataAutocomplete} from './metadata-autocomplete.js';
let nextLibraryId = 0;
export function createPromptLibrary({scope, api, settings, parseQuery, onUse, isConnected, onQuery, onFilters}) {
const element = document.createElement('section');
element.className = 'pm-editor-library';
element.setAttribute('aria-label', 'Prompt library');
element.hidden = true;
const heading = document.createElement('h3'); heading.textContent = 'Prompt library';
const libraryId = `pm-library-${++nextLibraryId}`;
const queryRow = document.createElement('div'); queryRow.className = 'pm-editor-query';
const query = document.createElement('input'); query.type = 'search'; query.placeholder = 'Search text or tag:portrait'; query.setAttribute('aria-label', 'Search saved prompts');
const search = createButton('Search', 'ca-primary');
const recent = createButton('Recent');
queryRow.append(query, search, recent);
const filters = document.createElement('div'); filters.className = 'pm-editor-fields';
const category = document.createElement('input'); category.type = 'text';
const tags = document.createElement('input'); tags.type = 'text';
filters.append(createField('Filter category',category,`${libraryId}-category`),createField('Filter tags',tags,`${libraryId}-tags`));
const hint = document.createElement('p'); hint.className = 'pm-editor-status'; hint.textContent = 'Search combines text, category and tags. Recent shows all recent prompts.';
const completions = [category,tags].map((input,index) => attachMetadataAutocomplete(input, {
scope,api,settings,multiple:index === 1,available:() => !element.hidden && input.getClientRects().length > 0,
}));
const status = document.createElement('p'); status.className = 'pm-editor-status'; status.setAttribute('role', 'status'); status.textContent = 'Search or browse recent prompts.';
const results = document.createElement('div'); results.className = 'pm-editor-results';
const preview = document.createElement('section'); preview.className = 'pm-editor-selection'; preview.hidden = true;
const previewText = document.createElement('p'); previewText.className = 'pm-editor-preview';
const actions = document.createElement('div'); actions.className = 'pm-editor-row';
const includeMetadata = createSwitch('Load metadata', '', `${libraryId}-load-metadata`);
const use = createButton('Use prompt', 'ca-primary'); use.disabled = true;
actions.append(includeMetadata, use); preview.append(previewText, actions);
element.append(heading, queryRow, filters, hint, status, results, preview);
let selected = null;
let request = null;
let sequence = 0;
let rows = createScope();
scope.cleanup(() => { sequence++; request?.abort(); rows.dispose(); });
function refreshAvailability() { use.disabled = !selected || isConnected(); }
function invalidate() {
sequence++; request?.abort(); selected = null; preview.hidden = true; refreshAvailability();
rows.dispose(); rows = createScope(); results.replaceChildren();
status.classList.remove('pm-editor-error'); status.setAttribute('role','status'); status.textContent = 'Search to refresh results.';
}
async function load(recentOnly = false) {
request?.abort(); request = new AbortController();
const current = ++sequence;
selected = null; preview.hidden = true; refreshAvailability();
rows.dispose(); rows = createScope(); results.replaceChildren();
const parsed = parseQuery(query.value);
const text = parsed.text;
const tagFilters = [...new Map([...parsed.tags,...tags.value.split(',')].map(tag => tag.trim()).filter(Boolean).map(tag => [tag.toLowerCase(),tag])).values()];
const categoryFilter = category.value.trim();
status.classList.remove('pm-editor-error'); status.setAttribute('role', 'status');
if (!recentOnly && !text && !tagFilters.length && !categoryFilter) { status.textContent = 'Enter search text, a category or tags.'; return; }
const params = new URLSearchParams(recentOnly ? {limit:'20', sort:'last_used_desc'} : {limit:'50'});
if (!recentOnly) {
if (text) params.set('text', text);
if (tagFilters.length) params.set('tags', tagFilters.join(','));
if (categoryFilter) params.set('category', categoryFilter);
}
status.textContent = 'Loading prompts…';
try {
const response = await fetch(api.fileURL(`/prompt_manager/${recentOnly ? 'recent' : 'search'}?${params}`), {signal:request.signal});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const data = await response.json();
if (scope.signal.aborted || current !== sequence) return;
if (!data.success) throw new Error(data.error || 'Could not load prompts');
const found = data.results || [];
status.textContent = found.length ? `${found.length} ${found.length === 1 ? 'prompt' : 'prompts'}` : 'No matching prompts.';
found.forEach(item => {
const button = createButton('', 'pm-editor-result'); button.setAttribute('aria-pressed','false');
const text = document.createElement('span'); text.textContent = item.text;
const metadata = document.createElement('span'); metadata.className = 'pm-editor-result-meta';
metadata.textContent = [item.category, Array.isArray(item.tags) ? item.tags.join(', ') : item.tags, item.rating ? `${item.rating}/5` : ''].filter(Boolean).join(' · ');
button.append(text, metadata);
rows.listen(button, 'click', () => {
selected = item; previewText.textContent = item.text; preview.hidden = false;
results.querySelectorAll('button').forEach(row => row.setAttribute('aria-pressed', String(row === button)));
status.textContent = 'Previewing selection. Editor unchanged.'; refreshAvailability();
});
results.append(button);
});
} catch (error) {
if (scope.signal.aborted || current !== sequence || request.signal.aborted) return;
status.classList.add('pm-editor-error'); status.setAttribute('role','alert'); status.textContent = `Could not load prompts: ${error.message}`;
}
}
scope.listen(search, 'click', () => load());
scope.listen(recent, 'click', () => load(true));
scope.listen(query, 'input', () => {invalidate();onQuery(query.value);});
[query,category,tags].forEach(input => scope.listen(input, 'keydown', event => {
if (event.key === 'Enter' && !event.defaultPrevented && !event.isComposing) { event.preventDefault(); load(); }
}));
[category,tags].forEach(input => scope.listen(input,'input',() => {
invalidate(); status.textContent = 'Filters changed. Search to refresh results.'; onFilters();
}));
scope.listen(use, 'click', () => {
if (!selected || isConnected()) return;
onUse(selected, includeMetadata.querySelector('input').checked);
});
return {element, focus:() => query.focus(), refreshAvailability, setQuery(value) { query.value = value || ''; },
filters:() => ({category:category.value,tags:tags.value}),
restoreFilters(state = {}) { invalidate();category.value = state.category || '';tags.value = state.tags || '';completions.forEach(completion => completion.close()); },
closeCompletions() { completions.forEach(completion => completion.close()); },
};
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 ComfyAssets
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+34
View File
@@ -0,0 +1,34 @@
# Initial source provenance
The initial browser controls are extracted from ComfyAssets' own
KikoTools modules at commit `1820fb19ea5d200e8cd2a09bcbbcc35926f13228`.
KikoTools remains unchanged by this extraction. Future toolkit changes belong
here and are validated in PromptManager before a later KikoTools adoption PR.
| Toolkit file | KikoTools source | Original source SHA-256 |
| --- | --- | --- |
| `ui/core.js` | `web/ui/core.js` | `cd10b700441a607cb1d56051cf0a430c0b36f901c0c351d8f4dae067090d8838` |
| `ui/controls.css` | `web/ui/controls.css` | `64d6b0f3059b0a21187796059fe14bfb5358a796a18f775543b7baef43874e80` |
Host orchestration and coverage helpers were extracted and generalized from
KikoTools' maintainer tooling, with isolated tracked-file installation and a
project preparation adapter proven against PromptManager. They have no runtime
imports from either node pack. All original code retains the ComfyAssets MIT
license. No Pixaroma implementation, styles or assets were copied.
`ui/core.js` is unchanged. `ui/controls.css` retains only the shared control,
range, touch-target and forced-colors rules (original lines 1–61, 97–99, 118,
170–172 and closing brace 174). Feature-view selectors remain in KikoTools.
The retained declarations are unchanged; the extracted stylesheet SHA-256 is
`3ae1ca39077ffc3621b86ae7ef77202841fa27061b0e4623f2bb0413b27ecd7b`.
## Suggestion view extraction
`ui/suggestions.js` derives from KikoTools `web/ui/autocomplete-suggestions.js`
at commit `1820fb19ea5d200e8cd2a09bcbbcc35926f13228` (SHA-256 `2aecc78f53b7be3d67b77337b4a340f948afcc4cb1196ccf3b70739ef1f0d002`).
The generic view preserves its scope-based cleanup, safe text rendering and
viewport positioning. Pack-specific classes and IDs are replaced by `ca-` names;
callers can supply a list label and omit type badges. Corresponding completion
styles are extracted from KikoTools' `web/ui/controls.css`. KikoTools is unchanged.
Keyboard handling, input ARIA state, matching and insertion remain in consumers.
The stylesheet hash above describes the initial extraction before this addition.
+79
View File
@@ -0,0 +1,79 @@
.ca-tools {
color-scheme: dark;
--ca-surface: #2b2b2e;
--ca-field: #202023;
--ca-control: #37373b;
--ca-hover: #45454b;
--ca-text: #eeeef1;
--ca-muted: #b5b5bd;
--ca-line: #48484f;
--ca-border: #7d7d87;
--ca-accent: #0877bb;
--ca-focus: #5bc2ff;
--ca-error: #d52430;
--ca-error-text: #ffadb3;
--ca-error-bg: #391d23;
font: 13px/1.5 Inter, ui-sans-serif, system-ui, sans-serif;
background: var(--ca-surface);
color: var(--ca-text);
}
.ca-tools[data-ca-theme="light"] {
color-scheme: light;
--ca-surface: #ffffff;
--ca-field: #f5f5f7;
--ca-control: #eeeef1;
--ca-hover: #e2e2e8;
--ca-text: #23232b;
--ca-muted: #5c5c68;
--ca-line: #d4d4dc;
--ca-border: #85858f;
--ca-focus: #0066a6;
--ca-error-text: #a20d20;
--ca-error-bg: #fff0f1;
}
.ca-tools :where(div, span, label, input, textarea, button, select, fieldset) { box-sizing: border-box; }
.ca-tools :where(button, input, select, textarea) { font: inherit; color: inherit; accent-color: var(--ca-accent); }
.ca-tools :where(button, select, input:not([type="radio"], [role="switch"]), textarea) { border: 1px solid var(--ca-border); border-radius: 5px; min-block-size: 30px; }
.ca-tools button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 10px; background: var(--ca-control); cursor: pointer; white-space: nowrap; }
.ca-tools button:hover:not(:disabled) { background: var(--ca-hover); }
.ca-tools button:disabled { opacity: .42; cursor: default; }
.ca-tools :where(input:not([type="radio"], [role="switch"]), select, textarea) { background: var(--ca-field); min-inline-size: 0; padding: 4px 8px; }
.ca-tools :where(button, input, select, textarea):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.ca-tools :where(input, textarea)::placeholder { color: var(--ca-muted); opacity: 1; }
.ca-tools .ca-primary, .ca-tools button[aria-current="page"] { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
.ca-tools .ca-primary:hover:not(:disabled) { background: #096ba6; }
.ca-tools .ca-danger { background: var(--ca-error); border-color: var(--ca-error); color: #fff; }
.ca-tools .ca-danger:hover:not(:disabled) { background: #b51c28; }
.ca-tools .ca-icon-button { min-inline-size: 30px; padding-inline: 6px; }
.ca-tools .ca-field-label { display: block; font-size: 11px; color: var(--ca-muted); }
.ca-tools .ca-field { display: inline-flex; align-items: center; gap: 6px; min-inline-size: 0; }
.ca-tools .ca-switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; min-block-size: 30px; font-size: 12px; }
.ca-tools .ca-switch input { appearance: none; position: relative; inline-size: 36px; min-block-size: 24px; border: 1px solid var(--ca-border); border-radius: 20px; background: var(--ca-control); margin: 0; cursor: pointer; flex-shrink: 0; }
.ca-tools .ca-switch input::before { content: ""; position: absolute; inline-size: 16px; block-size: 16px; background: var(--ca-muted); inset-block-start: 3px; inset-inline-start: 3px; border-radius: 50%; }
.ca-tools .ca-switch input:checked { background: var(--ca-accent); border-color: var(--ca-accent); }
.ca-tools .ca-switch input:checked::before { background: #fff; inset-inline-start: 15px; }
.ca-tools .ca-segments { border: 0; padding: 0; margin: 0; display: inline-flex; gap: 2px; min-inline-size: 0; }
.ca-tools .ca-segments label { position: relative; display: inline-flex; min-inline-size: 24px; min-block-size: 30px; cursor: pointer; }
.ca-tools .ca-segments input { position: absolute; inset: 0; margin: 0; opacity: 0; inline-size: 100%; block-size: 100%; cursor: pointer; }
.ca-tools .ca-segments label > span { padding: 4px 12px; background: var(--ca-field); border: 1px solid var(--ca-border); border-radius: 4px; color: var(--ca-text); font-size: 13px; }
.ca-tools .ca-segments input:checked + span { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
.ca-tools .ca-segments input:focus-visible + span { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.ca-tools .ca-visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
.ca-tools .ca-range { display: flex; align-items: center; gap: 10px; flex-shrink: 0; color: var(--ca-muted); font-size: 11px; }
.ca-tools .ca-range input[type="range"] { flex: 1; inline-size: 60px; min-inline-size: 0; margin: 0; padding: 0; border: 0; min-block-size: 30px; cursor: pointer; }
.ca-tools .ca-range output { min-inline-size: 44px; font-variant-numeric: tabular-nums; }
@media (pointer: coarse) { .ca-tools :where(button, select), .ca-tools .ca-segments label > span, .ca-tools .ca-switch { min-block-size: 40px; } }
@media (forced-colors: active) {
.ca-tools { --ca-accent: Highlight; --ca-focus: Highlight; --ca-border: ButtonText; --ca-line: CanvasText; }
.ca-tools .ca-switch input:checked::before { background: HighlightText; }
}
.ca-tools.ca-completions { position: fixed; z-index: 10000; min-inline-size: min(240px, calc(100dvw - 16px)); max-inline-size: min(420px, calc(100dvw - 16px)); max-block-size: min(240px, calc(100dvh - 16px)); overflow: auto; border: 1px solid var(--ca-border); border-radius: 6px; box-shadow: 0 6px 20px #0005; box-sizing: border-box; }
.ca-tools.ca-completions[hidden] { display: none; }
.ca-tools.ca-completions .ca-completion-option { display: flex; align-items: flex-start; gap: 8px; padding: 7px 10px; min-block-size: 32px; cursor: pointer; }
.ca-completion-type { flex-shrink: 0; font-size: 11px; color: var(--ca-focus); min-inline-size: 44px; }
.ca-completion-name { min-inline-size: 0; overflow-wrap: anywhere; }
.ca-completion-directory { color: var(--ca-muted); }
.ca-tools.ca-completions [aria-selected="true"] { background: var(--ca-accent); color: #fff; outline: 2px solid var(--ca-focus); outline-offset: -2px; }
.ca-tools.ca-completions [aria-selected="true"] :where(.ca-completion-type, .ca-completion-directory) { color: inherit; }
@media (pointer: coarse) { .ca-tools.ca-completions .ca-completion-option { min-block-size: 40px; } }
+138
View File
@@ -0,0 +1,138 @@
export function createScope() {
const controller = new AbortController();
const cleanups = [];
return {
signal: controller.signal,
listen(target, event, callback) {
target.addEventListener(event, callback, { signal: controller.signal });
},
cleanup(callback) { cleanups.push(callback); },
debounce(callback, delay) {
let timer;
cleanups.push(() => clearTimeout(timer));
return (...args) => {
clearTimeout(timer);
if (!controller.signal.aborted) timer = setTimeout(() => callback(...args), delay);
};
},
dispose() {
if (controller.signal.aborted) return;
controller.abort();
cleanups.splice(0).forEach(callback => callback());
},
};
}
export function themeFromColor(color) {
let values;
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
if (hex) {
const expanded = hex.length === 3 ? [...hex].map(value => value + value).join('') : hex;
values = expanded.match(/../g).map(value => parseInt(value, 16));
} else if (/^rgb\(/.test(color)) {
values = color.match(/[\d.]+/g)?.slice(0, 3).map(Number);
}
if (!values || values.length !== 3) return null;
const linear = values.map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
return linear[0] * .2126 + linear[1] * .7152 + linear[2] * .0722 > .35 ? 'light' : 'dark';
}
export function mountTheme(element, settings, scope) {
const stylesheet = new URL('./controls.css', import.meta.url).href;
if (![...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === stylesheet)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = stylesheet;
document.head.append(link);
}
const preference = matchMedia('(prefers-color-scheme: dark)');
const update = () => {
const background = getComputedStyle(document.body).getPropertyValue('--bg-color').trim();
element.dataset.caTheme = themeFromColor(background) || (preference.matches ? 'dark' : 'light');
};
const observer = new MutationObserver(update);
for (const target of [document.documentElement, document.body]) observer.observe(target, { attributes: true, attributeFilter: ['style', 'class', 'data-theme'] });
scope.cleanup(() => observer.disconnect());
scope.listen(preference, 'change', update);
if (settings?.addEventListener) scope.listen(settings, 'Comfy.ColorPalette.change', update);
update();
}
export function createButton(text, className = '') {
const element = document.createElement('button');
element.type = 'button';
element.className = className;
element.textContent = text;
return element;
}
export function createSlider(label, { id, min, max, step, value, unit }, scope, onInput) {
const field = document.createElement('div');
field.className = 'ca-range';
const caption = document.createElement('label');
caption.htmlFor = id;
caption.textContent = label;
const input = document.createElement('input');
Object.assign(input, { type: 'range', id, min, max, step, value });
const output = document.createElement('output');
output.setAttribute('for', id);
output.value = `${value} ${unit}`;
scope.listen(input, 'input', () => {
output.value = `${input.value} ${unit}`;
onInput(Number(input.value));
});
field.append(caption, input, output);
return field;
}
export function createField(label, input, id) {
const field = document.createElement('label');
field.className = 'ca-field';
field.htmlFor = id;
const caption = document.createElement('span');
caption.className = 'ca-field-label';
caption.textContent = label;
input.id = id;
field.append(caption, input);
return field;
}
export function createSwitch(label, className, id) {
const input = document.createElement('input');
input.type = 'checkbox';
input.role = 'switch';
input.checked = true;
input.className = className;
const field = createField(label, input, id);
field.className = 'ca-switch';
field.replaceChildren(input, document.createTextNode(label));
return field;
}
export function createSelect(options, className) {
const select = document.createElement('select');
select.className = className;
for (const [value, text] of options) select.add(new Option(text, value));
return select;
}
export function createSegments(label, options, name, scope, onChange) {
const fieldset = document.createElement('fieldset');
fieldset.className = 'ca-segments';
const legend = document.createElement('legend');
legend.className = 'ca-visually-hidden';
legend.textContent = label;
fieldset.append(legend);
options.forEach(([value, text], index) => {
const input = document.createElement('input');
input.type = 'radio';
input.name = name;
input.value = value;
input.checked = index === 0;
const field = createField(text, input, `${name}-${value}`);
field.replaceChildren(input, field.firstChild);
scope.listen(input, 'change', () => onChange(value));
fieldset.append(field);
});
return fieldset;
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@comfyassets/toolkit",
"version": "0.1.0",
"resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/b5a34b1d12336cd1bdfd6b890b80db63cbc3c389",
"integrity": "sha512-rhI9xDH0U1WPBq7zuAN4rE6bLWx7ujTM7IIBNXINexN/z4FoQK4jnGXPMtNhW1XbbEpoZPZdExnrJpcRRPZXUw==",
"files": {
"core.js": {
"source": "ui/core.js",
"sha256": "cd10b700441a607cb1d56051cf0a430c0b36f901c0c351d8f4dae067090d8838"
},
"suggestions.js": {
"source": "ui/suggestions.js",
"sha256": "979b7867bcf7c8d5bec2c6631cec4c15a5cbb5774c4eaa554989b954555b9a04"
},
"controls.css": {
"source": "ui/controls.css",
"sha256": "9bb8662a5c911537c0577c5b21a861356550ec6235fcf2101d6585cc148efb8d"
},
"LICENSE": {
"source": "LICENSE",
"sha256": "0c865294d0c580dbe365d9ff5ccd655bd2d3716ca3282cc485b5cf789ec8ea6f"
},
"SOURCE.md": {
"source": "SOURCE.md",
"sha256": "713be4367ac20a587aa7740b985086dc86a866706584de4d56646c6ed0e574c9"
}
}
}
+75
View File
@@ -0,0 +1,75 @@
import { createScope, mountTheme } from './core.js';
let nextList = 0;
export function suggestionPosition(field, popup, viewport) {
const left = Math.max(8, Math.min(field.left, viewport.width - popup.width - 8));
const below = field.bottom + 4;
const top = below + popup.height <= viewport.height - 8 ? below : field.top - popup.height - 4;
return { left, top: Math.max(8, Math.min(top, viewport.height - popup.height - 8)) };
}
export function createSuggestionView(scope, settings, { label = 'Suggestions' } = {}) {
const element = document.createElement('div');
element.className = 'ca-tools ca-completions';
do { element.id = `ca-completions-${++nextList}`; }
while (document.getElementById(element.id));
element.role = 'listbox';
element.setAttribute('aria-label', label);
element.hidden = true;
document.body.append(element);
mountTheme(element, settings, scope);
let optionsScope;
scope.cleanup(() => { optionsScope?.dispose(); element.remove(); });
return {
element,
hide() {
optionsScope?.dispose();
element.hidden = true;
element.replaceChildren();
},
render(suggestions, onSelect, onInsert) {
optionsScope?.dispose();
optionsScope = createScope();
element.replaceChildren();
suggestions.forEach((item, index) => {
const option = document.createElement('div');
option.className = 'ca-completion-option';
option.id = `${element.id}-${index}`;
option.role = 'option';
option.setAttribute('aria-selected', 'false');
const badge = document.createElement('span');
badge.className = 'ca-completion-type';
badge.textContent = item.type === 'embedding' ? '[EMB]' : item.type === 'lora' ? '[LORA]' : '[TAG]';
const name = document.createElement('span');
name.className = 'ca-completion-name';
const path = item.type === 'embedding' ? item.fullPath : null;
if (path?.includes('/')) {
const directory = document.createElement('span');
directory.className = 'ca-completion-directory';
directory.textContent = path.slice(0, path.lastIndexOf('/') + 1);
name.append(directory, document.createTextNode(path.slice(path.lastIndexOf('/') + 1)));
} else name.textContent = item.name;
if (item.type) option.append(badge);
option.append(name);
optionsScope.listen(option, 'pointerdown', event => event.preventDefault());
optionsScope.listen(option, 'pointermove', () => onSelect(index));
optionsScope.listen(option, 'click', () => onInsert(item));
element.append(option);
});
element.hidden = false;
},
select(index) {
const options = [...element.children];
options.forEach((option, position) => option.setAttribute('aria-selected', String(position === index)));
options[index]?.scrollIntoView({ block: 'nearest' });
return options[index]?.id;
},
position(field) {
const popup = element.getBoundingClientRect();
const { left, top } = suggestionPosition(field, popup, { width: innerWidth, height: innerHeight });
element.style.left = `${left}px`;
element.style.top = `${top}px`;
},
};
}