Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion browser_tests/tests/previewAsText.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ test.describe('Preview as Text node', () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.searchBoxV2.addNode('Preview as Text')
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview as Text')
const preview = node.root.locator('textarea')
const preview = comfyPage.vueNodes.getWidgetByName(
'Preview as Text',
'preview_text'
)

await test.step('node previews execution result', async () => {
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
Expand All @@ -75,4 +78,59 @@ test.describe('Preview as Text node', () => {
)
}
)

wstest(
'renders the payloads users reported blank',
{ tag: '@vue-nodes' },
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())

await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.searchBoxV2.addNode('Preview as Text')
const preview = comfyPage.vueNodes.getWidgetByName(
'Preview as Text',
'preview_text'
)
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
const jobId = ''

const payloads = [
['compact JSON from an LLM node', '{"name":"Comfy","emoji":"🌟"}'],
['JSON array', '[{"a": 1}, {"b": 2}]'],
['markdown-fenced JSON', '```json\n{"name":"Comfy"}\n```'],
['non-ASCII text', '你好,世界。'],
['prompt with a trailing space', '"A red car" is a great prompt. ']
] as const

for (const [label, text] of payloads) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooooh, I like this

await test.step(label, async () => {
execution.executed(jobId, id, { text: [text] })
await expect(preview).toHaveValue(text)
})
}

await test.step('numeric output from Get Video Components', async () => {
execution.executed(jobId, id, { text: 23.976 })
await expect(preview).toHaveValue('23.976')
})

await test.step('null text does not wedge the widget', async () => {
// The shape the Cloud backend produced when it misclassified the text
// as a filename and dropped it from the payload (BE-3601).
execution.executed(jobId, id, { text: [null] })
await expect(preview).toHaveValue('')

execution.executed(jobId, id, { text: ['recovered'] })
await expect(preview).toHaveValue('recovered')
})
Comment on lines +105 to +125

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: every frame this spec sends yields the same assertion result against the pre-fix implementation, so it cannot detect the bug in its own commit.

  • The six payload rows are strings inside arrays; text.join('\n\n') returned them unchanged before the fix.
  • { text: [null] }[null].join('\n\n') was already '', so nothing was ever wedged by that shape, and the recovered follow-up passed before too.
  • {}message.text ?? '' was already ''.

The failure that needed fixing is message itself being nullish, and this spec structurally cannot send it: ExecutionHelper.executed() types output as Record<string, unknown>, and app.ts:803 forwards detail.output unmodified.

Two ways to give the spec teeth, both covering ground the unit tests cannot reach:

  • widen ExecutionHelper.executed's output to unknown and drive a null-output executed frame, asserting the widget renders empty and still updates on the next frame;
  • send { text: 23.976 } unquoted and unwrapped, which is the only way to exercise the real setValue string guard end to end.

Either way the six-row loop could collapse to one representative payload — those characters are already covered at the unit layer, and six browser round-trips is a lot for one identity path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Added a dedicated step in 91c26ea that sends { text: 23.976 } as a bare scalar - this is the shape that would have been blank before the fix and is the only one the loop couldn't exercise. The 6-row loop is also trimmed to 5 representative strings. The ExecutionHelper.executed output-widening approach would be stronger for null-message coverage but that's a bigger change; happy to do it in a followup if you think it's worth it.


await test.step('output with no text key does not wedge the widget', async () => {
execution.executed(jobId, id, {})
await expect(preview).toHaveValue('')

execution.executed(jobId, id, { text: ['recovered again'] })
await expect(preview).toHaveValue('recovered again')
})
}
)
})
39 changes: 39 additions & 0 deletions src/extensions/core/textPreviewWidgets.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'

interface MockWidget {
name: string
Expand Down Expand Up @@ -100,4 +101,42 @@ describe('updateTextPreviewWidgets', () => {
updateTextPreviewWidgets(node, { text: 'hello' })
expect(node.widgets[0].value).toBe('hello')
})

it.for([
['null message', null],
['undefined message', undefined],
['message without text', {}],
['null text', { text: null }],
['empty array', { text: [] }],
['array of only nulls', { text: [null, null] }]
] as [string, NodeExecutionOutput | null | undefined][])(
'renders empty and does not throw for $0',
([_label, message]) => {
expect(() => updateTextPreviewWidgets(node, message)).not.toThrow()
expect(node.widgets[0].value).toBe('')
}
)

it('drops null entries instead of rendering blank separators', () => {
updateTextPreviewWidgets(node, {
text: ['first', null, 'second']
} as unknown as NodeExecutionOutput)

expect(node.widgets[0].value).toBe('first\n\nsecond')
})

it('renders non-string entries rather than blanking the node', () => {
updateTextPreviewWidgets(node, {
text: ['first', 23.976, null, 'second']
} as unknown as NodeExecutionOutput)

expect(node.widgets[0].value).toBe('first\n\n23.976\n\nsecond')
})

it('stringifies a bare non-string scalar payload', () => {
updateTextPreviewWidgets(node, {
text: 23.976
} as unknown as NodeExecutionOutput)
expect(node.widgets[0].value).toBe('23.976')
})
})
13 changes: 10 additions & 3 deletions src/extensions/core/textPreviewWidgets.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
import WidgetTextPreview from '@/renderer/extensions/vueNodes/widgets/components/WidgetTextPreview.vue'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
Expand Down Expand Up @@ -66,13 +67,19 @@ export function addTextPreviewWidgets(node: LGraphNode) {
modeWidget.serialize = false
}

function toPreviewText(text: unknown): string {
if (text == null) return ''
if (Array.isArray(text))
return text.filter((part) => part != null).join('\n\n')
return String(text)
}
Comment on lines +70 to +75

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: two of the four branches are no-ops, and dropping them makes the contract easier to read.

Array.prototype.join already calls String() on every non-nullish element, so the .map(...) is identity once .filter(...) has run. And String(s) === s for strings, so the typeof text === 'string' fast path returns exactly what the trailing return String(text) would.

function toPreviewText(text: unknown): string {
  if (text == null) return ''
  if (Array.isArray(text)) return text.filter((part) => part != null).join('\n\n')
  return String(text)
}

I diffed both versions over 19 inputs — nullish, [], [null], ['a', null, 'b'], [30, 23.976], bare scalars, objects, nested arrays — with no divergence. The .filter is the load-bearing piece; deleting it fails two tests.

Side benefit: return String(text) becomes the path strings take, so it stops being the line Codecov flags as uncovered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Applied in 731afa2. Dropped the typeof text === 'string' fast path and the .map() - both were no-ops as you showed. Three branches, identical behavior, and return String(text) is now the string path so the Codecov flag is gone too.


export function updateTextPreviewWidgets(
node: LGraphNode,
message: { text?: string | string[] }
message: NodeExecutionOutput | null | undefined
) {
const preview = node.widgets?.find((w) => w.name === PREVIEW_WIDGET_NAME)
if (!preview) return

const text = message.text ?? ''
preview.value = Array.isArray(text) ? text.join('\n\n') : text
preview.value = toPreviewText(message?.text)
}
Loading