Skip to content

Commit 9bf8ac9

Browse files
authored
Merge branch 'main' into add-german-locale
2 parents 7e89bfa + f9ec97a commit 9bf8ac9

8 files changed

Lines changed: 350 additions & 65 deletions

File tree

browser_tests/fixtures/ComfyPage.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -645,11 +645,7 @@ export const comfyPageFixture = base.extend<{
645645
...(isVueNodes && { 'Comfy.VueNodes.Enabled': true }),
646646
...initialSettings
647647
}
648-
try {
649-
await comfyPage.setupSettings(startupSettings)
650-
} catch (e) {
651-
console.error(e)
652-
}
648+
await comfyPage.setupSettings(startupSettings)
653649
if (testInfo.tags.includes('@cloud')) {
654650
const context = page.context()
655651
await context.route('**/api/auth/session', (route) =>

browser_tests/tests/previewAsText.spec.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@ test.describe('Preview as Text node', () => {
5555
await comfyPage.menu.topbar.newWorkflowButton.click()
5656
await comfyPage.searchBoxV2.addNode('Preview as Text')
5757
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview as Text')
58-
const preview = node.root.locator('textarea')
58+
const preview = comfyPage.vueNodes.getWidgetByName(
59+
'Preview as Text',
60+
'preview_text'
61+
)
5962

6063
await test.step('node previews execution result', async () => {
6164
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
@@ -75,4 +78,59 @@ test.describe('Preview as Text node', () => {
7578
)
7679
}
7780
)
81+
82+
wstest(
83+
'renders the payloads users reported blank',
84+
{ tag: '@vue-nodes' },
85+
async ({ comfyPage, getWebSocket }) => {
86+
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
87+
88+
await comfyPage.menu.topbar.newWorkflowButton.click()
89+
await comfyPage.searchBoxV2.addNode('Preview as Text')
90+
const preview = comfyPage.vueNodes.getWidgetByName(
91+
'Preview as Text',
92+
'preview_text'
93+
)
94+
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
95+
const jobId = ''
96+
97+
const payloads = [
98+
['compact JSON from an LLM node', '{"name":"Comfy","emoji":"🌟"}'],
99+
['JSON array', '[{"a": 1}, {"b": 2}]'],
100+
['markdown-fenced JSON', '```json\n{"name":"Comfy"}\n```'],
101+
['non-ASCII text', '你好,世界。'],
102+
['prompt with a trailing space', '"A red car" is a great prompt. ']
103+
] as const
104+
105+
for (const [label, text] of payloads) {
106+
await test.step(label, async () => {
107+
execution.executed(jobId, id, { text: [text] })
108+
await expect(preview).toHaveValue(text)
109+
})
110+
}
111+
112+
await test.step('numeric output from Get Video Components', async () => {
113+
execution.executed(jobId, id, { text: 23.976 })
114+
await expect(preview).toHaveValue('23.976')
115+
})
116+
117+
await test.step('null text does not wedge the widget', async () => {
118+
// The shape the Cloud backend produced when it misclassified the text
119+
// as a filename and dropped it from the payload (BE-3601).
120+
execution.executed(jobId, id, { text: [null] })
121+
await expect(preview).toHaveValue('')
122+
123+
execution.executed(jobId, id, { text: ['recovered'] })
124+
await expect(preview).toHaveValue('recovered')
125+
})
126+
127+
await test.step('output with no text key does not wedge the widget', async () => {
128+
execution.executed(jobId, id, {})
129+
await expect(preview).toHaveValue('')
130+
131+
execution.executed(jobId, id, { text: ['recovered again'] })
132+
await expect(preview).toHaveValue('recovered again')
133+
})
134+
}
135+
)
78136
})

src/extensions/core/textPreviewWidgets.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
22

33
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
4+
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
45

56
interface MockWidget {
67
name: string
@@ -100,4 +101,42 @@ describe('updateTextPreviewWidgets', () => {
100101
updateTextPreviewWidgets(node, { text: 'hello' })
101102
expect(node.widgets[0].value).toBe('hello')
102103
})
104+
105+
it.for([
106+
['null message', null],
107+
['undefined message', undefined],
108+
['message without text', {}],
109+
['null text', { text: null }],
110+
['empty array', { text: [] }],
111+
['array of only nulls', { text: [null, null] }]
112+
] as [string, NodeExecutionOutput | null | undefined][])(
113+
'renders empty and does not throw for $0',
114+
([_label, message]) => {
115+
expect(() => updateTextPreviewWidgets(node, message)).not.toThrow()
116+
expect(node.widgets[0].value).toBe('')
117+
}
118+
)
119+
120+
it('drops null entries instead of rendering blank separators', () => {
121+
updateTextPreviewWidgets(node, {
122+
text: ['first', null, 'second']
123+
} as unknown as NodeExecutionOutput)
124+
125+
expect(node.widgets[0].value).toBe('first\n\nsecond')
126+
})
127+
128+
it('renders non-string entries rather than blanking the node', () => {
129+
updateTextPreviewWidgets(node, {
130+
text: ['first', 23.976, null, 'second']
131+
} as unknown as NodeExecutionOutput)
132+
133+
expect(node.widgets[0].value).toBe('first\n\n23.976\n\nsecond')
134+
})
135+
136+
it('stringifies a bare non-string scalar payload', () => {
137+
updateTextPreviewWidgets(node, {
138+
text: 23.976
139+
} as unknown as NodeExecutionOutput)
140+
expect(node.widgets[0].value).toBe('23.976')
141+
})
103142
})

src/extensions/core/textPreviewWidgets.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
22
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
33
import WidgetTextPreview from '@/renderer/extensions/vueNodes/widgets/components/WidgetTextPreview.vue'
4+
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
45
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
56
import { app } from '@/scripts/app'
67
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
@@ -66,13 +67,19 @@ export function addTextPreviewWidgets(node: LGraphNode) {
6667
modeWidget.serialize = false
6768
}
6869

70+
function toPreviewText(text: unknown): string {
71+
if (text == null) return ''
72+
if (Array.isArray(text))
73+
return text.filter((part) => part != null).join('\n\n')
74+
return String(text)
75+
}
76+
6977
export function updateTextPreviewWidgets(
7078
node: LGraphNode,
71-
message: { text?: string | string[] }
79+
message: NodeExecutionOutput | null | undefined
7280
) {
7381
const preview = node.widgets?.find((w) => w.name === PREVIEW_WIDGET_NAME)
7482
if (!preview) return
7583

76-
const text = message.text ?? ''
77-
preview.value = Array.isArray(text) ? text.join('\n\n') : text
84+
preview.value = toPreviewText(message?.text)
7885
}

src/renderer/extensions/firstRunTour/gettingStarted/firstRunEntry.test.ts

Lines changed: 130 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
33
import type * as VueUseCoreModule from '@vueuse/core'
44

55
import type { StartupOutcome } from '@/platform/workflow/persistence/base/draftTypes'
6+
import type { SharedWorkflowUrlLoadStatus } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader'
67

78
type VueUseCore = typeof VueUseCoreModule
89

@@ -77,6 +78,8 @@ async function freshEntry() {
7778
return useFirstRunEntry()
7879
}
7980

81+
type FirstRunEntry = Awaited<ReturnType<typeof freshEntry>>
82+
8083
describe('useFirstRunEntry', () => {
8184
beforeEach(() => {
8285
mocks.isCloud = true
@@ -88,8 +91,23 @@ describe('useFirstRunEntry', () => {
8891
mocks.setSetting.mockImplementation((key: string, value: unknown) => {
8992
mocks.settings[key] = value
9093
})
94+
// beginTour reports whether a tour actually started; default to the
95+
// ordinary case so only tests about a refused start have to say so.
96+
mocks.beginTour.mockResolvedValue(true)
9197
})
9298

99+
const permanentDisqualifiers = [
100+
['not on cloud', () => void (mocks.isCloud = false)],
101+
['a returning user', () => void (mocks.isNewUser = false)]
102+
] as const
103+
104+
const transientDisqualifiers = [
105+
['below the md breakpoint', () => void (mocks.isDesktopWidth = false)],
106+
['subscription disabled', () => void (mocks.subscriptionEnabled = false)],
107+
['new-user state undetermined', () => void (mocks.isNewUser = null)],
108+
['the tour flag off', () => void (mocks.tourFlag = false)]
109+
] as const
110+
93111
describe('what a fresh user sees', () => {
94112
it('shows Getting Started to a candidate', async () => {
95113
const entry = await freshEntry()
@@ -100,18 +118,6 @@ describe('useFirstRunEntry', () => {
100118
expect(mocks.execute).not.toHaveBeenCalled()
101119
})
102120

103-
const permanentDisqualifiers: [string, () => void][] = [
104-
['not on cloud', () => void (mocks.isCloud = false)],
105-
['a returning user', () => void (mocks.isNewUser = false)]
106-
]
107-
108-
const transientDisqualifiers: [string, () => void][] = [
109-
['below the md breakpoint', () => void (mocks.isDesktopWidth = false)],
110-
['subscription disabled', () => void (mocks.subscriptionEnabled = false)],
111-
['new-user state undetermined', () => void (mocks.isNewUser = null)],
112-
['the tour flag off', () => void (mocks.tourFlag = false)]
113-
]
114-
115121
it.for([...permanentDisqualifiers, ...transientDisqualifiers])(
116122
'opens the template browser instead, with %s',
117123
async ([, disqualify]) => {
@@ -174,7 +180,7 @@ describe('useFirstRunEntry', () => {
174180
})
175181
})
176182

177-
it('marks a url-intent startup completed without taking over the screen', async () => {
183+
it('marks a url-intent startup completed once its tour starts, without taking over the screen', async () => {
178184
const entry = await freshEntry()
179185

180186
await entry.handleStartupOutcome('url-intent')
@@ -184,12 +190,86 @@ describe('useFirstRunEntry', () => {
184190
'A share or template link is the user’s choice; onboarding must not cover it'
185191
).toBe(false)
186192
expect(mocks.execute).not.toHaveBeenCalled()
193+
194+
await entry.handleUrlWorkflow('url-intent', 'image_z_image_turbo')
195+
187196
expect(
188197
mocks.setSetting,
189198
'Without this the template browser reopens on every launch, as it did before this flow existed'
190199
).toHaveBeenCalledWith('Comfy.TutorialCompleted', true)
191200
})
192201

202+
/** Both shapes of URL a first-run boot can arrive on. */
203+
const urlArrivals = [
204+
['a template link', 'image_z_image_turbo', undefined],
205+
['a share link', undefined, 'loaded']
206+
] as const satisfies readonly [
207+
string,
208+
string | undefined,
209+
SharedWorkflowUrlLoadStatus | undefined
210+
][]
211+
212+
const deferredUrlBoots = transientDisqualifiers.flatMap(([why, disqualify]) =>
213+
urlArrivals.map(
214+
([arrival, templateId, sharedStatus]) =>
215+
[
216+
`${arrival} with ${why}`,
217+
disqualify,
218+
templateId,
219+
sharedStatus
220+
] as const
221+
)
222+
)
223+
224+
it.for(deferredUrlBoots)(
225+
'runs a url-intent boot to the end for %s, offering no tour and keeping eligibility',
226+
async ([, disqualify, templateId, sharedStatus]) => {
227+
disqualify()
228+
const entry = await freshEntry()
229+
230+
await entry.handleStartupOutcome('url-intent')
231+
await entry.handleUrlWorkflow('url-intent', templateId, sharedStatus)
232+
233+
expect(
234+
mocks.beginTour,
235+
'an ineligible boot has no tour to give'
236+
).not.toHaveBeenCalled()
237+
expect(
238+
entry.gettingStartedVisible.value,
239+
'the link is the user’s choice; onboarding must not cover it'
240+
).toBe(false)
241+
expect(
242+
mocks.setSetting,
243+
'no tour ran, so the write-once flag that pays for one must stay unspent for the boot that can lift this'
244+
).not.toHaveBeenCalled()
245+
}
246+
)
247+
248+
it('tours a template link on the boot after one that only deferred', async () => {
249+
mocks.isDesktopWidth = false
250+
const phone = await freshEntry()
251+
await phone.handleStartupOutcome('url-intent')
252+
await phone.handleUrlWorkflow('url-intent', 'image_z_image_turbo')
253+
254+
expect(mocks.beginTour).not.toHaveBeenCalled()
255+
256+
mocks.isDesktopWidth = true
257+
// What `checkIsNewUser()` reads on the next launch.
258+
mocks.isNewUser = !mocks.settings['Comfy.TutorialCompleted']
259+
const laptop = await freshEntry()
260+
await laptop.handleStartupOutcome('url-intent')
261+
await laptop.handleUrlWorkflow('url-intent', 'image_z_image_turbo')
262+
263+
expect(
264+
mocks.beginTour,
265+
'opening a template link on a phone and returning on a laptop is ordinary behaviour'
266+
).toHaveBeenCalledWith('image_z_image_turbo')
267+
expect(
268+
mocks.settings['Comfy.TutorialCompleted'],
269+
'the flag is spent on the boot that finally delivered the tour, not before'
270+
).toBe(true)
271+
})
272+
193273
it('never onboards over restored work, even for an apparent new user', async () => {
194274
const entry = await freshEntry()
195275

@@ -205,18 +285,34 @@ describe('useFirstRunEntry', () => {
205285
).not.toHaveBeenCalled()
206286
})
207287

208-
it.for(['fresh', 'url-intent'] as const)(
288+
/**
289+
* A `url-intent` boot only settles once `handleUrlWorkflow` has seen what the
290+
* link loaded, so the invariant is asserted over the pair of handlers that
291+
* GraphCanvas awaits in turn, not over the first one alone.
292+
*/
293+
const startups: [StartupOutcome, (entry: FirstRunEntry) => Promise<void>][] =
294+
[
295+
['fresh', async () => {}],
296+
[
297+
'url-intent',
298+
(entry) => entry.handleUrlWorkflow('url-intent', 'image_z_image_turbo')
299+
]
300+
]
301+
302+
it.for(startups)(
209303
'settles the first-run decision on a %s startup rather than leaving it pending',
210-
async (outcome: StartupOutcome) => {
304+
async ([outcome, finishBoot]) => {
211305
const entry = await freshEntry()
212306

213307
await entry.handleStartupOutcome(outcome)
308+
await finishBoot(entry)
214309

215310
expect(
216311
entry.gettingStartedVisible.value ||
312+
mocks.beginTour.mock.calls.length > 0 ||
217313
mocks.setSetting.mock.calls.length > 0 ||
218314
mocks.execute.mock.calls.length > 0,
219-
'a startup that opened a blank canvas must offer onboarding, record that it is done, or open the template browser; anything else strands the user with an empty screen'
315+
'a startup that opened a blank canvas must offer onboarding, tour what the link loaded, record that it is done, or open the template browser; anything else strands the user with an empty screen'
220316
).toBe(true)
221317
}
222318
)
@@ -259,7 +355,7 @@ describe('useFirstRunEntry', () => {
259355
).toHaveBeenCalledWith(undefined)
260356
})
261357

262-
it('leaves the completion flag to the startup handler that already settled it', async () => {
358+
it('leaves the completion flag alone when the engine refused to start', async () => {
263359
const entry = await freshEntry()
264360
await entry.handleStartupOutcome('url-intent')
265361
mocks.setSetting.mockClear()
@@ -269,7 +365,23 @@ describe('useFirstRunEntry', () => {
269365

270366
expect(
271367
mocks.setSetting,
272-
'writing it again here would mark onboarding done for a user whose tour never started, and postpone() exists to offer that user the tour again'
368+
'writing it here would mark onboarding done for a user whose tour never started, and postpone() exists to offer that user the tour again'
369+
).not.toHaveBeenCalled()
370+
})
371+
372+
it('keeps the account eligible when the link loaded nothing to tour', async () => {
373+
const entry = await freshEntry()
374+
375+
await entry.handleStartupOutcome('url-intent')
376+
await entry.handleUrlWorkflow('url-intent', undefined, 'failed')
377+
378+
expect(
379+
mocks.beginTour,
380+
'there is no workflow on the canvas to tour'
381+
).not.toHaveBeenCalled()
382+
expect(
383+
mocks.setSetting,
384+
'a dead link must not spend the one tour the account gets; the next boot has no URL to honour and offers Getting Started instead'
273385
).not.toHaveBeenCalled()
274386
})
275387

0 commit comments

Comments
 (0)