diff --git a/browser_tests/tests/previewAsText.spec.ts b/browser_tests/tests/previewAsText.spec.ts new file mode 100644 index 0000000000..8d8338119e --- /dev/null +++ b/browser_tests/tests/previewAsText.spec.ts @@ -0,0 +1,42 @@ +import { + comfyPageFixture as test, + comfyExpect as expect +} from '../fixtures/ComfyPage' + +test.describe('Preview as Text node', () => { + test('does not include preview widget values in the API prompt', async ({ + comfyPage + }) => { + await comfyPage.page.evaluate(() => { + const node = window.LiteGraph!.createNode('PreviewAny')! + node.pos = [500, 200] + window.app!.graph.add(node) + }) + + // Simulate a previous execution: backend returned text and the frontend + // populated the preview widget values. The next prompt submission must + // NOT echo those values back as inputs (which would change the cache + // signature and trigger a redundant re-execution). + await comfyPage.page.evaluate(() => { + const node = window.app!.graph.nodes.find((n) => n.type === 'PreviewAny')! + for (const widget of node.widgets ?? []) { + if (widget.name?.startsWith('preview_')) { + widget.value = 'rendered preview content from previous execution' + } + } + }) + + const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({ + api: true + }) + + const previewEntry = Object.values(apiWorkflow).find( + (n) => n.class_type === 'PreviewAny' + ) + expect(previewEntry).toBeDefined() + + expect(previewEntry!.inputs).not.toHaveProperty('preview_markdown') + expect(previewEntry!.inputs).not.toHaveProperty('preview_text') + expect(previewEntry!.inputs).not.toHaveProperty('previewMode') + }) +}) diff --git a/src/extensions/core/previewAny.test.ts b/src/extensions/core/previewAny.test.ts new file mode 100644 index 0000000000..a8a2f14c1b --- /dev/null +++ b/src/extensions/core/previewAny.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ComfyExtension } from '@/types/comfy' + +const capturedExtensions: ComfyExtension[] = [] + +vi.mock('@/services/extensionService', () => ({ + useExtensionService: () => ({ + registerExtension: (ext: ComfyExtension) => { + capturedExtensions.push(ext) + } + }) +})) + +vi.mock('@/scripts/app', () => ({ app: {} })) + +interface MockWidget { + name: string + options: Record + element: { readOnly: boolean } + callback?: (value: unknown) => void + value: unknown + hidden: boolean + label: string + serialize?: boolean +} + +const createdWidgets: MockWidget[] = [] + +vi.mock('@/scripts/widgets', () => { + const create = + (kind: string) => + ( + node: { widgets?: MockWidget[] }, + name: string, + _info: unknown, + _app: unknown + ) => { + const widget: MockWidget = { + name, + options: {}, + element: { readOnly: false }, + value: kind === 'BOOLEAN' ? false : '', + hidden: false, + label: '' + } + node.widgets = node.widgets ?? [] + node.widgets.push(widget) + createdWidgets.push(widget) + return { widget } + } + return { + ComfyWidgets: { + MARKDOWN: create('MARKDOWN'), + STRING: create('STRING'), + BOOLEAN: create('BOOLEAN') + } + } +}) + +describe('PreviewAny extension', () => { + beforeEach(async () => { + capturedExtensions.length = 0 + createdWidgets.length = 0 + vi.resetModules() + await import('./previewAny') + }) + + async function setupNode() { + const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny') + expect(ext).toBeDefined() + + const nodeType = { prototype: {} } as unknown as Parameters< + NonNullable + >[0] + const nodeData = { name: 'PreviewAny' } as Parameters< + NonNullable + >[1] + + await ext!.beforeRegisterNodeDef!( + nodeType, + nodeData, + {} as Parameters>[2] + ) + + const node: { widgets?: MockWidget[] } = {} + const proto = nodeType.prototype as { onNodeCreated?: () => void } + proto.onNodeCreated!.call(node) + return node + } + + it('excludes preview widgets from the API prompt to prevent re-execution', async () => { + await setupNode() + + const previewMarkdown = createdWidgets.find( + (w) => w.name === 'preview_markdown' + ) + const previewText = createdWidgets.find((w) => w.name === 'preview_text') + const previewMode = createdWidgets.find((w) => w.name === 'previewMode') + + expect(previewMarkdown).toBeDefined() + expect(previewText).toBeDefined() + expect(previewMode).toBeDefined() + + // widget.options.serialize === false is what executionUtil.graphToPrompt + // checks to exclude a widget from the API prompt sent to the backend. + // Without this, post-execution widget value updates (the rendered preview + // text) get serialized as inputs, change the cache signature, and cause + // the node to re-execute on the next prompt. + expect(previewMarkdown!.options.serialize).toBe(false) + expect(previewText!.options.serialize).toBe(false) + expect(previewMode!.options.serialize).toBe(false) + }) +}) diff --git a/src/extensions/core/previewAny.ts b/src/extensions/core/previewAny.ts index 02d07ae4bf..5fc2ac98dc 100644 --- a/src/extensions/core/previewAny.ts +++ b/src/extensions/core/previewAny.ts @@ -57,6 +57,7 @@ useExtensionService().registerExtension({ showValueWidget.hidden = true showValueWidget.options.hidden = true showValueWidget.options.read_only = true + showValueWidget.options.serialize = false showValueWidget.element.readOnly = true showValueWidget.serialize = false @@ -64,8 +65,14 @@ useExtensionService().registerExtension({ showValueWidgetPlain.hidden = false showValueWidgetPlain.options.hidden = false showValueWidgetPlain.options.read_only = true + showValueWidgetPlain.options.serialize = false showValueWidgetPlain.element.readOnly = true showValueWidgetPlain.serialize = false + + // The previewMode toggle is a frontend-only display preference and + // is not declared in the backend INPUT_TYPES, so it must not be + // serialized into the API prompt (would alter the cache signature). + showAsPlaintextWidget.widget.options.serialize = false } const onExecuted = nodeType.prototype.onExecuted