Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01ed65ac73 | ||
|
|
5fa16d4aa3 | ||
|
|
cf0090d3f5 | ||
|
|
035e816db6 | ||
|
|
f98f6fa08d | ||
|
|
ce82dfb912 | ||
|
|
5ded3189c1 |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -208,3 +208,6 @@ playwright-report/
|
||||
|
||||
# Combined frontend coverage reports
|
||||
coverage/
|
||||
|
||||
# Disposable upstream checkout for real-host CI
|
||||
.ci-comfyui/
|
||||
|
||||
@@ -142,6 +142,26 @@ _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.
|
||||
|
||||
### 🔬 ComfyUI Workflow Analysis
|
||||
|
||||
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
|
||||
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
@@ -44,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
|
||||
@@ -64,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.
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ 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
|
||||
- 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 +39,20 @@ def get_version():
|
||||
return "unknown"
|
||||
|
||||
|
||||
from .prompt_manager import PromptManager # noqa: E402
|
||||
from .prompt_manager import PromptManager, PromptManagerFocused # noqa: E402
|
||||
from .prompt_manager_text import PromptManagerText # noqa: E402
|
||||
from .prompt_search_list import PromptSearchList # noqa: E402
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PromptManager": PromptManager,
|
||||
"PromptManagerFocused": PromptManagerFocused,
|
||||
"PromptManagerText": PromptManagerText,
|
||||
"PromptSearchList": PromptSearchList,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PromptManager": "Prompt Manager",
|
||||
"PromptManager": "Prompt Manager (Classic)",
|
||||
"PromptManagerFocused": "Prompt Manager (Focused)",
|
||||
"PromptManagerText": "Prompt Manager Text",
|
||||
"PromptSearchList": "Prompt Search List",
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 91 KiB |
@@ -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.
|
||||
@@ -281,3 +281,18 @@ 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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
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 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()=>{
|
||||
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){document.body.append(element);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:'PromptManagerFocused'},fixtureApp);
|
||||
window.node=new Node();node.onNodeCreated();
|
||||
});
|
||||
await expect(page.locator('.pm-focused')).toBeVisible();
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
|
||||
|
||||
test('Focused 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 page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
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.focusedUI.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);
|
||||
await page.getByRole('button',{name:'Hide library',exact:true}).click();
|
||||
await expect(page.getByRole('listbox')).toBeHidden();
|
||||
});
|
||||
|
||||
test('Focused 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 page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
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('Focused 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('Focused 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('Focused 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('Focused 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('Focused 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 page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
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('Focused 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('Focused 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 page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
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('Focused 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 page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
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('Focused 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 page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
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('Focused keeps the latest result when searches and recent requests overlap', async ({page}) => {
|
||||
await deferLibraryRequests(page);
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
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('Focused removal aborts work and stops theme and control listeners', async ({page}) => {
|
||||
await deferLibraryRequests(page);
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
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');
|
||||
await page.getByRole('button',{name:'Hide library',exact:true}).click();
|
||||
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible();
|
||||
});
|
||||
|
||||
test('Focused 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('Focused 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('Focused 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('Focused expands its main editor when the node gets taller',async({page})=>{
|
||||
await page.locator('.pm-focused').evaluate(element=>{element.style.width='550px';element.style.height='800px';});
|
||||
const editor=await page.getByRole('textbox',{name:'Main prompt',exact:true}).boundingBox();
|
||||
expect(editor.height).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
test('Focused 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('Focused 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();
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
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 addFocused(page) {
|
||||
return page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('PromptManagerFocused');
|
||||
if (!node) throw new Error('PromptManagerFocused must be registered by the real host');
|
||||
app.graph.add(node);
|
||||
node.setSize([680, 1050]);
|
||||
app.canvas.ds.scale = .8;
|
||||
app.canvas.centerOnNode(node);
|
||||
return node.id;
|
||||
});
|
||||
}
|
||||
|
||||
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 browse(page, text) {
|
||||
const root = page.locator('.pm-focused');
|
||||
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
|
||||
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;
|
||||
}
|
||||
|
||||
test('Focused definition preserves Classic encoding and exposes composition STRING sockets', async ({ request }) => {
|
||||
const definitions = await (await request.get('/object_info')).json();
|
||||
const focused = definitions.PromptManagerFocused;
|
||||
expect(focused).toBeDefined();
|
||||
expect(focused.display_name).toBe('Prompt Manager (Focused)');
|
||||
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('Focused 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 addFocused(page);
|
||||
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('focused-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('Focused 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 addFocused(page);
|
||||
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 root.getByRole('button', { name: 'Browse library', exact: true }).click();
|
||||
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('Focused 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 addFocused(page);
|
||||
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('focused-disconnected.png') });
|
||||
});
|
||||
|
||||
test('Focused follows actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
|
||||
await addFocused(page);
|
||||
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);
|
||||
await expect(root.getByRole('button', { name: 'Browse library', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
||||
await page.screenshot({ path: testInfo.outputPath(`focused-${palette}.png`) });
|
||||
}
|
||||
});
|
||||
|
||||
test('Focused Web UI button opens actual standalone pages', async ({ page }) => {
|
||||
await addFocused(page);
|
||||
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')).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('Focused 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 addFocused(page);
|
||||
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(`focused-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('Focused 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 addFocused(page);
|
||||
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 root.getByRole('button', { name: 'Browse library', exact: true }).click();
|
||||
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(`focused-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);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
}
|
||||
@@ -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`) });
|
||||
}
|
||||
});
|
||||
@@ -327,3 +327,37 @@ 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");
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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"])
|
||||
@@ -135,7 +135,12 @@ class TestPackageWiring(PackageTestCase):
|
||||
def test_nodes_are_registered(self):
|
||||
self.assertEqual(
|
||||
sorted(self.package.NODE_CLASS_MAPPINGS),
|
||||
["PromptManager", "PromptManagerText", "PromptSearchList"],
|
||||
[
|
||||
"PromptManager",
|
||||
"PromptManagerFocused",
|
||||
"PromptManagerText",
|
||||
"PromptSearchList",
|
||||
],
|
||||
)
|
||||
|
||||
def test_db_factory_returns_the_shared_database(self):
|
||||
|
||||
@@ -54,6 +54,19 @@ 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_negative_only_prompt_manager_is_never_positive(self):
|
||||
graph = {
|
||||
"176": pm("blurry"),
|
||||
|
||||
@@ -126,6 +126,16 @@ 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_requeue_counts_again_even_when_comfyui_skips_the_cached_node(self):
|
||||
positive = self._save("a cat")
|
||||
self._queue(workflow())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')]);
|
||||
});
|
||||
@@ -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\]/);
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 } },
|
||||
],
|
||||
});
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
)
|
||||
|
||||
# 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.
|
||||
"""
|
||||
|
||||
@@ -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 } = await import(api.fileURL('/prompt_manager/js/focused-node.js'));
|
||||
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused']);
|
||||
|
||||
// ============================================================================
|
||||
// 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,10 @@ app.registerExtension({
|
||||
},
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === 'PromptManagerFocused') {
|
||||
installFocusedNode(nodeType, app, api, parseSearchQuery);
|
||||
return;
|
||||
}
|
||||
if (nodeData.name === "PromptManager" || nodeData.name === "PromptManagerText") {
|
||||
// Store original methods
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
@@ -302,7 +309,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 +342,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 +388,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 +994,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 +1009,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 +1188,7 @@ app.registerExtension({
|
||||
document.body.removeChild(notification);
|
||||
}, 3000);
|
||||
};
|
||||
if (nodeData.name === 'PromptManager') installClassicControls(nodeType, app, parseSearchQuery, api);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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); }
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -235,6 +235,7 @@
|
||||
CLIPTextEncode: ["text"],
|
||||
PromptManager: PROMPT_MANAGER_WIDGETS,
|
||||
PromptManagerText: PROMPT_MANAGER_WIDGETS,
|
||||
PromptManagerFocused: PROMPT_MANAGER_WIDGETS,
|
||||
CheckpointLoader: ["config_name", "ckpt_name"],
|
||||
UNETLoader: ["unet_name", "weight_dtype"],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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 installFocusedNode(nodeType, app, api, parseQuery) {
|
||||
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';
|
||||
element.setAttribute('aria-label','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, browse, web);
|
||||
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`);
|
||||
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, 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); element.append(section); details.set(key,section);
|
||||
scope.listen(section, 'toggle', () => {
|
||||
if (!section.open) completions.forEach(completion => completion.close());
|
||||
if (!scope.signal.aborted) thisNode.properties.focusedUI = 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.focusedUI = 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();
|
||||
}});
|
||||
element.append(library.element,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.focusedUI || {};
|
||||
details.forEach((section,name) => {section.open = Boolean(state[name]);});
|
||||
library.element.hidden = !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.focusedUI = 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 = [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.focusedUI = 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); };
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.pm-focused { 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-focused > .ca-field { 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; 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 { 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)); } }
|
||||
@@ -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()); },
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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; } }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
},
|
||||
};
|
||||
}
|
||||