Skip to content

Commit 5a2d47d

Browse files
authored
Merge branch 'main' into cb/track-a-subgraph-creation-repro
2 parents 74a1030 + fb030dc commit 5a2d47d

78 files changed

Lines changed: 3288 additions & 889 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
44

55
- `docs/guidance/engineering.md` — general engineering guidelines, project philosophy, code-review checklist, external resource links
66
- `docs/guidance/vue-components.md` — Vue 3 Composition API best practices
7+
- `docs/guidance/state-and-effects.md` — modelling a feature's state: one discriminated union, named events, a pure transition, effects reserved for synchronising outward
78
- `docs/guidance/typescript.md` — TypeScript type-safety rules
89
- `docs/guidance/vitest.md` — Vitest unit/component test conventions
910
- `docs/guidance/playwright.md` — Playwright E2E conventions and API-mock typing table

apps/website/e2e/events.spec.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,22 +124,42 @@ test.describe('Events page — desktop @smoke', () => {
124124
test('a video slide that ends while hovered advances once the pointer leaves', async ({
125125
page
126126
}) => {
127-
test.skip(
128-
!featuredEvents.some((event) => event.media.type === 'video'),
129-
'needs a featured video slide'
127+
const firstVideoIndex = featuredEvents.findIndex(
128+
(event) => event.media.type === 'video'
130129
)
130+
test.skip(firstVideoIndex < 0, 'needs a featured video slide')
131131

132132
await page.goto(PATH_EN)
133133
const hero = heroSection(page, 'en')
134134
const nextSlide = hero.getByRole('button', {
135135
name: t('events.hero.nextSlide', 'en')
136136
})
137137
await nextSlide.scrollIntoViewIfNeeded()
138+
// Hovering pauses auto-advance, so the carousel only moves on our clicks and
139+
// then holds the video slide once the pointer stays inside.
138140
await nextSlide.hover()
139141

142+
const activeSlide = hero.locator('[aria-hidden="false"]')
143+
144+
// The first featured slide is an image; advance to the first video slide.
145+
// Retry to wait out island hydration, but click only while the active slide
146+
// is not yet the video slide so a slow render never overshoots past it.
147+
const videoSlideTitle = featuredEvents[firstVideoIndex].title.en
148+
await expect(async () => {
149+
const activeLabel = await activeSlide
150+
.locator('a')
151+
.getAttribute('aria-label')
152+
if (activeLabel !== videoSlideTitle) await nextSlide.click()
153+
await expect(activeSlide.locator('a')).toHaveAccessibleName(
154+
videoSlideTitle
155+
)
156+
}).toPass({ timeout: 15_000 })
157+
// Clicking left the button focused; drop that focus so only the hover holds
158+
// the slide, letting the pointer leaving be what releases the advance.
159+
await nextSlide.blur()
160+
140161
// With the pointer inside the carousel, the active video finishes (the
141162
// fixture serves a 0.12s placeholder) and the carousel holds its slide.
142-
const activeSlide = hero.locator('[aria-hidden="false"]')
143163
await expect
144164
.poll(() =>
145165
activeSlide

apps/website/src/data/events.ts

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -201,11 +201,6 @@ export function deriveFeaturedEvents(
201201
}))
202202
}
203203

204-
const showdownStreamHref: LocalizedText = {
205-
en: 'https://www.youtube.com/live/VeG1bveKZco',
206-
'zh-CN': 'https://www.youtube.com/live/VeG1bveKZco'
207-
}
208-
209204
// zh-CN copy is a first pass and pending native review.
210205
const events: readonly ComfyEvent[] = [
211206
{
@@ -230,7 +225,15 @@ const events: readonly ComfyEvent[] = [
230225
media: eventImage('livestream-aug-19.jpg', {
231226
en: 'Using Comfy to Go Beyond the Models livestream',
232227
'zh-CN': '善用 Comfy,超越模型本身直播'
233-
})
228+
}),
229+
featured: {
230+
order: 0,
231+
media: eventImage('livestream-aug-19.jpg', {
232+
en: 'Using Comfy to Go Beyond the Models livestream',
233+
'zh-CN': '善用 Comfy,超越模型本身直播'
234+
}),
235+
showTitle: false
236+
}
234237
},
235238
{
236239
id: 'future-ai-post-production',
@@ -277,25 +280,12 @@ const events: readonly ComfyEvent[] = [
277280
'zh-CN': 'Purz 与 Allyson 现场对决开源与付费 AI 视频模型,实测效果对比。'
278281
},
279282
location: { en: 'Online', 'zh-CN': '线上' },
280-
dateLabel: {
281-
en: 'August 12, 2026 · 10AM PT',
282-
'zh-CN': '2026年8月12日 · 上午10点(PT)'
283-
},
283+
media: eventImage('august-12-livestream_v2.png', {
284+
en: 'Video Model Showdown livestream recording',
285+
'zh-CN': '视频模型对决直播回放'
286+
}),
284287
startDateTime: '2026-08-12T10:00:00-07:00',
285-
link: { href: showdownStreamHref, newTab: true },
286-
liveVideoId: 'VeG1bveKZco',
287-
featured: {
288-
order: 3,
289-
media: eventVideo(
290-
'august-12-livestream.mp4',
291-
{
292-
en: 'Video Model Showdown livestream',
293-
'zh-CN': '视频模型对决直播'
294-
},
295-
'august-12-livestream.jpg'
296-
),
297-
autoplayMs: 5000
298-
}
288+
recordingVideoId: 'VeG1bveKZco'
299289
},
300290
{
301291
id: 'comfy-creatives-model-jam',

apps/website/src/i18n/translations.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1715,12 +1715,8 @@ const translations = {
17151715

17161716
// LearningCallToActionSection
17171717
'learning.cta.heading': {
1718-
en: 'Schedule a demo and see how ComfyUI fits your team’s creative needs.',
1719-
'zh-CN': '预约演示,了解 ComfyUI 如何契合你的团队创作需求。'
1720-
},
1721-
'learning.cta.contactSales': {
1722-
en: 'Contact Sales',
1723-
'zh-CN': '联系销售'
1718+
en: 'Everything Comfy ships. All in one place.',
1719+
'zh-CN': 'Comfy 的全部内容,一处尽享。'
17241720
},
17251721
'learning.cta.runComfy': {
17261722
en: 'Run Comfy for free',

browser_tests/fixtures/components/SidebarTab.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -476,21 +476,18 @@ export class AssetsSidebarTab extends SidebarTab {
476476
async openSettingsMenu() {
477477
await this.dismissToasts()
478478
await this.settingsButton.click()
479-
// Wait for popover content to render
480-
await this.listViewOption
481-
.or(this.gridSmallOption)
482-
.or(this.gridLargeOption)
483-
.first()
484-
.waitFor({ state: 'visible', timeout: 3000 })
479+
await expect(
480+
this.listViewOption
481+
.or(this.gridSmallOption)
482+
.or(this.gridLargeOption)
483+
.first()
484+
).toBeVisible()
485485
}
486486

487487
async openFilterMenu() {
488488
await this.dismissToasts()
489489
await this.filterButton.click()
490-
await this.mediaTypeFilterMenuItem.waitFor({
491-
state: 'visible',
492-
timeout: 3000
493-
})
490+
await expect(this.mediaTypeFilterMenuItem).toBeVisible()
494491
}
495492

496493
async closeFilterMenu() {
@@ -508,10 +505,7 @@ export class AssetsSidebarTab extends SidebarTab {
508505
return
509506
}
510507
await this.mediaTypeFilterMenuItem.click()
511-
await this.filterCheckbox('Image').waitFor({
512-
state: 'visible',
513-
timeout: 3000
514-
})
508+
await expect(this.filterCheckbox('Image')).toBeVisible()
515509
}
516510

517511
async toggleMediaTypeFilter(

browser_tests/tests/currentUserPopoverCredits.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type {
88
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
99
import type { operations } from '@/types/comfyRegistryTypes'
1010
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
11+
import { APP_URL, setupCloudApp } from '@e2e/fixtures/utils/cloudAppSetup'
12+
import { workspace } from '@e2e/fixtures/utils/workspaceMocks'
1113

1214
type CustomerBalanceResponse = NonNullable<
1315
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
@@ -164,4 +166,36 @@ test.describe('Current user popover credits row', { tag: '@cloud' }, () => {
164166
const resubscribeRight = resubscribeBox!.x + resubscribeBox!.width
165167
expect(resubscribeRight).toBeLessThanOrEqual(popoverRight)
166168
})
169+
170+
test(
171+
'renders Manage plan as a plain full-width menu row',
172+
{ tag: '@screenshot' },
173+
async ({ page }) => {
174+
test.setTimeout(60_000)
175+
await setupCloudApp(page, {
176+
workspace: workspace('personal', 'owner'),
177+
features: { subscription_required: false }
178+
})
179+
await page.goto(APP_URL)
180+
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
181+
timeout: 45_000
182+
})
183+
await page.getByRole('button', { name: 'Close dialog' }).click()
184+
await expect(page.getByTestId('dialog-overlay')).toBeHidden()
185+
186+
await page.getByRole('button', { name: 'Current user' }).click()
187+
188+
const managePlan = page.getByRole('button', { name: 'Manage plan' })
189+
await expect(managePlan).toBeVisible()
190+
await expect(managePlan).toHaveScreenshot('manage-plan-menu-item.png')
191+
192+
await managePlan.focus()
193+
await page.keyboard.press('Shift+Tab')
194+
await page.keyboard.press('Tab')
195+
await expect(managePlan).toBeFocused()
196+
await expect(managePlan).toHaveScreenshot(
197+
'manage-plan-menu-item-focused.png'
198+
)
199+
}
200+
)
167201
})
1.75 KB
Loading
1.83 KB
Loading

browser_tests/tests/gettingStartedTour.spec.ts

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,16 @@ import type { AssetResponse } from '@/platform/assets/schemas/assetSchema'
1111
import type { RemoteConfig } from '@/platform/remoteConfig/types'
1212
import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceApi'
1313

14-
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
14+
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
1515
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
16+
import { onboardingFixture } from '@e2e/fixtures/tourFixture'
17+
import type { Position } from '@e2e/fixtures/types'
1618
import { mockBilling } from '@e2e/fixtures/utils/cloudBillingMocks'
1719
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
20+
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
1821
import { webSocketFixture } from '@e2e/fixtures/ws'
1922

23+
const test = mergeTests(comfyPageFixture, onboardingFixture)
2024
const wstest = mergeTests(test, webSocketFixture)
2125

2226
const { firstRun } = enMessages.onboardingCoachmarks
@@ -113,6 +117,82 @@ async function firstPinnedTemplateOnScreen(
113117
return templateId!
114118
}
115119

120+
const DRAG_BY = { x: 120, y: 80 }
121+
122+
/** The node the spotlight is framing, by the id on its Vue element. */
123+
async function spotlitNodeId(page: Page, spotlight: Locator): Promise<string> {
124+
let nodeId: string | null = null
125+
126+
await expect
127+
.poll(
128+
async () => {
129+
const box = await spotlight.boundingBox()
130+
if (!box) return null
131+
132+
nodeId = await page.evaluate(
133+
({ x, y }) =>
134+
document
135+
.elementsFromPoint(x, y)
136+
.map((element) => element.closest('[data-node-id]'))
137+
.find(Boolean)
138+
?.getAttribute('data-node-id') ?? null,
139+
{ x: box.x + box.width / 2, y: box.y + box.height / 2 }
140+
)
141+
return nodeId
142+
},
143+
{ message: 'the spotlight never framed a Vue node' }
144+
)
145+
.not.toBeNull()
146+
147+
return nodeId!
148+
}
149+
150+
/** Waits until an element's position stops changing between frames. */
151+
async function settled(element: Locator) {
152+
let previous: number | null = null
153+
154+
await expect
155+
.poll(
156+
async () => {
157+
const box = await element.boundingBox()
158+
const stable = box !== null && box.x === previous
159+
previous = box?.x ?? null
160+
return stable
161+
},
162+
{ message: 'the node never stopped moving' }
163+
)
164+
.toBe(true)
165+
}
166+
167+
/** Where the spotlight sits relative to the node it frames. */
168+
async function framing(node: Locator, spotlight: Locator) {
169+
let seen: { node: Position; offset: Position } | null = null
170+
171+
await expect
172+
.poll(
173+
async () => {
174+
const [nodeBox, spotlightBox] = await Promise.all([
175+
node.boundingBox(),
176+
spotlight.boundingBox()
177+
])
178+
if (!nodeBox || !spotlightBox) return null
179+
180+
seen = {
181+
node: { x: nodeBox.x, y: nodeBox.y },
182+
offset: {
183+
x: spotlightBox.x - nodeBox.x,
184+
y: spotlightBox.y - nodeBox.y
185+
}
186+
}
187+
return seen
188+
},
189+
{ message: 'the node and its spotlight never both had layout' }
190+
)
191+
.not.toBeNull()
192+
193+
return seen!
194+
}
195+
116196
/**
117197
* The walk the review asked for: a fresh user reaches Getting Started, picks a
118198
* template, and the tour guides them through to a result.
@@ -311,6 +391,73 @@ test.describe('First-run tour', { tag: ['@cloud', '@ui'] }, () => {
311391
await expect(page.getByTestId('coach-spotlight')).toBeVisible()
312392
await expect(page.getByTestId('coach-card')).toContainText('Step 1 of')
313393
})
394+
395+
/**
396+
* The tour holds a node's layout ref for the whole tour, while the node's
397+
* own component holds the same ref only while it is mounted. Whatever
398+
* unmounts the node -- here a renderer toggle -- must not leave the
399+
* spotlight watching a ref the store has stopped notifying, or the
400+
* highlight sits on empty canvas while the user drags the node it is
401+
* meant to be pointing at.
402+
*/
403+
test('keeps the spotlight on its node after the node remounts', async ({
404+
comfyPage,
405+
comfyMouse,
406+
onboarding
407+
}) => {
408+
const { page } = comfyPage
409+
await expect(onboarding.spotlight).toBeVisible()
410+
411+
const nodeId = await spotlitNodeId(page, onboarding.spotlight)
412+
const node = new VueNodeFixture(comfyPage.vueNodes.getNodeLocator(nodeId))
413+
414+
// A pan moves every node, so a second node tells a drag from a pan.
415+
const otherId = (
416+
await comfyPage.vueNodes.nodes.evaluateAll((nodes) =>
417+
nodes.map((node) => node.getAttribute('data-node-id') ?? '')
418+
)
419+
).find((id) => id && id !== nodeId)
420+
expect(
421+
otherId,
422+
'a tour pins a source and a sink, so its graph has more than one node'
423+
).toBeDefined()
424+
const other = comfyPage.vueNodes.getNodeLocator(otherId!)
425+
426+
// The node component unmounts and comes back; the tour never let go.
427+
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
428+
await expect(comfyPage.vueNodes.nodes).toHaveCount(0)
429+
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
430+
await comfyPage.vueNodes.waitForNodes()
431+
await expect(onboarding.spotlight).toBeVisible()
432+
await settled(node.root)
433+
434+
const before = await framing(node.root, onboarding.spotlight)
435+
const otherBefore = await framing(other, onboarding.spotlight)
436+
437+
await comfyMouse.dragElementBy(node.header, DRAG_BY)
438+
439+
await expect
440+
.poll(async () => (await node.root.boundingBox())?.x, {
441+
message: 'the drag never moved the node, so it proves nothing'
442+
})
443+
.not.toBe(before.node.x)
444+
445+
const after = await framing(node.root, onboarding.spotlight)
446+
const otherAfter = await framing(other, onboarding.spotlight)
447+
448+
expect(
449+
otherAfter.node,
450+
'the untouched node moved too, so this panned the canvas'
451+
).toEqual(otherBefore.node)
452+
expect(
453+
after.offset.x,
454+
'the spotlight stopped following its node horizontally'
455+
).toBeCloseTo(before.offset.x, 0)
456+
expect(
457+
after.offset.y,
458+
'the spotlight stopped following its node vertically'
459+
).toBeCloseTo(before.offset.y, 0)
460+
})
314461
})
315462

316463
test.describe('arriving on a link that loads nothing', () => {

0 commit comments

Comments
 (0)