Compare commits

...
25 changed files with 1174 additions and 22 deletions
+19
View File
@@ -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
View File
@@ -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.
+49
View File
@@ -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.
+104
View File
@@ -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/);
});
+5 -1
View File
@@ -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)=>{
+192
View File
@@ -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();
}
});
+142
View File
@@ -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');
});
+4
View File
@@ -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'));
+68
View File
@@ -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
View File
@@ -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/);
});
+2 -2
View File
@@ -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 });
+11 -9
View File
@@ -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,
});
}
+4
View File
@@ -0,0 +1,4 @@
{
"repository": "ComfyAssets/ComfyUI-KikoTools",
"revision": "eb2db82a80c99399bad982ede6ab4f9fefc17a56"
}
+18
View File
@@ -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
}
}
+24
View File
@@ -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]}/`},
}))),
});
+51
View File
@@ -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
View File
@@ -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": {
+3 -2
View File
@@ -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"
+1 -1
View File
@@ -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') };
+2
View File
@@ -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();
+113
View File
@@ -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};
}
+37
View File
@@ -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;
}
+169
View File
@@ -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,
};
}
+7 -3
View File
@@ -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"