Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f8f98edb2 | ||
|
|
3bb96943f8 | ||
|
|
6c7bd0e609 |
@@ -59,6 +59,9 @@ jobs:
|
||||
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}`);
|
||||
import crossPack from './tools/vendor/cross-pack-runtime.json' with { type: 'json' };
|
||||
console.log(`kikoRepository=${crossPack.repository}`);
|
||||
console.log(`kikoRevision=${crossPack.revision}`);
|
||||
JS
|
||||
- name: Check out pinned ComfyUI
|
||||
uses: actions/checkout@v7
|
||||
@@ -67,6 +70,13 @@ jobs:
|
||||
ref: ${{ steps.runtime.outputs.revision }}
|
||||
path: .ci-comfyui
|
||||
persist-credentials: false
|
||||
- name: Check out pinned KikoTools for compatibility
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ steps.runtime.outputs.kikoRepository }}
|
||||
ref: ${{ steps.runtime.outputs.kikoRevision }}
|
||||
path: .ci-kikotools
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ steps.runtime.outputs.pythonVersion }}
|
||||
@@ -87,6 +97,12 @@ jobs:
|
||||
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
|
||||
PM_TEST_PYTHON: python
|
||||
run: npm run --prefix tools/vendor test:comfyui
|
||||
- name: Independent and combined pack compatibility
|
||||
env:
|
||||
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
|
||||
PM_KIKO_ROOT: ${{ github.workspace }}/.ci-kikotools
|
||||
PM_TEST_PYTHON: python
|
||||
run: npm run --prefix tools/vendor test:compat
|
||||
- name: Upload real-host reports and startup logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -148,5 +164,8 @@ jobs:
|
||||
node --input-type=module --check < "$f" || exit 1
|
||||
done
|
||||
|
||||
- name: Install pinned shared test contracts
|
||||
run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
|
||||
- name: Run JS tests
|
||||
run: node --test tests/js/*.test.js
|
||||
|
||||
+28
@@ -155,3 +155,31 @@ node --test tests/js/workspace-*.test.js
|
||||
Use the disposable fixture and managed-host prerequisites above. A maintainer
|
||||
live check needs a ComfyUI restart after adding the Python route, then opening
|
||||
`/prompt_manager/workspace`; the mockup preview server is a separate design tool.
|
||||
|
||||
## Prompt sending and cross-pack compatibility
|
||||
|
||||
`tests/js/prompt-bridge-contracts.test.js` runs the Toolkit's shared protocol
|
||||
contracts against the actual checked-in browser asset. Adapter and lifecycle
|
||||
units cover role isolation, connected inputs, stale previews, callback recovery,
|
||||
serialization normalization and cached-page restoration. Browser tab workflows
|
||||
exercise real BroadcastChannel delivery; `tests/comfyui/prompt-bridge.spec.mjs`
|
||||
checks the real widgets, execution serialization and renderer transitions.
|
||||
|
||||
The existing required real-ComfyUI job also runs `test:compat`: PromptManager
|
||||
alone, pinned KikoTools alone and both together, each starting in Classic and
|
||||
Nodes 2.0. It copies tracked source into disposable hosts, preserves independent
|
||||
configuration and checks edits/reload with both packs present. KikoTools remains
|
||||
on its current embedded foundation; this does not adopt the Toolkit in that pack.
|
||||
|
||||
For local runs, use a clean KikoTools checkout at the revision in
|
||||
`tools/vendor/cross-pack-runtime.json`, plus the managed-host prerequisites above:
|
||||
|
||||
```sh
|
||||
PM_COMFY_ROOT=/path/to/pinned/comfyui PM_KIKO_ROOT=/path/to/pinned/kikotools \
|
||||
npm run --prefix tools/vendor test:compat
|
||||
```
|
||||
|
||||
The compatibility runner reserves loopback ports 8193–8195; `PM_COMPAT_PORT`
|
||||
selects another consecutive unprivileged range. Never run automated tests against
|
||||
the maintainer's running ComfyUI. This JavaScript host extension needs a full
|
||||
browser refresh after deployment; its first slice adds no gallery send controls.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Prompt sending bridge
|
||||
|
||||
Issue #202 establishes the transport and host adapter before the redesigned
|
||||
inspector displays prompt text. Gallery paper-plane controls and normalized
|
||||
metadata follow in a separate slice; existing node layouts are unchanged.
|
||||
|
||||
## Shared transport
|
||||
|
||||
PromptManager consumes the exact Toolkit 0.2.0 artifact recorded in
|
||||
`web/js/shared/toolkit/provenance.json`. The shared module has no ComfyUI imports.
|
||||
Its client discovers explicit host sessions and compatible destinations, requests
|
||||
a preview, and applies that preview using a one-use, client-bound, expiring ticket.
|
||||
Success requires an acknowledgement with verified destination values.
|
||||
|
||||
BroadcastChannel connects independently opened tabs on the same origin and storage
|
||||
partition. Deployment prefixes have separate channel namespaces. Exact origin,
|
||||
protocol and peer checks reject unrelated messages. Same-origin scripts are
|
||||
trusted; this transport is not authentication against other scripts on that origin.
|
||||
Unsupported messaging is reported without breaking existing ComfyUI nodes.
|
||||
|
||||
An apply timeout or abort after posting means the outcome is unknown. It cannot
|
||||
prove the host cancelled the write. The sender must inspect a fresh destination
|
||||
preview before deciding to send again; automatic retry must not overwrite edits.
|
||||
|
||||
## PromptManager adapter
|
||||
|
||||
Classic, Text, Focused and Split accept positive text; +/- accepts either role or
|
||||
both. Plain CLIP encoders are not assigned roles by title or position. An omitted
|
||||
role remains untouched; an explicitly empty string clears only the selected
|
||||
field. Unknown/ambiguous metadata must not become an empty string.
|
||||
|
||||
Previews show original and requested text plus positive prepend/append composition.
|
||||
Connected composition is marked unknown rather than guessing upstream values.
|
||||
Connected destination fields reject writes. Applying rechecks graph, node, widgets,
|
||||
values and connections. Ordinary serialization cache synchronization is accepted
|
||||
only when it preserves live values. Both requested roles are checked before writing.
|
||||
|
||||
Widget values, visible inputs, serialization and composed previews are synchronized.
|
||||
Callback failures restore local fields; arbitrary external callback side effects
|
||||
cannot be undone. Sending never queues execution or loads/replaces a workflow.
|
||||
|
||||
## Lifecycle and validation
|
||||
|
||||
Page hiding closes the host and invalidates tickets; restored pages mount one fresh
|
||||
host. Client disposal clears pending timers/listeners and closes its channel.
|
||||
Shared protocol contracts run against the actual bundled file, alongside adapter
|
||||
units, cross-browser tab workflows and real Classic/Nodes 2.0 scenarios. Pinned
|
||||
PromptManager-only, KikoTools-only and combined disposable hosts verify coexistence
|
||||
and workflow reload without modifying either live installation.
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
const moduleUrl = '/prompt_manager/js/shared/toolkit/prompt-bridge.js';
|
||||
|
||||
test.beforeEach(async ({ page, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
page.bridgeErrors = [];
|
||||
page.on('pageerror', error => page.bridgeErrors.push(error.message));
|
||||
await page.goto('/prompt_manager/workspace#/overview');
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await page.evaluate(() => { window.client?.dispose(); window.host?.dispose(); });
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.bridgeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
async function host(page, namespace = '/', suffix = '') {
|
||||
await page.evaluate(async ({ moduleUrl, namespace, suffix }) => {
|
||||
const { createBridgeHost } = await import(moduleUrl + suffix);
|
||||
window.applied = [];
|
||||
window.host = createBridgeHost({ origin: location.origin, namespace, label: 'Independent host', adapter: {
|
||||
listTargets: () => [{ id: 'node-1', label: 'Pair', roles: ['positive', 'negative'] }],
|
||||
preview: (id, roles) => ({ preview: { target: { id }, fields: roles }, snapshot: structuredClone(roles) }),
|
||||
apply: roles => { window.applied.push(roles); return { targetId: 'node-1', values: roles }; },
|
||||
} });
|
||||
}, { moduleUrl, namespace, suffix });
|
||||
}
|
||||
|
||||
async function client(page, namespace = '/') {
|
||||
await page.evaluate(async ({ moduleUrl, namespace }) => {
|
||||
const { createBridgeClient } = await import(moduleUrl);
|
||||
window.client = createBridgeClient({ origin: location.origin, namespace, timeoutMs: 1000 });
|
||||
}, { moduleUrl, namespace });
|
||||
}
|
||||
|
||||
test('separately opened tabs preview and acknowledge exact roles through the shipped transport', async ({ page, context }) => {
|
||||
const destination = await context.newPage();
|
||||
try {
|
||||
await destination.goto('/prompt_manager/workspace#/overview');
|
||||
await host(destination);
|
||||
await client(page);
|
||||
const result = await page.evaluate(async () => {
|
||||
const [host] = await window.client.discover({ waitMs: 400 });
|
||||
const preview = await window.client.preview(host.hostId, 'node-1', { positive: 'A mountain', negative: '' });
|
||||
window.previewTicket = preview.ticket;
|
||||
return { host, preview };
|
||||
});
|
||||
expect(result.host.label).toBe('Independent host');
|
||||
expect(result.preview.fields).toEqual({ positive: 'A mountain', negative: '' });
|
||||
expect(await destination.evaluate(() => window.applied)).toEqual([]);
|
||||
const ack = await page.evaluate(hostId => window.client.apply(hostId, window.previewTicket), result.host.hostId);
|
||||
expect(ack).toEqual({ targetId: 'node-1', values: { positive: 'A mountain', negative: '' } });
|
||||
expect(await destination.evaluate(() => window.applied)).toEqual([ack.values]);
|
||||
expect(await page.evaluate(async hostId => {
|
||||
try { await window.client.apply(hostId, window.previewTicket); return false; } catch { return true; }
|
||||
}, result.host.hostId)).toBe(true);
|
||||
} finally {
|
||||
await destination.evaluate(() => window.host?.dispose()).catch(() => {});
|
||||
await destination.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('independent bundled hosts coexist and deployment namespaces isolate discovery', async ({ page, context }) => {
|
||||
const second = await context.newPage();
|
||||
try {
|
||||
await second.goto('/prompt_manager/workspace#/overview');
|
||||
await host(page, '/', '?bundle=first');
|
||||
await host(second, '/', '?bundle=second');
|
||||
await client(page);
|
||||
const hosts = await page.evaluate(() => window.client.discover({ waitMs: 400 }));
|
||||
expect(new Set(hosts.map(host => host.hostId)).size).toBe(2);
|
||||
await page.evaluate(() => window.client.dispose());
|
||||
await client(page, '/other-deployment/');
|
||||
expect(await page.evaluate(() => window.client.discover({ waitMs: 400 }))).toEqual([]);
|
||||
} finally {
|
||||
await second.evaluate(() => window.host?.dispose()).catch(() => {});
|
||||
await second.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnected hosts, cancellation and disposal reject pending requests cleanly', async ({ page }) => {
|
||||
await host(page);
|
||||
await client(page);
|
||||
const errors = await page.evaluate(async () => {
|
||||
const [host] = await window.client.discover({ waitMs: 400 });
|
||||
window.host.dispose();
|
||||
const errors = [];
|
||||
try { await window.client.preview(host.hostId, 'node-1', { positive: 'No destination' }); } catch (error) { errors.push(error.message); }
|
||||
const controller = new AbortController();
|
||||
const aborted = window.client.preview(host.hostId, 'node-1', { negative: 'Cancelled' }, { signal: controller.signal });
|
||||
controller.abort();
|
||||
try { await aborted; } catch (error) { errors.push(error.message); }
|
||||
const pending = window.client.preview(host.hostId, 'node-1', { positive: 'Disposed' });
|
||||
window.client.dispose();
|
||||
try { await pending; } catch (error) { errors.push(error.message); }
|
||||
return errors;
|
||||
});
|
||||
expect(errors).toHaveLength(3);
|
||||
expect(errors[0]).toMatch(/timed out/);
|
||||
expect(errors[1]).toMatch(/abort/);
|
||||
expect(errors[2]).toMatch(/disposed/);
|
||||
});
|
||||
@@ -70,7 +70,11 @@ test('paired library filters search both texts, persist on reload and keep edito
|
||||
await page.screenshot({path:testInfo.outputPath('pair-library-preview.png')});
|
||||
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});await expect(root.getByLabel('Filter category',{exact:true})).toHaveValue('Paired host tests');await expect(root.getByLabel('Filter tags',{exact:true})).toHaveValue('pair-host, ');
|
||||
await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);node.setSize([560,1000]);app.canvas.centerOnNode(node);},id);
|
||||
const [p,n]=await Promise.all(['Positive prompt','Negative prompt'].map(name=>root.getByRole('textbox',{name,exact:true}).boundingBox()));expect(n.y).toBeGreaterThan(p.y+p.height-1);expect(Math.abs(p.width-n.width)).toBeLessThan(1);await page.screenshot({path:testInfo.outputPath('pair-narrow.png')});
|
||||
await expect.poll(async()=>{
|
||||
const [p,n]=await Promise.all(['Positive prompt','Negative prompt'].map(name=>root.getByRole('textbox',{name,exact:true}).boundingBox()));
|
||||
return Boolean(p&&n&&p.width>0&&p.height>0&&n.height>0&&n.y>p.y+p.height-1&&Math.abs(p.width-n.width)<1);
|
||||
},{message:'Narrow prompt editors stack with equal widths after canvas resize'}).toBe(true);
|
||||
await page.screenshot({path:testInfo.outputPath('pair-narrow.png')});
|
||||
});
|
||||
|
||||
for(const field of ['category','tags'])test(`manual Save pair guards real connected ${field} and resumes after disconnect`,async({page},testInfo)=>{
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {installHostHooks} from './host-fixture.mjs';
|
||||
|
||||
const TYPES=['PromptManager','PromptManagerText','PromptManagerFocused','PromptManagerSplit','PromptManagerPair'];
|
||||
installHostHooks(test,TYPES);
|
||||
|
||||
async function addNode(page,type,values={}) {
|
||||
return page.evaluate(async({type,values})=>{
|
||||
const {app}=await import('/scripts/app.js');
|
||||
app.graph.clear();
|
||||
const node=LiteGraph.createNode(type);app.graph.add(node);
|
||||
node.setSize([type==='PromptManagerSplit'?1000:700,1050]);
|
||||
app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);
|
||||
for(const [name,value]of Object.entries(values)) {
|
||||
const widget=node.widgets.find(widget=>widget.name===name);
|
||||
widget.value=value;if(widget.inputEl)widget.inputEl.value=value;
|
||||
widget.callback?.(value,app.canvas,node);
|
||||
}
|
||||
return node.id;
|
||||
},{type,values});
|
||||
}
|
||||
|
||||
async function withClient(page,context,run) {
|
||||
const clientPage=await context.newPage();const errors=[];
|
||||
clientPage.on('pageerror',error=>errors.push(error.message));
|
||||
try {
|
||||
await clientPage.goto(new URL('/prompt_manager/workspace#/overview',page.url()).href,{waitUntil:'domcontentloaded'});
|
||||
await clientPage.evaluate(async()=>{
|
||||
const {createBridgeClient}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
window.bridgeClient=createBridgeClient({origin:location.origin,namespace:'/'});
|
||||
});
|
||||
await run(clientPage);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
await clientPage.evaluate(()=>window.bridgeClient?.dispose()).catch(()=>{});
|
||||
await clientPage.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function destination(clientPage,id) {
|
||||
const hosts=await clientPage.evaluate(()=>window.bridgeClient.discover({waitMs:200}));
|
||||
const host=hosts.find(host=>host.targets.some(target=>String(target.id)===String(id)));
|
||||
expect(host,'independently opened workspace discovers the real host').toBeDefined();
|
||||
const target=host.targets.find(target=>String(target.id)===String(id));
|
||||
return {hostId:host.hostId,target};
|
||||
}
|
||||
|
||||
async function preview(clientPage,hostId,target,payload) {
|
||||
return clientPage.evaluate(async({hostId,target,payload})=>{
|
||||
window.bridgePreview=await window.bridgeClient.preview(hostId,target.id,payload);
|
||||
return window.bridgePreview;
|
||||
},{hostId,target,payload});
|
||||
}
|
||||
|
||||
async function apply(clientPage,hostId) {
|
||||
return clientPage.evaluate(hostId=>window.bridgeClient.apply(hostId,window.bridgePreview.ticket),hostId);
|
||||
}
|
||||
|
||||
async function rejection(clientPage,action) {
|
||||
return clientPage.evaluate(async action=>{
|
||||
try {
|
||||
if(action.kind==='preview')await window.bridgeClient.preview(action.hostId,action.targetId,action.payload);
|
||||
else await window.bridgeClient.apply(action.hostId,window.bridgePreview.ticket);
|
||||
return null;
|
||||
} catch(error) {return String(error.message);}
|
||||
},action);
|
||||
}
|
||||
|
||||
async function snapshot(page,id) {
|
||||
return page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);
|
||||
const serialized=node.serialize();
|
||||
return {
|
||||
widgets:Object.fromEntries(node.widgets.map(widget=>[widget.name,widget.value])),
|
||||
inputs:Object.fromEntries(node.widgets.filter(widget=>widget.inputEl).map(widget=>[widget.name,widget.inputEl.value])),
|
||||
serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),
|
||||
prompt:(await app.graphToPrompt()).output[String(id)].inputs,
|
||||
};
|
||||
},id);
|
||||
}
|
||||
|
||||
async function connect(page,id,field) {
|
||||
return page.evaluate(async({id,field})=>{
|
||||
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
|
||||
const node=app.graph.getNodeById(id);const widget=node.widgets.find(widget=>widget.name===field);
|
||||
if(!node.inputs.some(input=>input.name===field))convertToInput(node,widget);
|
||||
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
|
||||
source.widgets.find(widget=>widget.name==='value').value=`Upstream ${field}`;
|
||||
source.connect(0,node,node.inputs.findIndex(input=>input.name===field));
|
||||
return [String(source.id),0];
|
||||
},{id,field});
|
||||
}
|
||||
|
||||
test('independent workspace discovers all Prompt Manager destinations and writes real widgets without executing or loading workflows',async({page,context})=>{
|
||||
await withClient(page,context,async clientPage=>{
|
||||
for(const type of TYPES) {
|
||||
const id=await addNode(page,type,{text:'Before bridge'});
|
||||
const draft=`Visible draft ${type}`;
|
||||
await page.locator('textarea:visible').first().fill(draft);
|
||||
const {hostId,target}=await destination(clientPage,id);
|
||||
expect(target.type).toBe(type);expect(target.label).toBeTruthy();expect(target.roles).toContain('positive');
|
||||
expect(target.roles.includes('negative')).toBe(type==='PromptManagerPair');
|
||||
await page.evaluate(async()=>{
|
||||
const {app}=await import('/scripts/app.js');window.bridgeSideEffects={queue:0,load:0};
|
||||
for(const [method,key]of [['queuePrompt','queue'],['loadGraphData','load']]) {
|
||||
const original=app[method];app[method]=function(...args){window.bridgeSideEffects[key]++;return original.apply(this,args);};
|
||||
}
|
||||
});
|
||||
const text=`Bridge sent to ${type}`;const proposed=await preview(clientPage,hostId,target,{positive:text});
|
||||
expect(proposed.ticket).toBeTruthy();expect(proposed.fields).toContainEqual({role:'positive',before:draft,after:text});
|
||||
expect((await snapshot(page,id)).widgets.text).toBe(draft);
|
||||
expect(await apply(clientPage,hostId)).toMatchObject({targetId:target.id,values:{positive:text}});
|
||||
await expect.poll(()=>page.locator('textarea:visible').evaluateAll(inputs=>inputs.map(input=>input.value))).toContain(text);
|
||||
const state=await snapshot(page,id);
|
||||
for(const name of ['widgets','inputs','serialized','prompt'])expect(state[name].text).toBe(text);
|
||||
expect(await page.evaluate(()=>window.bridgeSideEffects)).toEqual({queue:0,load:0});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('pair sends preserve omitted negatives, accept an empty negative and synchronize visible editors and composition',async({page,context})=>{
|
||||
const id=await addNode(page,'PromptManagerPair',{text:'Old positive',negative_text:'Keep negative',prepend_text:'LoRA trigger',append_text:'fine detail'});
|
||||
await withClient(page,context,async clientPage=>{
|
||||
const {hostId,target}=await destination(clientPage,id);
|
||||
const proposed=await preview(clientPage,hostId,target,{positive:'New positive'});
|
||||
expect(proposed.composition).toMatchObject({prepend:'LoRA trigger',append:'fine detail',prependConnected:false,appendConnected:false,before:'LoRA trigger Old positive fine detail',after:'LoRA trigger New positive fine detail'});
|
||||
await apply(clientPage,hostId);
|
||||
expect((await snapshot(page,id)).widgets).toMatchObject({text:'New positive',negative_text:'Keep negative',prepend_text:'LoRA trigger',append_text:'fine detail'});
|
||||
const root=page.locator('.pm-pair');
|
||||
await expect(root.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('New positive');
|
||||
await expect(root.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue('Keep negative');
|
||||
await root.getByText('Composed text preview',{exact:true}).click();
|
||||
await expect(root.locator('[data-composed-preview]')).toHaveText('LoRA trigger New positive fine detail');
|
||||
await preview(clientPage,hostId,target,{positive:'Paired positive',negative:''});
|
||||
await apply(clientPage,hostId);
|
||||
await expect(root.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue('');
|
||||
const state=await snapshot(page,id);
|
||||
for(const name of ['widgets','inputs','serialized','prompt'])expect(state[name]).toMatchObject({text:'Paired positive',negative_text:''});
|
||||
});
|
||||
});
|
||||
|
||||
test('connected requested fields reject atomically while connected composition remains linked',async({page,context})=>{
|
||||
const id=await addNode(page,'PromptManagerPair',{text:'Keep positive',negative_text:'Keep negative',prepend_text:'Local prepend'});
|
||||
const negativeLink=await connect(page,id,'negative_text');const prependLink=await connect(page,id,'prepend_text');
|
||||
await withClient(page,context,async clientPage=>{
|
||||
const {hostId,target}=await destination(clientPage,id);
|
||||
expect(await rejection(clientPage,{kind:'preview',hostId,targetId:target.id,payload:{positive:'Must not apply',negative:'Blocked'}})).toMatch(/connected/i);
|
||||
expect((await snapshot(page,id)).widgets).toMatchObject({text:'Keep positive',negative_text:'Keep negative'});
|
||||
const proposed=await preview(clientPage,hostId,target,{positive:'Allowed positive'});
|
||||
expect(proposed.composition.prependConnected).toBe(true);
|
||||
await apply(clientPage,hostId);
|
||||
expect((await snapshot(page,id)).prompt).toMatchObject({text:'Allowed positive',negative_text:negativeLink,prepend_text:prependLink});
|
||||
const positiveLink=await connect(page,id,'text');
|
||||
expect(await rejection(clientPage,{kind:'preview',hostId,targetId:target.id,payload:{positive:'Blocked positive'}})).toMatch(/connected/i);
|
||||
expect((await snapshot(page,id)).prompt.text).toEqual(positiveLink);
|
||||
});
|
||||
});
|
||||
|
||||
test('user edits and workflow replacement invalidate acknowledged previews without overwriting changes',async({page,context})=>{
|
||||
const id=await addNode(page,'PromptManagerFocused',{text:'Original draft'});
|
||||
await withClient(page,context,async clientPage=>{
|
||||
let {hostId,target}=await destination(clientPage,id);
|
||||
await preview(clientPage,hostId,target,{positive:'Pending change'});
|
||||
await page.locator('.pm-focused').getByRole('textbox',{name:'Main prompt',exact:true}).fill('User edited draft');
|
||||
expect(await rejection(clientPage,{kind:'apply',hostId})).toMatch(/changed|stale|workflow/i);
|
||||
expect((await snapshot(page,id)).widgets.text).toBe('User edited draft');
|
||||
({hostId,target}=await destination(clientPage,id));
|
||||
await preview(clientPage,hostId,target,{positive:'Pending after replacement'});
|
||||
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});
|
||||
expect(await rejection(clientPage,{kind:'apply',hostId})).toMatch(/changed|stale|workflow/i);
|
||||
expect((await snapshot(page,id)).widgets.text).toBe('User edited draft');
|
||||
});
|
||||
});
|
||||
|
||||
test('bridge remains live through both renderer switches and workflow reload with synchronized Focused controls',async({page,context},testInfo)=>{
|
||||
const id=await addNode(page,'PromptManagerFocused',{text:'Initial',prepend_text:'trigger',append_text:'detail'});
|
||||
await withClient(page,context,async clientPage=>{
|
||||
for(const renderer of [!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
|
||||
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
const {hostId,target}=await destination(clientPage,id);const text=`Renderer ${renderer}`;
|
||||
await preview(clientPage,hostId,target,{positive:text});await apply(clientPage,hostId);
|
||||
await expect(page.locator('.pm-focused').getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(text);
|
||||
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});
|
||||
const state=await snapshot(page,id);
|
||||
for(const name of ['widgets','inputs','serialized','prompt'])expect(state[name].text).toBe(text);
|
||||
}
|
||||
const {hostId,target}=await destination(clientPage,id);
|
||||
await preview(clientPage,hostId,target,{positive:'After reload'});await apply(clientPage,hostId);
|
||||
await expect(page.locator('.pm-focused').getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('After reload');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import runtime from '../../tools/vendor/comfyui-runtime.json' with {type:'json'};
|
||||
|
||||
const bridgeSource=await readFile(new URL('../../web/js/shared/toolkit/prompt-bridge.js',import.meta.url),'utf8');
|
||||
|
||||
test('independent and combined packs preserve real controls, bridge isolation and workflow state',async({page,context,request},testInfo)=>{
|
||||
const {mode,initialRenderer}=testInfo.project.metadata;
|
||||
const hasPM=mode!=='kiko',hasKiko=mode!=='promptmanager';const errors=[];
|
||||
page.on('pageerror',error=>errors.push(error.message));
|
||||
const response=await request.get('/system_stats');expect(response.ok()).toBe(true);
|
||||
expect((await response.json()).system.comfyui_version).toBe(runtime.serverVersion);
|
||||
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':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()=>Boolean((await import('/scripts/app.js')).app.graph));
|
||||
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(initialRenderer);
|
||||
const definitions=await(await request.get('/object_info')).json();
|
||||
expect(Boolean(definitions.PromptManagerFocused)).toBe(hasPM);expect(Boolean(definitions.TextInput)).toBe(hasKiko);
|
||||
await page.waitForFunction(({hasPM,hasKiko})=>
|
||||
(!hasPM||Boolean(LiteGraph.registered_node_types.PromptManagerFocused))&&
|
||||
(!hasKiko||Boolean(LiteGraph.registered_node_types.TextInput)),{hasPM,hasKiko});
|
||||
const ids=await page.evaluate(async({hasPM,hasKiko})=>{
|
||||
const {app}=await import('/scripts/app.js');app.graph.clear();const ids={};
|
||||
for(const [key,type]of [...(hasPM?[['pm','PromptManagerFocused']]:[]),...(hasKiko?[['kiko','TextInput']]:[])]) {
|
||||
const node=LiteGraph.createNode(type);app.graph.add(node);node.setSize([680,1000]);
|
||||
node.pos=[key==='kiko'?800:0,0];ids[key]=node.id;
|
||||
}
|
||||
app.canvas.ds.scale=.8;app.canvas.centerOnNode(app.graph.getNodeById(ids.kiko??ids.pm));return ids;
|
||||
},{hasPM,hasKiko});
|
||||
const clientPage=await context.newPage();clientPage.on('pageerror',error=>errors.push(error.message));
|
||||
try {
|
||||
await clientPage.goto(new URL(hasPM?'/prompt_manager/workspace#/overview':'/system_stats',page.url()).href,{waitUntil:'domcontentloaded'});
|
||||
await clientPage.evaluate(async source=>{
|
||||
const url=URL.createObjectURL(new Blob([source],{type:'text/javascript'}));
|
||||
try {const {createBridgeClient}=await import(url);window.packClient=createBridgeClient({origin:location.origin,namespace:'/'});}
|
||||
finally {URL.revokeObjectURL(url);}
|
||||
},bridgeSource);
|
||||
for(const [index,renderer]of [initialRenderer,!initialRenderer,initialRenderer].entries()) {
|
||||
await page.evaluate(async({renderer,ids})=>{
|
||||
const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);
|
||||
app.canvas.centerOnNode(app.graph.getNodeById(ids.kiko??ids.pm));
|
||||
},{renderer,ids});
|
||||
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
const kikoText=`Kiko independent edit ${index}`;
|
||||
if(hasKiko) {
|
||||
const control=page.locator('textarea:visible:not(.pm-focused textarea)');
|
||||
await expect(control).toHaveCount(1);await expect(control).toBeVisible();await control.fill(kikoText);
|
||||
expect(await control.evaluate(async(_element,id)=>{
|
||||
const {app}=await import('/scripts/app.js');return(await app.graphToPrompt()).output[String(id)].inputs.text;
|
||||
},ids.kiko)).toBe(kikoText);
|
||||
}
|
||||
const hosts=await clientPage.evaluate(()=>window.packClient.discover({waitMs:200}));
|
||||
expect(hosts).toHaveLength(hasPM?1:0);
|
||||
if(hasPM) {
|
||||
expect(hosts[0].targets).toHaveLength(1);
|
||||
const target=hosts[0].targets[0];expect(String(target.id)).toBe(String(ids.pm));
|
||||
expect(target.type).toBe('PromptManagerFocused');
|
||||
const text=`PM isolated send ${index}`;
|
||||
const result=await clientPage.evaluate(async({hostId,target,text})=>{
|
||||
const preview=await window.packClient.preview(hostId,target.id,{positive:text});
|
||||
return window.packClient.apply(hostId,preview.ticket);
|
||||
},{hostId:hosts[0].hostId,target,text});
|
||||
expect(result.values.positive).toBe(text);
|
||||
const state=await page.evaluate(async ids=>{
|
||||
const {app}=await import('/scripts/app.js');const prompt=(await app.graphToPrompt()).output;
|
||||
return {pm:prompt[String(ids.pm)].inputs.text,kiko:ids.kiko?prompt[String(ids.kiko)].inputs.text:null};
|
||||
},ids);
|
||||
expect(state.pm).toBe(text);if(hasKiko)expect(state.kiko).toBe(kikoText);
|
||||
}
|
||||
await page.evaluate(async ids=>{
|
||||
const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());
|
||||
app.canvas.centerOnNode(app.graph.getNodeById(ids.kiko??ids.pm));
|
||||
},ids);
|
||||
if(hasKiko)expect(await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');return(await app.graphToPrompt()).output[String(id)].inputs.text;
|
||||
},ids.kiko)).toBe(kikoText);
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
await clientPage.evaluate(()=>window.packClient?.dispose()).catch(()=>{});await clientPage.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/prompt-bridge-adapter.js');
|
||||
function fixture(type='PromptManagerPair') {
|
||||
const widgets = ['text','negative_text','prepend_text','append_text','category','tags'].map((name,index) => ({name,value:['old','bad',' start ',' end ','cat','tag'][index],inputEl:{value:['old','bad',' start ',' end ','cat','tag'][index]}}));
|
||||
const node = {id:7,type,title:'My prompts',widgets,widgets_values:widgets.map(w=>w.value),inputs:[],_focusedView:{render(){node.rendered=(node.rendered||0)+1;}},setDirtyCanvas(){}};
|
||||
const app = {graph:{_nodes:[node],_version:1,setDirtyCanvas(){}},canvas:{}};
|
||||
return {app,node,widget:name=>node.widgets.find(w=>w.name===name)};
|
||||
}
|
||||
test('lists only explicit PromptManager destinations and roles for all five schemas',async()=>{
|
||||
const {createPromptAdapter}=await source;
|
||||
const types=['PromptManager','PromptManagerText','PromptManagerFocused','PromptManagerSplit','PromptManagerPair'];
|
||||
const nodes=types.map((type,id)=>({...fixture(type).node,id})); nodes.push({...fixture().node,id:8,type:'CLIPTextEncode'});
|
||||
const targets=createPromptAdapter({graph:{_nodes:nodes}}).listTargets();
|
||||
assert.deepEqual(targets.map(t=>t.type),types);
|
||||
assert.deepEqual(targets.map(t=>t.roles),types.map(type=>type==='PromptManagerPair'?['positive','negative']:['positive']));
|
||||
assert.equal(targets[0].id,'0'); assert.equal(targets[0].label,'My prompts');
|
||||
assert.deepEqual(createPromptAdapter({}).listTargets(),[]);
|
||||
});
|
||||
test('previews exact composition, updates requested visible/serialized values, preserves other fields',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); const adapter=createPromptAdapter(app);
|
||||
const {preview,snapshot}=adapter.preview('7',{positive:'new'});
|
||||
assert.deepEqual(preview.fields,[{role:'positive',before:'old',after:'new'}]);
|
||||
assert.deepEqual(preview.composition,{prepend:' start ',append:' end ',prependConnected:false,appendConnected:false,before:'start old end',after:'start new end'});
|
||||
let callback; widget('text').callback=(value,canvas,target)=>{callback=[value,canvas,target];};
|
||||
assert.deepEqual(adapter.apply(snapshot),{targetId:'7',values:{positive:'new'}});
|
||||
assert.equal(widget('text').inputEl.value,'new'); assert.equal(node.widgets_values[0],'new'); assert.equal(node.rendered,1);
|
||||
assert.deepEqual(callback,['new',app.canvas,node]);
|
||||
for(const [name,value] of [['negative_text','bad'],['prepend_text',' start '],['append_text',' end '],['category','cat'],['tags','tag']]) assert.equal(widget(name).value,value);
|
||||
assert.throws(()=>adapter.apply(snapshot),/snapshot|preview/i);
|
||||
});
|
||||
test('omitted positive stays intact, empty negative clears explicitly',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,widget}=fixture(); const adapter=createPromptAdapter(app);
|
||||
adapter.apply(adapter.preview('7',{negative:''}).snapshot);
|
||||
assert.equal(widget('text').value,'old'); assert.equal(widget('negative_text').value,'');
|
||||
});
|
||||
test('rejects invalid roles, unsupported targets, missing/connected fields before mutation',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node,widget}=fixture('PromptManagerText'); const adapter=createPromptAdapter(app);
|
||||
for(const roles of [null,{},[],{positive:2},{positive:'x',other:'y'},{negative:'x'},Object.create({positive:'x'})]) assert.throws(()=>adapter.preview('7',roles));
|
||||
assert.throws(()=>adapter.preview('99',{positive:'x'}));
|
||||
node.inputs=[{name:'text',link:0}]; assert.throws(()=>adapter.preview('7',{positive:'x'}),/connect/i);
|
||||
node.inputs=[]; node.widgets=node.widgets.filter(w=>w.name!=='text'); assert.throws(()=>adapter.preview('7',{positive:'x'}),/widget/i);
|
||||
assert.equal(widget('negative_text').value,'bad');
|
||||
});
|
||||
test('connected composition is unknown and never overwritten',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node}=fixture(); node.inputs=[{name:'prepend_text',link:0}];
|
||||
const adapter=createPromptAdapter(app), result=adapter.preview('7',{positive:'x'});
|
||||
assert.equal(result.preview.composition.before,null); assert.equal(result.preview.composition.after,null); assert.equal(result.preview.composition.prependConnected,true);
|
||||
adapter.apply(result.snapshot); assert.equal(node.inputs[0].link,0);
|
||||
});
|
||||
test('rejects stale workflow, node, widget, values, serialization, visible values, and connections',async()=>{
|
||||
const {createPromptAdapter}=await source;
|
||||
const mutations=[f=>{f.app.graph={...f.app.graph};},f=>{f.app.graph._version++;},f=>{f.app.graph._nodes=[{...f.node}];},f=>{f.node.type='PromptManagerText';},f=>{f.node.widgets[0]={...f.node.widgets[0]};},f=>{f.widget('negative_text').value='changed';},f=>{f.widget('text').inputEl.value='typing';},f=>{f.node.widgets_values[0]='changed';},f=>{f.node.inputs.push({name:'text',link:2});},f=>{f.node.inputs.push({name:'append_text',link:2});}];
|
||||
for(const mutate of mutations){const f=fixture(),adapter=createPromptAdapter(f.app),{snapshot}=adapter.preview('7',{positive:'new',negative:'newbad'}); mutate(f); assert.throws(()=>adapter.apply(snapshot),/changed|stale/i); assert.notEqual(f.widget('text').value,'new');}
|
||||
});
|
||||
test('validates both roles before writing and rejects forged snapshots',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new',negative:'newbad'});
|
||||
node.inputs.push({name:'negative_text',link:3}); assert.throws(()=>adapter.apply(snapshot)); assert.equal(widget('text').value,'old'); assert.throws(()=>adapter.apply({}));
|
||||
});
|
||||
test('callback failure or transformed value rolls all requested values back without an acknowledgement',async()=>{
|
||||
const {createPromptAdapter}=await source;
|
||||
for(const fail of ['throw','transform','visible','serialization']) {
|
||||
const {app,node,widget}=fixture(),adapter=createPromptAdapter(app);
|
||||
widget('negative_text').callback=()=>{if(fail==='throw') throw new Error('callback failed'); if(fail==='transform') widget('text').value='altered'; if(fail==='visible') widget('text').inputEl.value='altered'; if(fail==='serialization') node.widgets_values[0]='altered';};
|
||||
assert.throws(()=>adapter.apply(adapter.preview('7',{positive:'new',negative:'newbad'}).snapshot),/failed|changed|match/i);
|
||||
assert.equal(widget('text').value,'old'); assert.equal(widget('text').inputEl.value,'old'); assert.equal(node.widgets_values[0],'old'); assert.equal(widget('negative_text').value,'bad');
|
||||
}
|
||||
});
|
||||
test('snapshots isolate caller mutations and require the originating adapter',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,widget}=fixture(),adapter=createPromptAdapter(app),roles={positive:'new'};
|
||||
const result=adapter.preview('7',roles); roles.positive='changed'; result.preview.fields[0].after='changed';
|
||||
assert.throws(()=>createPromptAdapter(app).apply(result.snapshot)); adapter.apply(result.snapshot); assert.equal(widget('text').value,'new');
|
||||
});
|
||||
test('applies positive text for every supported schema without a serialization array',async()=>{
|
||||
const {createPromptAdapter}=await source;
|
||||
for(const type of ['PromptManager','PromptManagerText','PromptManagerFocused','PromptManagerSplit','PromptManagerPair']) {
|
||||
const {app,node,widget}=fixture(type); delete node.widgets_values; delete node._focusedView; delete node.setDirtyCanvas; delete app.graph.setDirtyCanvas;
|
||||
const adapter=createPromptAdapter(app); adapter.apply(adapter.preview('7',{positive:''}).snapshot);
|
||||
assert.equal(widget('text').value,''); assert.equal(node.widgets_values[0],'');
|
||||
}
|
||||
});
|
||||
test('matches Python whitespace composition and validates the combined one MiB UTF-8 role limit',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); node.title=''; widget('prepend_text').value=' '; widget('append_text').value='';
|
||||
const adapter=createPromptAdapter(app);
|
||||
assert.equal(adapter.preview('7',{positive:' x '}).preview.composition.after,' x ');
|
||||
assert.equal(adapter.preview('7',{positive:''}).preview.composition.after,'');
|
||||
assert.equal(adapter.preview('7',{positive:'x'.repeat(1_048_576)}).preview.fields[0].after.length,1_048_576);
|
||||
assert.throws(()=>adapter.preview('7',{positive:'x'.repeat(1_048_577)}));
|
||||
assert.throws(()=>adapter.preview('7',{positive:'x'.repeat(524_288),negative:'y'.repeat(524_289)}));
|
||||
assert.throws(()=>adapter.preview('7',{positive:'é'.repeat(524_289)}));
|
||||
assert.equal(adapter.preview('7',{positive:'é'.repeat(524_288)}).preview.fields[0].after.length,524_288);
|
||||
assert.throws(()=>adapter.preview('7',{get positive(){throw new Error('Getter must not run');}}),/strings/i);
|
||||
assert.throws(()=>adapter.preview('7',{positive:'x',[Symbol('unknown')]:'y'}));
|
||||
assert.equal(adapter.preview('7',Object.assign(Object.create(null),{positive:'x'})).preview.fields[0].after,'x');
|
||||
assert.equal(adapter.listTargets()[0].label,'PromptManagerPair');
|
||||
});
|
||||
test('callback changes to graph, connections, widgets or protected prompt fields fail and restore fields',async()=>{
|
||||
const {createPromptAdapter}=await source;
|
||||
const mutations=[f=>{f.app.graph={...f.app.graph};},f=>{f.node.inputs=[{name:'text',link:4}];},f=>{f.node.widgets[0]={...f.node.widgets[0]};},f=>{f.widget('negative_text').value='unexpected';},f=>{f.widget('prepend_text').value='unexpected';},f=>{f.widget('category').value='unexpected';}];
|
||||
for(const mutate of mutations){const f=fixture(),adapter=createPromptAdapter(f.app); f.widget('text').callback=()=>mutate(f); assert.throws(()=>adapter.apply(adapter.preview('7',{positive:'new'}).snapshot),/failed/i); assert.equal(f.widget('negative_text').value,'bad'); assert.equal(f.widget('prepend_text').value,' start '); assert.equal(f.widget('category').value,'cat');}
|
||||
});
|
||||
test('minimal classic node without optional widgets remains a valid destination',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node}=fixture('PromptManager'); node.widgets=node.widgets.slice(0,1); node.widgets_values=['old']; delete node.widgets[0].inputEl;
|
||||
const adapter=createPromptAdapter(app), result=adapter.preview('7',{positive:'new'});
|
||||
assert.equal(result.preview.composition.before,'old'); assert.equal(result.preview.composition.after,'new');
|
||||
assert.deepEqual(adapter.apply(result.snapshot).values,{positive:'new'});
|
||||
});
|
||||
test('callback removal of serialization or replacement of visible controls cannot acknowledge success',async()=>{
|
||||
const {createPromptAdapter}=await source;
|
||||
for(const mutate of [f=>{delete f.node.widgets_values;},f=>{f.widget('text').inputEl={value:'new'};},f=>{f.node.id=8;},f=>{f.node.widgets.reverse();},f=>{delete f.node.widgets;},f=>{f.node.widgets=[];}]) {
|
||||
const f=fixture(),adapter=createPromptAdapter(f.app); const originalInput=f.widget('text').inputEl;
|
||||
f.widget('text').callback=()=>mutate(f);
|
||||
assert.throws(()=>adapter.apply(adapter.preview('7',{positive:'new'}).snapshot),/failed/i);
|
||||
assert.equal(f.widget('text').value,'old'); assert.equal(originalInput.value,'old'); assert.equal(f.node.widgets_values[0],'old');
|
||||
}
|
||||
});
|
||||
test('benign host serialization may establish or normalize equivalent serialized values after preview',async()=>{
|
||||
const {createPromptAdapter}=await source;
|
||||
for (const initial of ['absent','sparse','old']) {
|
||||
const {app,node,widget}=fixture();
|
||||
if(initial==='absent') delete node.widgets_values;
|
||||
if(initial==='sparse') node.widgets_values=[];
|
||||
if(initial==='old') node.widgets_values[0]='earlier serialized draft';
|
||||
const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new',negative:'newbad'});
|
||||
// ComfyUI serialize() materializes the live widget values without editing them.
|
||||
node.widgets_values=node.widgets.map(item=>item.inputEl ? item.inputEl.value : item.value);
|
||||
assert.deepEqual(adapter.apply(snapshot).values,{positive:'new',negative:'newbad'});
|
||||
assert.equal(widget('text').value,'new');
|
||||
}
|
||||
});
|
||||
test('serialization normalization still rejects a different execution value',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); delete node.widgets_values;
|
||||
const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new'});
|
||||
node.widgets_values=node.widgets.map(item=>item.value); node.widgets_values[0]='unapproved';
|
||||
assert.throws(()=>adapter.apply(snapshot),/changed/i); assert.equal(widget('text').value,'old');
|
||||
});
|
||||
test('callback failure preserves serialization normalized between preview and apply',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); node.widgets_values[0]='earlier draft';
|
||||
const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new'});
|
||||
node.widgets_values=node.widgets.map(item=>item.value); widget('text').callback=()=>{throw new Error('callback failed');};
|
||||
assert.throws(()=>adapter.apply(snapshot),/failed/); assert.equal(node.widgets_values[0],'old'); assert.equal(widget('text').value,'old');
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import * as bridge from '../../web/js/shared/toolkit/prompt-bridge.js';
|
||||
import { registerPromptBridgeContracts } from '../../tools/vendor/node_modules/@comfyassets/toolkit/testing/prompt-bridge-contracts.mjs';
|
||||
|
||||
registerPromptBridgeContracts(bridge, () => import('../../web/js/shared/toolkit/prompt-bridge.js?independent-consumer=0.1'));
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installPromptBridge } from '../../web/js/prompt-bridge-host.js';
|
||||
|
||||
function fixture() {
|
||||
const runtime = new EventTarget();
|
||||
runtime.location = { origin: 'https://comfy.example', href: 'https://comfy.example/team/' };
|
||||
runtime.document = { title: 'My workflow' };
|
||||
const warnings = []; runtime.console = { warn: (...args) => warnings.push(args) };
|
||||
const hosts = [], adapter = {};
|
||||
const options = { runtime, baseUrl: '/team/prompt_manager/', adapterFactory: () => adapter,
|
||||
hostFactory: config => { const host = { config, disposed: 0, dispose() { this.disposed++; } }; hosts.push(host); return host; } };
|
||||
return { runtime, options, hosts, adapter, warnings };
|
||||
}
|
||||
|
||||
test('host uses the exact deployment prefix and explicit pack label', () => {
|
||||
const f = fixture(), app = {};
|
||||
const controller = installPromptBridge(app, f.options);
|
||||
assert.equal(f.hosts.length, 1);
|
||||
assert.equal(f.hosts[0].config.namespace, '/team/');
|
||||
assert.equal(f.hosts[0].config.origin, 'https://comfy.example');
|
||||
assert.equal(f.hosts[0].config.label, 'PromptManager — My workflow');
|
||||
assert.equal(f.hosts[0].config.adapter, f.adapter);
|
||||
assert.equal(installPromptBridge(app, f.options), controller);
|
||||
controller.dispose(); controller.dispose();
|
||||
assert.equal(f.hosts[0].disposed, 1);
|
||||
});
|
||||
|
||||
test('page hide disposes and cached restoration mounts exactly one fresh host', () => {
|
||||
const f = fixture(), controller = installPromptBridge({}, f.options);
|
||||
f.runtime.dispatchEvent(new Event('pagehide'));
|
||||
assert.equal(f.hosts[0].disposed, 1);
|
||||
f.runtime.dispatchEvent(new Event('pageshow'));
|
||||
f.runtime.dispatchEvent(new Event('pageshow'));
|
||||
assert.equal(f.hosts.length, 2);
|
||||
controller.dispose();
|
||||
f.runtime.dispatchEvent(new Event('pageshow'));
|
||||
assert.equal(f.hosts.length, 2);
|
||||
assert.equal(f.hosts[1].disposed, 1);
|
||||
});
|
||||
|
||||
test('a disposed installation can be installed again', () => {
|
||||
const f = fixture(), app = {};
|
||||
installPromptBridge(app, f.options).dispose();
|
||||
const next = installPromptBridge(app, f.options);
|
||||
assert.equal(f.hosts.length, 2);
|
||||
next.dispose();
|
||||
});
|
||||
|
||||
test('unavailable messaging leaves the existing host usable and can recover after restoration', () => {
|
||||
const f = fixture(); let unavailable = true;
|
||||
const factory = f.options.hostFactory;
|
||||
f.options.hostFactory = config => { if (unavailable) throw new Error('BroadcastChannel unavailable'); return factory(config); };
|
||||
const controller = installPromptBridge({}, f.options);
|
||||
assert.equal(f.warnings.length, 1);
|
||||
unavailable = false;
|
||||
f.runtime.dispatchEvent(new Event('pageshow'));
|
||||
assert.equal(f.hosts.length, 1);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('deployment namespace rejects external origins and unexpected paths', () => {
|
||||
for (const baseUrl of ['https://comfy.example.attacker.test/prompt_manager/', '/wrong/', 'data:text/plain,hello']) {
|
||||
const f = fixture();
|
||||
assert.throws(() => installPromptBridge({}, { ...f.options, baseUrl }), /deployment|origin|path/i);
|
||||
assert.equal(f.hosts.length, 0);
|
||||
}
|
||||
});
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { compatibilityPorts, validateKikoCheckout, launchPackHost } from '../../tools/vendor/pack-host.mjs';
|
||||
|
||||
test('cross-pack ports reserve three distinct unprivileged addresses', () => {
|
||||
assert.deepEqual(compatibilityPorts('8193'), { promptmanager: 8193, kiko: 8194, both: 8195 });
|
||||
assert.deepEqual(compatibilityPorts(65533), { promptmanager: 65533, kiko: 65534, both: 65535 });
|
||||
for (const value of [0, 1023, 65534, 'bad', 8193.5]) assert.throws(() => compatibilityPorts(value), /ports/);
|
||||
});
|
||||
|
||||
test('cross-pack launcher refuses missing and unreviewed sibling sources before starting a host', async t => {
|
||||
await assert.rejects(validateKikoCheckout(), /PM_KIKO_ROOT/);
|
||||
await assert.rejects(launchPackHost({ mode: 'arbitrary' }), /Select/);
|
||||
const root = await mkdtemp(join(tmpdir(), 'pm-kiko-pin-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
await writeFile(join(root, '__init__.py'), '');
|
||||
execFileSync('git', ['init', '--quiet', root]);
|
||||
execFileSync('git', ['-C', root, 'add', '__init__.py']);
|
||||
execFileSync('git', ['-C', root, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', 'commit', '--quiet', '-m', 'fixture']);
|
||||
await assert.rejects(validateKikoCheckout(root), /revision must be/);
|
||||
});
|
||||
Vendored
+2
-2
@@ -15,14 +15,14 @@ function fixture(t) {
|
||||
writeFileSync(join(tooling, 'package.json'), JSON.stringify({ devDependencies: { '@comfyassets/toolkit': resolved } }));
|
||||
writeFileSync(join(tooling, 'package-lock.json'), JSON.stringify({ packages: { 'node_modules/@comfyassets/toolkit': { version: '0.1.0', resolved, integrity: 'sha512-fixture' } } }));
|
||||
writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: '@comfyassets/toolkit', version: '0.1.0', license: 'MIT' }));
|
||||
for (const file of ['ui/core.js', 'ui/suggestions.js', 'ui/controls.css', 'LICENSE', 'SOURCE.md']) writeFileSync(join(dependency, file), file);
|
||||
for (const file of ['ui/core.js', 'ui/suggestions.js', 'ui/prompt-bridge.js', 'ui/controls.css', 'LICENSE', 'SOURCE.md']) writeFileSync(join(dependency, file), file);
|
||||
return { root, tooling, dependency, destination: join(root, 'web/js/shared/toolkit') };
|
||||
}
|
||||
|
||||
test('toolkit browser assets ship unchanged with deterministic locked provenance', t => {
|
||||
const f = fixture(t);
|
||||
syncToolkit({ root: f.root });
|
||||
for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'suggestions.js': 'ui/suggestions.js', 'controls.css': 'ui/controls.css', LICENSE: 'LICENSE', 'SOURCE.md': 'SOURCE.md' })) assert.deepEqual(readFileSync(join(f.destination, name)), readFileSync(join(f.dependency, source)));
|
||||
for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'suggestions.js': 'ui/suggestions.js', 'prompt-bridge.js': 'ui/prompt-bridge.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 });
|
||||
|
||||
Vendored
+11
-9
@@ -6,20 +6,22 @@ import runtime from './comfyui-runtime.json' with { type: 'json' };
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
|
||||
|
||||
export async function preparePromptManagerPlugin({ 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: '' };
|
||||
}
|
||||
|
||||
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: '' };
|
||||
},
|
||||
preparePlugin: preparePromptManagerPlugin,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"repository": "ComfyAssets/ComfyUI-KikoTools",
|
||||
"revision": "eb2db82a80c99399bad982ede6ab4f9fefc17a56"
|
||||
}
|
||||
+18
@@ -172,5 +172,23 @@
|
||||
"branches": 87,
|
||||
"functions": 98,
|
||||
"statements": 95
|
||||
},
|
||||
"prompt-bridge-adapter.js": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"prompt-bridge-host.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"shared/toolkit/prompt-bridge.js": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
import {defineConfig} from './node_modules/@playwright/test/index.mjs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {compatibilityPorts,validateKikoCheckout} from './pack-host.mjs';
|
||||
|
||||
if(!process.env.PM_COMFY_ROOT)throw new Error('Set PM_COMFY_ROOT to a pinned disposable ComfyUI checkout');
|
||||
await validateKikoCheckout(process.env.PM_KIKO_ROOT);
|
||||
const ports=compatibilityPorts(process.env.PM_COMPAT_PORT||8193);
|
||||
const modes=Object.keys(ports);
|
||||
const servers=modes.map(mode=>({
|
||||
command:'node tools/vendor/pack-host.mjs',cwd:fileURLToPath(new URL('../../',import.meta.url)),
|
||||
url:`http://127.0.0.1:${ports[mode]}/system_stats`,timeout:120000,reuseExistingServer:false,
|
||||
env:{PM_COMPAT_MODE:mode},gracefulShutdown:{signal:'SIGTERM',timeout:10000},
|
||||
}));
|
||||
|
||||
export default defineConfig({
|
||||
testDir:'../../tests/compatibility',outputDir:'./test-results/compatibility',
|
||||
timeout:90000,workers:1,forbidOnly:Boolean(process.env.CI),retries:0,
|
||||
use:{browserName:'chromium',viewport:{width:1600,height:1100},trace:'retain-on-failure',screenshot:'only-on-failure'},
|
||||
webServer:servers,
|
||||
projects:modes.flatMap(mode=>[false,true].map(initialRenderer=>({
|
||||
name:`${mode}-${initialRenderer?'nodes2':'classic'}`,
|
||||
metadata:{mode,initialRenderer},use:{baseURL:`http://127.0.0.1:${ports[mode]}/`},
|
||||
}))),
|
||||
});
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {access,realpath} from 'node:fs/promises';
|
||||
import {join,resolve} from 'node:path';
|
||||
import {fileURLToPath,pathToFileURL} from 'node:url';
|
||||
import {launchComfyHost} from '@comfyassets/toolkit/testing/host';
|
||||
import {preparePromptManagerPlugin} from './comfyui-host.mjs';
|
||||
import runtime from './comfyui-runtime.json' with {type:'json'};
|
||||
import crossPack from './cross-pack-runtime.json' with {type:'json'};
|
||||
|
||||
const repository=fileURLToPath(new URL('../../',import.meta.url));
|
||||
const MODES=['promptmanager','kiko','both'];
|
||||
export function compatibilityPorts(value=8193) {
|
||||
const first=Number(value);
|
||||
if(!Number.isInteger(first)||first<1024||first>65533)throw new Error('PM_COMPAT_PORT must reserve three unprivileged ports');
|
||||
return Object.fromEntries(MODES.map((mode,index)=>[mode,first+index]));
|
||||
}
|
||||
|
||||
export async function validateKikoCheckout(checkout) {
|
||||
if(!checkout)throw new Error('Set PM_KIKO_ROOT to the pinned disposable KikoTools checkout');
|
||||
const repository=await realpath(checkout);
|
||||
await access(join(repository,'__init__.py'));
|
||||
const git=args=>execFileSync('git',['-C',repository,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
|
||||
if(await realpath(git(['rev-parse','--show-toplevel']))!==repository)throw new Error('PM_KIKO_ROOT must name the repository root');
|
||||
if(git(['rev-parse','HEAD'])!==crossPack.revision)throw new Error(`KikoTools revision must be ${crossPack.revision}`);
|
||||
try {git(['diff','--quiet','HEAD','--']);}
|
||||
catch {throw new Error('KikoTools disposable checkout has modified tracked files');}
|
||||
return repository;
|
||||
}
|
||||
|
||||
export async function launchPackHost({mode=process.env.PM_COMPAT_MODE,kikoRoot=process.env.PM_KIKO_ROOT,
|
||||
checkout=process.env.PM_COMFY_ROOT,python=process.env.PM_TEST_PYTHON||'python',
|
||||
firstPort=process.env.PM_COMPAT_PORT||8193}={}) {
|
||||
if(!MODES.includes(mode))throw new Error('Select promptmanager, kiko or both for PM_COMPAT_MODE');
|
||||
const ports=compatibilityPorts(firstPort);const kikoRepository=await validateKikoCheckout(kikoRoot);
|
||||
const pm={pluginName:'promptmanager',repository,preparePlugin:preparePromptManagerPlugin};
|
||||
const kiko={pluginName:'kikotools',repository:kikoRepository};
|
||||
const primary=mode==='kiko'?kiko:pm;
|
||||
return launchComfyHost({...primary,additionalPlugins:mode==='both'?[kiko]:[],checkout,python,
|
||||
port:ports[mode],pins:runtime,logPath:join(repository,`tools/vendor/test-results/compatibility-${mode}-host.log`)});
|
||||
}
|
||||
|
||||
if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href) {
|
||||
try {
|
||||
const host=await launchPackHost();
|
||||
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);
|
||||
process.exitCode=result.code;
|
||||
} catch(error) {console.error(error.message);process.exitCode=1;}
|
||||
}
|
||||
+4
-4
@@ -8,7 +8,7 @@
|
||||
"name": "promptmanager-vendor-tools",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@comfyassets/toolkit": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/b5a34b1d12336cd1bdfd6b890b80db63cbc3c389",
|
||||
"@comfyassets/toolkit": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/ac245953df4c0d4b019c7bc59e93ad011c07ec22",
|
||||
"@playwright/test": "1.64.1",
|
||||
"c8": "12.0.0",
|
||||
"viewerjs": "1.15.2"
|
||||
@@ -25,9 +25,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@comfyassets/toolkit": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/b5a34b1d12336cd1bdfd6b890b80db63cbc3c389",
|
||||
"integrity": "sha512-rhI9xDH0U1WPBq7zuAN4rE6bLWx7ujTM7IIBNXINexN/z4FoQK4jnGXPMtNhW1XbbEpoZPZdExnrJpcRRPZXUw==",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/ac245953df4c0d4b019c7bc59e93ad011c07ec22",
|
||||
"integrity": "sha512-ax+xm+/uZBrYHsByaVqNuON55aNUVbAPiPpgBl5vN/3CmgFcYD7KLs0SOK8EtwZqdKt6PKyQVyNER4gIxy7RoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
Vendored
+3
-2
@@ -13,10 +13,11 @@
|
||||
"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"
|
||||
"coverage:update": "node frontend-coverage.mjs --update-baseline",
|
||||
"test:compat": "playwright test --config pack-compat.config.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@comfyassets/toolkit": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/b5a34b1d12336cd1bdfd6b890b80db63cbc3c389",
|
||||
"@comfyassets/toolkit": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/ac245953df4c0d4b019c7bc59e93ad011c07ec22",
|
||||
"@playwright/test": "1.64.1",
|
||||
"c8": "12.0.0",
|
||||
"viewerjs": "1.15.2"
|
||||
|
||||
Vendored
+1
-1
@@ -15,7 +15,7 @@ export function syncToolkit({ root = repositoryRoot, check = false } = {}) {
|
||||
if (locked?.resolved !== pin || !locked.integrity || locked.version !== info.version || info.name !== '@comfyassets/toolkit' || info.license !== 'MIT') throw new Error('Toolkit package identity or lock mismatch; run npm ci --ignore-scripts');
|
||||
const output = new Map();
|
||||
const files = {};
|
||||
for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'suggestions.js': 'ui/suggestions.js', 'controls.css': 'ui/controls.css', LICENSE: 'LICENSE', 'SOURCE.md': 'SOURCE.md' })) {
|
||||
for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'suggestions.js': 'ui/suggestions.js', 'prompt-bridge.js': 'ui/prompt-bridge.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') };
|
||||
|
||||
@@ -4,6 +4,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'));
|
||||
const { installFocusedNode, installSplitNode, installPairNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js'));
|
||||
const { installPromptBridge } = await import(api.fileURL('/prompt_manager/js/prompt-bridge-host.js'));
|
||||
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']);
|
||||
|
||||
// ============================================================================
|
||||
@@ -235,6 +236,7 @@ app.registerExtension({
|
||||
name: "PromptManager.UI",
|
||||
|
||||
async setup() {
|
||||
installPromptBridge(app, { baseUrl: api.fileURL('/prompt_manager/') });
|
||||
// Add the top bar button after a short delay to ensure UI is ready
|
||||
setTimeout(() => {
|
||||
addPMTopBarButton();
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import {validateRoles} from './shared/toolkit/prompt-bridge.js';
|
||||
|
||||
const schemas = new Map([
|
||||
['PromptManager', {positive:'text'}],
|
||||
['PromptManagerText', {positive:'text'}],
|
||||
['PromptManagerFocused', {positive:'text'}],
|
||||
['PromptManagerSplit', {positive:'text'}],
|
||||
['PromptManagerPair', {positive:'text',negative:'negative_text'}],
|
||||
]);
|
||||
const compositionNames = ['prepend_text','append_text'];
|
||||
|
||||
function nodes(graph) { return graph?._nodes || []; }
|
||||
function target(node) { return {id:String(node.id),label:String(node.title || node.type),type:node.type}; }
|
||||
function connected(node,name) { return Boolean(node.inputs?.some(input => input.name === name && input.link != null)); }
|
||||
function links(node) { return JSON.stringify((node.inputs || []).map(input => [input.name,input.link ?? null])); }
|
||||
function capture(node,name) {
|
||||
const widget = node.widgets?.find(item => item.name === name);
|
||||
if (!widget) return {name,widget:null};
|
||||
const index = node.widgets.indexOf(widget);
|
||||
return {name,widget,index,value:widget.value,input:widget.inputEl,visible:widget.inputEl?.value,serialized:node.widgets_values?.[index],hasSerialization:Array.isArray(node.widgets_values)};
|
||||
}
|
||||
function sameField(node,field) {
|
||||
const now = capture(node,field.name);
|
||||
const unchanged = ['widget','index','value','input','visible'].every(key => now[key] === field[key]);
|
||||
// Host serialization may materialize or synchronize its cache from live values.
|
||||
const serialized = now.hasSerialization === field.hasSerialization && now.serialized === field.serialized;
|
||||
const normalized = now.hasSerialization && now.serialized === field.value;
|
||||
return unchanged && (serialized || normalized);
|
||||
}
|
||||
function composition(node,fields,roles) {
|
||||
const value = name => String(fields.find(field => field.name === name)?.value ?? '');
|
||||
const prependConnected = connected(node,'prepend_text'), appendConnected = connected(node,'append_text');
|
||||
const mainConnected = connected(node,'text');
|
||||
const combine = main => [value('prepend_text').trim(),main,value('append_text').trim()].filter(Boolean).join(' ');
|
||||
return {prepend:value('prepend_text'),append:value('append_text'),prependConnected,appendConnected,
|
||||
before:prependConnected || appendConnected || mainConnected ? null : combine(value('text')),
|
||||
after:prependConnected || appendConnected || (mainConnected && !Object.hasOwn(roles,'positive')) ? null : combine(roles.positive ?? value('text'))};
|
||||
}
|
||||
function write(node,field,value) {
|
||||
field.widget.value = value;
|
||||
if (field.widget.inputEl) field.widget.inputEl.value = value;
|
||||
if (node.widgets_values) node.widgets_values[field.index] = value;
|
||||
}
|
||||
function redraw(app,node) {
|
||||
node._focusedView?.render();
|
||||
node.setDirtyCanvas?.(true,true);
|
||||
app.graph?.setDirtyCanvas?.(true,true);
|
||||
}
|
||||
|
||||
export function createPromptAdapter(app) {
|
||||
const snapshots = new WeakMap();
|
||||
function listTargets() {
|
||||
return nodes(app.graph).filter(node => schemas.has(node.type)).map(node => ({...target(node),roles:Object.keys(schemas.get(node.type))}));
|
||||
}
|
||||
function preview(targetId,rawRoles) {
|
||||
const roles = validateRoles(rawRoles);
|
||||
const graph = app.graph;
|
||||
const node = nodes(graph).find(item => String(item.id) === targetId);
|
||||
const schema = schemas.get(node?.type);
|
||||
if (!schema) throw new Error('Prompt destination is unavailable.');
|
||||
const fields = [...new Set([...Object.values(schema),...compositionNames,'category','tags'])].map(name => capture(node,name));
|
||||
const requested = Object.entries(roles).map(([role,after]) => {
|
||||
if (!schema[role]) throw new Error(`Destination does not support ${role}.`);
|
||||
const field = fields.find(item => item.name === schema[role]);
|
||||
if (!field.widget) throw new Error(`Destination widget ${schema[role]} is unavailable.`);
|
||||
if (connected(node,field.name)) throw new Error(`${role} is connected to an upstream input.`);
|
||||
return {role,after,field};
|
||||
});
|
||||
const snapshot = Object.freeze({});
|
||||
snapshots.set(snapshot,{graph,node,type:node.type,id:String(node.id),version:graph._version,widgets:node.widgets,widgetOrder:[...node.widgets],fields,links:links(node),requested});
|
||||
return {preview:{target:target(node),fields:requested.map(({role,after,field}) => ({role,before:String(field.value ?? ''),after})),composition:composition(node,fields,roles)},snapshot};
|
||||
}
|
||||
function apply(snapshot) {
|
||||
const state = snapshots.get(snapshot);
|
||||
if (!state) throw new Error('Invalid or expired prompt preview snapshot.');
|
||||
const {graph,node,fields,requested} = state;
|
||||
if (app.graph !== graph || !nodes(graph).includes(node) || String(node.id) !== state.id || node.widgets !== state.widgets || node.type !== state.type || graph._version !== state.version || links(node) !== state.links || fields.some(field => !sameField(node,field))) throw new Error('Workflow or prompt changed. Preview again.');
|
||||
snapshots.delete(snapshot);
|
||||
const serializedBefore = node.widgets_values;
|
||||
const rollbackFields = fields.map(field => capture(node,field.name));
|
||||
try {
|
||||
node.widgets_values ||= node.widgets.map(widget => widget.value);
|
||||
for (const {field,after} of requested) write(node,field,after);
|
||||
for (const {field,after} of requested) field.widget.callback?.(after,app.canvas,node,null,null);
|
||||
redraw(app,node);
|
||||
if (app.graph !== graph || !nodes(graph).includes(node) || String(node.id) !== state.id || node.widgets !== state.widgets || node.type !== state.type || links(node) !== state.links || fields.some(field => {
|
||||
const current = capture(node,field.name);
|
||||
return current.widget !== field.widget || current.index !== field.index || current.input !== field.input;
|
||||
})) throw new Error('Workflow changed during prompt update.');
|
||||
if (fields.some(field => field.widget && !requested.some(item => item.field === field) && !sameField(node,{...field,hasSerialization:true,serialized:field.hasSerialization ? field.serialized : field.value}))) throw new Error('Protected prompt fields changed during update.');
|
||||
for (const {field,after} of requested) {
|
||||
if (field.widget.value !== after || (field.widget.inputEl && field.widget.inputEl.value !== after) || (!Array.isArray(node.widgets_values) || node.widgets_values[field.index] !== after)) throw new Error('Prompt values do not match the approved preview.');
|
||||
}
|
||||
} catch (error) {
|
||||
// Widget callbacks are host hooks; restore our fields without repeating them.
|
||||
node.widgets = state.widgets;
|
||||
node.widgets.splice(0,node.widgets.length,...state.widgetOrder);
|
||||
node.widgets_values = serializedBefore;
|
||||
for (const field of rollbackFields.filter(item => item.widget)) {
|
||||
node.widgets[field.index] = field.widget;
|
||||
field.widget.value = field.value;
|
||||
if (field.widget.inputEl !== field.input) field.widget.inputEl = field.input;
|
||||
if (field.input) field.input.value = field.visible;
|
||||
if (field.hasSerialization) node.widgets_values[field.index] = field.serialized;
|
||||
}
|
||||
node.widgets_values = serializedBefore;
|
||||
try { redraw(app,node); } catch { /* Preserve the original callback failure. */ }
|
||||
throw new Error(`Prompt update failed: ${error.message}`);
|
||||
}
|
||||
return {targetId:state.id,values:Object.fromEntries(requested.map(({role,after}) => [role,after]))};
|
||||
}
|
||||
return {listTargets,preview,apply};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createBridgeHost } from './shared/toolkit/prompt-bridge.js';
|
||||
import { createPromptAdapter } from './prompt-bridge-adapter.js';
|
||||
|
||||
const installations = new WeakMap();
|
||||
|
||||
export function installPromptBridge(app, { baseUrl, runtime = globalThis,
|
||||
hostFactory = createBridgeHost, adapterFactory = createPromptAdapter } = {}) {
|
||||
if (installations.has(app)) return installations.get(app);
|
||||
const url = new URL(baseUrl, runtime.location.href);
|
||||
if (url.origin !== runtime.location.origin || !url.pathname.endsWith('/prompt_manager/')) {
|
||||
throw new Error('Invalid PromptManager deployment origin or path');
|
||||
}
|
||||
const namespace = url.pathname.slice(0, -'prompt_manager/'.length);
|
||||
let host = null, disposed = false;
|
||||
function mount() {
|
||||
if (host || disposed) return;
|
||||
try {
|
||||
host = hostFactory({ namespace, origin: runtime.location.origin,
|
||||
label: `PromptManager — ${runtime.document.title || 'ComfyUI'}`, adapter: adapterFactory(app) });
|
||||
} catch (error) {
|
||||
runtime.console.warn('[PromptManager] Prompt sending is unavailable:', error.message);
|
||||
}
|
||||
}
|
||||
function unmount() { host?.dispose(); host = null; }
|
||||
const controller = { dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
runtime.removeEventListener('pagehide', unmount);
|
||||
runtime.removeEventListener('pageshow', mount);
|
||||
unmount(); installations.delete(app);
|
||||
} };
|
||||
runtime.addEventListener('pagehide', unmount);
|
||||
runtime.addEventListener('pageshow', mount);
|
||||
installations.set(app, controller);
|
||||
mount();
|
||||
return controller;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
const VERSION = 1;
|
||||
const MAX_TEXT = 1048576;
|
||||
const id = () => {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID();
|
||||
// getRandomValues also works on HTTP LAN origins where randomUUID is unavailable.
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
const plain = value => value !== null && typeof value === 'object' && [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
||||
const identifier = value => typeof value === 'string' && value.length > 0 && value.length <= 200;
|
||||
|
||||
export function validateRoles(roles) {
|
||||
if (!plain(roles)) throw new TypeError('Prompt roles must be an object');
|
||||
const keys = Reflect.ownKeys(roles);
|
||||
if (!keys.length || keys.some(key => !['positive', 'negative'].includes(key))) throw new TypeError('Unknown or missing prompt role');
|
||||
const result = {}; let size = 0;
|
||||
for (const key of keys) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(roles, key);
|
||||
if (!('value' in descriptor) || typeof descriptor.value !== 'string') throw new TypeError('Prompt roles must be strings');
|
||||
size += new TextEncoder().encode(descriptor.value).length;
|
||||
if (size > MAX_TEXT) throw new TypeError('Prompt roles are too large');
|
||||
result[key] = descriptor.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function normalizedOrigin(value) {
|
||||
const url = new URL(value);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.origin === 'null') throw new TypeError('Bridge requires an HTTP(S) origin');
|
||||
return url.origin;
|
||||
}
|
||||
function sameOrigin(value, expected) {
|
||||
try {
|
||||
if (globalThis.Origin?.from) {
|
||||
const actual = globalThis.Origin.from(value);
|
||||
return !actual.opaque && actual.isSameOrigin(globalThis.Origin.from(expected));
|
||||
}
|
||||
return normalizedOrigin(value) === expected;
|
||||
} catch { return false; }
|
||||
}
|
||||
function transport(options, receive, fail) {
|
||||
if (!identifier(options.namespace)) throw new TypeError('Bridge namespace is required');
|
||||
const origin = normalizedOrigin(options.origin);
|
||||
const factory = options.channelFactory ?? (name => {
|
||||
if (typeof globalThis.BroadcastChannel !== 'function') throw new Error('BroadcastChannel is unavailable');
|
||||
return new globalThis.BroadcastChannel(name);
|
||||
});
|
||||
const channel = factory(`comfyassets:prompt-bridge:v${VERSION}:${options.namespace}`);
|
||||
let closed = false;
|
||||
const message = event => {
|
||||
const data = event.data;
|
||||
if (!closed && sameOrigin(event.origin, origin) && plain(data) && data.version === VERSION && data.origin === origin && data.namespace === options.namespace && identifier(data.requestId) && identifier(data.clientId) && ['discover', 'preview', 'apply'].includes(data.op)) receive(data);
|
||||
};
|
||||
const error = () => fail(new Error('Bridge transport failed'));
|
||||
channel.addEventListener('message', message); channel.addEventListener('messageerror', error);
|
||||
return {
|
||||
send(data) { if (closed) throw new Error('Bridge disposed'); channel.postMessage({ ...data, version: VERSION, origin, namespace: options.namespace }); },
|
||||
dispose() { if (closed) return; closed = true; channel.removeEventListener('message', message); channel.removeEventListener('messageerror', error); channel.close(); },
|
||||
};
|
||||
}
|
||||
function boundedSet(map, key, value, max) {
|
||||
map.set(key, value);
|
||||
while (map.size > max) map.delete(map.keys().next().value);
|
||||
}
|
||||
function duration(value, fallback) {
|
||||
return Number.isFinite(value) && value > 0 ? Math.min(value, 2147483647) : fallback;
|
||||
}
|
||||
|
||||
export function createBridgeHost(options) {
|
||||
const hostId = options.hostId ?? id();
|
||||
if (!identifier(hostId) || typeof options.label !== 'string' || !options.adapter || ['listTargets', 'preview', 'apply'].some(key => typeof options.adapter[key] !== 'function')) throw new TypeError('Invalid host adapter');
|
||||
const tickets = new Map(); const replay = new Map();
|
||||
const ttl = duration(options.previewTtlMs, 30000);
|
||||
const maxTickets = Math.max(1, Math.floor(duration(options.maxTickets, 100)));
|
||||
const maxReplay = Math.max(1, Math.floor(duration(options.maxReplay, 200)));
|
||||
let disposed = false;
|
||||
const dispose = () => { disposed = true; tickets.clear(); replay.clear(); wire.dispose(); };
|
||||
const wire = transport(options, data => {
|
||||
if (data.kind !== 'request' || (data.op !== 'discover' && data.hostId !== hostId) || (data.op === 'discover' && data.hostId !== undefined)) return;
|
||||
const key = JSON.stringify([data.clientId, data.requestId]);
|
||||
let fingerprint;
|
||||
try { fingerprint = JSON.stringify([data.op, data.hostId, data.targetId, data.roles, data.ticket]); } catch { return; }
|
||||
if (fingerprint.length > MAX_TEXT * 6 + 1000) return;
|
||||
const previous = replay.get(key);
|
||||
if (previous) { if (previous.fingerprint === fingerprint) send(previous.response); return; }
|
||||
const response = { kind: 'response', op: data.op, requestId: data.requestId, clientId: data.clientId, hostId };
|
||||
try {
|
||||
const now = Date.now();
|
||||
for (const [ticket, entry] of tickets) if (entry.expires <= now) tickets.delete(ticket);
|
||||
if (data.op === 'discover') response.result = { hostId, label: options.label, targets: options.adapter.listTargets() };
|
||||
if (data.op === 'preview') {
|
||||
if (!identifier(data.targetId)) throw new TypeError('Invalid target');
|
||||
const value = options.adapter.preview(data.targetId, validateRoles(data.roles));
|
||||
if (!plain(value) || !plain(value.preview) || !Object.hasOwn(value, 'snapshot')) throw new TypeError('Invalid preview');
|
||||
const ticket = id();
|
||||
response.result = { ...value.preview, ticket };
|
||||
// Serialize public output before retaining a snapshot; uncloneable results fail closed.
|
||||
structuredClone(response.result);
|
||||
boundedSet(tickets, ticket, { clientId: data.clientId, snapshot: value.snapshot, expires: now + ttl }, maxTickets);
|
||||
}
|
||||
if (data.op === 'apply') {
|
||||
if (!identifier(data.ticket)) throw new TypeError('Invalid ticket');
|
||||
const entry = tickets.get(data.ticket);
|
||||
if (!entry || entry.clientId !== data.clientId) throw new Error('Preview expired or unavailable; preview again');
|
||||
tickets.delete(data.ticket);
|
||||
response.result = options.adapter.apply(entry.snapshot);
|
||||
}
|
||||
} catch (error) { response.error = error instanceof Error ? error.message : 'Bridge request failed'; delete response.result; }
|
||||
boundedSet(replay, key, { fingerprint, response }, maxReplay);
|
||||
send(response);
|
||||
}, dispose);
|
||||
function send(response) { try { wire.send(response); } catch { dispose(); } }
|
||||
return { hostId, dispose, get disposed() { return disposed; } };
|
||||
}
|
||||
|
||||
export function createBridgeClient(options) {
|
||||
const clientId = options.clientId ?? id();
|
||||
if (!identifier(clientId)) throw new TypeError('Invalid client ID');
|
||||
const pending = new Map(); let disposed = false;
|
||||
const dispose = (reason = new Error('Bridge disposed')) => {
|
||||
if (disposed) return; disposed = true;
|
||||
for (const entry of [...pending.values()]) entry.finish(reason);
|
||||
wire.dispose();
|
||||
};
|
||||
const wire = transport(options, data => {
|
||||
if (data.kind !== 'response' || data.clientId !== clientId || !identifier(data.hostId)) return;
|
||||
const entry = pending.get(data.requestId);
|
||||
if (!entry || data.op !== entry.op || (entry.hostId && data.hostId !== entry.hostId)) return;
|
||||
if (entry.op === 'discover') {
|
||||
if (!data.error && plain(data.result) && data.result.hostId === data.hostId && typeof data.result.label === 'string' && Array.isArray(data.result.targets)) entry.hosts.set(data.hostId, data.result);
|
||||
} else if (typeof data.error === 'string') entry.finish(new Error(data.error));
|
||||
else if (Object.hasOwn(data, 'result') && (entry.op !== 'preview' || (plain(data.result) && identifier(data.result.ticket)))) entry.finish(null, data.result);
|
||||
}, dispose);
|
||||
function request(op, payload = {}, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (disposed) return reject(new Error('Bridge disposed'));
|
||||
const signal = options.signal;
|
||||
if (signal?.aborted) return reject(new Error('Bridge request aborted'));
|
||||
const requestId = id(); const hosts = new Map();
|
||||
let timer;
|
||||
const abort = () => finish(new Error('Bridge request aborted'));
|
||||
const finish = (error, result) => {
|
||||
clearTimeout(timer); signal?.removeEventListener('abort', abort); pending.delete(requestId);
|
||||
if (error) reject(error); else resolve(result);
|
||||
};
|
||||
pending.set(requestId, { op, hostId: payload.hostId, hosts, finish });
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timeout = op === 'discover' ? duration(options.waitMs, 150) : duration(options.timeoutMs ?? options.requestTimeoutMs, duration(clientOptions.timeoutMs ?? clientOptions.requestTimeoutMs, 3000));
|
||||
timer = setTimeout(() => op === 'discover' ? finish(null, [...hosts.values()]) : finish(new Error('Bridge request timed out')), timeout);
|
||||
try { wire.send({ kind: 'request', op, requestId, clientId, ...payload }); } catch (error) { dispose(error); }
|
||||
});
|
||||
}
|
||||
const clientOptions = options;
|
||||
return {
|
||||
clientId,
|
||||
discover: (options = {}) => request('discover', {}, options),
|
||||
preview(hostId, targetId, roles, options = {}) {
|
||||
try {
|
||||
if (!identifier(hostId) || !identifier(targetId)) throw new TypeError('Invalid destination');
|
||||
return request('preview', { hostId, targetId, roles: validateRoles(roles) }, options);
|
||||
} catch (error) { return Promise.reject(error); }
|
||||
},
|
||||
apply(hostId, ticket, options = {}) {
|
||||
if (!identifier(hostId) || !identifier(ticket)) return Promise.reject(new TypeError('Invalid destination or ticket'));
|
||||
return request('apply', { hostId, ticket }, options);
|
||||
},
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@comfyassets/toolkit",
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/b5a34b1d12336cd1bdfd6b890b80db63cbc3c389",
|
||||
"integrity": "sha512-rhI9xDH0U1WPBq7zuAN4rE6bLWx7ujTM7IIBNXINexN/z4FoQK4jnGXPMtNhW1XbbEpoZPZdExnrJpcRRPZXUw==",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/ac245953df4c0d4b019c7bc59e93ad011c07ec22",
|
||||
"integrity": "sha512-ax+xm+/uZBrYHsByaVqNuON55aNUVbAPiPpgBl5vN/3CmgFcYD7KLs0SOK8EtwZqdKt6PKyQVyNER4gIxy7RoQ==",
|
||||
"files": {
|
||||
"core.js": {
|
||||
"source": "ui/core.js",
|
||||
@@ -12,6 +12,10 @@
|
||||
"source": "ui/suggestions.js",
|
||||
"sha256": "979b7867bcf7c8d5bec2c6631cec4c15a5cbb5774c4eaa554989b954555b9a04"
|
||||
},
|
||||
"prompt-bridge.js": {
|
||||
"source": "ui/prompt-bridge.js",
|
||||
"sha256": "66f98b31c52a2fe360c3a604738b7d5fb849323fd2a988d681c28859400acb25"
|
||||
},
|
||||
"controls.css": {
|
||||
"source": "ui/controls.css",
|
||||
"sha256": "9bb8662a5c911537c0577c5b21a861356550ec6235fcf2101d6585cc148efb8d"
|
||||
|
||||
Reference in New Issue
Block a user