diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 23c8336b..49966923 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/TESTING.md b/TESTING.md index db34c129..9f40f7f0 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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. diff --git a/docs/prompt-send-bridge.md b/docs/prompt-send-bridge.md new file mode 100644 index 00000000..5c5e29d6 --- /dev/null +++ b/docs/prompt-send-bridge.md @@ -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. diff --git a/tests/browser/prompt-bridge.spec.mjs b/tests/browser/prompt-bridge.spec.mjs new file mode 100644 index 00000000..0219dfc5 --- /dev/null +++ b/tests/browser/prompt-bridge.spec.mjs @@ -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/); +}); diff --git a/tests/comfyui/prompt-bridge.spec.mjs b/tests/comfyui/prompt-bridge.spec.mjs new file mode 100644 index 00000000..34255d09 --- /dev/null +++ b/tests/comfyui/prompt-bridge.spec.mjs @@ -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'); + }); +}); diff --git a/tests/compatibility/pack-coexistence.spec.mjs b/tests/compatibility/pack-coexistence.spec.mjs new file mode 100644 index 00000000..4155da8b --- /dev/null +++ b/tests/compatibility/pack-coexistence.spec.mjs @@ -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(); + } +}); diff --git a/tests/js/prompt-bridge-adapter.test.js b/tests/js/prompt-bridge-adapter.test.js new file mode 100644 index 00000000..90f31b9e --- /dev/null +++ b/tests/js/prompt-bridge-adapter.test.js @@ -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'); +}); diff --git a/tests/js/prompt-bridge-contracts.test.js b/tests/js/prompt-bridge-contracts.test.js new file mode 100644 index 00000000..4333d120 --- /dev/null +++ b/tests/js/prompt-bridge-contracts.test.js @@ -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')); diff --git a/tests/js/prompt-bridge-host.test.js b/tests/js/prompt-bridge-host.test.js new file mode 100644 index 00000000..0731cc0a --- /dev/null +++ b/tests/js/prompt-bridge-host.test.js @@ -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); + } +}); diff --git a/tests/vendor/cross-pack-config.test.mjs b/tests/vendor/cross-pack-config.test.mjs new file mode 100644 index 00000000..36148f3b --- /dev/null +++ b/tests/vendor/cross-pack-config.test.mjs @@ -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/); +}); diff --git a/tests/vendor/vendor-toolkit.test.mjs b/tests/vendor/vendor-toolkit.test.mjs index c03ce15b..1a930fac 100644 --- a/tests/vendor/vendor-toolkit.test.mjs +++ b/tests/vendor/vendor-toolkit.test.mjs @@ -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 }); diff --git a/tools/vendor/comfyui-host.mjs b/tools/vendor/comfyui-host.mjs index 56b95b0d..33b5168b 100644 --- a/tools/vendor/comfyui-host.mjs +++ b/tools/vendor/comfyui-host.mjs @@ -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, }); } diff --git a/tools/vendor/cross-pack-runtime.json b/tools/vendor/cross-pack-runtime.json new file mode 100644 index 00000000..69118f6a --- /dev/null +++ b/tools/vendor/cross-pack-runtime.json @@ -0,0 +1,4 @@ +{ + "repository": "ComfyAssets/ComfyUI-KikoTools", + "revision": "eb2db82a80c99399bad982ede6ab4f9fefc17a56" +} diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index d49e7d76..7c7a753a 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -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 } } diff --git a/tools/vendor/pack-compat.config.mjs b/tools/vendor/pack-compat.config.mjs new file mode 100644 index 00000000..455cef31 --- /dev/null +++ b/tools/vendor/pack-compat.config.mjs @@ -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]}/`}, + }))), +}); diff --git a/tools/vendor/pack-host.mjs b/tools/vendor/pack-host.mjs new file mode 100644 index 00000000..559da958 --- /dev/null +++ b/tools/vendor/pack-host.mjs @@ -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;} +} diff --git a/tools/vendor/package-lock.json b/tools/vendor/package-lock.json index 6bd43be4..87ab5182 100644 --- a/tools/vendor/package-lock.json +++ b/tools/vendor/package-lock.json @@ -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": { diff --git a/tools/vendor/package.json b/tools/vendor/package.json index 9440944a..0a4c465f 100644 --- a/tools/vendor/package.json +++ b/tools/vendor/package.json @@ -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" diff --git a/tools/vendor/vendor-toolkit.mjs b/tools/vendor/vendor-toolkit.mjs index 83122fbd..51f76ca2 100644 --- a/tools/vendor/vendor-toolkit.mjs +++ b/tools/vendor/vendor-toolkit.mjs @@ -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') }; diff --git a/web/comfy/prompt_manager.js b/web/comfy/prompt_manager.js index e1f10efb..4a74bb79 100644 --- a/web/comfy/prompt_manager.js +++ b/web/comfy/prompt_manager.js @@ -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(); diff --git a/web/js/prompt-bridge-adapter.js b/web/js/prompt-bridge-adapter.js new file mode 100644 index 00000000..39c224e3 --- /dev/null +++ b/web/js/prompt-bridge-adapter.js @@ -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}; +} diff --git a/web/js/prompt-bridge-host.js b/web/js/prompt-bridge-host.js new file mode 100644 index 00000000..1a4e2fa2 --- /dev/null +++ b/web/js/prompt-bridge-host.js @@ -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; +} diff --git a/web/js/shared/toolkit/prompt-bridge.js b/web/js/shared/toolkit/prompt-bridge.js new file mode 100644 index 00000000..a5e87ff0 --- /dev/null +++ b/web/js/shared/toolkit/prompt-bridge.js @@ -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, + }; +} diff --git a/web/js/shared/toolkit/provenance.json b/web/js/shared/toolkit/provenance.json index 8a487b21..bc4d4f48 100644 --- a/web/js/shared/toolkit/provenance.json +++ b/web/js/shared/toolkit/provenance.json @@ -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"