Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6583e65
feat(workspace): creator-only canManageSubscriptionLifecycle permissi…
dante01yoon Jun 13, 2026
b4ee092
fix(workspace): pass is_creator through the shared type + auth schema…
dante01yoon Jun 13, 2026
1bc10fa
docs(workspace): mark is_creator shape confirmed by BE (FE-770)
dante01yoon Jun 17, 2026
8c098ee
docs(workspace): note generated ingest-types swap once is_creator shi…
dante01yoon Jun 17, 2026
96ac29b
docs(workspace): reword ingest-types TODO without PR-scoped phrasing …
dante01yoon Jun 18, 2026
a51183a
fix(workspace): gate creator lifecycle on members-list original owner…
dante01yoon Jun 18, 2026
0092a45
fix(workspace): type is_original_owner as optional to match the BE co…
dante01yoon Jun 18, 2026
2d109b2
feat(billing): gate cancel/reactivate to the original owner (FE-978) …
dante01yoon Jun 19, 2026
8278c9d
refactor(workspace): move members preload into store ensureMembersLoaded
dante01yoon Jun 19, 2026
dde38af
fix(workspace): only preload members for team workspaces
dante01yoon Jun 19, 2026
0f28e1b
Merge remote-tracking branch 'origin/jaewon/fe-770-creator-lifecycle-…
dante01yoon Jun 19, 2026
0708c01
feat(billing): deep link to open the pricing table (FE-1104)
dante01yoon Jun 19, 2026
c9babcd
test(billing): add @cloud e2e for the pricing deep link (FE-1104)
dante01yoon Jun 19, 2026
4cd6f2f
fix(billing): await members fetch before the deep-link gate (FE-1104)
dante01yoon Jun 19, 2026
20b41a0
fix(billing): guarantee pricing deep-link URL cleanup before await
dante01yoon Jun 19, 2026
2652de6
fix(test): repoint AppMode type import to @/utils/appMode
dante01yoon Jun 19, 2026
a82a3af
test: stabilize mask editor screenshot e2e (#13011)
jaeone94 Jun 19, 2026
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
2 changes: 1 addition & 1 deletion browser_tests/fixtures/helpers/WorkflowHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { readFileSync } from 'fs'

import { test } from '@playwright/test'

import type { AppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/utils/appMode'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
Expand Down
243 changes: 243 additions & 0 deletions browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'

import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
Member,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'

import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'

/**
* The `?pricing=` deep link opens the pricing table on app load, gated to the
* original owner (canManageSubscriptionLifecycle). Drives a raw `page` so the
* cloud app boots against fully mocked endpoints, like the survey-gate spec.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'

// CloudAuthHelper.mockAuth() signs in as this email; the original-owner gate
// matches it against the members self-row.
const SELF_EMAIL = 'e2e@test.comfy.org'

function jsonRoute(body: unknown) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
}
}

async function mockCloudBoot(page: Page) {
// `/api/features` is the remote-config source; enable team workspaces so the
// unified pricing table (and the lifecycle gate) are live.
await page.route('**/api/features', (r) =>
r.fulfill(
jsonRoute({ team_workspaces_enabled: true } satisfies RemoteConfig)
)
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
await page.route('**/api/user', (r) =>
r.fulfill(jsonRoute({ status: 'active' }))
)
// Disable the experimental Asset API: with it on (cloud default) the
// unmocked asset endpoints 403 and workflow restore throws uncaught,
// aborting the GraphCanvas onMounted chain before the deep-link loader.
await page.route('**/api/settings', (r) =>
r.fulfill(jsonRoute({ 'Comfy.Assets.UseAssetAPI': false }))
)
await page.route('**/api/settings/**', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
// Queue/prompt status: a missing exec_info throws on boot and aborts the
// GraphCanvas onMounted chain before the deep-link loader runs.
await page.route('**/api/prompt', (r) =>
r.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
)
await page.route('**/api/queue', (r) =>
r.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
)
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
}

async function mockBilling(page: Page) {
// Minimal valid shapes so the billing facade resolves while the dialog mounts.
await page.route('**/api/billing/status', (r) =>
r.fulfill(
jsonRoute({
is_active: true,
has_funds: true,
subscription_status: 'active',
subscription_tier: 'pro',
subscription_duration: 'MONTHLY',
billing_status: 'paid'
})
)
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(jsonRoute({ plans: [] }))
)
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(jsonRoute({ is_active: false }))
)
await page.route('**/customers/balance', (r) =>
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
)
}

function workspace(
type: 'personal' | 'team',
role: 'owner' | 'member'
): WorkspaceWithRole {
return {
id: `ws-${type}`,
name: type === 'team' ? 'My Team' : 'Personal Workspace',
type,
role,
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z'
}
}

async function mockWorkspace(
page: Page,
ws: WorkspaceWithRole,
members: Member[]
) {
await page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill(jsonRoute({ workspaces: [ws] }))
})
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: { id: ws.id, name: ws.name, type: ws.type },
role: ws.role,
permissions: []
})
)
)
await page.route('**/api/workspace/members**', (r) =>
r.fulfill(
jsonRoute({
members,
pagination: { offset: 0, limit: 50, total: members.length }
})
)
)
}

async function bootCloud(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
// Pre-select the mock user to skip the user-select screen.
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
}

const pricingHeading = (page: Page) =>
page.getByRole('heading', { name: 'Choose a Plan' })

function member(
overrides: Partial<Member> & Pick<Member, 'email' | 'role'>
): Member {
return {
id: `user-${overrides.email}`,
name: overrides.email,
joined_at: '2026-01-01T00:00:00Z',
is_original_owner: false,
...overrides
}
}

test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
test('opens the pricing table for a personal owner', async ({ page }) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockBilling(page)
await mockWorkspace(page, workspace('personal', 'owner'), [])
await bootCloud(page)

await page.goto(`${APP_URL}/?pricing=1`)

await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
await expect(page).not.toHaveURL(/[?&]pricing=/)
})

test('opens on the Team tab for ?pricing=team', async ({ page }) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockBilling(page)
await mockWorkspace(page, workspace('personal', 'owner'), [])
await bootCloud(page)

await page.goto(`${APP_URL}/?pricing=team`)

await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
await expect(
page.getByRole('button', { name: 'For Teams' })
).toHaveAttribute('aria-pressed', 'true')
})

test('opens for a team original owner', async ({ page }) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockBilling(page)
await mockWorkspace(page, workspace('team', 'owner'), [
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
])
await bootCloud(page)

await page.goto(`${APP_URL}/?pricing=1`)

await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
})

test('is a silent no-op for a team member', async ({ page }) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockBilling(page)
await mockWorkspace(page, workspace('team', 'member'), [
member({
email: 'creator@test.comfy.org',
role: 'owner',
is_original_owner: true
}),
member({ email: SELF_EMAIL, role: 'member' })
])
await bootCloud(page)

await page.goto(`${APP_URL}/?pricing=1`)

await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await expect(page).not.toHaveURL(/[?&]pricing=/)
await expect(pricingHeading(page)).toBeHidden()
})
})
4 changes: 4 additions & 0 deletions browser_tests/tests/maskEditor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(dialog.getByText('Save')).toBeVisible()
await expect(dialog.getByText('Cancel')).toBeVisible()

await dialog.getByTestId('pointer-zone').hover()
await dialog.getByText('Brush Settings').hover()
await expect(dialog.getByTestId('brush-cursor')).toHaveCSS('opacity', '0')

await comfyPage.expectScreenshot(dialog, 'mask-editor-dialog-open.png')
}
)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions src/components/graph/GraphCanvas.vue
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { isCloud } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { usePricingTableUrlLoader } from '@/platform/cloud/subscription/composables/usePricingTableUrlLoader'
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'

Expand Down Expand Up @@ -458,6 +459,7 @@ const { flags } = useFeatureFlags()
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
const pricingTableUrlLoader = isCloud ? usePricingTableUrlLoader() : null
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
Expand Down Expand Up @@ -583,6 +585,19 @@ onMounted(async () => {
}
}

// Open the pricing table from URL if present (e.g., ?pricing=1 / ?pricing=team).
// Not gated on the team-workspaces flag: it also drives personal/legacy users.
if (pricingTableUrlLoader) {
try {
await pricingTableUrlLoader.loadPricingTableFromUrl()
} catch (error) {
console.error(
'[GraphCanvas] Failed to load pricing table from URL:',
error
)
}
}

// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } =
await import('@/platform/updates/common/releaseStore')
Expand Down
Loading
Loading