Compare commits

..
Author SHA1 Message Date
Vito Sansevero 5160dbe703 fix(tooling): reject lookalike toolkit download URLs (#186) 2026-10-16 22:12:22 -07:00
Vito Sansevero cde61bffbb test(ui): retain Text node Web UI coverage alongside Classic 2026-10-16 22:06:23 -07:00
Vito Sansevero 062a7c3c79 feat(ui): adopt shared toolkit for Prompt Manager Classic (#186) 2026-10-16 22:00:23 -07:00
Vito f98f6fa08d test(ci): validate shared toolkit in real PromptManager hosts (#189)
* test(ci): prove shared toolkit with real PromptManager host coverage

* test(ci): match KikoTools Python 3.13 compatibility coverage
2026-10-16 20:56:50 -07:00
Vito ce82dfb912 fix: quiet routine console logging and prevent duplicate output (#183) 2026-10-16 09:58:05 -07:00
Vito 5ded3189c1 test: cover backup restore and destructive browser workflows (#185) 2026-10-16 07:56:15 -07:00
Vito f1b1d4731b test: exercise real page workflows and enforce frontend coverage (#181) 2026-10-16 06:45:20 -07:00
Vito e9fe586442 fix: repair prompt edit hashes, auto-tag review, and stale UI responses (#180)
* fix: keep edited prompt hashes consistent

Fixes #176

* fix: align gallery auto-tag review with scoped image APIs

Fixes #177

* fix: keep Tags prompt results on the current filter

Fixes #178

* fix: ignore stale metadata in admin image viewers

Fixes #179

* test: compare canonical gallery paths on macOS
2026-10-16 06:14:06 -07:00
54 changed files with 3029 additions and 40 deletions
+29
View File
@@ -0,0 +1,29 @@
{
"required_status_checks": {
"strict": true,
"contexts": [
"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",
"real ComfyUI compatibility"
]
},
"enforce_admins": true,
"required_pull_request_reviews": null,
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false
}
+68 -2
View File
@@ -17,7 +17,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v7
@@ -37,6 +37,62 @@ jobs:
- name: Run tests
run: python -m unittest discover -s tests -v
comfyui:
name: real ComfyUI compatibility
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
cache-dependency-path: tools/vendor/package-lock.json
- name: Read the reviewed runtime pins
id: runtime
run: |
node --input-type=module <<'JS' >> "$GITHUB_OUTPUT"
import runtime from './tools/vendor/comfyui-runtime.json' with { type: 'json' };
for (const [key, value] of Object.entries(runtime)) console.log(`${key}=${value}`);
JS
- name: Check out pinned ComfyUI
uses: actions/checkout@v7
with:
repository: ${{ steps.runtime.outputs.repository }}
ref: ${{ steps.runtime.outputs.revision }}
path: .ci-comfyui
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: ${{ steps.runtime.outputs.pythonVersion }}
cache: pip
cache-dependency-path: |
requirements-dev.txt
.ci-comfyui/requirements.txt
tools/vendor/comfyui-runtime.json
- name: Install CPU runtime and test dependencies
run: |
python -m pip install --upgrade pip
pip install "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}" --index-url https://download.pytorch.org/whl/cpu
pip install -r .ci-comfyui/requirements.txt -r requirements-dev.txt "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}"
- run: npm ci --prefix tools/vendor --ignore-scripts
- run: npx --prefix tools/vendor playwright install --with-deps chromium
- name: Classic and Nodes 2.0 startup and compatibility
env:
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
PM_TEST_PYTHON: python
run: npm run --prefix tools/vendor test:comfyui
- name: Upload real-host reports and startup logs
if: always()
uses: actions/upload-artifact@v7
with:
name: real-comfyui-reports
path: tools/vendor/test-results/
if-no-files-found: ignore
retention-days: 14
coverage:
name: coverage gate (85%)
runs-on: ubuntu-latest
@@ -78,6 +134,16 @@ jobs:
steps:
- uses: actions/checkout@v7
# Pure-JS frontend modules, tested with the runner's preinstalled Node
- uses: actions/setup-node@v7
with:
node-version: "24"
- name: Check shipped extension and page modules parse
run: |
shopt -s globstar nullglob
for f in web/comfy/**/*.js web/comfy/**/*.mjs web/js/**/*.js web/js/**/*.mjs; do
node --input-type=module --check < "$f" || exit 1
done
- name: Run JS tests
run: node --test tests/js/*.test.js
+23 -17
View File
@@ -2,22 +2,9 @@ name: Viewer.js vendor and compatibility
on:
pull_request:
paths:
- 'tools/vendor/**'
- 'tests/vendor/**'
- 'tests/js/**'
- 'web/js/**'
- 'web/lib/**'
- '.github/workflows/viewerjs-*.yml'
branches: [main]
push:
branches: [main]
paths:
- 'tools/vendor/**'
- 'tests/vendor/**'
- 'tests/js/**'
- 'web/js/**'
- 'web/lib/**'
- '.github/workflows/viewerjs-*.yml'
workflow_dispatch:
permissions:
@@ -26,8 +13,15 @@ permissions:
jobs:
viewerjs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.12'
cache: pip
cache-dependency-path: requirements-dev.txt
- run: python -m pip install -r requirements-dev.txt
- uses: actions/setup-node@v7
with:
node-version: '24'
@@ -36,6 +30,18 @@ jobs:
- run: npm ci --prefix tools/vendor --ignore-scripts
- run: npm run --prefix tools/vendor check
- run: npm run --prefix tools/vendor test
- run: node --test tests/js/*.test.js
- run: npx --prefix tools/vendor playwright install --with-deps chromium
- run: npm run --prefix tools/vendor test:browser
- run: npx --prefix tools/vendor playwright install --with-deps chromium firefox webkit
- name: Frontend unit, viewer, workflow, and coverage checks
run: npm run --prefix tools/vendor test:coverage
- name: Firefox and WebKit page workflows
run: npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit
- name: Upload browser traces and frontend coverage
if: always()
uses: actions/upload-artifact@v7
with:
name: frontend-test-reports
path: |
tools/vendor/test-results/
coverage/js/
!coverage/js/raw/**
retention-days: 14
+6
View File
@@ -205,3 +205,9 @@ cython_debug/
node_modules/
test-results/
playwright-report/
# Combined frontend coverage reports
coverage/
# Disposable upstream checkout for real-host CI
.ci-comfyui/
+8
View File
@@ -142,6 +142,14 @@ _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).
### 🔬 ComfyUI Workflow Analysis
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
+130
View File
@@ -0,0 +1,130 @@
# Testing and merge confidence
Routine changes should merge after the required checks pass and the diff is
reviewed. Add a regression test for each bug; prove it fails before the fix.
## What runs
| Layer | Real components | Replaced boundaries |
| --- | --- | --- |
| Python tests | Domain logic, SQLite, API handlers, filesystem checks | ComfyUI host and ML runtimes where needed |
| JavaScript tests | Frontend modules and controller methods | DOM/network fixtures for focused edge cases |
| Viewer checks | Production Viewer.js and frontend methods in Chromium desktop/touch | Controlled images and page fixtures |
| Real ComfyUI | Pinned CPU host, actual extension, node definitions, widgets, routes and SQLite | Inference is not queued; browser startup settings and host preference writes are isolated |
| Page workflows | Production HTML, startup, click handlers, HTTP routes, SQLite, PNG files, thumbnails | ComfyUI host services and the tagging model |
Page workflows cover save/edit/reload, duplicate-edit rejection, gallery image
identity, Tags selection and viewer cleanup, delayed responses, auto-tag review
and persistence, and infinite scrolling without replacing existing cards.
Data safety workflows download and upload actual SQLite backups, verify restored
prompts/tags/image links, reject corrupt uploads without changing live data, and
exercise cancellation and network-failure recovery for restores and deletion.
Duplicate cleanup verifies only the selected file disappears; prompt deletion
verifies the other prompt and original image files survive.
They run in Chromium, Firefox, WebKit, and Chromium touch emulation. No GPU,
model downloads, running ComfyUI instance, or personal configuration is required.
The server binds to localhost and creates disposable directories and a database;
its reset endpoint exists only in the test harness.
## Run locally
Use a Python environment containing `requirements-dev.txt` and Node.js 24:
```sh
python -m unittest discover -s tests -q
npm ci --prefix tools/vendor --ignore-scripts
npx --prefix tools/vendor playwright install --with-deps chromium firefox webkit
npm run --prefix tools/vendor test
npm run --prefix tools/vendor test:coverage
npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit
```
`PM_TEST_PYTHON` selects the Python executable; `PM_TEST_PORT` selects the local
port (default 8765). Set these environment variables when Python is outside PATH
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` 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
metric. CI rejects regressions, missing measurements, and new files without a
reviewed baseline. These floors reflect current coverage, with two percentage
points of allowance for runtime/timing differences; they do not claim 80% coverage
of the large controllers. New files need at least 80% line coverage when added via
the baseline updater. Raise floors with `npm run --prefix tools/vendor
coverage:update`; it reruns the suites and cannot lower an existing floor.
Review baseline changes like code changes.
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.
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 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.
A repository administrator applies the configuration after the new workflow is
passing, then verifies it:
```sh
gh api --method PUT repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection \
--input .github/branch-protection.json
gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required_status_checks
```
Update the manifest whenever required job names change. Keep the frontend check
free of path filters so a required check is never silently skipped.
+1 -1
View File
@@ -49,7 +49,7 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptManager": "Prompt Manager",
"PromptManager": "Prompt Manager (Classic)",
"PromptManagerText": "Prompt Manager Text",
"PromptSearchList": "Prompt Search List",
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+71
View File
@@ -0,0 +1,71 @@
# Shared ComfyAssets UI adoption
Status: approved direction; toolkit/testing foundation in progress.
Product UI brainstorming comes before node changes.
Tracking: [PromptManager #186](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/186)
and [KikoTools #81](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues/81).
KikoTools is the validated model. The
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit) is the
shared source; PromptManager proves the package boundaries before a later
KikoTools adoption PR. Other custom nodes can become additional consumers.
## Approved sequence
1. Extract reusable controls and test infrastructure from the working KikoTools
reference into the toolkit. Keep routes, node schemas and controllers local.
2. Bring PromptManager's automated tests up to parity using the pinned toolkit.
3. Brainstorm PromptManager node layouts, controls and behavior with the maintainer
before changing its production UI. Select one bounded first consumer together.
4. Validate shared distribution, themes, cleanup and compatibility through that
consumer, then migrate incrementally. Test both packs together as appropriate.
5. Backport toolkit adoption to KikoTools after PromptManager proves it, preserving
the existing KikoTools appearance, behavior and workflow compatibility.
KikoTools is not being migrated again during this phase. The original
[foundation mockup](https://github.com/ComfyAssets/ComfyUI-KikoTools/pull/82)
records the approved visual direction and inspiration.
## PromptManager requirements
- Preserve existing node names, inputs/outputs, API routes, saved workflows,
gallery behavior, and existing test/coverage gates.
- Use shared control factories, editor slots, settings/help/window behavior and
the ComfyUI adapter where applicable. Keep PromptManager controllers and HTTP
behavior in this repository.
- Both projects must look identical: one shared set of controls, typography,
spacing, colors and interaction states. Follow ComfyUI light/dark mode with
our own styling, a blue primary accent and strong red errors/destructive actions.
Use compact buttons, pill switches, segmented selections and responsive rows.
Review the pilot mockup before production styling. Existing `--pm-*` tokens
are migration context, not an approved design to retain.
- Theme standalone pages explicitly: `window.open` pages do not inherit the
host's CSS variables. Test the agreed palette policy, live changes, fallbacks
and persistence, including errors when host settings are unavailable.
- Ship a pinned, reproducible browser distribution. Installation must work
without npm or KikoTools, including when both packs ship different core versions.
- Attribute [Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma) as architectural
inspiration. Do not copy/vendor his code, styles, or assets.
## First migration
With the maintainer, inventory the PromptManager nodes and gallery dialogs and
choose one bounded first UI consumer after brainstorming. Record its current behavior with tests before changing it.
Verify keyboard opening/closing, focus restoration, accessible labels, confirm
and cancel behavior, network failures, and light/dark appearances. Keep the
existing gallery and destructive-action browser workflows passing.
Build shared-source extraction and distribution checks before importing the core.
Test both packs alone and together, scoped styling, isolated settings/storage,
and independent cleanup. ComfyUI-facing changes require the real-host Classic,
Nodes 2.0 and live-switching matrix from the foundation proposal. Standalone
dialogs additionally run through PromptManager's existing browser harness.
## Delivery
Use small issue-linked PRs and TDD: verify each new behavioral test fails before
implementing it, then run relevant suites and required CI checks. Include visual
evidence for appearance changes. Migrate additional nodes/dialogs/editors only after
the first consumer validates the package boundaries; do not rewrite all pages
or change backend schemas as part of this initiative.
+1
View File
@@ -0,0 +1 @@
"""Browser workflow fixtures; never imported by production code."""
+150
View File
@@ -0,0 +1,150 @@
import { readFileSync } from 'node:fs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
test.beforeEach(async ({page, browserName}) => {
await startCoverage(page, browserName);
page.classicErrors = [];
page.on('pageerror', error => page.classicErrors.push(error.message));
await page.route('**/scripts/app.js', route => route.fulfill({contentType:'text/javascript', body:'export const app = window.fixtureApp;'}));
await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => path, queuePrompt: async (...args) => args};'}));
await page.route('**/extensions/promptmanager/prompt_manager.js', route => route.fulfill({contentType:'text/javascript', body:readFileSync(new URL('../../web/comfy/prompt_manager.js', import.meta.url),'utf8')}));
await page.goto('/prompt_manager/test');
await page.evaluate(() => {
document.body.replaceChildren();
document.body.style.setProperty('--bg-color','#222');
window.fixtureApp={extensions:[],registerExtension(extension){this.extensions.push(extension);},ui:{settings:new EventTarget()},canvas:{},graph:{setDirtyCanvas(){}}};
});
await page.evaluate(async () => {
await import('/extensions/promptmanager/prompt_manager.js');
class Node {
constructor(){
this.widgets=['text','category','tags','search_text'].map(name=>({name,value:'',inputEl:document.createElement('textarea')}));
this.widgets_values=[]; this.inputs=[];
}
addDOMWidget(name,type,element){document.body.append(element);return {element};}
setSize(){} setDirtyCanvas(){} serialize(){return {properties:{...this.properties}};}
}
await fixtureApp.extensions[0].beforeRegisterNodeDef(Node,{name:'PromptManager'},fixtureApp);
window.node=new Node();node.onNodeCreated();
});
await expect(page.locator('.pm-classic')).toBeVisible();
});
test.afterEach(async ({page,browserName}) => {
await saveCoverage(page,browserName);
expect(page.classicErrors).toEqual([]);
});
test('Classic keeps immediate selection, search query and metadata serialization',async({page})=>{
await page.route('**/prompt_manager/search?**', route=>route.fulfill({json:{success:true,results:[{text:'A new portrait',category:'Portrait',tags:['film grain'],rating:5}]}}));
await page.evaluate(()=>{node.widgets.find(w=>w.name==='search_text').value='tag:"film grain" portrait';});
const response=page.waitForRequest('**/prompt_manager/search?**');
await page.getByRole('button',{name:'🔍 Search',exact:true}).click();
expect(new URL((await response).url()).searchParams.get('tags')).toBe('film grain');
const result=page.getByRole('button',{name:/A new portrait/});
await result.focus();await result.press('Enter');
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,3))).toEqual(['A new portrait','Portrait','film grain']);
await expect(result).toHaveAttribute('aria-pressed','true');
const released=await page.evaluate(()=>{
const previous=node.resultsSection.querySelector('button');
node.displayResults([{text:'Replacement',tags:[]}]);
previous.click();
return node.widgets.find(widget=>widget.name==='text').value;
});
expect(released).toBe('A new portrait');
await expect(page.getByRole('button',{name:'🌐 Web UI',exact:true})).toBeVisible();
});
test('Classic detaches native text synchronization on removal',async({page})=>{
await page.waitForTimeout(60);
const values=await page.evaluate(()=>{
const widget=node.widgets.find(item=>item.name==='text');
widget.inputEl.value='Before removal';widget.inputEl.dispatchEvent(new Event('input'));
const before=widget.value;node.onRemoved();
widget.inputEl.value='After removal';widget.inputEl.dispatchEvent(new Event('input'));
return [before,widget.value];
});
expect(values).toEqual(['Before removal','Before removal']);
});
test('Classic follows host themes, keeps restore behavior and disposes resources',async({page})=>{
const panel=page.locator('.pm-classic');
await expect(panel).toHaveAttribute('data-ca-theme','dark');
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#fff'));
await expect(panel).toHaveAttribute('data-ca-theme','light');
await page.evaluate(()=>{node.properties.resultTimeout=0;node.displayResults([{text:'Example',tags:[]}]);node.hideResults();});
await page.getByRole('button',{name:/Results auto-hidden/}).click();
await expect(page.getByRole('button',{name:/Example/})).toBeVisible();
const state=await page.evaluate(()=>{
node.showNotification('Failure','error');node.onRemoved();
const disposed=node._classicScope.signal.aborted;
node.showNotification('Late response','info');
return {disposed,notifications:document.querySelectorAll('.pm-classic-notification').length,style:node._styleSheet.isConnected};
});
expect(state).toEqual({disposed:true,notifications:0,style:false});
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#222'));
await expect(panel).toHaveAttribute('data-ca-theme','light');
});
test('Classic handles empty queries, empty results, failed requests and late responses',async({page})=>{
await page.getByRole('button',{name:'🔍 Search',exact:true}).click();
await expect(page.locator('.pm-classic-results')).toContainText('Enter search text');
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[]}}));
await page.getByRole('button',{name:'📋 Recent',exact:true}).click();
await expect(page.locator('.pm-classic-results')).toContainText('No prompts found');
await page.evaluate(async()=>{
node.callNodeMethod=async()=>{throw new Error('<img src=x onerror=alert(1)>');};
await node.loadRecentPrompts();
});
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('<img src=x');
await expect(page.locator('.pm-classic-results img')).toHaveCount(0);
const late=await page.evaluate(async()=>{
let finish; node.callNodeMethod=()=>new Promise(resolve=>{finish=resolve;});
const loading=node.loadRecentPrompts();node.onRemoved();finish({results:[{text:'Late'}]});await loading;
return {notifications:node._classicNotifications.size,late:node.resultsSection.textContent.includes('Late')};
});
expect(late).toEqual({notifications:0,late:false});
});
test('shipped shared factories keep keyboard behavior and theme isolation across mounts',async({page})=>{
await page.evaluate(async()=>{
const ui=await import('/prompt_manager/js/shared/toolkit/core.js');
const scope=ui.createScope();const panel=document.createElement('div');panel.className='ca-tools';panel.id='shared-contract';
window.shared={ui,scope,panel,events:[]};
document.body.append(panel);
ui.mountTheme(panel,null,scope);
const save=ui.createButton('Save contract','ca-primary');scope.listen(save,'click',()=>shared.events.push('save'));
panel.append(save,ui.createSlider('Size',{id:'contract-size',min:0,max:100,step:1,value:50,unit:'px'},scope,value=>shared.events.push(value)),ui.createSwitch('Enabled','','contract-enabled'),ui.createField('Order',ui.createSelect([['name','Name'],['date','Date']],''),'contract-order'),ui.createSegments('View',[['grid','Grid'],['list','List']],'contract-view',scope,value=>shared.events.push(value)));
});
await page.getByRole('button',{name:'Save contract',exact:true}).press('Enter');
await page.getByRole('slider',{name:'Size'}).press('ArrowRight');
await expect(page.locator('#shared-contract output')).toHaveText('51 px');
await page.getByRole('switch',{name:'Enabled'}).uncheck();
await page.getByLabel('Order').selectOption('date');
await page.getByRole('radio',{name:'List',exact:true}).check();
expect(await page.evaluate(()=>shared.events)).toEqual(['save',51,'list']);
await page.evaluate(()=>{document.body.style.removeProperty('--bg-color');fixtureApp.ui.settings.dispatchEvent(new Event('Comfy.ColorPalette.change'));});
await page.emulateMedia({colorScheme:'light'});
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','light');
await page.emulateMedia({colorScheme:'dark'});
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','dark');
await page.evaluate(()=>{shared.scope.dispose();document.body.style.setProperty('--bg-color','#fff');});
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','dark');
await expect(page.locator('.pm-classic')).toHaveAttribute('data-ca-theme','light');
});
test('Classic test button reports success and failure and releases expired notifications',async({page})=>{
await page.clock.install();
await page.evaluate(()=>{node.properties.showTestButton=true;node.createSearchUI(node._classicContainer);});
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
await expect(page.locator('.pm-classic-results')).toContainText('API Connection Successful');
await page.route('**/prompt_manager/test',route=>route.fulfill({status:503,body:'unavailable'}));
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('503');
await page.route('**/prompt_manager/test',route=>route.fulfill({json:{success:false,message:'Not ready'}}));
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('Not ready');
await page.clock.fastForward(3001);
await expect(page.locator('.pm-classic-notification')).toHaveCount(0);
});
+154
View File
@@ -0,0 +1,154 @@
"""Serve production pages and API routes with disposable state for browser tests."""
import argparse
import json
import os
import shutil
from pathlib import Path
import sys
import tempfile
import time
import types
from aiohttp import web
from PIL import Image
from PIL.PngImagePlugin import PngInfo
class TestModel:
default_prompt = "Describe the image"
wd14_general_threshold = 0.35
wd14_character_threshold = 0.85
custom_prompt = ""
loaded = None
def get_models_status(self) -> dict:
return {
name: {"downloaded": True, "loaded": self.loaded == name}
for name in ("gguf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
}
def is_model_loaded(self) -> bool:
return self.loaded is not None
def get_loaded_model_type(self) -> str | None:
return self.loaded
def load_model(self, model_type: str, use_gpu: bool = True) -> bool:
self.loaded = model_type
return True
def unload_model(self) -> None:
self.loaded = None
def generate_tags(self, image_path: str, **kwargs: object) -> list[str]:
return ["generated", Path(image_path).parent.parent.name]
def create_app(root: Path) -> web.Application:
os.environ["PROMPT_MANAGER_CONFIG_PATH"] = str(root / "config.json")
os.chdir(root)
app = web.Application()
routes = web.RouteTableDef()
server = types.ModuleType("server")
server.PromptServer = types.SimpleNamespace(
instance=types.SimpleNamespace(app=app, routes=routes)
)
sys.modules["server"] = server
folder_paths = types.ModuleType("folder_paths")
folder_paths.base_path = str(root)
folder_paths.models_dir = str(root / "models")
for name in ("output", "input", "temp", "user"):
directory = root / name
directory.mkdir()
setattr(folder_paths, f"get_{name}_directory", lambda path=directory: str(path))
sys.modules["folder_paths"] = folder_paths
from py.config import GalleryConfig, PromptManagerConfig
PromptManagerConfig.DEFAULT_DB_PATH = str(root / "browser.db")
GalleryConfig.MONITORING_DIRECTORIES = [str(root / "output"), str(root / "input")]
from py.api import PromptManagerAPI
from py import autotag
from utils.hashing import generate_prompt_hash
model = TestModel()
autotag.get_autotag_service = lambda: model
api = PromptManagerAPI()
api.add_routes(routes)
def seed(extra_images: int = 0) -> None:
with api.db.model.get_connection() as conn:
for table in ("generated_images", "prompt_tags", "tags", "prompts"):
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
shutil.rmtree(root / name)
(root / name).mkdir()
for index, (directory, text, tag) in enumerate(
[("output", "Cat portrait", "cats"), ("input", "Dog landscape", "dogs")]
):
image_path = root / directory / "shared" / "same.png"
image_path.parent.mkdir(exist_ok=True)
info = PngInfo()
info.add_text(
"prompt",
json.dumps(
{"1": {"class_type": "CLIPTextEncode", "inputs": {"text": text}}}
),
)
with Image.new("RGB", (64, 64), (index * 255, 64, 128)) as image:
image.save(image_path, pnginfo=info)
stamp = time.time() - index * 10
os.utime(image_path, (stamp, stamp))
pid = api.db.save_prompt(
text, tags=[tag], prompt_hash=generate_prompt_hash(text)
)
api.db.link_image_to_prompt(pid, str(image_path))
if index == 0:
for number in range(extra_images):
extra = root / directory / "bulk" / f"{number:04}.png"
extra.parent.mkdir(exist_ok=True)
shutil.copyfile(image_path, extra)
api.db.link_image_to_prompt(pid, str(extra))
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
api.invalidate_gallery_cache()
@routes.post("/__test__/reset")
async def reset(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else {}
extra = data.get("extra_images", 0)
if (
isinstance(extra, bool)
or not isinstance(extra, int)
or not 0 <= extra <= 200
):
raise web.HTTPBadRequest()
await api._run_in_executor(seed, extra)
return web.json_response({"success": True})
@routes.get("/api/settings/Comfy.ColorPalette")
async def palette(request: web.Request) -> web.Response:
return web.json_response("dark")
async def cleanup(application: web.Application) -> None:
api.db.close_all()
seed()
app.on_cleanup.append(cleanup)
app.add_routes(routes)
return app
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix="pm-browser-") as directory:
web.run_app(
create_app(Path(directory).resolve()), host="127.0.0.1", port=args.port
)
if __name__ == "__main__":
main()
+344
View File
@@ -0,0 +1,344 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
test.beforeEach(async ({ request, page, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.workflowErrors = [];
page.on('pageerror', error => page.workflowErrors.push(error.message));
page.on('response', response => {
if (response.status() >= 500 && response.url().includes('/prompt_manager/')) {
page.workflowErrors.push(`${response.status()} ${response.url()}`);
}
});
await page.addInitScript(() => { localStorage.setItem('pm_update_notice_dismissed', '3.2.0'); });
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.workflowErrors).toEqual([]);
});
test('actual gallery form saves a prompt that the admin page can edit and reload', async ({ page, request }) => {
await page.goto('/prompt_manager/gallery');
await page.locator('#addPromptBtn').click();
await page.locator('#addPromptText').fill('Browser saved landscape');
await page.locator('#addPromptCategory').fill('Browser tests');
await page.locator('#addPromptTagInput').fill('browser-tag');
await page.locator('#addPromptTagInput').press('Enter');
await page.locator('#saveNewPromptBtn').click();
await expect(page.locator('#addPromptModal')).toBeHidden();
await page.goto('/prompt_manager/admin');
const text = page.locator('.prompt-text').filter({ hasText: 'Browser saved landscape' });
await expect(text).toBeVisible();
const id = await text.getAttribute('data-id');
await page.locator(`[data-action="edit"][data-prompt-id="${id}"]`).click();
const editor = page.locator(`.prompt-text[data-id="${id}"]`);
await editor.fill('Browser edited portrait');
await editor.press('Enter');
await expect(page.locator('.prompt-text').filter({ hasText: 'Browser edited portrait' })).toBeVisible();
await page.reload();
await expect(page.locator('.prompt-text').filter({ hasText: 'Browser edited portrait' })).toBeVisible();
const saved = await request.post('/prompt_manager/save', { data: { text: 'Browser saved landscape' } });
expect((await saved.json()).prompt_id).not.toBe(Number(id));
});
test('actual gallery review generates and persists tags for the selected root', async ({ page, request }) => {
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
await page.locator('#autoTagBtn').click();
const generated = page.waitForResponse(response => response.url().endsWith('/prompt_manager/autotag/single'));
await page.locator('#startReviewBtn').click();
const generation = await generated;
expect(generation.request().postDataJSON().root_index).toBe(0);
await expect(page.locator('#reviewTagsContainer')).toContainText('generated');
const result = page.waitForResponse(response => response.url().endsWith('/prompt_manager/autotag/apply'));
await page.locator('#applyReviewBtn').click();
expect((await (await result).json()).success).toBe(true);
const data = await (await request.get('/prompt_manager/recent?limit=20')).json();
expect(data.results.find(prompt => prompt.text === 'Cat portrait').tags).toContain('generated');
expect(data.results.find(prompt => prompt.text === 'Dog landscape').tags).not.toContain('generated');
});
test('actual Tags page reloads the selected filter and opens its image viewer', async ({ page }) => {
await page.goto('/prompt_manager/admin#/tags');
await page.locator('[data-tag-name="cats"]').click();
await expect(page.locator('#tagPromptsGrid')).toContainText('Cat portrait');
await page.locator('[data-tag-name="dogs"]').click();
await expect(page.locator('#tagPromptsGrid')).toContainText('Dog landscape');
await expect(page.locator('#tagPromptsGrid')).not.toContainText('Cat portrait');
await page.locator('#tagPromptsGrid img').first().click();
await expect(page.locator('.viewer-canvas img')).toBeVisible();
await page.locator('.viewer-container').getByRole('button', { name: 'Close', exact: true }).click();
await expect(page.locator('.viewer-container')).toHaveCount(0);
});
test('actual gallery serves colliding filenames and shows the matching prompt', async ({ page }) => {
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
const images = page.locator('#galleryGrid img[data-original]');
const first = await images.nth(0).getAttribute('data-original');
const second = await images.nth(1).getAttribute('data-original');
expect(first).not.toBe(second);
await page.locator('#galleryGrid .image-item[data-image-index="0"]').click();
await expect(page.locator('#metadata-content')).toContainText('Cat portrait');
await page.keyboard.press('ArrowRight');
await expect(page.locator('#metadata-content')).toContainText('Dog landscape');
await page.keyboard.press('Escape');
await expect(page.locator('#metadata-sidebar')).toHaveCount(0);
});
test('an edit collision is rejected by the backend and the page restores the original text', async ({ page }) => {
await page.goto('/prompt_manager/admin');
const original = page.locator('.prompt-text').filter({ hasText: 'Cat portrait' });
const id = await original.getAttribute('data-id');
await page.locator(`[data-action="edit"][data-prompt-id="${id}"]`).click();
const editor = page.locator(`.prompt-text[data-id="${id}"]`);
await editor.fill('Dog landscape');
const rejected = page.waitForResponse(response => response.request().method() === 'PUT' && response.url().endsWith(`/prompts/${id}`));
await editor.press('Enter');
expect((await rejected).status()).toBe(409);
await expect(editor).toHaveText('Cat portrait');
await page.reload();
await expect(page.locator(`.prompt-text[data-id="${id}"]`)).toHaveText('Cat portrait');
});
test('a delayed real Tags response cannot replace a newer selection', async ({ page }) => {
let release;
const delayed = new Promise(resolve => { release = resolve; });
let received;
const waiting = new Promise(resolve => { received = resolve; });
await page.route('**/prompt_manager/tags/cats/prompts?*', async route => {
const response = await route.fetch();
received();
await delayed;
await route.fulfill({ response });
});
await page.goto('/prompt_manager/admin#/tags');
await page.locator('[data-tag-name="cats"]').click();
await waiting;
await page.locator('[data-tag-name="dogs"]').click();
await expect(page.locator('#tagPromptsGrid')).toContainText('Dog landscape');
const completed = page.waitForResponse(response => response.url().includes('/tags/cats/prompts?'));
release();
await completed;
await expect(page.locator('#tagPromptsGrid')).not.toContainText('Cat portrait');
});
test('gallery infinite scroll appends a real backend page while retaining existing cards', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 108 } })).toBeOK();
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(100);
await page.locator('#settingsBtn').click();
await page.locator('#infiniteScrollToggle').check();
await page.locator('#saveSettingsBtn').click();
await expect(page.locator('#settingsModal')).toBeHidden();
const first = page.locator('#galleryGrid .image-item').first();
await first.evaluate(element => { element.dataset.preserved = 'yes'; });
await page.locator('#galleryScroll').evaluate(element => { element.scrollTop = element.scrollHeight; });
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(110);
await expect(first).toHaveAttribute('data-preserved', 'yes');
});
async function promptSnapshot(request) {
const response = await request.get('/prompt_manager/recent?limit=100');
await expect(response).toBeOK();
return (await response.json()).results.map(({ id, text, tags, images }) => ({
id, text, tags: [...tags].sort(),
images: images.map(image => image.image_path).sort(),
})).sort((a, b) => a.id - b.id);
}
async function downloadBackup(page) {
const downloading = page.waitForEvent('download');
await page.locator('#backupBtn').click();
const download = await downloading;
expect(download.suggestedFilename()).toMatch(/^prompts_backup_.*\.db$/);
expect(await download.failure()).toBeNull();
const chunks = [];
for await (const chunk of await download.createReadStream()) chunks.push(chunk);
const buffer = Buffer.concat(chunks);
expect(buffer.subarray(0, 16).toString()).toBe('SQLite format 3\0');
return { name: download.suggestedFilename(), mimeType: 'application/octet-stream', buffer };
}
async function chooseRestore(page, file) {
await page.locator('#restoreBtn').click();
await expect(page.locator('#restoreModal')).toBeVisible();
await page.locator('#restoreFileInput').setInputFiles(file);
}
function confirmNextDialog(page, accept, message) {
page.once('dialog', async dialog => {
expect(dialog.type()).toBe('confirm');
expect(dialog.message()).toContain(message);
await (accept ? dialog.accept() : dialog.dismiss());
});
}
async function deletePromptButton(page, text) {
const prompt = page.locator('.prompt-text').filter({ hasText: text });
await expect(prompt).toBeVisible();
const id = await prompt.getAttribute('data-id');
return page.locator(`[data-action="delete"][data-prompt-id="${id}"]`);
}
test('downloaded backup restores deleted prompts, tags, and image links through the actual upload form', async ({ page, request }) => {
await page.goto('/prompt_manager/admin');
await expect(page.locator('.prompt-text')).toHaveCount(2);
const before = await promptSnapshot(request);
expect(before.map(prompt => prompt.tags)).toEqual([['cats'], ['dogs']]);
expect(before.every(prompt => prompt.images.length === 1)).toBe(true);
const backup = await downloadBackup(page);
const button = await deletePromptButton(page, 'Cat portrait');
confirmNextDialog(page, true, 'delete this prompt');
await button.click();
await expect(page.locator('.prompt-text')).toHaveCount(1);
await expect(await request.post('/prompt_manager/save', { data: { text: 'Added after backup', tags: ['temporary'] } })).toBeOK();
expect(await promptSnapshot(request)).not.toEqual(before);
await chooseRestore(page, backup);
confirmNextDialog(page, true, backup.name);
const restored = page.waitForResponse(response => response.url().endsWith('/prompt_manager/restore'));
await page.locator('#confirmRestore').click();
const response = await restored;
expect(response.ok()).toBe(true);
expect(await response.json()).toMatchObject({ success: true, prompt_count: 2, backup_created: expect.any(String) });
await expect(page.locator('#restoreModal')).toBeHidden();
await expect(page.locator('.prompt-text')).toHaveCount(2);
await page.reload();
await expect(page.locator('.prompt-text').filter({ hasText: 'Cat portrait' })).toBeVisible();
await expect(page.locator('.prompt-text').filter({ hasText: 'Added after backup' })).toHaveCount(0);
expect(await promptSnapshot(request)).toEqual(before);
await page.goto('/prompt_manager/admin#/tags');
await page.locator('[data-tag-name="cats"]').click();
await expect(page.locator('#tagPromptsGrid')).toContainText('Cat portrait');
await page.locator('#tagPromptsGrid img').first().click();
await expect(page.locator('.viewer-canvas img')).toBeVisible();
await expect.poll(() => page.locator('.viewer-canvas img').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
});
test('restore validation and cancellation preserve live data and allow a valid retry', async ({ page, request }) => {
await page.goto('/prompt_manager/admin');
const before = await promptSnapshot(request);
const backup = await downloadBackup(page);
let uploads = 0;
page.on('request', request => { if (request.url().endsWith('/prompt_manager/restore')) uploads++; });
await page.locator('#restoreBtn').click();
await page.locator('#confirmRestore').click();
await expect(page.getByText('Please select a database file to restore', { exact: true })).toBeVisible();
await page.locator('#restoreFileInput').setInputFiles({ ...backup, name: 'backup.txt' });
await page.locator('#confirmRestore').click();
await expect(page.getByText('Please select a valid .db file', { exact: true })).toBeVisible();
expect(uploads).toBe(0);
await page.locator('#restoreFileInput').setInputFiles(backup);
confirmNextDialog(page, false, 'replace your current database');
await page.locator('#confirmRestore').click();
await expect(page.locator('#restoreModal')).toBeVisible();
expect(uploads).toBe(0);
await page.locator('#restoreFileInput').setInputFiles({ name: 'corrupt.db', mimeType: 'application/octet-stream', buffer: Buffer.from('not a SQLite database') });
confirmNextDialog(page, true, 'corrupt.db');
const rejected = page.waitForResponse(response => response.url().endsWith('/prompt_manager/restore'));
await page.locator('#confirmRestore').click();
expect((await rejected).status()).toBe(400);
await expect(page.getByText(/Restore failed:/)).toBeVisible();
await expect(page.locator('#restoreModal')).toBeVisible();
await expect(page.locator('#confirmRestore')).toBeEnabled();
expect(await promptSnapshot(request)).toEqual(before);
await page.locator('#restoreFileInput').setInputFiles(backup);
confirmNextDialog(page, true, backup.name);
await page.locator('#confirmRestore').click();
await expect(page.locator('#restoreModal')).toBeHidden();
expect(await promptSnapshot(request)).toEqual(before);
});
test('backup and restore recover from interrupted network requests without losing data', async ({ page, request }) => {
await page.goto('/prompt_manager/admin');
const before = await promptSnapshot(request);
await page.route('**/prompt_manager/backup', route => route.abort('failed'), { times: 1 });
await page.locator('#backupBtn').click();
await expect(page.getByText(/Backup failed:/)).toBeVisible();
const backup = await downloadBackup(page);
await chooseRestore(page, backup);
await page.route('**/prompt_manager/restore', route => route.abort('failed'), { times: 1 });
confirmNextDialog(page, true, backup.name);
await page.locator('#confirmRestore').click();
await expect(page.getByText(/Restore failed:/)).toBeVisible();
await expect(page.locator('#confirmRestore')).toBeEnabled();
await expect(page.locator('#restoreModal')).toBeVisible();
expect(await promptSnapshot(request)).toEqual(before);
confirmNextDialog(page, true, backup.name);
await page.locator('#confirmRestore').click();
await expect(page.locator('#restoreModal')).toBeHidden();
expect(await promptSnapshot(request)).toEqual(before);
});
test('prompt deletion requires confirmation and can retry after a network failure', async ({ page, request }) => {
await page.goto('/prompt_manager/admin');
const before = await promptSnapshot(request);
const button = await deletePromptButton(page, 'Cat portrait');
let deletes = 0;
page.on('request', request => { if (request.method() === 'DELETE') deletes++; });
confirmNextDialog(page, false, 'delete this prompt');
await button.click();
expect(deletes).toBe(0);
expect(await promptSnapshot(request)).toEqual(before);
await page.route('**/prompt_manager/delete/*', route => route.abort('failed'), { times: 1 });
confirmNextDialog(page, true, 'delete this prompt');
await button.click();
await expect(page.getByText('Failed to delete prompt', { exact: true })).toBeVisible();
expect(await promptSnapshot(request)).toEqual(before);
confirmNextDialog(page, true, 'delete this prompt');
await button.click();
await expect(page.locator('.prompt-text')).toHaveCount(1);
await page.reload();
await expect(page.locator('.prompt-text')).toHaveCount(1);
expect(await promptSnapshot(request)).toEqual(before.filter(prompt => prompt.text !== 'Cat portrait'));
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
for (const url of await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original))) {
await expect(await request.get(url)).toBeOK();
}
});
test('duplicate cleanup cancels safely and deletes only the selected file after a retry', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 2 } })).toBeOK();
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(4);
const originals = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
await page.locator('#settingsBtn').click();
await page.locator('#scanDuplicatesBtn').click();
await expect(page.locator('#duplicatesCount')).toHaveText('1');
const candidates = page.locator('.duplicate-checkbox');
await expect(candidates).toHaveCount(2);
const selected = await candidates.first().getAttribute('data-image-path');
const retained = await candidates.last().getAttribute('data-image-path');
const serve = path => `/prompt_manager/images/serve/${path.split(/[\\/]/).map(encodeURIComponent).join('/')}`;
await candidates.first().check();
let deletes = 0;
page.on('request', request => { if (request.url().endsWith('/delete_duplicate_images')) deletes++; });
confirmNextDialog(page, false, 'permanently delete 1 duplicate image files');
await page.locator('#removeDuplicatesBtn').click();
expect(deletes).toBe(0);
await expect(await request.get(serve(selected))).toBeOK();
await page.route('**/prompt_manager/delete_duplicate_images', route => route.abort('failed'), { times: 1 });
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
await page.locator('#removeDuplicatesBtn').click();
await expect(page.getByText('Failed to delete duplicate images', { exact: true })).toBeVisible();
await expect(page.locator('#removeDuplicatesBtn')).toBeEnabled();
await expect(await request.get(serve(selected))).toBeOK();
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images'));
await page.locator('#removeDuplicatesBtn').click();
expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 });
await expect(page.locator('#duplicatesModal')).toBeHidden();
expect((await request.get(serve(selected))).status()).toBe(404);
await expect(await request.get(serve(retained))).toBeOK();
await page.reload();
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(3);
const remaining = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
expect(remaining).toHaveLength(originals.length - 1);
for (const url of remaining) {
expect(originals).toContain(url);
await expect(await request.get(url)).toBeOK();
}
});
+159
View File
@@ -0,0 +1,159 @@
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
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 () => {
const { app } = await import('/scripts/app.js');
return !!app.graph && ['PromptManager', 'PromptManagerText', 'PromptSearchList'].every(name => globalThis.LiteGraph?.registered_node_types?.[name]);
});
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([]);
});
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`) });
}
});
+1 -1
View File
@@ -10,7 +10,7 @@ function fixture() {
getElementById: id => id === 'metadata-content' ? panel : null },
window: {}, console: { error() {} },
});
const Admin = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/admin.js'), 'utf8') + '\nPromptAdmin;', context);
const Admin = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/admin.js'), 'utf8') + '\nPromptAdmin;', context, { filename: path.join(__dirname, '../../web/js/admin.js') });
const manager = Object.create(Admin.prototype);
manager.extractImageMetadata = url => new Promise((resolve, reject) => requests.set(url, { resolve, reject }));
manager.updateMetadataSidebar = metadata => { panel.innerHTML = metadata?.imagePath || 'No metadata'; };
+1 -1
View File
@@ -13,7 +13,7 @@ function deferred() {
function fixture() {
const context = vm.createContext({ document: { addEventListener() {}, getElementById() {} },
window: {}, console: { error() {} }, escapeHtml: value => value });
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context);
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
const manager = Object.create(Manager.prototype);
const requests = new Map();
manager.extractImageMetadata = url => {
+1 -1
View File
@@ -47,7 +47,7 @@ function fixture(count = 2000) {
ListState: { nextPageState: (state, next) => ({ ...state, ...next }) },
});
const source = fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8');
const Manager = vm.runInContext(source + '\nGalleryManager;', context);
const Manager = vm.runInContext(source + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
const manager = Object.create(Manager.prototype);
Object.assign(manager, { images: Array.from({ length: count }, (_, i) => ({
filename: `${i}.png`, url: `/original/${i}.png`, thumbnail_url: `/thumb/${i}.jpg`,
+1 -1
View File
@@ -24,7 +24,7 @@ function fixture() {
return request.promise;
} },
});
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context);
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
const manager = Object.create(Manager.prototype);
const notifications = [];
Object.assign(manager, { autoTagState: { reviewImages: [
+1 -1
View File
@@ -13,7 +13,7 @@ test('gallery metadata requests retain root identity without including cache par
return { ok: true, json: async () => ({ success: true, prompt: { text: url } }) };
} },
});
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context);
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
const manager = Object.create(Manager.prototype);
manager.extractImageMetadata = async () => null;
manager.displayMetadata = (metadata, panel) => { panel.prompt = metadata.positivePrompt; };
+1 -1
View File
@@ -23,7 +23,7 @@ function fixture(status, settings = {}) {
total: 10000, images: Array.from({ length: 50 }, () => ({ thumbnail_url: null })),
}) }) },
});
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context);
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
const manager = Object.create(Manager.prototype);
Object.assign(manager, { getSettings: () => ({ checkThumbnailsAtStartup: true, ...settings }),
api: { get: async url => { requests.push(url); return status; } },
+1 -1
View File
@@ -10,7 +10,7 @@ function fixture() {
requestAnimationFrame() {}, document: { getElementById: id => id === 'tagPromptsGrid' ? grid : null,
querySelector: () => null, createElement: () => ({ appendChild() {} }) },
});
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/tags-page.js'), 'utf8') + '\nTagsPageManager;', context);
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/tags-page.js'), 'utf8') + '\nTagsPageManager;', context, { filename: path.join(__dirname, '../../web/js/tags-page.js') });
const manager = Object.create(Manager.prototype);
Object.assign(manager, { selectedTags: ['cats'], filterMode: 'and', promptLimit: 20,
promptOffset: 0, isLoadingPrompts: false, prompts: [], hasMorePrompts: false,
+42
View File
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createScope, themeFromColor } from '../../web/js/shared/toolkit/core.js';
test('host background luminance determines mode without assuming palette names', () => {
for (const color of ['#fff', '#F4F4F6', 'rgb(240, 240, 240)']) assert.equal(themeFromColor(color), 'light');
for (const color of ['#222', '#202020', 'rgb(30, 32, 34)']) assert.equal(themeFromColor(color), 'dark');
assert.equal(themeFromColor(''), null);
assert.equal(themeFromColor('transparent'), null);
});
test('scope cancels requests and callbacks, removes listeners and disposes once', () => {
const scope = createScope();
const target = new EventTarget();
let events = 0;
let cleanups = 0;
scope.listen(target, 'change', () => events++);
scope.cleanup(() => cleanups++);
target.dispatchEvent(new Event('change'));
assert.equal(events, 1);
scope.dispose();
scope.dispose();
target.dispatchEvent(new Event('change'));
assert.equal(events, 1);
assert.equal(cleanups, 1);
assert.equal(scope.signal.aborted, true);
});
test('debounced work runs once with the latest value and stops after disposal', async () => {
const scope = createScope();
const values = [];
const change = scope.debounce(value => values.push(value), 1);
change('old');
change('new');
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(values, ['new']);
change('cancelled');
scope.dispose();
change('after removal');
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(values, ['new']);
});
+1 -1
View File
@@ -1867,7 +1867,7 @@ class TestScanReadsVideos(AdminAPITestCase):
(self.output_dir / "bad.png").write_bytes(b"\x89PNG not really")
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
with self.assertLogs(level="WARNING") as cm:
with self.assertLogs("prompt_manager", level="WARNING") as cm:
body = await (
await self.client.request("POST", "/prompt_manager/scan")
).text()
+30
View File
@@ -263,5 +263,35 @@ class TestConfiguration(IsolatedLoggerTestCase):
self.manager.update_config({"console_logging": False})
class TestConsoleNoise(IsolatedLoggerTestCase):
"""ComfyUI's console only sees warnings unless the level is DEBUG."""
def _console_handler(self) -> logging.StreamHandler:
return next(
h
for h in logging.getLogger("prompt_manager").handlers
if type(h) is logging.StreamHandler
)
def test_records_do_not_propagate_to_comfyui_root_logger(self) -> None:
self.assertFalse(logging.getLogger("prompt_manager").propagate)
def test_console_hides_info_at_the_default_level(self) -> None:
self.manager.update_config({"console_logging": True})
self.assertEqual(self._console_handler().level, logging.WARNING)
self.logger.info("startup chatter")
self.assertEqual(self.manager.get_recent_logs(limit=1)[0]["level"], "INFO")
log_file = pathlib.Path(self.log_dir) / "prompt_manager.log"
self.assertIn("startup chatter", log_file.read_text())
self.manager.update_config({"console_logging": False})
def test_debug_level_opens_the_console(self) -> None:
self.manager.update_config({"console_logging": True, "level": "DEBUG"})
self.assertEqual(self._console_handler().level, logging.DEBUG)
self.manager.update_config({"level": "INFO"})
self.assertEqual(self._console_handler().level, logging.WARNING)
self.manager.update_config({"console_logging": False})
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
test('browser coverage maps nested JS/MJS and excludes unrelated and traversal paths', async t => {
const directory = mkdtempSync(join(tmpdir(), 'pm-browser-coverage-'));
const previous = process.env.PM_JS_COVERAGE;
process.env.PM_JS_COVERAGE = directory;
t.after(() => {
if (previous === undefined) delete process.env.PM_JS_COVERAGE;
else process.env.PM_JS_COVERAGE = previous;
rmSync(directory, { recursive: true, force: true });
});
const urls = [
'/prompt_manager/js/gallery.js',
'/prompt_manager/js/shared/controls/button.mjs?v=1',
'/prompt_manager/js/shared/panel.js',
'/scripts/app.js', '/prompt_manager/js/../private.js',
'/prompt_manager/js/shared/theme.css',
];
await saveCoverage({ coverage: { stopJSCoverage: async () => urls.map(url => ({ url: `http://127.0.0.1:8765${url}`, functions: [] })) } }, 'chromium');
const report = JSON.parse(readFileSync(join(directory, readdirSync(directory)[0]), 'utf8'));
const root = fileURLToPath(new URL('../../web/js/', import.meta.url));
assert.deepEqual(report.result.map(entry => fileURLToPath(entry.url)), [join(root, 'gallery.js'), join(root, 'shared/controls/button.mjs'), join(root, 'shared/panel.js')]);
});
+15
View File
@@ -0,0 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../../', import.meta.url));
test('real-ComfyUI suite registers Classic and Nodes 2.0 startup projects', () => {
const output = execFileSync(process.execPath, ['tools/vendor/node_modules/@playwright/test/cli.js',
'test', '--config', 'tools/vendor/comfyui.config.mjs', '--list'], {
cwd: root, encoding: 'utf8', env: { ...process.env,
PM_COMFY_ROOT: '/unused-pinned-checkout', PM_COMFY_URL: 'http://127.0.0.1:8199/' },
});
assert.match(output, /\[classic-start\]/);
assert.match(output, /\[nodes2-start\]/);
});
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtemp, writeFile, mkdir, readFile, realpath, rm, access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { once } from 'node:events';
import { launchComfyHost } from '../../tools/vendor/comfyui-host.mjs';
async function fixture(t, program) {
const directory = await mkdtemp(join(tmpdir(), 'pm-host-test-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const checkout = join(directory, 'host');
const repository = join(directory, 'plugin');
await mkdir(checkout);
await mkdir(repository);
await writeFile(join(repository, '__init__.py'), '');
execFileSync('git', ['init', '--quiet', repository]);
execFileSync('git', ['-C', repository, 'add', '__init__.py']);
await writeFile(join(repository, 'config.json'), 'private-user-config');
await writeFile(join(repository, 'prompts.db'), 'private-user-database');
await writeFile(join(checkout, 'main.py'), program);
const git = args => execFileSync('git', ['-C', checkout, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
git(['init', '--quiet']);
git(['add', 'main.py']);
git(['-c', 'user.name=PromptManager Tests', '-c', 'user.email=tests@example.invalid', 'commit', '--quiet', '-m', 'fixture']);
return { checkout, repository, python: process.execPath, port: 8199,
pins: { revision: git(['rev-parse', 'HEAD']) }, logPath: join(directory, 'host.log'), forwardOutput: false };
}
const idle = `console.log('ready'); setInterval(() => {}, 1000);`;
test('PromptManager adapter isolates database, configuration and monitored directories', async t => {
const options = await fixture(t, idle);
const host = await launchComfyHost(options);
t.after(() => host.stop());
await once(host.child.stdout, 'data');
const args = host.child.spawnargs;
for (const flag of ['--cpu', '--offline', '--disable-all-custom-nodes']) assert.ok(args.includes(flag));
assert.equal(args[args.indexOf('--base-directory') + 1], host.stateDirectory);
assert.equal(args[args.indexOf('--listen') + 1], '127.0.0.1');
assert.equal(args[args.indexOf('--whitelist-custom-nodes') + 1], 'promptmanager');
const installed = join(host.stateDirectory, 'custom_nodes/promptmanager');
assert.notEqual(await realpath(installed), await realpath(options.repository));
assert.equal(await readFile(join(installed, '__init__.py'), 'utf8'), '');
await assert.rejects(access(join(installed, 'prompts.db')), { code: 'ENOENT' });
assert.equal(JSON.parse(await readFile(join(installed, 'config.json'), 'utf8')).database.default_path, join(host.stateDirectory, 'prompts.db'));
await writeFile(join(installed, 'prompts.db'), 'test data');
assert.equal(await readFile(join(options.repository, 'prompts.db'), 'utf8'), 'private-user-database');
assert.equal(await readFile(join(options.repository, 'config.json'), 'utf8'), 'private-user-config');
const config = JSON.parse(await readFile(join(installed, 'config.json'), 'utf8'));
assert.equal(config.gallery.monitoring.enabled, false);
assert.deepEqual(config.gallery.monitoring.directories, [join(host.stateDirectory, 'output')]);
assert.equal(config.web_ui.result_timeout, 0);
await access(join(host.stateDirectory, 'user'));
await host.stop();
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
await access(join(options.checkout, 'main.py'));
await access(join(options.repository, '__init__.py'));
});
+33
View File
@@ -0,0 +1,33 @@
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, 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 } };
test('coverage gate accepts measured files at or above their reviewed floor', () => {
assert.deepEqual(checkCoverage(summary, baseline, sourceRoot, ['a.js']), []);
});
test('coverage gate rejects a regression even when the aggregate percentage is high', () => {
assert.match(checkCoverage({ ...summary, [join(sourceRoot, 'a.js')]: metrics(70) }, baseline, sourceRoot, ['a.js']).join('\n'), /a.js.*lines/);
});
test('coverage gate rejects absent source files and unreviewed new files', () => {
assert.match(checkCoverage({ total: metrics(100) }, baseline, sourceRoot, ['a.js']).join('\n'), /missing.*a.js/i);
assert.match(checkCoverage(summary, baseline, sourceRoot, ['a.js', 'new.js']).join('\n'), /baseline.*new.js/i);
});
test('coverage gate rejects invalid baselines and missing metrics', () => {
assert.throws(() => checkCoverage(summary, { 'a.js': { lines: -1 } }, sourceRoot, ['a.js']), /baseline/i);
assert.match(checkCoverage({ total: metrics(100), [join(sourceRoot, 'a.js')]: {} }, baseline, sourceRoot, ['a.js']).join('\n'), /lines/);
});
test('coverage discovers nested JS and MJS while excluding non-source files', t => {
const root = mkdtempSync(join(tmpdir(), 'pm-coverage-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'shared/controls'), { recursive: true });
for (const file of ['entry.js', 'shared/core.mjs', 'shared/controls/button.js', 'shared/theme.css', 'shared/notes.txt']) writeFileSync(join(root, file), '');
assert.deepEqual(discoverSources(root), ['entry.js', 'shared/controls/button.js', 'shared/core.mjs']);
assert.match(checkCoverage({}, {}, root, discoverSources(root)).join('\n'), /baseline.*shared\/controls\/button.js/i);
});
+62
View File
@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { syncToolkit } from '../../tools/vendor/vendor-toolkit.mjs';
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'pm-toolkit-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const tooling = join(root, 'tools/vendor');
const dependency = join(tooling, 'node_modules/@comfyassets/toolkit');
mkdirSync(join(dependency, 'ui'), { recursive: true });
const resolved = 'https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/' + 'a'.repeat(40);
writeFileSync(join(tooling, 'package.json'), JSON.stringify({ devDependencies: { '@comfyassets/toolkit': resolved } }));
writeFileSync(join(tooling, 'package-lock.json'), JSON.stringify({ packages: { 'node_modules/@comfyassets/toolkit': { version: '0.1.0', resolved, integrity: 'sha512-fixture' } } }));
writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: '@comfyassets/toolkit', version: '0.1.0', license: 'MIT' }));
for (const file of ['ui/core.js', 'ui/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', '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/);
}
});
+32
View File
@@ -28,6 +28,10 @@ version banners must agree. The lockfile records npm's archive integrity;
`provenance.json` records the upstream source, package integrity, and SHA-256 of
each copied file. A normal `npm ci --ignore-scripts` verifies archive integrity.
Full-page workflows, frontend coverage gates, and required merge checks are
documented in [TESTING.md](../../TESTING.md). The `viewerjs` CI check runs both
the fixture tests above and the real-page workflows on every PR.
## Update
```sh
@@ -74,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.
+18
View File
@@ -0,0 +1,18 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { mapBrowserCoverage } from '@comfyassets/toolkit/testing/coverage';
import { randomUUID } from 'node:crypto';
import { repositoryRoot } from './vendor-viewerjs.mjs';
export async function startCoverage(page, browserName) {
if (process.env.PM_JS_COVERAGE && browserName === 'chromium') {
await page.coverage.startJSCoverage({ resetOnNavigation: false });
}
}
export async function saveCoverage(page, browserName) {
if (!process.env.PM_JS_COVERAGE || browserName !== 'chromium') return;
const entries = await page.coverage.stopJSCoverage();
const result = mapBrowserCoverage(entries, { urlPrefix: '/prompt_manager/js/', sourceRoot: join(repositoryRoot, 'web/js') });
mkdirSync(process.env.PM_JS_COVERAGE, { recursive: true });
writeFileSync(join(process.env.PM_JS_COVERAGE, `browser-${randomUUID()}.json`), JSON.stringify({ result }));
}
+39
View File
@@ -0,0 +1,39 @@
import { writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { launchComfyHost as launchSharedHost } from '@comfyassets/toolkit/testing/host';
import runtime from './comfyui-runtime.json' with { type: 'json' };
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
export function launchComfyHost({ checkout, repository = repositoryRoot,
python = process.env.PM_TEST_PYTHON || 'python', port = 8189, pins = runtime,
logPath = join(repositoryRoot, 'tools/vendor/test-results/comfyui-host.log'), forwardOutput = true }) {
return launchSharedHost({ checkout, repository, python, port, pins, logPath, forwardOutput,
pluginName: 'promptmanager',
async preparePlugin({ installed, stateDirectory }) {
const configPath = join(installed, 'config.json');
await writeFile(configPath, JSON.stringify({
database: { default_path: join(stateDirectory, 'prompts.db') },
gallery: { monitoring: { enabled: false, directories: [join(stateDirectory, 'output')] } },
web_ui: { result_timeout: 0 },
}), { mode: 0o600 });
return { PROMPT_MANAGER_CONFIG_PATH: configPath, PROMPT_MANAGER_EXTRA_GALLERY_ROOTS: '' };
},
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const host = await launchComfyHost({ checkout: process.env.PM_COMFY_ROOT,
port: Number(process.env.PM_COMFY_PORT || 8189) });
const stop = () => { host.stop().catch(error => { console.error(error.message); process.exitCode = 1; }); };
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
const result = await host.finished;
process.removeListener('SIGINT', stop);
process.removeListener('SIGTERM', stop);
if (result.code) console.error(`ComfyUI host exited with status ${result.code}; see tools/vendor/test-results/comfyui-host.log`);
process.exitCode = result.code;
} catch (error) { console.error(error.message); process.exitCode = 1; }
}
+9
View File
@@ -0,0 +1,9 @@
{
"repository": "Comfy-Org/ComfyUI",
"revision": "e8665ca8e4089ab917567cdb491308e1397663d4",
"serverVersion": "0.39.0",
"frontendVersion": "1.55.14",
"pythonVersion": "3.12",
"torchVersion": "2.11.0",
"torchvisionVersion": "0.26.0"
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from './node_modules/@playwright/test/index.mjs';
import { fileURLToPath } from 'node:url';
if (!process.env.PM_COMFY_ROOT) throw new Error('Set PM_COMFY_ROOT to a pinned disposable ComfyUI checkout');
const host = new URL(process.env.PM_COMFY_URL || 'http://127.0.0.1:8189/');
if (host.protocol !== 'http:' || host.hostname !== '127.0.0.1' || !host.port) throw new Error('A managed host needs an HTTP 127.0.0.1 URL with an explicit port');
const servers = [{
command: 'node tools/vendor/comfyui-host.mjs',
cwd: fileURLToPath(new URL('../../', import.meta.url)),
url: new URL('system_stats', host).href, timeout: 120000, reuseExistingServer: false,
env: { PM_COMFY_PORT: host.port },
gracefulShutdown: { signal: 'SIGTERM', timeout: 10000 },
}];
export default defineConfig({
testDir: '../../tests/comfyui',
outputDir: './test-results/comfyui',
timeout: 60000,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: 0,
use: { baseURL: host.href, browserName: 'chromium', viewport: { width: 1600, height: 1100 }, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
webServer: servers,
projects: [
{ name: 'classic-start', metadata: { initialRenderer: false } },
{ name: 'nodes2-start', metadata: { initialRenderer: true } },
],
});
+116
View File
@@ -0,0 +1,116 @@
{
"admin.js": {
"lines": 28,
"branches": 58,
"functions": 23,
"statements": 28
},
"api-client.js": {
"lines": 98,
"branches": 94,
"functions": 98,
"statements": 98
},
"comfy-metadata.js": {
"lines": 97,
"branches": 90,
"functions": 98,
"statements": 97
},
"data-actions.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"gallery-settings.js": {
"lines": 98,
"branches": 90,
"functions": 98,
"statements": 98
},
"gallery.js": {
"lines": 39,
"branches": 63,
"functions": 43,
"statements": 39
},
"html-escape.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"image-helpers.js": {
"lines": 96,
"branches": 91,
"functions": 98,
"statements": 96
},
"list-state.js": {
"lines": 98,
"branches": 92,
"functions": 98,
"statements": 98
},
"png-metadata.js": {
"lines": 98,
"branches": 90,
"functions": 98,
"statements": 98
},
"prompt-list-sort.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"scan-progress.js": {
"lines": 97,
"branches": 91,
"functions": 98,
"statements": 97
},
"sse-stream.js": {
"lines": 98,
"branches": 90,
"functions": 98,
"statements": 98
},
"tag-autocomplete.js": {
"lines": 98,
"branches": 94,
"functions": 98,
"statements": 98
},
"tags-page.js": {
"lines": 57,
"branches": 48,
"functions": 56,
"statements": 57
},
"theme-bridge.js": {
"lines": 90,
"branches": 53,
"functions": 98,
"statements": 90
},
"view-router.js": {
"lines": 98,
"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
}
}
+60
View File
@@ -0,0 +1,60 @@
import { readFileSync, readdirSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';
import { repositoryRoot } from './vendor-viewerjs.mjs';
import { discoverSources, checkCoverage } from '@comfyassets/toolkit/testing/coverage';
export { discoverSources, checkCoverage };
const metrics = ['lines', 'branches', 'functions', 'statements'];
function run(args, env = {}) {
const result = spawnSync(process.execPath, args, { cwd: repositoryRoot, stdio: 'inherit',
env: { ...process.env, ...env } });
if (result.status !== 0) throw new Error(`Command failed (${result.status}): ${args.join(' ')}`);
}
async function main() {
const tools = dirname(fileURLToPath(import.meta.url));
const reportRoot = join(repositoryRoot, 'coverage/js');
const raw = join(reportRoot, 'raw');
const sourceRoot = join(repositoryRoot, 'web/js');
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}`);
run(['--test', ...tests], { NODE_V8_COVERAGE: raw });
const playwright = join(tools, 'node_modules/@playwright/test/cli.js');
run([playwright, 'test', '--config', join(tools, 'playwright.config.mjs')]);
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', '--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');
if (process.argv.includes('--update-baseline')) {
const previous = JSON.parse(readFileSync(baselinePath, 'utf8'));
const failures = checkCoverage(summary, previous, sourceRoot, files.filter(file => previous[file]));
if (failures.length) throw new Error(`Baseline updates cannot lower coverage:\n${failures.join('\n')}`);
const next = Object.fromEntries(files.map(file => {
const measured = summary[join(sourceRoot, file)];
if (!measured || metrics.some(metric => !Number.isFinite(measured[metric]?.pct))) {
throw new Error(`Missing coverage for ${file}`);
}
if (Object.keys(previous).length && !previous[file] && measured.lines.pct < 80) {
throw new Error(`New frontend files need at least 80% line coverage: ${file}`);
}
return [file, Object.fromEntries(metrics.map(metric => [metric, Math.max(previous[file]?.[metric] || 0, Math.floor(measured[metric].pct) - 2, 0)]))];
}));
writeFileSync(baselinePath, JSON.stringify(next, null, 2) + '\n');
} else {
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
const failures = checkCoverage(summary, baseline, sourceRoot, files);
if (failures.length) throw new Error(`Frontend coverage regressed:\n${failures.join('\n')}`);
}
console.log(`Frontend coverage passed for ${files.length} files. Report: ${relative(repositoryRoot, reportRoot)}/index.html`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch(error => { console.error(error.message); process.exitCode = 1; });
}
+735
View File
@@ -8,10 +8,70 @@
"name": "promptmanager-vendor-tools",
"version": "1.0.0",
"devDependencies": {
"@comfyassets/toolkit": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14",
"@playwright/test": "1.64.1",
"c8": "12.0.0",
"viewerjs": "1.15.2"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@comfyassets/toolkit": {
"version": "0.1.0",
"resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14",
"integrity": "sha512-jvDj3iYJHjRCH7nZimoF4KNLwEVdzKVQIOZrMfoLg9aLnHm98R8YvBUOvnlJ+JNrM8/LnD2m30u3ggSTnR0/sw==",
"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",
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
@@ -28,6 +88,443 @@
"node": ">=20"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"dev": true,
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/c8": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz",
"integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==",
"dev": true,
"license": "ISC",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.1",
"@istanbuljs/schema": "^0.1.3",
"find-up": "^5.0.0",
"foreground-child": "^3.1.1",
"istanbul-lib-coverage": "^3.2.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.1.6",
"test-exclude": "^8.0.0",
"v8-to-istanbul": "^9.0.0",
"yargs": "^18.0.0",
"yargs-parser": "^21.1.1"
},
"bin": {
"c8": "bin/c8.js"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
},
"peerDependencies": {
"monocart-coverage-reports": "^2"
},
"peerDependenciesMeta": {
"monocart-coverage-reports": {
"optional": true
}
}
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cliui/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
@@ -57,12 +554,250 @@
"node": ">=20"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/string-width": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
"integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/test-exclude": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
"integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^13.0.6",
"minimatch": "^10.2.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.12",
"@types/istanbul-lib-coverage": "^2.0.1",
"convert-source-map": "^2.0.0"
},
"engines": {
"node": ">=10.12.0"
}
},
"node_modules/viewerjs": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.15.2.tgz",
"integrity": "sha512-CYEqSFfPUSnmn1PjrhYxXOScaUC7n8+NpSqI+MrOSM8wcKPe35MlwQYwksuD5rjFkYv/ePmY5NsUa51uRNN2jA==",
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
"integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^8.2.1",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yargs/node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}
+9 -2
View File
@@ -5,13 +5,20 @@
"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: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/66644f5620e10efeaba090dca2d5be54baa2ee14",
"@playwright/test": "1.64.1",
"c8": "12.0.0",
"viewerjs": "1.15.2"
}
}
+1
View File
@@ -2,6 +2,7 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
outputDir: './test-results/viewer',
testMatch: '*.spec.mjs',
fullyParallel: true,
workers: 2,
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
import { repositoryRoot } from './vendor-viewerjs.mjs';
const port = Number(process.env.PM_TEST_PORT || 8765);
export default defineConfig({
testDir: '../../tests/browser',
outputDir: './test-results/workflows',
testMatch: '*.spec.mjs',
workers: 1,
fullyParallel: false,
forbidOnly: Boolean(process.env.CI),
retries: 0,
reporter: 'list',
use: { baseURL: `http://127.0.0.1:${port}`, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
webServer: {
command: `node tools/vendor/start-browser-server.mjs --port ${port}`,
cwd: repositoryRoot,
url: `http://127.0.0.1:${port}/prompt_manager/test`,
reuseExistingServer: false,
gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 },
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
{ name: 'touch', use: { ...devices['Pixel 7'], browserName: 'chromium' } },
],
});
+8
View File
@@ -0,0 +1,8 @@
import { spawn } from 'node:child_process';
const child = spawn(process.env.PM_TEST_PYTHON || 'python',
['-m', 'tests.browser.server', ...process.argv.slice(2)], { stdio: 'inherit' });
child.on('error', error => { console.error(error.message); process.exitCode = 1; });
child.on('exit', code => { process.exitCode = code ?? 1; });
for (const signal of ['SIGTERM', 'SIGINT']) {
process.once(signal, () => child.kill(signal));
}
+43
View File
@@ -0,0 +1,43 @@
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { repositoryRoot } from './vendor-viewerjs.mjs';
export function syncToolkit({ root = repositoryRoot, check = false } = {}) {
const tooling = join(root, 'tools/vendor');
const readJson = path => JSON.parse(readFileSync(path, 'utf8'));
const pin = readJson(join(tooling, 'package.json')).devDependencies['@comfyassets/toolkit'];
if (!/^https:\/\/codeload\.github\.com\/ComfyAssets\/ComfyAssets-Toolkit\/tar\.gz\/[a-f0-9]{40}$/.test(pin)) throw new Error('Toolkit needs an immutable commit pin');
const locked = readJson(join(tooling, 'package-lock.json')).packages['node_modules/@comfyassets/toolkit'];
const installed = join(tooling, 'node_modules/@comfyassets/toolkit');
const info = readJson(join(installed, 'package.json'));
if (locked?.resolved !== pin || !locked.integrity || locked.version !== info.version || info.name !== '@comfyassets/toolkit' || info.license !== 'MIT') throw new Error('Toolkit package identity or lock mismatch; run npm ci --ignore-scripts');
const output = new Map();
const files = {};
for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'controls.css': 'ui/controls.css', LICENSE: 'LICENSE', 'SOURCE.md': 'SOURCE.md' })) {
const content = readFileSync(join(installed, source));
output.set(name, content);
files[name] = { source, sha256: createHash('sha256').update(content).digest('hex') };
}
output.set('provenance.json', Buffer.from(JSON.stringify({ name: info.name, version: info.version, resolved: pin, integrity: locked.integrity, files }, null, 2) + '\n'));
const destination = join(root, 'web/js/shared/toolkit');
if (check) {
const drift = [...output].filter(([name, content]) => {
try { return !readFileSync(join(destination, name)).equals(content); }
catch (error) { if (error.code === 'ENOENT') return true; throw error; }
}).map(([name]) => name);
if (drift.length) throw new Error(`Toolkit browser distribution drift: ${drift.join(', ')}`);
} else {
mkdirSync(destination, { recursive: true });
for (const [name, content] of output) writeFileSync(join(destination, name), content);
}
return info.version;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
if (process.argv.slice(2).some(arg => arg !== '--check')) throw new Error('Usage: vendor-toolkit.mjs [--check]');
console.log(`Toolkit ${syncToolkit({ check: process.argv.includes('--check') })}: verified`);
} catch (error) { console.error(error.message); process.exitCode = 1; }
}
+16
View File
@@ -85,6 +85,8 @@ class PromptManagerLogger:
# Main logger
self.logger = logging.getLogger("prompt_manager")
self.logger.setLevel(getattr(logging, self.config["level"]))
# ComfyUI's root handler would print every record a second time.
self.logger.propagate = False
# Clear existing handlers
for handler in self.logger.handlers[:]:
@@ -100,6 +102,7 @@ class PromptManagerLogger:
# Console handler
if self.config["console_logging"]:
console_handler = logging.StreamHandler()
console_handler.setLevel(self._console_level())
console_handler.setFormatter(formatter)
self.logger.addHandler(console_handler)
@@ -124,6 +127,15 @@ class PromptManagerLogger:
# Component-specific loggers
self._setup_component_loggers()
def _console_level(self) -> int:
"""Console threshold: everything at DEBUG, otherwise warnings only.
INFO still reaches the log file and the web log viewer.
"""
if str(self.config["level"]).upper() == "DEBUG":
return logging.DEBUG
return logging.WARNING
def _setup_component_loggers(self):
"""Set up loggers for specific components.
@@ -363,6 +375,10 @@ class PromptManagerLogger:
logger = logging.getLogger(logger_name)
logger.setLevel(level)
for handler in self.logger.handlers:
if type(handler) is logging.StreamHandler:
handler.setLevel(self._console_level())
# Re-setup loggers if handlers changed
if any(
key in new_config
+19 -10
View File
@@ -2,6 +2,7 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js'));
// ============================================================================
// Top Bar Button Integration (similar to rgthree-comfy approach)
@@ -302,7 +303,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 +336,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 +382,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 +988,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 +1003,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 +1182,7 @@ app.registerExtension({
document.body.removeChild(notification);
}, 3000);
};
if (nodeData.name === 'PromptManager') installClassicControls(nodeType, app, parseSearchQuery, api);
}
},
});
+14
View File
@@ -0,0 +1,14 @@
.pm-classic .pm-classic-workspace { min-block-size: 0; }
.pm-classic .pm-classic-toolbar { display: flex; flex-wrap: wrap; gap: 8px; flex-shrink: 0; }
.pm-classic-toolbar > button { flex: 1; }
.pm-classic .pm-classic-results { flex: 1; min-block-size: 100px; overflow: auto; padding: 8px; border: 1px solid var(--ca-line); border-radius: 5px; background: var(--ca-field); }
.pm-classic-message { color: var(--ca-muted); text-align: center; padding: 12px; margin: 0; overflow-wrap: anywhere; }
.pm-classic-error { color: var(--ca-error-text); background: var(--ca-error-bg); }
.pm-classic-count { color: var(--ca-text); text-align: center; margin: 0 0 10px; }
.pm-classic button.pm-classic-result { display: flex; flex-direction: column; align-items: stretch; text-align: start; inline-size: 100%; white-space: normal; margin-block-end: 5px; }
.pm-classic-result[aria-pressed="true"] { border-color: var(--ca-accent); box-shadow: inset 3px 0 var(--ca-accent); }
.pm-classic-preview { overflow-wrap: anywhere; }
.pm-classic-metadata { color: var(--ca-muted); font-size: 11px; overflow-wrap: anywhere; }
.pm-classic-restore { white-space: normal !important; }
.pm-classic-notification { position: fixed; inset-block-start: 20px; inset-inline-end: 20px; padding: 10px 15px; border: 1px solid var(--ca-border); border-radius: 5px; z-index: 10000; max-inline-size: min(300px, calc(100vw - 40px)); overflow-wrap: anywhere; }
.pm-classic-notification[data-kind="error"] { color: var(--ca-error-text); background: var(--ca-error-bg); border-color: var(--ca-error); }
+156
View File
@@ -0,0 +1,156 @@
import { createScope, createButton, mountTheme } from './shared/toolkit/core.js';
function loadStyles() {
const href = new URL('./classic-node.css', import.meta.url).href;
if ([...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === href)) return;
const link = document.createElement('link');
Object.assign(link, { rel: 'stylesheet', href });
document.head.append(link);
}
function message(node, text, error = false) {
node._classicResultsScope?.dispose();
const element = document.createElement('p');
element.className = error ? 'pm-classic-message pm-classic-error' : 'pm-classic-message';
element.textContent = text;
element.setAttribute('role', error ? 'alert' : 'status');
node.resultsSection.replaceChildren(element);
}
export function installClassicControls(nodeType, app, parseSearchQuery, api) {
const prototype = nodeType.prototype;
const onCreated = prototype.onNodeCreated;
const onRemoved = prototype.onRemoved;
prototype.onNodeCreated = function (...args) {
this._classicScope = createScope();
this._classicNotifications = new Set();
this._classicScope.cleanup(() => {
clearTimeout(this._textSyncTimer);
clearTimeout(this.resultHideTimer);
this._classicResultsScope?.dispose();
for (const item of this._classicNotifications) { clearTimeout(item.timer); item.element.remove(); }
this._classicNotifications.clear();
});
onCreated.apply(this, args);
};
prototype.onRemoved = function (...args) {
this._classicScope?.dispose();
return onRemoved?.apply(this, args);
};
prototype.createSearchUI = function (container) {
loadStyles();
container.classList.add('ca-tools', 'pm-classic');
mountTheme(container, app.ui?.settings, this._classicScope);
this._classicContainer = container;
const wrapper = document.createElement('div');
wrapper.className = 'pm-classic-workspace';
const buttons = document.createElement('div');
buttons.className = 'pm-classic-toolbar';
for (const [label, action, primary] of [
['🔍 Search', () => this.performSearch(), true],
['📋 Recent', () => this.loadRecentPrompts()],
['🌐 Web UI', () => this.openWebInterface()],
]) {
const button = createButton(label, primary ? 'ca-primary' : '');
this._classicScope.listen(button, 'click', action);
buttons.append(button);
}
wrapper.append(buttons);
if (this.properties.showTestButton) {
const test = createButton('🔧 Test API');
this._classicScope.listen(test, 'click', () => this.testApiConnection());
wrapper.append(test);
}
this.resultsSection = document.createElement('div');
this.resultsSection.className = 'pm-classic-results';
wrapper.append(this.resultsSection);
container.replaceChildren(wrapper);
message(this, '💡 Click Search or Recent to find prompts');
};
prototype.performSearch = async function () {
const { text, tags } = parseSearchQuery(this.widgets?.find(widget => widget.name === 'search_text')?.value || '');
if (!text && !tags.length) { message(this, '⚠️ Enter search text or tag:name'); return; }
await this._classicQuery('search_prompts', { search_text: text, tags }, '🔍 Searching database...');
};
prototype.loadRecentPrompts = async function () {
await this._classicQuery('get_recent_prompts', { limit: 20 }, '📋 Loading recent prompts...');
};
prototype._classicQuery = async function (method, params, pending) {
message(this, pending);
try {
const response = await this.callNodeMethod(method, params);
if (this._classicScope.signal.aborted) return;
this.displayResults(response.results || []);
this.showNotification(response.results?.length ? `Found ${response.results.length} prompts` : 'No recent prompts found');
} catch (error) {
if (this._classicScope.signal.aborted) return;
message(this, `❌ ${error.message}`, true);
this.showNotification(error.message, 'error');
}
};
prototype.displayResults = function (results) {
if (this._classicScope.signal.aborted) return;
this._searchResults = results || [];
this._selectedPromptIndex = -1;
if (!results?.length) { message(this, '📭 No prompts found'); return; }
this._classicResultsScope?.dispose();
this._classicResultsScope = createScope();
const header = document.createElement('p');
header.className = 'pm-classic-count';
header.textContent = `📚 Found ${results.length} prompt${results.length === 1 ? '' : 's'}`;
this.resultsSection.replaceChildren(header);
results.forEach((result, index) => {
const button = createButton('', 'pm-classic-result');
button.setAttribute('aria-pressed', 'false');
const preview = document.createElement('span');
preview.className = 'pm-classic-preview';
preview.textContent = result.text.length > 80 ? result.text.substring(0, 80) + '...' : result.text;
const metadata = document.createElement('span');
metadata.className = 'pm-classic-metadata';
metadata.textContent = `${result.category || 'No category'} · ${result.rating ? '⭐' + result.rating : 'No rating'} · ${result.created_at ? new Date(result.created_at).toLocaleDateString() : 'Unknown'} · ${Array.isArray(result.tags) ? result.tags.join(', ') : 'No tags'}`;
button.append(preview, metadata);
this._classicResultsScope.listen(button, 'click', () => this.usePrompt(result, index));
this.resultsSection.append(button);
});
this.startAutoHideTimer();
};
prototype.highlightSelectedResult = function (index) {
this._selectedPromptIndex = index;
this.resultsSection.querySelectorAll('.pm-classic-result').forEach((element, i) => element.setAttribute('aria-pressed', String(i === index)));
};
prototype.testApiConnection = async function () {
message(this, '🔧 Testing API connection...');
try {
const response = await fetch(api.fileURL('/prompt_manager/test'), { signal: this._classicScope.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const data = await response.json();
if (!data.success) throw new Error(data.message || 'Test failed');
if (this._classicScope.signal.aborted) return;
message(this, `✅ API Connection Successful! ${data.message || ''} ${data.timestamp || ''}`);
this.showNotification('API connection test passed!', 'success');
} catch (error) {
if (this._classicScope.signal.aborted) return;
message(this, `❌ API Connection Failed: ${error.message}`, true);
this.showNotification(`API test failed: ${error.message}`, 'error');
}
};
prototype.hideResults = function () {
if (this._classicScope.signal.aborted || this.restoreButton) return;
this.resultsSection.style.display = 'none';
this.restoreButton = createButton('🔄 Results auto-hidden · Click to restore', 'pm-classic-restore');
this._classicScope.listen(this.restoreButton, 'click', () => this.showResults());
this.resultsSection.after(this.restoreButton);
};
prototype.showNotification = function (text, type = 'info') {
if (this._classicScope.signal.aborted) return;
const element = document.createElement('div');
element.className = 'ca-tools pm-classic-notification';
element.dataset.caTheme = this._classicContainer.dataset.caTheme;
element.dataset.kind = type;
element.setAttribute('role', type === 'error' ? 'alert' : 'status');
element.textContent = text;
document.body.append(element);
const item = { element, timer: setTimeout(() => { element.remove(); this._classicNotifications.delete(item); }, 3000) };
this._classicNotifications.add(item);
};
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 ComfyAssets
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+23
View File
@@ -0,0 +1,23 @@
# 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`.
+69
View File
@@ -0,0 +1,69 @@
.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; }
}
+138
View File
@@ -0,0 +1,138 @@
export function createScope() {
const controller = new AbortController();
const cleanups = [];
return {
signal: controller.signal,
listen(target, event, callback) {
target.addEventListener(event, callback, { signal: controller.signal });
},
cleanup(callback) { cleanups.push(callback); },
debounce(callback, delay) {
let timer;
cleanups.push(() => clearTimeout(timer));
return (...args) => {
clearTimeout(timer);
if (!controller.signal.aborted) timer = setTimeout(() => callback(...args), delay);
};
},
dispose() {
if (controller.signal.aborted) return;
controller.abort();
cleanups.splice(0).forEach(callback => callback());
},
};
}
export function themeFromColor(color) {
let values;
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
if (hex) {
const expanded = hex.length === 3 ? [...hex].map(value => value + value).join('') : hex;
values = expanded.match(/../g).map(value => parseInt(value, 16));
} else if (/^rgb\(/.test(color)) {
values = color.match(/[\d.]+/g)?.slice(0, 3).map(Number);
}
if (!values || values.length !== 3) return null;
const linear = values.map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
return linear[0] * .2126 + linear[1] * .7152 + linear[2] * .0722 > .35 ? 'light' : 'dark';
}
export function mountTheme(element, settings, scope) {
const stylesheet = new URL('./controls.css', import.meta.url).href;
if (![...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === stylesheet)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = stylesheet;
document.head.append(link);
}
const preference = matchMedia('(prefers-color-scheme: dark)');
const update = () => {
const background = getComputedStyle(document.body).getPropertyValue('--bg-color').trim();
element.dataset.caTheme = themeFromColor(background) || (preference.matches ? 'dark' : 'light');
};
const observer = new MutationObserver(update);
for (const target of [document.documentElement, document.body]) observer.observe(target, { attributes: true, attributeFilter: ['style', 'class', 'data-theme'] });
scope.cleanup(() => observer.disconnect());
scope.listen(preference, 'change', update);
if (settings?.addEventListener) scope.listen(settings, 'Comfy.ColorPalette.change', update);
update();
}
export function createButton(text, className = '') {
const element = document.createElement('button');
element.type = 'button';
element.className = className;
element.textContent = text;
return element;
}
export function createSlider(label, { id, min, max, step, value, unit }, scope, onInput) {
const field = document.createElement('div');
field.className = 'ca-range';
const caption = document.createElement('label');
caption.htmlFor = id;
caption.textContent = label;
const input = document.createElement('input');
Object.assign(input, { type: 'range', id, min, max, step, value });
const output = document.createElement('output');
output.setAttribute('for', id);
output.value = `${value} ${unit}`;
scope.listen(input, 'input', () => {
output.value = `${input.value} ${unit}`;
onInput(Number(input.value));
});
field.append(caption, input, output);
return field;
}
export function createField(label, input, id) {
const field = document.createElement('label');
field.className = 'ca-field';
field.htmlFor = id;
const caption = document.createElement('span');
caption.className = 'ca-field-label';
caption.textContent = label;
input.id = id;
field.append(caption, input);
return field;
}
export function createSwitch(label, className, id) {
const input = document.createElement('input');
input.type = 'checkbox';
input.role = 'switch';
input.checked = true;
input.className = className;
const field = createField(label, input, id);
field.className = 'ca-switch';
field.replaceChildren(input, document.createTextNode(label));
return field;
}
export function createSelect(options, className) {
const select = document.createElement('select');
select.className = className;
for (const [value, text] of options) select.add(new Option(text, value));
return select;
}
export function createSegments(label, options, name, scope, onChange) {
const fieldset = document.createElement('fieldset');
fieldset.className = 'ca-segments';
const legend = document.createElement('legend');
legend.className = 'ca-visually-hidden';
legend.textContent = label;
fieldset.append(legend);
options.forEach(([value, text], index) => {
const input = document.createElement('input');
input.type = 'radio';
input.name = name;
input.value = value;
input.checked = index === 0;
const field = createField(text, input, `${name}-${value}`);
field.replaceChildren(input, field.firstChild);
scope.listen(input, 'change', () => onChange(value));
fieldset.append(field);
});
return fieldset;
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@comfyassets/toolkit",
"version": "0.1.0",
"resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14",
"integrity": "sha512-jvDj3iYJHjRCH7nZimoF4KNLwEVdzKVQIOZrMfoLg9aLnHm98R8YvBUOvnlJ+JNrM8/LnD2m30u3ggSTnR0/sw==",
"files": {
"core.js": {
"source": "ui/core.js",
"sha256": "cd10b700441a607cb1d56051cf0a430c0b36f901c0c351d8f4dae067090d8838"
},
"controls.css": {
"source": "ui/controls.css",
"sha256": "3ae1ca39077ffc3621b86ae7ef77202841fa27061b0e4623f2bb0413b27ecd7b"
},
"LICENSE": {
"source": "LICENSE",
"sha256": "0c865294d0c580dbe365d9ff5ccd655bd2d3716ca3282cc485b5cf789ec8ea6f"
},
"SOURCE.md": {
"source": "SOURCE.md",
"sha256": "46c226483f1d0c01524b1363435ccb3b1e67c2b0f6ed9161f7f087e49e8ed5a9"
}
}
}