Compare commits

..

7 Commits

Author SHA1 Message Date
huang47
6e0af560cc feat: filter disabled partner nodes from discovery 2026-07-17 02:00:29 -07:00
huang47
cbdacb3316 feat: add partner node enforcement control 2026-07-17 01:32:55 -07:00
huang47
4e27f203c9 fix: address allowlist review feedback 2026-07-17 01:25:48 -07:00
huang47
9459aa1b04 feat: add partner node allowlist editor 2026-07-17 00:49:05 -07:00
huang47
71f37c4daf test: cover governance eligibility gates 2026-07-17 00:00:59 -07:00
huang47
8938305de7 test: use real policy responses 2026-07-16 23:55:05 -07:00
huang47
a4ffecd49d feat: add partner node governance foundation 2026-07-16 23:52:11 -07:00
111 changed files with 1912 additions and 23025 deletions

3
.gitattributes vendored
View File

@@ -6,6 +6,3 @@ packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true
packages/ingest-types/src/types.gen.ts linguist-generated=true
packages/ingest-types/src/zod.gen.ts linguist-generated=true
src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true
# Vendored upstream workflow templates used as resolver test fixtures
src/renderer/extensions/firstRunTour/__fixtures__/templates/*.json linguist-generated=true

View File

@@ -4,7 +4,6 @@ import { config as dotenvConfig } from 'dotenv'
import MCR from 'monocart-coverage-reports'
import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig'
import { TOURS, TOUR_SEEN_SETTING } from '@/platform/onboarding/onboardingTours'
import { NodeBadgeMode } from '@/types/nodeSource'
import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar'
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
@@ -543,8 +542,6 @@ export const comfyPageFixture = base.extend<{
'Comfy.userId': userId,
// Set tutorial completed to true to avoid loading the tutorial workflow.
'Comfy.TutorialCompleted': true,
// An auto-opened tour's blocker would break unrelated tests.
[TOUR_SEEN_SETTING]: Object.keys(TOURS),
'Comfy.Queue.MaxHistoryItems': 64,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
// Disable toast warning about version compatibility, as they may or

View File

@@ -1,77 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { TOUR_SEEN_SETTING } from '@/platform/onboarding/onboardingTours'
export type CoachTour = 'appMode'
/** Accessible name of each tour's in-app replay (help) button. */
const TOUR_REPLAY_BUTTONS: Record<CoachTour, string> = {
appMode: 'Take a tour of App Mode'
}
/** Coach-mark overlay (src/platform/onboarding/TourOverlay.vue). */
export class OnboardingCoachmarks {
public readonly landing: Locator
public readonly landingStartButton: Locator
public readonly landingSkipButton: Locator
/** The current spotlight step card (the dialog carrying a "Step N of M" label). */
public readonly card: Locator
public readonly cardNextButton: Locator
public readonly cardDoneButton: Locator
constructor(public readonly page: Page) {
this.landing = page.getByTestId('coach-landing')
this.landingStartButton = this.landing.getByRole('button', {
name: 'Start tutorial'
})
this.landingSkipButton = this.landing.getByRole('button', {
name: 'Skip',
exact: true
})
this.card = page.getByRole('dialog').filter({ hasText: /Step \d+ of \d+/ })
this.cardNextButton = this.card.getByRole('button', { name: 'Next' })
this.cardDoneButton = this.card.getByRole('button', { name: 'Done' })
}
/** The tour's in-app help button, which replays it past the seen-flag. */
replayButton(tour: CoachTour): Locator {
return this.page.getByRole('button', { name: TOUR_REPLAY_BUTTONS[tour] })
}
/** The spotlight card while it is showing the given step number. */
cardForStep(step: number): Locator {
return this.card.filter({ hasText: new RegExp(`Step ${step} of `) })
}
/**
* Clears the pre-seeded seen-flag (so dismissal assertions observe it being
* set again) and clicks the tour's replay button, which must be mounted.
*/
async startTour(tour: CoachTour) {
await this.clearSeen()
await this.replayButton(tour).click()
}
private async clearSeen() {
await this.page.evaluate(
async (key) => window.app!.extensionManager.setting.set(key, []),
TOUR_SEEN_SETTING
)
}
/** An element a tour points at, by its `data-coach-id` anchor. */
coachAnchor(id: string): Locator {
return this.page.locator(`[data-coach-id="${id}"]`)
}
async seen(tour: CoachTour): Promise<boolean> {
const seen = await this.page.evaluate(
async (key) =>
(await window.app!.extensionManager.setting.get(key)) as
| string[]
| undefined,
TOUR_SEEN_SETTING
)
return !!seen?.includes(tour)
}
}

View File

@@ -1,11 +0,0 @@
import { test as base } from '@playwright/test'
import { OnboardingCoachmarks } from '@e2e/fixtures/components/Tour'
export const onboardingFixture = base.extend<{
onboarding: OnboardingCoachmarks
}>({
onboarding: async ({ page }, use) => {
await use(new OnboardingCoachmarks(page))
}
})

View File

@@ -0,0 +1,126 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { ListAssetsResponse } from '@comfyorg/ingest-types'
import type { PartnerNodePolicyResponse } from '@/platform/workspace/api/partnerNodePolicyApi'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { WORKSPACE_FEATURE_FLAG } from '@e2e/fixtures/data/cloudWorkspace'
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
function partnerNode(
name: string,
displayName: string,
provider: string
): ComfyNodeDef {
return {
name,
display_name: displayName,
category: `partner/image/${provider}`,
python_module: `comfy_api_nodes.${provider.toLocaleLowerCase()}`,
description: '',
input: {},
output: [],
output_is_list: [],
output_name: [],
output_node: false,
api_node: true
}
}
async function openAllowlist(page: Page) {
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await page
.getByRole('button', { name: /^Settings/ })
.first()
.click()
const dialog = page.getByTestId('settings-dialog')
await expect(dialog).toBeVisible()
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
const content = dialog.getByRole('main')
await content.getByRole('tab', { name: 'Allowlist' }).click()
await expect(
content.getByRole('heading', { name: 'Partner nodes' })
).toBeVisible()
return content
}
test.describe('Partner node allowlist', { tag: '@cloud' }, () => {
test.describe.configure({ timeout: 60_000 })
test('saves enforcement and allowlist as one policy', async ({ page }) => {
await new CloudWorkspaceMockHelper(page).setup()
await page.route('**/api/features', (route) =>
route.fulfill(
jsonRoute({
...WORKSPACE_FEATURE_FLAG,
partner_node_governance_enabled: true
} satisfies RemoteConfig)
)
)
await page.route('**/api/object_info', (route) =>
route.fulfill(
jsonRoute({
FluxFill: partnerNode('FluxFill', 'Flux Fill', 'BFL'),
FluxExpand: partnerNode('FluxExpand', 'Flux Expand', 'BFL'),
VeoVideo: partnerNode('VeoVideo', 'Veo Video', 'Google')
})
)
)
await page.route(/\/api\/assets(?:\?.*)?$/, (route) =>
route.fulfill(
jsonRoute({
assets: [],
total: 0,
has_more: false
} satisfies ListAssetsResponse)
)
)
let policy: PartnerNodePolicyResponse = {
enforcement_enabled: false,
nodes: { FluxFill: false, FluxExpand: true, VeoVideo: true }
} satisfies PartnerNodePolicyResponse
const updates: unknown[] = []
await page.route('**/api/workspace/partner-node-policy', (route) => {
if (route.request().method() === 'PUT') {
policy = route.request().postDataJSON() as PartnerNodePolicyResponse
updates.push(policy)
}
return route.fulfill(jsonRoute(policy))
})
const content = await openAllowlist(page)
const fluxFill = content.getByRole('switch', { name: 'Allow Flux Fill' })
await expect(fluxFill).not.toBeChecked()
await expect(
content.getByRole('switch', { name: 'Allow Flux Expand' })
).toBeChecked()
await content
.getByRole('switch', { name: 'Enforce partner node allowlist' })
.click()
await fluxFill.click()
await content.getByRole('button', { name: 'Save' }).click()
await expect(page.getByText('Partner node policy saved')).toBeVisible()
expect(updates).toEqual([
{
enforcement_enabled: true,
nodes: { FluxFill: true, FluxExpand: true, VeoVideo: true }
}
] satisfies PartnerNodePolicyResponse[])
})
})

View File

@@ -0,0 +1,97 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { ListAssetsResponse } from '@comfyorg/ingest-types'
import type { PartnerNodePolicyResponse } from '@/platform/workspace/api/partnerNodePolicyApi'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { WORKSPACE_FEATURE_FLAG } from '@e2e/fixtures/data/cloudWorkspace'
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
function partnerNode(name: string): ComfyNodeDef {
return {
name,
display_name: name,
category: 'partner/image/Acme',
python_module: 'comfy_api_nodes.acme',
description: '',
input: {},
output: [],
output_is_list: [],
output_name: [],
output_node: false,
api_node: true
}
}
async function setupGovernedWorkspace(page: Page) {
await new CloudWorkspaceMockHelper(page).setup()
await page.route('**/api/features', (route) =>
route.fulfill(
jsonRoute({
...WORKSPACE_FEATURE_FLAG,
partner_node_governance_enabled: true
} satisfies RemoteConfig)
)
)
await page.route('**/api/object_info', (route) =>
route.fulfill(
jsonRoute({
AllowedPartnerNode: partnerNode('AllowedPartnerNode'),
DisabledPartnerNode: partnerNode('DisabledPartnerNode')
})
)
)
await page.route(/\/api\/assets(?:\?.*)?$/, (route) =>
route.fulfill(
jsonRoute({
assets: [],
total: 0,
has_more: false
} satisfies ListAssetsResponse)
)
)
await page.route('**/api/workspace/partner-node-policy', (route) =>
route.fulfill(
jsonRoute({
enforcement_enabled: true,
nodes: {
AllowedPartnerNode: true,
DisabledPartnerNode: false
}
} satisfies PartnerNodePolicyResponse)
)
)
}
test.describe('Partner node governance discovery', { tag: '@cloud' }, () => {
test('hides disabled nodes from search', async ({ page }) => {
await setupGovernedWorkspace(page)
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await page.evaluate(async () => {
await window.app!.extensionManager.setting.set(
'Comfy.NodeSearchBoxImpl',
'default'
)
await window.app!.extensionManager.command.execute(
'Workspace.SearchBox.Toggle'
)
})
const search = page.getByRole('search')
await expect(search).toBeVisible()
await search.getByRole('combobox').fill('PartnerNode')
await expect(search.getByText('AllowedPartnerNode')).toBeVisible()
await expect(search.getByText('DisabledPartnerNode')).toHaveCount(0)
})
})

View File

@@ -1,135 +0,0 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { onboardingFixture } from '@e2e/fixtures/tourFixture'
import {
COACH_IDS,
TOUR_SEEN_SETTING
} from '@/platform/onboarding/onboardingTours'
const test = mergeTests(comfyPageFixture, onboardingFixture)
// Relies on the test server's default workflow (locally: pnpm dev:test).
test.describe('Onboarding coachmarks', { tag: '@ui' }, () => {
test.describe('app-mode tour', () => {
// With no tour pre-seeded as seen, entering the app auto-opens it.
test.use({
initialSettings: { [TOUR_SEEN_SETTING]: [] }
})
test('auto-opens on the welcome landing, focuses Start, and Skip dismisses it', async ({
comfyPage,
onboarding
}) => {
await comfyPage.appMode.enterAppModeWithInputs([])
const coach = onboarding
await expect(coach.landing).toBeVisible()
await expect(coach.landing.getByRole('heading')).toHaveText(
'Welcome to Apps'
)
await expect(coach.landingStartButton).toBeFocused()
await coach.landingSkipButton.click()
await expect(coach.landing).toBeHidden()
await expect.poll(() => coach.seen('appMode')).toBe(true)
})
test('Escape dismisses the welcome landing and marks it seen', async ({
comfyPage,
onboarding
}) => {
await comfyPage.appMode.enterAppModeWithInputs([])
const coach = onboarding
await expect(coach.landing).toBeVisible()
await expect(coach.landingStartButton).toBeFocused()
await comfyPage.page.keyboard.press('Escape')
await expect(coach.landing).toBeHidden()
await expect.poll(() => coach.seen('appMode')).toBe(true)
})
})
test.describe('coach anchors', () => {
test('every registry id resolves to an element (drift guard)', async ({
comfyPage,
onboarding
}) => {
const coach = onboarding
await comfyPage.appMode.enterAppModeWithInputs([])
// The assets panel only mounts once the assets sidebar tab is open.
for (const id of Object.values(COACH_IDS).filter(
(id) => id !== COACH_IDS.assetsPanel
)) {
await expect(coach.coachAnchor(id)).toBeVisible()
}
await comfyPage.menu.assetsTab.tabButton.click()
await expect(coach.coachAnchor(COACH_IDS.assetsPanel)).toBeVisible()
})
})
test.describe('spotlight focus', () => {
test('focuses the primary action, traps Tab in the card, and re-focuses per step', async ({
comfyPage,
onboarding
}) => {
const coach = onboarding
await comfyPage.page.emulateMedia({ reducedMotion: 'reduce' })
await comfyPage.appMode.enterAppModeWithInputs([])
await coach.startTour('appMode')
await expect(coach.landing).toBeVisible()
await coach.landingStartButton.click()
const step1 = coach.cardForStep(1)
await expect(step1).toBeVisible()
// FocusScope's mount-auto-focus is suppressed so focus lands on the
// primary action rather than the first focusable (Skip).
await expect(coach.cardNextButton).toBeFocused()
// Tab more times than the card has controls; the looped trap must keep
// focus inside the card, never escaping to the app behind the overlay.
for (let i = 0; i < 4; i++) {
await comfyPage.page.keyboard.press('Tab')
await expect(step1.locator(':focus')).toBeVisible()
}
await comfyPage.page.keyboard.press('Shift+Tab')
await expect(step1.locator(':focus')).toBeVisible()
await coach.cardNextButton.click()
await expect(coach.cardForStep(2)).toBeVisible()
await expect(coach.cardNextButton).toBeFocused()
})
})
test.describe('spotlight placement', () => {
test('every spotlight card stays fully within the viewport and Done completes the tour', async ({
comfyPage,
onboarding
}) => {
const coach = onboarding
// Read settled placements, not a transient mid-animation frame.
await comfyPage.page.emulateMedia({ reducedMotion: 'reduce' })
await comfyPage.appMode.enterAppModeWithInputs([])
await coach.startTour('appMode')
await expect(coach.landing).toBeVisible()
await coach.landingStartButton.click()
for (const step of [1, 2, 3]) {
const card = coach.cardForStep(step)
await expect(card).toBeVisible()
await expect(card).toBeInViewport({ ratio: 1 })
await coach.cardNextButton.click()
}
// The final assets step auto-opens the assets panel — no target click.
await expect(coach.cardForStep(4)).toBeInViewport({ ratio: 1 })
await coach.cardDoneButton.click()
await expect(coach.card).toBeHidden()
await expect.poll(() => coach.seen('appMode')).toBe(true)
})
})
})

View File

@@ -74,7 +74,6 @@
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@customerio/cdp-analytics-browser": "catalog:",
"@floating-ui/vue": "catalog:",
"@formkit/auto-animate": "catalog:",
"@iconify/json": "catalog:",
"@primeuix/forms": "catalog:",

View File

@@ -102,10 +102,6 @@
--color-alpha-magenta-700-60: #6a246a99;
--color-alpha-magenta-300-60: #ceaac999;
/* Onboarding coachmark overlay (always renders over a dark scrim) */
--color-coach-scrim: rgb(0 0 0 / 0.6);
--color-coach-ring: #fff;
/* PrimeVue pulled colors */
--color-muted: var(--p-text-muted-color);
--color-highlight: var(--p-primary-color);

6
pnpm-lock.yaml generated
View File

@@ -30,9 +30,6 @@ catalogs:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1
'@floating-ui/vue':
specifier: ^1.1.11
version: 1.1.11
'@formkit/auto-animate':
specifier: ^0.9.0
version: 0.9.0
@@ -474,9 +471,6 @@ importers:
'@customerio/cdp-analytics-browser':
specifier: 'catalog:'
version: 0.5.3
'@floating-ui/vue':
specifier: 'catalog:'
version: 1.1.11(vue@3.5.34(typescript@5.9.3))
'@formkit/auto-animate':
specifier: 'catalog:'
version: 0.9.0

View File

@@ -18,7 +18,6 @@ catalog:
'@comfyorg/comfyui-electron-types': 0.6.2
'@customerio/cdp-analytics-browser': ^0.5.3
'@eslint/js': ^10.0.1
'@floating-ui/vue': ^1.1.11
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

View File

@@ -15,7 +15,6 @@ const IGNORE_PATTERNS = [
/^dataTypes\./, // Data types might be referenced dynamically
/^contextMenu\./, // Context menu items might be dynamic
/^color\./, // Color names might be used dynamically
/^onboardingCoachmarks\.[^.]+\.[^.]+\./, // Step keys derived as onboardingCoachmarks.<tour>.<step>.*
// Auto-generated categories from collect-i18n-general.ts
/^menuLabels\./, // Menu labels generated from command labels
/^settingsCategories\./, // Settings categories generated from setting definitions

View File

@@ -11,7 +11,6 @@
<Panel
ref="panelRef"
data-testid="comfy-actionbar"
class="pointer-events-auto"
:style="style"
:class="panelClass"

View File

@@ -1,10 +1,6 @@
import { ZIndex } from '@primeuix/utils/zindex'
import type { Directive } from 'vue'
/** Shared PrimeVue/Reka modal stacking sequence; later registrations cover earlier ones. */
export const MODAL_Z_KEY = 'modal'
export const MODAL_Z_BASE = 1700
// Both Reka and PrimeVue dialogs can appear at any depth in dialogStack, in
// any order. PrimeVue auto-increments a per-key z-index counter so later
// dialogs always cover earlier ones; Reka uses a static z-1700 class which
@@ -13,7 +9,7 @@ export const MODAL_Z_BASE = 1700
// renderers share one stacking sequence: whichever dialog opens last wins.
export const vRekaZIndex: Directive<HTMLElement> = {
mounted(el) {
ZIndex.set(MODAL_Z_KEY, el, MODAL_Z_BASE)
ZIndex.set('modal', el, 1700)
},
beforeUnmount(el) {
ZIndex.clear(el)

View File

@@ -180,7 +180,6 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import { useFirstRunTourController } from '@/renderer/extensions/firstRunTour/useFirstRunTourController'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { UnauthorizedError } from '@/scripts/api'
@@ -505,9 +504,6 @@ useEventListener(
onMounted(async () => {
comfyApp.vueAppReady = true
workspaceStore.spinner = true
let templateFromUrl: Awaited<
ReturnType<typeof workflowPersistence.loadTemplateFromUrlIfPresent>
>
try {
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
@@ -565,38 +561,11 @@ onMounted(async () => {
// Restore saved workflow and workflow tabs state
await workflowPersistence.initializeWorkflow()
await workflowPersistence.restoreWorkflowTabsState()
templateFromUrl = await workflowPersistence.loadTemplateFromUrlIfPresent()
await workflowPersistence.loadTemplateFromUrlIfPresent()
} finally {
workspaceStore.spinner = false
}
const sharedFromUrl =
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
// A ?template=/?share= URL loads a workflow directly (Getting Started is
// skipped). Start the onboarding tour on it; beginTour() self-gates and reads
// the now-loaded graph, so the loaders above must have finished first.
const startTourFromUrl = (
options?: Parameters<
ReturnType<typeof useFirstRunTourController>['beginTour']
>[0]
) =>
useFirstRunTourController()
.beginTour(options)
.catch((error: unknown) => {
console.error('[onboardingTour] failed to start from URL', error)
})
if (templateFromUrl.loaded) {
void startTourFromUrl({
templateId: templateFromUrl.templateId,
entry: 'template_url'
})
} else if (
sharedFromUrl === 'loaded' ||
sharedFromUrl === 'loaded-without-assets'
) {
void startTourFromUrl({ entry: 'share_url' })
}
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
comfyApp.canvas.onSelectionChange = useChainCallback(
comfyApp.canvas.onSelectionChange,

View File

@@ -1,21 +1,14 @@
<template>
<div role="tablist" :class="cn('flex w-full items-center gap-2', className)">
<div role="tablist" class="flex w-full items-center gap-2">
<slot />
</div>
</template>
<script setup lang="ts" generic="T extends string = string">
import type { HTMLAttributes } from 'vue'
import { provide } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const modelValue = defineModel<T>({ required: true })
function select(value: string) {

View File

@@ -1,10 +0,0 @@
import {
breakpointsTailwind,
createSharedComposable,
useBreakpoints
} from '@vueuse/core'
/** `md`+ viewport — the width the onboarding tours need to place their coach-marks. */
export const useDesktopLayout = createSharedComposable(() =>
useBreakpoints(breakpointsTailwind).greaterOrEqual('md')
)

View File

@@ -182,6 +182,20 @@ describe('useFeatureFlags', () => {
})
})
describe('partnerNodeGovernanceEnabled', () => {
afterEach(() => {
remoteConfig.value = {}
})
it('uses the workspace eligibility flag', () => {
remoteConfig.value = { partner_node_governance_enabled: true }
const { flags } = useFeatureFlags()
expect(flags.partnerNodeGovernanceEnabled).toBe(true)
})
})
describe('dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()

View File

@@ -24,6 +24,7 @@ export enum ServerFeatureFlag {
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
PARTNER_NODE_GOVERNANCE_ENABLED = 'partner_node_governance_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements',
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
@@ -34,8 +35,7 @@ export enum ServerFeatureFlag {
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
BILLING_CONTROL_ENABLED = 'billing_control_enabled',
FREE_TIER_JOB_ALLOWANCE_ENABLED = 'free_tier_job_allowance_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile',
ONBOARDING_TOUR_ENABLED = 'onboarding_tour_enabled'
SIGNUP_TURNSTILE = 'signup_turnstile'
}
/**
@@ -135,6 +135,13 @@ export function useFeatureFlags() {
cachedTeamWorkspacesEnabled
)
},
get partnerNodeGovernanceEnabled() {
return resolveFlag(
ServerFeatureFlag.PARTNER_NODE_GOVERNANCE_ENABLED,
remoteConfig.value.partner_node_governance_enabled,
false
)
},
get userSecretsEnabled() {
return resolveFlag(
ServerFeatureFlag.USER_SECRETS_ENABLED,
@@ -220,13 +227,6 @@ export function useFeatureFlags() {
remoteConfig.value.signup_turnstile,
'off'
)
},
get onboardingTourEnabled() {
return resolveFlag(
ServerFeatureFlag.ONBOARDING_TOUR_ENABLED,
remoteConfig.value.onboarding_tour_enabled,
false
)
}
})

View File

@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const registerExtension = vi.hoisted(() => vi.fn())
const usePartnerNodeGovernanceStore = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
usePartnerNodeGovernanceStore
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({ registerExtension })
}))
describe('cloudPartnerNodeGovernance', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
})
it('initializes governance during Cloud setup', async () => {
await import('./cloudPartnerNodeGovernance')
expect(registerExtension).toHaveBeenCalledOnce()
const extension = registerExtension.mock.calls[0]?.[0] as {
name: string
setup: () => void
}
expect(extension.name).toBe('Comfy.Cloud.PartnerNodeGovernance')
extension.setup()
expect(usePartnerNodeGovernanceStore).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,10 @@
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.Cloud.PartnerNodeGovernance',
setup: () => {
usePartnerNodeGovernanceStore()
}
})

View File

@@ -38,6 +38,7 @@ if (isCloud) {
await import('./cloudRemoteConfig')
await import('./cloudBadges')
await import('./cloudSessionCookie')
await import('./cloudPartnerNodeGovernance')
}
// Feedback button for cloud and nightly builds

View File

@@ -2317,91 +2317,6 @@
"member": "Member"
}
},
"onboardingTour": {
"overlayLabel": "Getting started tour",
"preparing": "Building your template…",
"skip": "Skip",
"back": "Back",
"next": "Next",
"complete": "Finish",
"stepCounter": "{current} of {total}",
"step": {
"upload": {
"t2i": {
"title": "Start with an image",
"body": "This image feeds the workflow. Swap in your own whenever you like."
},
"i2v": {
"title": "Start with an image",
"body": "This picture is your first frame. Swap in your own whenever you like."
},
"image-edit": {
"title": "Start with an image",
"body": "This is the picture you'll edit. Swap in your own whenever you like."
},
"other": {
"title": "Start with an image",
"body": "This image feeds the workflow. Swap in your own whenever you like."
}
},
"prompt": {
"t2i": {
"title": "Describe what you want",
"body": "Your image gets built from this description. Try anything you like."
},
"i2v": {
"title": "Describe how it should move",
"body": "The image sets the scene. This tells it what happens next."
},
"image-edit": {
"title": "Say what to change",
"body": "Your image stays as it is. Describe what you want different."
},
"other": {
"title": "Tell it what to make",
"body": "This is your prompt. Change it to change your result."
}
},
"run": {
"title": "Run your workflow",
"body": "Press Run to start generating your result"
},
"result": {
"image": {
"title": "And there it is",
"body": "Your new image lands right here — ready to download or share."
},
"video": {
"title": "And there it is",
"body": "Your new video lands right here — ready to download or share."
}
}
},
"generating": "Generating…",
"gettingStarted": {
"title": "Get started with graph",
"subtitle": "Start a workflow from scratch or load one that's ready to go.",
"loadFailed": "Failed to load templates",
"screenLabel": "Get started with graph",
"tabsLabel": "Getting started options",
"tabs": {
"templates": "Templates",
"tutorials": "Tutorials"
},
"tutorials": {
"interfaceOverview": "Interface overview",
"textToImage": "Text to image",
"imageToImage": "Image to image",
"inpaint": "Inpainting"
}
},
"nudge": {
"title": "That was one of hundreds",
"body": "You just made your first. Explore what else you can build.",
"dismiss": "Not now",
"explore": "Explore templates"
}
},
"auth": {
"apiKey": {
"title": "API Key",
@@ -3004,7 +2919,27 @@
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
"membersCount": "Members ({count})"
"membersCount": "Members ({count})",
"allowlist": "Allowlist"
},
"allowlist": {
"title": "Partner nodes",
"description": "Choose which partner nodes are allowed when workspace enforcement is enabled.",
"searchPlaceholder": "Search partner nodes...",
"loading": "Loading partner node policy...",
"loadError": "Partner node policy could not be loaded.",
"retry": "Try again",
"empty": "No partner nodes are available.",
"noMatches": "No partner nodes match your search.",
"allowedCount": "{enabled} of {total} allowed",
"nodeToggle": "Allow {name}",
"saved": "Partner node policy saved",
"saveError": "Partner node policy could not be saved.",
"enforcement": {
"title": "Enforce partner node allowlist",
"description": "Block partner nodes that are not explicitly allowed.",
"toggle": "Enforce partner node allowlist"
}
},
"dashboard": {
"placeholder": "Dashboard workspace settings"
@@ -4667,37 +4602,5 @@
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
},
"onboardingCoachmarks": {
"stepLabel": "Step {current} of {total}",
"skip": "Skip",
"next": "Next",
"back": "Back",
"done": "Done",
"loadError": "Something went wrong showing this tour",
"appMode": {
"replay": "Take a tour of App Mode",
"landing": {
"title": "Welcome to Apps",
"body": "A quick tour of the essentials, in about a minute. We'll show you where to add inputs, run your app, and find your results.",
"primary": "Start tutorial"
},
"inputs": {
"title": "Add your inputs",
"body": "Add what you want to work with. Your inputs are what the app turns into results."
},
"run": {
"title": "Run your app",
"body": "Happy with your inputs? Hit Run and your result appears in the center the moment it's ready."
},
"outputs": {
"title": "Get your results",
"body": "Your finished results show up here in the center. Download them, or tweak an input and run again."
},
"assets": {
"title": "Find all your assets",
"body": "Every generation and import lives in Media Assets. Open it anytime to browse, download, or reuse past work."
}
}
}
}

View File

@@ -13,9 +13,6 @@ const DIALOG_KEY = 'subscription-required'
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
/** Dialog keys the subscription/upgrade flow opens; callers gate around these. */
export const UPGRADE_DIALOG_KEYS = [FREE_TIER_DIALOG_KEY, DIALOG_KEY] as const
export interface SubscriptionDialogOptions {
reason?: PaymentIntentSource
/**

View File

@@ -1,67 +0,0 @@
import { cleanup, render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it } from 'vitest'
import { h } from 'vue'
import CoachmarkCard from './CoachmarkCard.vue'
afterEach(cleanup)
describe('CoachmarkCard', () => {
it('renders the title, message and subtitle', () => {
render(CoachmarkCard, {
props: {
title: 'This is your canvas',
message: 'Scroll to zoom.',
subtitle: 'Step 1 of 3'
}
})
expect(screen.getByRole('heading')).toHaveTextContent('This is your canvas')
expect(screen.getByText('Scroll to zoom.')).toBeTruthy()
expect(screen.getByText('Step 1 of 3')).toBeTruthy()
})
it('applies titleId to the heading for aria-labelledby wiring', () => {
render(CoachmarkCard, {
props: { title: 'Heading', message: 'M', titleId: 'title-1' }
})
expect(screen.getByRole('heading').id).toBe('title-1')
})
it('applies messageId to the message for aria-describedby wiring', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'Body copy', messageId: 'desc-1' }
})
expect(screen.getByText('Body copy').id).toBe('desc-1')
})
it('omits the subtitle when not provided', () => {
render(CoachmarkCard, { props: { title: 'T', message: 'M' } })
expect(screen.queryByText('Step 1 of 3')).toBeNull()
})
it('renders the image when an image src is given', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'M', image: '/foo.png' }
})
expect(screen.getByAltText('')).toHaveAttribute('src', '/foo.png')
})
it('renders an image slot in place of the default image', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'M' },
slots: { image: () => h('img', { src: '/slot.png', alt: 'preview' }) }
})
expect(screen.getByRole('img', { name: 'preview' })).toHaveAttribute(
'src',
'/slot.png'
)
})
it('renders the actions slot', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'M' },
slots: { actions: () => h('button', 'Next') }
})
expect(screen.getByRole('button', { name: 'Next' })).toBeTruthy()
})
})

View File

@@ -1,50 +0,0 @@
<template>
<div
class="flex w-full flex-col items-start justify-center gap-3 rounded-2xl bg-secondary-background p-4 drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
>
<div
v-if="image || $slots.image"
class="flex h-[146px] flex-col items-start justify-center gap-4 self-stretch overflow-hidden rounded-xl bg-base-background"
>
<slot name="image">
<img v-if="image" :src="image" alt="" class="size-full object-cover" />
</slot>
</div>
<div class="flex flex-col items-end justify-end gap-6 self-stretch">
<div class="flex flex-col items-start gap-2 self-stretch">
<p
v-if="subtitle"
:id="subtitleId"
class="m-0 text-xs/normal text-base-foreground"
>
{{ subtitle }}
</p>
<h3
:id="titleId"
class="m-0 text-base/normal font-semibold text-base-foreground"
>
{{ title }}
</h3>
<p :id="messageId" class="m-0 text-sm/normal text-muted-foreground">
{{ message }}
</p>
</div>
<div v-if="$slots.actions" class="flex w-full items-center gap-3">
<slot name="actions" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
const { title, titleId, message, subtitle, subtitleId, image, messageId } =
defineProps<{
title: string
titleId?: string
message: string
subtitle?: string
subtitleId?: string
image?: string
messageId?: string
}>()
</script>

View File

@@ -1,67 +0,0 @@
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CoachmarkLanding from './CoachmarkLanding.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderLanding() {
return render(CoachmarkLanding, {
props: {
title: 'Welcome to Apps',
message: 'A quick tour of the essentials.',
primaryLabel: 'Start tutorial',
skipLabel: 'Skip',
waitingForTarget: false
},
global: { plugins: [i18n] }
})
}
describe('CoachmarkLanding', () => {
afterEach(cleanup)
it('renders a modal backdrop behind the landing card', async () => {
renderLanding()
expect(await screen.findByTestId('coach-landing-overlay')).toBeTruthy()
})
it('renders the title and message', async () => {
renderLanding()
expect(await screen.findByText('Welcome to Apps')).toBeTruthy()
expect(screen.getByText('A quick tour of the essentials.')).toBeTruthy()
})
it('emits start when the primary action is clicked', async () => {
const user = userEvent.setup()
const { emitted } = renderLanding()
await user.click(
await screen.findByRole('button', { name: 'Start tutorial' })
)
expect(emitted().start).toHaveLength(1)
})
it('emits skip when Skip is clicked', async () => {
const user = userEvent.setup()
const { emitted } = renderLanding()
await user.click(await screen.findByRole('button', { name: 'Skip' }))
expect(emitted().skip).toHaveLength(1)
})
it('emits skip when Escape is pressed', async () => {
const user = userEvent.setup()
const { emitted } = renderLanding()
await screen.findByText('Welcome to Apps')
await user.keyboard('{Escape}')
// The explicit listener and Reka's own dismiss may both fire here.
expect(emitted().skip?.length).toBeGreaterThanOrEqual(1)
})
})

View File

@@ -1,120 +0,0 @@
<template>
<!-- The Dialog wrapper boolean-casts an absent `modal` to false, which
suppresses the DialogOverlay backdrop; it must be passed explicitly. -->
<Dialog :open="true" modal @update:open="(value) => !value && emit('skip')">
<DialogPortal>
<DialogOverlay
v-reka-z-index
data-testid="coach-landing-overlay"
class="bg-coach-scrim"
/>
<DialogContent
v-reka-z-index
data-testid="coach-landing"
class="w-[800px] max-w-[calc(100vw-2.5rem)] overflow-hidden rounded-2xl border-border-default bg-secondary-background p-0 shadow-[0_24px_80px_rgba(0,0,0,0.85)] md:h-fit md:min-h-100 md:max-w-[800px] md:flex-row"
@pointer-down-outside.prevent
@open-auto-focus="onOpenAutoFocus"
>
<DialogClose as-child>
<Button
variant="muted-textonly"
size="icon"
:aria-label="t('g.close')"
class="absolute top-3 right-3 z-20"
>
<i class="icon-[lucide--x]" />
</Button>
</DialogClose>
<div
class="flex aspect-video items-center justify-center bg-base-background md:aspect-auto md:w-1/2"
>
<img
v-if="image"
:src="image"
alt=""
class="size-full object-cover"
/>
</div>
<div
class="flex flex-col items-start justify-between self-stretch px-15 pt-15 pb-10 md:w-1/2"
>
<div class="flex flex-col gap-2.5">
<DialogTitle
class="m-0 text-[28px] leading-normal font-medium text-base-foreground"
>
{{ title }}
</DialogTitle>
<DialogDescription
class="flex flex-col items-start gap-2 self-stretch text-base/5 text-muted-foreground"
>
{{ message }}
</DialogDescription>
</div>
<div class="flex w-full items-center justify-end gap-2">
<Button variant="secondary" size="lg" @click="emit('skip')">
{{ skipLabel }}
</Button>
<Button
ref="startButtonRef"
variant="inverted"
size="lg"
class="grow"
:disabled="waitingForTarget"
@click="emit('start')"
>
{{ primaryLabel }}
</Button>
</div>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogDescription from '@/components/ui/dialog/DialogDescription.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import Button from '@/components/ui/button/Button.vue'
import { vRekaZIndex } from '@/components/dialog/vRekaZIndex'
const { title, message, image, primaryLabel, skipLabel, waitingForTarget } =
defineProps<{
title: string
message: string
image?: string
primaryLabel: string
skipLabel: string
waitingForTarget: boolean
}>()
const emit = defineEmits<{
start: []
skip: []
}>()
const { t } = useI18n()
// The global keybinding handler preventDefaults Escape before Reka's
// DismissableLayer sees it, so Reka skips its own dismiss; do it explicitly.
useEventListener(document, 'keydown', (e) => {
if (e.key === 'Escape') emit('skip')
})
const startButtonRef = useTemplateRef('startButtonRef')
// Land focus on the primary action, not the close button Reka would pick.
function onOpenAutoFocus(event: Event) {
event.preventDefault()
const el = startButtonRef.value?.$el as HTMLButtonElement | undefined
el?.focus()
}
</script>

View File

@@ -1,127 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, reactive, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import TourOverlay from './TourOverlay.vue'
import type { CoachStep, EntryPath } from './onboardingTours'
import { useOnboardingTourStore } from './onboardingTourStore'
vi.mock('./onboardingTourStore', () => ({ useOnboardingTourStore: vi.fn() }))
function makeTourState() {
return {
activeTour: ref<EntryPath>('appMode'),
step: ref<CoachStep | null>(null),
title: ref('Canvas title'),
body: ref('Canvas body'),
isLast: ref(false),
primaryLabel: ref('Next'),
skipLabel: ref('Skip'),
countedStepIdx: ref(0),
countedStepsTotal: ref(0),
waitingForTarget: ref(false),
next: vi.fn(),
skip: vi.fn()
}
}
// Stubbed so the suite covers only TourOverlay's branching and intent wiring.
vi.mock('./TourSpotlight.vue', () => ({
default: defineComponent({
emits: ['advance', 'skip'],
setup(_, { emit }) {
return () =>
h('div', { 'data-testid': 'spotlight' }, [
h('button', { onClick: () => emit('advance') }, 'advance'),
h('button', { onClick: () => emit('skip') }, 'skip')
])
}
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
let s: ReturnType<typeof makeTourState>
const spotlightStep: CoachStep = {
name: 'run',
placement: 'right'
}
function landingStep(): CoachStep {
return { name: 'landing', placement: 'center', landing: true }
}
function renderOverlay() {
return render(TourOverlay, { global: { plugins: [i18n] } })
}
describe('TourOverlay', () => {
beforeEach(() => {
s = makeTourState()
vi.mocked(useOnboardingTourStore).mockReturnValue(fromPartial(reactive(s)))
})
afterEach(cleanup)
it('renders nothing when no tour step is active', () => {
renderOverlay()
expect(screen.queryByTestId('spotlight')).toBeNull()
expect(screen.queryByRole('dialog')).toBeNull()
})
it('renders the spotlight for a non-landing step and wires its intents', async () => {
const user = userEvent.setup()
s.step.value = spotlightStep
renderOverlay()
expect(screen.getByTestId('spotlight')).toBeTruthy()
await user.click(screen.getByRole('button', { name: 'advance' }))
expect(s.next).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: 'skip' }))
expect(s.skip).toHaveBeenCalledOnce()
})
it('renders the landing step and starts the tour on its primary action', async () => {
const user = userEvent.setup()
s.step.value = landingStep()
s.primaryLabel.value = 'Start tutorial'
renderOverlay()
await user.click(
await screen.findByRole('button', { name: 'Start tutorial' })
)
expect(s.next).toHaveBeenCalledOnce()
})
it('disables the landing primary action while waiting for a deferred target', async () => {
s.step.value = landingStep()
s.primaryLabel.value = 'Start tutorial'
s.waitingForTarget.value = true
renderOverlay()
expect(
await screen.findByRole('button', { name: 'Start tutorial' })
).toBeDisabled()
})
it('ends the tour when the landing is dismissed', async () => {
const user = userEvent.setup()
s.step.value = landingStep()
renderOverlay()
await user.click(await screen.findByRole('button', { name: 'Skip' }))
expect(s.skip).toHaveBeenCalledOnce()
})
})

View File

@@ -1,46 +0,0 @@
<template>
<CoachmarkLanding
v-if="isRegistryTour && tour.step?.landing"
:title="tour.title"
:message="tour.body"
:image="tour.step.image"
:primary-label="tour.primaryLabel"
:skip-label="tour.skipLabel"
:waiting-for-target="tour.waitingForTarget"
@start="tour.next"
@skip="tour.skip"
/>
<TourSpotlight
v-else-if="isRegistryTour && tour.step"
:step="tour.step"
:title="tour.title"
:body="tour.body"
:is-last="tour.isLast"
:can-go-back="tour.canGoBack"
:primary-label="tour.primaryLabel"
:skip-label="tour.skipLabel"
:back-label="tour.backLabel"
:counted-step-idx="tour.countedStepIdx"
:counted-steps-total="tour.countedStepsTotal"
:waiting-for-target="tour.waitingForTarget"
@advance="tour.next"
@back="tour.back"
@skip="tour.skip"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import CoachmarkLanding from './CoachmarkLanding.vue'
import TourSpotlight from './TourSpotlight.vue'
import { TOURS } from './onboardingTours'
import { useOnboardingTourStore } from './onboardingTourStore'
const tour = useOnboardingTourStore()
// firstRun renders its own overlay; here, render only registry-backed tours.
const isRegistryTour = computed(
() => tour.activeTour != null && tour.activeTour in TOURS
)
</script>

View File

@@ -1,147 +0,0 @@
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComponentProps } from 'vue-component-type-helpers'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import { clearCoachmarks } from './coachmarkRegistry'
import TourSpotlight from './TourSpotlight.vue'
import type { CoachStep } from './onboardingTours'
vi.mock('@primeuix/utils/zindex', () => ({
ZIndex: { set: vi.fn(), clear: vi.fn() }
}))
import { ZIndex } from '@primeuix/utils/zindex'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function spotlightStep(overrides: Partial<CoachStep> = {}): CoachStep {
return { name: 'run', placement: 'right', ...overrides }
}
const baseProps = {
title: 'Run your app',
body: 'Press to run',
isLast: false,
canGoBack: false,
primaryLabel: 'Next',
skipLabel: 'Skip',
backLabel: 'Back',
countedStepIdx: 0,
countedStepsTotal: 1,
waitingForTarget: false
}
function renderSpotlight(
props: Partial<ComponentProps<typeof TourSpotlight>> = {}
) {
return render(TourSpotlight, {
props: { step: spotlightStep(), ...baseProps, ...props },
global: { plugins: [i18n] }
})
}
describe('TourSpotlight', () => {
afterEach(() => {
cleanup()
clearCoachmarks()
document.body.replaceChildren()
vi.useRealTimers()
})
it('renders the spotlight and card for a step', () => {
renderSpotlight()
expect(screen.getByTestId('coach-spotlight')).toBeTruthy()
expect(screen.getByRole('dialog', { name: 'Run your app' })).toBeTruthy()
expect(screen.getByText('Press to run')).toBeTruthy()
expect(screen.getByText('Step 1 of 1')).toBeTruthy()
})
it('hides the Skip button on the last step', () => {
renderSpotlight({ isLast: true })
expect(screen.queryByRole('button', { name: 'Skip' })).toBeNull()
expect(screen.getByRole('button', { name: 'Next' })).toBeTruthy()
})
it('shows Back and emits back when there is a previous step', async () => {
const user = userEvent.setup()
const { emitted } = renderSpotlight({ canGoBack: true })
await user.click(screen.getByRole('button', { name: 'Back' }))
expect(emitted().back).toHaveLength(1)
})
it('hides Back on the first step', () => {
renderSpotlight({ canGoBack: false })
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull()
})
it('claims the modal stack on mount and releases it on unmount', async () => {
vi.mocked(ZIndex.set).mockClear()
vi.mocked(ZIndex.clear).mockClear()
const { unmount } = renderSpotlight()
await nextTick()
await nextTick()
expect(ZIndex.set).toHaveBeenCalled()
unmount()
expect(ZIndex.clear).toHaveBeenCalled()
})
it('re-claims the modal stack per step without leaking entries', async () => {
vi.mocked(ZIndex.set).mockClear()
vi.mocked(ZIndex.clear).mockClear()
const { rerender, unmount } = renderSpotlight()
await nextTick()
await nextTick()
await rerender({ step: spotlightStep({ placement: 'left' }) })
await nextTick()
await nextTick()
unmount()
// Sets must pair with clears or entries leak; the +1 is the unmount clear.
expect(vi.mocked(ZIndex.clear).mock.calls.length).toBe(
vi.mocked(ZIndex.set).mock.calls.length + 1
)
expect(ZIndex.set).toHaveBeenCalled()
})
it('emits advance on the primary button and skip on the secondary', async () => {
const user = userEvent.setup()
const { emitted } = renderSpotlight()
await user.click(screen.getByRole('button', { name: 'Next' }))
expect(emitted().advance).toHaveLength(1)
await user.click(screen.getByRole('button', { name: 'Skip' }))
expect(emitted().skip).toHaveLength(1)
})
it('emits skip when Escape is pressed', async () => {
const user = userEvent.setup()
const { emitted } = renderSpotlight()
await user.keyboard('{Escape}')
expect(emitted().skip).toHaveLength(1)
})
it('disables the primary button while waiting for a deferred target', () => {
renderSpotlight({ waitingForTarget: true })
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled()
})
it('hides the spotlight and dims via the blocker for a step with no target', () => {
renderSpotlight({ step: spotlightStep({ placement: 'center' }) })
expect(screen.getByTestId('coach-spotlight').style.opacity).toBe('0')
})
})

View File

@@ -1,234 +0,0 @@
<template>
<div ref="overlayRef" class="pointer-events-none fixed inset-0">
<div
:class="
cn(
'pointer-events-auto absolute inset-0',
!targetRect && 'bg-coach-scrim'
)
"
/>
<div
aria-hidden="true"
data-testid="coach-spotlight"
class="pointer-events-none absolute rounded-xl shadow-[0_0_0_9999px_var(--color-coach-scrim)] outline-2 outline-coach-ring motion-safe:transition-[left,top,width,height,opacity] motion-safe:duration-300"
:style="spotlightStyle"
/>
<FocusScope as-child trapped loop @mount-auto-focus.prevent>
<div
ref="cardRef"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
:aria-describedby="`${subtitleId} ${bodyId}`"
class="pointer-events-auto absolute max-h-[calc(100vh-var(--comfy-topbar-height)-2rem)] overflow-y-auto motion-safe:transition-[left,top] motion-safe:duration-300"
:style="cardStyle"
>
<CoachmarkCard
:subtitle="
t('onboardingCoachmarks.stepLabel', {
current: countedStepIdx + 1,
total: countedStepsTotal
})
"
:subtitle-id="subtitleId"
:title
:title-id="titleId"
:message="body"
:message-id="bodyId"
:image="step.image"
>
<template #actions>
<Button
v-if="showSkip"
variant="textonly"
size="md"
@click="emit('skip')"
>
{{ skipLabel }}
</Button>
<div class="ml-auto flex items-center gap-3">
<Button
v-if="canGoBack"
variant="secondary"
size="md"
class="border border-solid border-border-default"
@click="emit('back')"
>
<i class="icon-[lucide--arrow-left]" />
{{ backLabel }}
</Button>
<Button
ref="primaryButton"
variant="inverted"
size="md"
:disabled="waitingForTarget"
@click="emit('advance')"
>
{{ primaryLabel }}
<i v-if="!isLast" class="icon-[lucide--arrow-right]" />
</Button>
</div>
</template>
</CoachmarkCard>
</div>
</FocusScope>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener, useWindowSize } from '@vueuse/core'
import { ZIndex } from '@primeuix/utils/zindex'
import { FocusScope } from 'reka-ui'
import {
computed,
nextTick,
onBeforeUnmount,
ref,
useId,
useTemplateRef,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import { MODAL_Z_BASE, MODAL_Z_KEY } from '@/components/dialog/vRekaZIndex'
import Button from '@/components/ui/button/Button.vue'
import CoachmarkCard from './CoachmarkCard.vue'
import {
CARD_WIDTH,
SPOTLIGHT_PAD,
VIEWPORT_MARGIN,
clampSpotlight,
noTargetCardLeft
} from './coachmarkLayout'
import type { CoachStep } from './onboardingTours'
import { useCoachmarkTarget } from './useCoachmarkTarget'
const {
step,
title,
body,
isLast,
canGoBack,
primaryLabel,
skipLabel,
backLabel,
countedStepIdx,
countedStepsTotal,
waitingForTarget
} = defineProps<{
step: CoachStep
title: string
body: string
isLast: boolean
canGoBack: boolean
primaryLabel: string
skipLabel: string
backLabel: string
countedStepIdx: number
countedStepsTotal: number
waitingForTarget: boolean
}>()
const emit = defineEmits<{
advance: []
back: []
skip: []
}>()
const { t } = useI18n()
const bodyId = useId()
const subtitleId = useId()
const titleId = useId()
const overlayRef = ref<HTMLElement | null>(null)
const cardRef = ref<HTMLElement | null>(null)
const { width: windowWidth, height: windowHeight } = useWindowSize()
const { targetRect, targetEl, floatingStyles, isPositioned } =
useCoachmarkTarget(() => step, cardRef)
// Last step's "Done" already dismisses, so hide Skip there.
const showSkip = computed(() => !isLast)
const primaryButton =
useTemplateRef<InstanceType<typeof Button>>('primaryButton')
async function focusPrimary() {
await nextTick()
const el = primaryButton.value?.$el as HTMLElement | undefined
el?.focus()
}
useEventListener(
document,
'keydown',
(e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.preventDefault()
e.stopPropagation()
emit('skip')
},
{ capture: true }
)
async function raiseOverlay() {
await nextTick()
const el = overlayRef.value
if (!el) return
// ZIndex.set pushes a fresh entry into the shared modal sequence on every
// call, so clear the previous one or per-step re-raises leak entries.
ZIndex.clear(el)
ZIndex.set(MODAL_Z_KEY, el, MODAL_Z_BASE)
}
watch(
() => step,
() => void raiseOverlay(),
{ immediate: true }
)
watch(
[() => step, () => waitingForTarget],
() => {
if (!waitingForTarget) void focusPrimary()
},
{ immediate: true }
)
onBeforeUnmount(() => {
if (overlayRef.value) ZIndex.clear(overlayRef.value)
})
function viewport() {
return { width: windowWidth.value, height: windowHeight.value }
}
const spotlightStyle = computed(() => {
const r = targetRect.value
if (!r) return { opacity: '0' }
return { ...clampSpotlight(r, SPOTLIGHT_PAD, viewport()), opacity: '1' }
})
const cardStyle = computed(() => {
const width = `${CARD_WIDTH}px`
const maxWidth = `calc(100vw - ${VIEWPORT_MARGIN * 2}px)`
if (!targetEl.value) {
return {
width,
maxWidth,
left: `${noTargetCardLeft(windowWidth.value)}px`,
top: '30%'
}
}
// Hidden until Floating UI positions it, avoiding a first-frame jump.
return {
...floatingStyles.value,
width,
maxWidth,
opacity: isPositioned.value ? '1' : '0'
}
})
</script>

View File

@@ -1,72 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
CARD_GAP,
clampSpotlight,
noTargetCardLeft,
topSafeInset
} from './coachmarkLayout'
const VIEWPORT = { width: 1000, height: 800 }
describe('clampSpotlight', () => {
it('grows the target rect by the pad on every side', () => {
const r = new DOMRect(100, 100, 50, 40)
expect(clampSpotlight(r, 8, VIEWPORT)).toEqual({
left: '92px',
top: '92px',
width: '66px',
height: '56px'
})
})
it('clamps the near edges to the viewport inset', () => {
const r = new DOMRect(0, 0, 10, 10)
expect(clampSpotlight(r, 8, VIEWPORT)).toMatchObject({
left: '2px',
top: '2px'
})
})
it('clamps the far edges to the viewport inset', () => {
const r = new DOMRect(990, 100, 50, 40)
const { left, width } = clampSpotlight(r, 8, VIEWPORT)
expect(left).toBe('982px')
expect(width).toBe('16px')
})
it('never produces a negative size for an off-screen target', () => {
const r = new DOMRect(2000, 100, 50, 40)
expect(clampSpotlight(r, 8, VIEWPORT)).toMatchObject({ width: '0px' })
})
})
describe('topSafeInset', () => {
afterEach(() => {
document.documentElement.style.removeProperty('--comfy-topbar-height')
})
it('converts a rem top bar height to px and adds the card gap', () => {
document.documentElement.style.setProperty('--comfy-topbar-height', '3rem')
expect(topSafeInset()).toBe(48 + CARD_GAP)
})
it('reads a px top bar height directly and adds the card gap', () => {
document.documentElement.style.setProperty('--comfy-topbar-height', '50px')
expect(topSafeInset()).toBe(50 + CARD_GAP)
})
it('falls back to the card gap alone when the token is unset', () => {
expect(topSafeInset()).toBe(CARD_GAP)
})
})
describe('noTargetCardLeft', () => {
it('centers the card on a wide viewport', () => {
expect(noTargetCardLeft(1000)).toBe(350)
})
it('clamps to the viewport margin when the viewport is narrower than the card', () => {
expect(noTargetCardLeft(200)).toBe(12)
})
})

View File

@@ -1,55 +0,0 @@
export interface Viewport {
width: number
height: number
}
export interface BoxStyle {
left: string
top: string
width: string
height: string
}
const SPOTLIGHT_EDGE_INSET = 2
export const CARD_WIDTH = 300
export const VIEWPORT_MARGIN = 12
export const CARD_GAP = 16
// Kept tight so the spotlight glow doesn't spill onto an adjacent clickable control.
export const SPOTLIGHT_PAD = 4
export function clampSpotlight(
r: DOMRect,
pad: number,
viewport: Viewport
): BoxStyle {
const left = Math.max(SPOTLIGHT_EDGE_INSET, r.left - pad)
const top = Math.max(SPOTLIGHT_EDGE_INSET, r.top - pad)
const right = Math.min(viewport.width - SPOTLIGHT_EDGE_INSET, r.right + pad)
const bottom = Math.min(
viewport.height - SPOTLIGHT_EDGE_INSET,
r.bottom + pad
)
return {
left: `${left}px`,
top: `${top}px`,
width: `${Math.max(0, right - left)}px`,
height: `${Math.max(0, bottom - top)}px`
}
}
export function noTargetCardLeft(viewportWidth: number): number {
return Math.max(VIEWPORT_MARGIN, (viewportWidth - CARD_WIDTH) / 2)
}
const TOP_BAR_HEIGHT_VAR = '--comfy-topbar-height'
/** The top bar's height, read from the theme token, plus the standard gap. */
export function topSafeInset(): number {
const root = document.documentElement
const raw = getComputedStyle(root).getPropertyValue(TOP_BAR_HEIGHT_VAR).trim()
const px = raw.endsWith('rem')
? parseFloat(raw) * parseFloat(getComputedStyle(root).fontSize)
: parseFloat(raw)
return (Number.isFinite(px) ? px : 0) + CARD_GAP
}

View File

@@ -1,157 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import {
clearCoachmarks,
coachmarkElements,
registerCoachmark,
targetMounted,
unregisterCoachmark,
waitForTarget
} from './coachmarkRegistry'
/** An element with a non-zero measured rect, so it counts as laid out. */
function laidOut(): HTMLElement {
const el = document.createElement('div')
el.getBoundingClientRect = () => new DOMRect(0, 0, 80, 30)
return el
}
describe('coachmarkRegistry', () => {
const a = document.createElement('div')
const b = document.createElement('div')
afterEach(clearCoachmarks)
it('resolves every element registered for an id', () => {
registerCoachmark('app-run-button', a)
registerCoachmark('app-run-button', b)
expect(coachmarkElements('app-run-button')).toEqual([a, b])
})
it('keeps the remaining elements when one of several unregisters', () => {
registerCoachmark('app-run-button', a)
registerCoachmark('app-run-button', b)
unregisterCoachmark('app-run-button', a)
expect(coachmarkElements('app-run-button')).toEqual([b])
})
})
describe('targetMounted', () => {
afterEach(clearCoachmarks)
it('is true once a laid-out element is registered', () => {
expect(targetMounted('app-run-button')).toBe(false)
registerCoachmark('app-run-button', laidOut())
expect(targetMounted('app-run-button')).toBe(true)
})
it('ignores a registered target that is not laid out (e.g. hidden)', () => {
registerCoachmark('outputs', document.createElement('div'))
expect(targetMounted('outputs')).toBe(false)
})
})
describe('waitForTarget', () => {
let frames: Array<() => void>
beforeEach(() => {
frames = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
frames.push(() => cb(0))
return frames.length
})
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
clearCoachmarks()
vi.useRealTimers()
vi.unstubAllGlobals()
})
// Drain the poll queue; an unresolved poll reschedules, so cap the drain to
// avoid spinning when the target never lays out.
function runFrames(max = 50) {
let ran = 0
while (frames.length && ran < max) {
frames.shift()!()
ran++
}
}
it('resolves true immediately when a laid-out target is already mounted', async () => {
registerCoachmark('app-run-button', laidOut())
const signal = new AbortController().signal
await expect(waitForTarget('app-run-button', signal, 1000)).resolves.toBe(
true
)
})
it('resolves true once the target lays out before the timeout', async () => {
const signal = new AbortController().signal
const found = waitForTarget('app-run-button', signal, 1000)
registerCoachmark('app-run-button', laidOut())
runFrames()
await expect(found).resolves.toBe(true)
})
it('keeps waiting for a registered target until it lays out', async () => {
const el = document.createElement('div')
registerCoachmark('outputs', el)
const signal = new AbortController().signal
let resolved: boolean | undefined
void waitForTarget('outputs', signal, 1000).then((v) => (resolved = v))
runFrames()
await Promise.resolve()
expect(resolved).toBeUndefined()
el.getBoundingClientRect = () => new DOMRect(0, 0, 80, 30)
runFrames()
await Promise.resolve()
expect(resolved).toBe(true)
})
it('does not schedule rAF polling while no candidate is registered', () => {
const signal = new AbortController().signal
void waitForTarget('outputs', signal, 1000)
expect(frames).toHaveLength(0)
})
it('parks the poll when the last candidate unregisters and resumes on re-registration', async () => {
const el = document.createElement('div')
const signal = new AbortController().signal
let resolved: boolean | undefined
void waitForTarget('outputs', signal, 1000).then((v) => (resolved = v))
registerCoachmark('outputs', el)
await nextTick()
expect(frames.length).toBeGreaterThan(0)
unregisterCoachmark('outputs', el)
await nextTick()
runFrames()
expect(frames).toHaveLength(0)
registerCoachmark('outputs', laidOut())
await nextTick()
await Promise.resolve()
expect(resolved).toBe(true)
})
it('resolves false when the target never mounts (transient failure)', async () => {
vi.useFakeTimers()
const signal = new AbortController().signal
const found = waitForTarget('outputs', signal, 1000)
await vi.advanceTimersByTimeAsync(1000)
await expect(found).resolves.toBe(false)
})
it('resolves false when aborted before the target mounts', async () => {
const controller = new AbortController()
const found = waitForTarget('outputs', controller.signal, 10000)
controller.abort()
await expect(found).resolves.toBe(false)
})
})

View File

@@ -1,84 +0,0 @@
import { shallowReactive, watch } from 'vue'
import type { CoachId } from './onboardingTours'
const EMPTY: readonly HTMLElement[] = []
/** Laid out — a registered target that is currently visible and has a size. */
export function isLaidOut(el: HTMLElement): boolean {
const r = el.getBoundingClientRect()
return r.width > 0 && r.height > 0
}
// An id can map to several elements (e.g. responsive variants); consumers pick
// the first laid-out one.
const registry = shallowReactive(new Map<CoachId, readonly HTMLElement[]>())
export function registerCoachmark(id: CoachId, el: HTMLElement) {
registry.set(id, [...(registry.get(id) ?? EMPTY), el])
}
export function unregisterCoachmark(id: CoachId, el: HTMLElement) {
const next = (registry.get(id) ?? EMPTY).filter((entry) => entry !== el)
if (next.length) registry.set(id, next)
else registry.delete(id)
}
export function coachmarkElements(id: CoachId): readonly HTMLElement[] {
return registry.get(id) ?? EMPTY
}
export function targetMounted(id: CoachId): boolean {
return coachmarkElements(id).some(isLaidOut)
}
/** Resolves once a laid-out element for the id exists; false on timeout or abort. */
export function waitForTarget(
id: CoachId,
signal: AbortSignal,
timeoutMs: number
): Promise<boolean> {
if (targetMounted(id)) return Promise.resolve(true)
// An already-aborted signal never fires 'abort', so resolve up front.
if (signal.aborted) return Promise.resolve(false)
return new Promise((resolve) => {
let done = false
let frame = 0
function finish(found: boolean) {
if (done) return
done = true
stopWatch()
cancelAnimationFrame(frame)
clearTimeout(timer)
signal.removeEventListener('abort', onAbort)
resolve(found)
}
function onAbort() {
finish(false)
}
// Laid-out-ness is a layout read the registry can't observe, so it needs
// polling — but only while a candidate exists. Registration is reactive,
// so the watch (re)starts the poll instead of spinning every frame while
// the target hasn't even mounted.
function poll() {
if (targetMounted(id)) finish(true)
else if (coachmarkElements(id).length) frame = requestAnimationFrame(poll)
}
const stopWatch = watch(
() => coachmarkElements(id).length,
() => {
cancelAnimationFrame(frame)
poll()
},
{ flush: 'post' }
)
const timer = setTimeout(() => finish(false), timeoutMs)
signal.addEventListener('abort', onAbort)
poll()
})
}
/** Resets shared state between tests. */
export function clearCoachmarks() {
registry.clear()
}

View File

@@ -1,516 +0,0 @@
import type { DetachedWindowAPI } from 'happy-dom'
import { createPinia, disposePinia, setActivePinia } from 'pinia'
import type { Pinia } from 'pinia'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import type { Ref } from 'vue'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { AppMode } from '@/utils/appMode'
import { clearCoachmarks, registerCoachmark } from './coachmarkRegistry'
import { TOUR_SEEN_SETTING } from './onboardingTours'
import type { CoachId } from './onboardingTours'
import { useOnboardingTourStore } from './onboardingTourStore'
const settings = vi.hoisted(() => ({ store: new Map<string, unknown>() }))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
settings.store.get(key) ?? (key === TOUR_SEEN_SETTING ? [] : undefined),
set: (key: string, value: unknown) => {
settings.store.set(key, value)
return Promise.resolve()
}
})
}))
const telemetry = vi.hoisted(() => ({ track: vi.fn() }))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackOnboardingTour: telemetry.track })
}))
const appModeMock = vi.hoisted(
() =>
({ mode: null, hasOutputs: null }) as {
mode: Ref<AppMode> | null
hasOutputs: Ref<boolean> | null
}
)
vi.mock('@/composables/useAppMode', async () => {
const { ref: r } = await import('vue')
appModeMock.mode = r<AppMode>('graph')
return { useAppMode: () => ({ mode: appModeMock.mode }) }
})
vi.mock('@/stores/appModeStore', async () => {
const { ref: r } = await import('vue')
appModeMock.hasOutputs = r(false)
const hasOutputs = appModeMock.hasOutputs
return {
useAppModeStore: () => ({
get hasOutputs() {
return hasOutputs.value
}
})
}
})
const APP_MODE_TARGETS: CoachId[] = [
'inputs-list',
'app-run-button',
'outputs',
'assets-panel'
]
function seenTours(): string[] {
return (settings.store.get(TOUR_SEEN_SETTING) as string[] | undefined) ?? []
}
function setViewport(viewport: { width: number; height: number }) {
const happyDOM = (window as unknown as { happyDOM?: DetachedWindowAPI })
.happyDOM
if (!happyDOM) {
throw new Error('window.happyDOM is unavailable to set viewport')
}
happyDOM.setViewport(viewport)
}
let pinia: Pinia | undefined
function mountStore() {
pinia = createPinia()
setActivePinia(pinia)
return useOnboardingTourStore()
}
function startedCount() {
return telemetry.track.mock.calls.filter(([stage]) => stage === 'started')
.length
}
describe('onboardingTourStore', () => {
// Removed in teardown even when a test throws before its own cleanup.
const appendedTargets: HTMLElement[] = []
afterEach(() => {
if (pinia) disposePinia(pinia)
pinia = undefined
clearCoachmarks()
appendedTargets.forEach((el) => el.remove())
appendedTargets.length = 0
settings.store.clear()
setViewport({ width: 1024, height: 768 })
if (appModeMock.mode) appModeMock.mode.value = 'graph'
if (appModeMock.hasOutputs) appModeMock.hasOutputs.value = false
telemetry.track.mockClear()
vi.useRealTimers()
})
/** Register one laid-out element for a coach id, so its step resolves at once. */
function mountTarget(id: CoachId): HTMLElement {
const el = document.createElement('div')
el.getBoundingClientRect = () => new DOMRect(0, 0, 100, 100)
document.body.appendChild(el)
appendedTargets.push(el)
registerCoachmark(id, el)
return el
}
function registerAppModeTargets(
ids: CoachId[] = APP_MODE_TARGETS
): Map<CoachId, HTMLElement> {
return new Map(ids.map((id) => [id, mountTarget(id)]))
}
function enterApp(mode: AppMode, hasOutputs: boolean) {
const modeRef = appModeMock.mode
const outputsRef = appModeMock.hasOutputs
if (!modeRef || !outputsRef)
throw new Error('app mode mock not initialised')
modeRef.value = mode
outputsRef.value = hasOutputs
}
it('auto-opens when entering a populated app it has not seen', async () => {
mountStore()
enterApp('app', true)
await nextTick()
expect(startedCount()).toBe(1)
})
it('auto-opens when instantiated in an already-populated app', async () => {
enterApp('app', true)
mountStore()
await nextTick()
expect(startedCount()).toBe(1)
})
it('does not auto-open in an empty app with no linear controls', async () => {
mountStore()
enterApp('app', false)
await nextTick()
expect(startedCount()).toBe(0)
})
it('does not auto-open in arrange (builder) mode', async () => {
mountStore()
enterApp('builder:arrange', true)
await nextTick()
expect(startedCount()).toBe(0)
})
it('does not auto-open on a mobile-width viewport', async () => {
setViewport({ width: 500, height: 800 })
enterApp('app', true)
mountStore()
await nextTick()
expect(startedCount()).toBe(0)
})
it('does not auto-open a populated app once the tour has been dismissed', async () => {
settings.store.set(TOUR_SEEN_SETTING, ['appMode'])
mountStore()
enterApp('app', true)
await nextTick()
expect(startedCount()).toBe(0)
})
it('replays a seen tour when explicitly requested', async () => {
settings.store.set(TOUR_SEEN_SETTING, ['appMode'])
const store = mountStore()
store.replayTour('appMode')
await nextTick()
expect(startedCount()).toBe(1)
})
it('dismisses a replayed tour without the seen-flag when the user leaves its mode', async () => {
settings.store.set(TOUR_SEEN_SETTING, ['appMode'])
const store = mountStore()
enterApp('app', false)
await nextTick()
store.replayTour('appMode')
await nextTick()
expect(store.step?.name).toBe('landing')
enterApp('graph', false)
await nextTick()
expect(store.step).toBeNull()
const skipped = telemetry.track.mock.calls.find(
([stage]) => stage === 'skipped'
)
expect(skipped?.[1]).toMatchObject({ skip_reason: 'trigger_lost' })
})
it('keeps the tour running when outputs disappear but the mode still holds', async () => {
registerAppModeTargets()
const store = mountStore()
enterApp('app', true)
await nextTick()
expect(startedCount()).toBe(1)
enterApp('app', false)
await nextTick()
expect(store.step).not.toBeNull()
})
it('marks the tour seen when it ends normally', async () => {
const store = mountStore()
store.replayTour('appMode')
await nextTick()
store.skip()
expect(seenTours()).toContain('appMode')
// A deliberate dismissal reports the user as the skip reason.
const skipped = telemetry.track.mock.calls.find(
([stage]) => stage === 'skipped'
)
expect(skipped?.[1]).toMatchObject({ skip_reason: 'user' })
})
it('skips without the seen-flag and toasts when a deferred target never appears', async () => {
vi.useFakeTimers()
const store = mountStore()
store.replayTour('appMode')
await vi.advanceTimersByTimeAsync(0)
store.next()
expect(store.waitingForTarget).toBe(true)
// The deferred target (inputs list) is never registered; exhaust the wait.
await vi.advanceTimersByTimeAsync(8000)
expect(seenTours()).not.toContain('appMode')
// The skipped event reports the timed-out step, not the prior landing,
// and attributes the skip to the timeout rather than the user.
const skipped = telemetry.track.mock.calls.find(
([stage]) => stage === 'skipped'
)
expect(skipped?.[1]).toMatchObject({
step_number: 1,
coach_id: 'inputs-list',
skip_reason: 'target_timeout'
})
expect(useToastStore().messagesToAdd).toContainEqual(
expect.objectContaining({
severity: 'error',
detail: 'Something went wrong showing this tour'
})
)
})
it('advances once a deferred target mounts during the wait', async () => {
const store = mountStore()
store.replayTour('appMode')
await nextTick()
store.next()
expect(store.waitingForTarget).toBe(true)
mountTarget('inputs-list')
await nextTick()
expect(store.waitingForTarget).toBe(false)
expect(store.step?.coachId).toBe('inputs-list')
})
it('keeps the original deadline when advance is requested again mid-wait', async () => {
vi.useFakeTimers()
const store = mountStore()
store.replayTour('appMode')
await vi.advanceTimersByTimeAsync(0)
store.next()
expect(store.waitingForTarget).toBe(true)
await vi.advanceTimersByTimeAsync(5000)
store.next()
await vi.advanceTimersByTimeAsync(3000)
expect(useToastStore().messagesToAdd).toHaveLength(1)
})
it('does not toast or double-report when the user skips during a deferred wait', async () => {
vi.useFakeTimers()
const store = mountStore()
store.replayTour('appMode')
await vi.advanceTimersByTimeAsync(0)
store.next()
expect(store.waitingForTarget).toBe(true)
store.skip()
await vi.advanceTimersByTimeAsync(8000)
const skipped = telemetry.track.mock.calls.filter(
([stage]) => stage === 'skipped'
)
expect(skipped).toHaveLength(1)
expect(skipped[0]?.[1]).toMatchObject({ skip_reason: 'user' })
expect(useToastStore().messagesToAdd).toHaveLength(0)
})
it('ends an active tour without the seen-flag when its trigger stops holding', async () => {
registerAppModeTargets()
const store = mountStore()
enterApp('app', true)
await nextTick()
expect(startedCount()).toBe(1)
enterApp('graph', true)
await nextTick()
expect(store.step).toBeNull()
expect(seenTours()).not.toContain('appMode')
const skipped = telemetry.track.mock.calls.find(
([stage]) => stage === 'skipped'
)
expect(skipped?.[1]).toMatchObject({ skip_reason: 'trigger_lost' })
enterApp('app', true)
await nextTick()
expect(startedCount()).toBe(2)
})
it('aborts a pending deferred wait without a toast when the trigger stops holding', async () => {
vi.useFakeTimers()
const store = mountStore()
enterApp('app', true)
await vi.advanceTimersByTimeAsync(0)
store.next()
expect(store.waitingForTarget).toBe(true)
enterApp('graph', true)
await vi.advanceTimersByTimeAsync(8000)
expect(useToastStore().messagesToAdd).toHaveLength(0)
const skipReasons = telemetry.track.mock.calls
.filter(([stage]) => stage === 'skipped')
.map(([, meta]) => meta.skip_reason)
expect(skipReasons).toEqual(['trigger_lost'])
})
it('ignores a second concurrent request while the first tour is resolving', async () => {
const store = mountStore()
store.replayTour('appMode')
store.replayTour('appMode')
await nextTick()
expect(startedCount()).toBe(1)
})
it('advances through every step to completion and marks the tour seen', async () => {
registerAppModeTargets()
const store = mountStore()
store.replayTour('appMode')
await nextTick()
// Capped so a stuck tour fails instead of hanging.
for (let i = 0; i < 12 && !seenTours().includes('appMode'); i++) {
store.next()
await nextTick()
}
expect(seenTours()).toContain('appMode')
const completed = telemetry.track.mock.calls.find(
([stage]) => stage === 'completed'
)
expect(completed).toBeTruthy()
expect(completed?.[1]).not.toHaveProperty('skip_reason')
})
it('opens the assets sidebar tab and resolves its deferred panel on the assets step', async () => {
registerAppModeTargets(
APP_MODE_TARGETS.filter((id) => id !== 'assets-panel')
)
const store = mountStore()
store.replayTour('appMode')
await nextTick()
expect(store.countedStepsTotal).toBe(4)
// Advance landing -> inputs -> run -> outputs, then onto the assets step.
for (let i = 0; i < 4; i++) {
store.next()
await nextTick()
}
// The assets step defers on its panel; the tab auto-opens while it waits.
expect(store.waitingForTarget).toBe(true)
expect(useSidebarTabStore().activeSidebarTabId).toBe('assets')
mountTarget('assets-panel')
await nextTick()
expect(store.waitingForTarget).toBe(false)
expect(store.step?.coachId).toBe('assets-panel')
})
it('leaves an already-open assets tab open when reaching the assets step', async () => {
registerAppModeTargets()
const store = mountStore()
const sidebar = useSidebarTabStore()
sidebar.toggleSidebarTab('assets')
expect(sidebar.activeSidebarTabId).toBe('assets')
store.replayTour('appMode')
await nextTick()
for (let i = 0; i < 4; i++) {
store.next()
await nextTick()
}
expect(store.step?.coachId).toBe('assets-panel')
expect(sidebar.activeSidebarTabId).toBe('assets')
})
it('steps back to the previous step', async () => {
registerAppModeTargets()
const store = mountStore()
store.replayTour('appMode')
await nextTick()
store.next()
await nextTick()
store.next()
await nextTick()
expect(store.step?.coachId).toBe('app-run-button')
expect(store.canGoBack).toBe(true)
store.back()
await nextTick()
expect(store.step?.coachId).toBe('inputs-list')
})
it('reports step index 0 while no tour is active', () => {
const store = mountStore()
expect(store.countedStepIdx).toBe(0)
})
it('labels the buttons from the step, falling back to Next then Done', async () => {
registerAppModeTargets()
const store = mountStore()
store.replayTour('appMode')
await nextTick()
// The landing's `primary` entry overrides only the primary label.
expect(store.step?.landing).toBe(true)
expect(store.primaryLabel).toBe('Start tutorial')
expect(store.skipLabel).toBe('Skip')
expect(store.countedStepsTotal).toBe(4)
store.next()
await nextTick()
expect(store.primaryLabel).toBe('Next')
expect(store.skipLabel).toBe('Skip')
for (let i = 0; i < 3; i++) {
store.next()
await nextTick()
}
expect(store.isLast).toBe(true)
expect(store.primaryLabel).toBe('Done')
})
it('reports the user-visible step numbering, omitting it for the landing', async () => {
registerAppModeTargets()
const store = mountStore()
store.replayTour('appMode')
await nextTick()
// The landing isn't numbered, so the four spotlight steps carry the count.
const started = telemetry.track.mock.calls.find(
([stage]) => stage === 'started'
)
expect(started?.[1]).toEqual({ tour: 'appMode', step_count: 4 })
const landingShown = telemetry.track.mock.calls.find(
([stage]) => stage === 'step_shown'
)
expect(landingShown?.[1]).toEqual({ tour: 'appMode', step_count: 4 })
store.next()
await nextTick()
const shown = telemetry.track.mock.calls
.filter(([stage]) => stage === 'step_shown')
.at(-1)
expect(shown?.[1]).toEqual({
tour: 'appMode',
step_count: 4,
step_number: 1,
coach_id: 'inputs-list'
})
})
it('advancing off the landing does not mark the tour skipped', async () => {
registerAppModeTargets()
const store = mountStore()
store.replayTour('appMode')
await nextTick()
expect(store.step?.landing).toBe(true)
store.next()
await nextTick()
expect(store.step?.landing).toBeFalsy()
const skipped = telemetry.track.mock.calls.some(
([stage]) => stage === 'skipped'
)
expect(skipped).toBe(false)
})
})

View File

@@ -1,238 +0,0 @@
import { defineStore } from 'pinia'
import { computed, readonly, ref, shallowRef, watch } from 'vue'
import { t, te } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type {
OnboardingTourSkipReason,
OnboardingTourStage
} from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { targetMounted, waitForTarget } from './coachmarkRegistry'
import { TOURS, TOUR_SEEN_SETTING, resolveSteps } from './onboardingTours'
import type { CoachStep, EntryPath } from './onboardingTours'
import { useTourTriggers } from './useTourTriggers'
const DEFER_TIMEOUT_MS = 8000
/**
* The tour state machine: which tour starts and when, which steps run, and the
* advance/skip/complete lifecycle.
*/
export const useOnboardingTourStore = defineStore('onboardingTour', () => {
const settingStore = useSettingStore()
const telemetry = useTelemetry()
const steps = shallowRef<CoachStep[]>([])
const stepIdx = ref(0)
const waitingForTarget = ref(false)
const activeTour = ref<EntryPath | null>(null)
let stepController: AbortController | null = null
const step = computed<CoachStep | null>(
() => steps.value[stepIdx.value] ?? null
)
const isLast = computed(() => stepIdx.value === steps.value.length - 1)
const countedSteps = computed(() => steps.value.filter((s) => !s.landing))
const countedStepsTotal = computed(() => countedSteps.value.length)
const countedStepIdx = computed(() => {
const s = step.value
return s ? countedSteps.value.indexOf(s) : 0
})
// Back navigates the numbered steps only — never into the landing.
const canGoBack = computed(() => countedStepIdx.value > 0)
function trackTour(
stage: OnboardingTourStage,
skipReason?: OnboardingTourSkipReason
) {
const tour = activeTour.value
// Definition-driven tours (firstRun) aren't in TOURS and report their own telemetry.
if (!tour || !TOURS[tour]) return
telemetry?.trackOnboardingTour(stage, {
tour,
step_count: countedSteps.value.length,
...(stage !== 'started' &&
countedStepIdx.value >= 0 && {
step_number: countedStepIdx.value + 1,
coach_id: step.value?.coachId
}),
...(skipReason && { skip_reason: skipReason })
})
}
function stepKey(suffix: string) {
return `onboardingCoachmarks.${activeTour.value}.${step.value?.name}.${suffix}`
}
const title = computed(() => (step.value ? t(stepKey('title')) : ''))
const body = computed(() => (step.value ? t(stepKey('body')) : ''))
// A step overrides the generic button labels by declaring `primary`/`skip`
// entries under its translation keys.
const primaryLabel = computed(() => {
if (step.value && te(stepKey('primary'))) return t(stepKey('primary'))
return isLast.value
? t('onboardingCoachmarks.done')
: t('onboardingCoachmarks.next')
})
const skipLabel = computed(() =>
step.value && te(stepKey('skip'))
? t(stepKey('skip'))
: t('onboardingCoachmarks.skip')
)
const backLabel = computed(() => t('onboardingCoachmarks.back'))
async function showStep(idx: number) {
const nextStep = steps.value[idx]
if (!nextStep) return
stepController?.abort()
const controller = new AbortController()
stepController = controller
const { signal } = controller
waitingForTarget.value = false
if (nextStep.openSidebarTab) openSidebarTab(nextStep.openSidebarTab)
if (
nextStep.deferTarget &&
nextStep.coachId &&
!targetMounted(nextStep.coachId)
) {
waitingForTarget.value = true
const found = await waitForTarget(
nextStep.coachId,
signal,
DEFER_TIMEOUT_MS
)
if (signal.aborted) {
if (stepController === controller) waitingForTarget.value = false
return
}
waitingForTarget.value = false
// Point at the timed-out step so telemetry reports it, and skip without
// the seen-flag so a missed target isn't permanent.
if (!found) {
stepIdx.value = idx
finish('skipped', { markSeen: false, skipReason: 'target_timeout' })
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('onboardingCoachmarks.loadError')
})
return
}
}
stepIdx.value = idx
trackTour('step_shown')
}
function openSidebarTab(tabId: string) {
const sidebar = useSidebarTabStore()
if (sidebar.activeSidebarTabId !== tabId) sidebar.toggleSidebarTab(tabId)
}
function next() {
if (waitingForTarget.value) return
if (isLast.value) {
finish('completed')
return
}
void showStep(stepIdx.value + 1)
}
function back() {
if (canGoBack.value) void showStep(stepIdx.value - 1)
}
function skip() {
finish('skipped')
}
function finish(
outcome: 'completed' | 'skipped',
{
markSeen = true,
skipReason = 'user'
}: { markSeen?: boolean; skipReason?: OnboardingTourSkipReason } = {}
) {
trackTour(outcome, outcome === 'skipped' ? skipReason : undefined)
stepController?.abort()
waitingForTarget.value = false
steps.value = []
stepIdx.value = 0
if (markSeen && activeTour.value) markTourSeen(activeTour.value)
activeTour.value = null
}
for (const [entryPath, trigger] of useTourTriggers()) {
watch(
trigger.autoOpen,
(visible) => {
if (visible) startTour(entryPath)
},
{ immediate: true }
)
watch(trigger.holds, (holding) => {
if (!holding && activeTour.value === entryPath)
finish('skipped', { markSeen: false, skipReason: 'trigger_lost' })
})
}
function hasSeenTour(entryPath: EntryPath): boolean {
return settingStore.get(TOUR_SEEN_SETTING).includes(entryPath)
}
function markTourSeen(entryPath: EntryPath) {
const seen = settingStore.get(TOUR_SEEN_SETTING)
if (seen.includes(entryPath)) return
void settingStore.set(TOUR_SEEN_SETTING, [...seen, entryPath])
}
function startTour(
entryPath: EntryPath,
{
force = false,
definition
}: { force?: boolean; definition?: CoachStep[] } = {}
) {
if (steps.value.length) return
if (!force && hasSeenTour(entryPath)) return
const def = definition ?? TOURS[entryPath]
if (!def) return
const resolved = resolveSteps(def, targetMounted)
if (!resolved.length) return
steps.value = resolved
activeTour.value = entryPath
trackTour('started')
void showStep(0)
}
function replayTour(entryPath: EntryPath) {
startTour(entryPath, { force: true })
}
return {
step,
isLast,
canGoBack,
title,
body,
primaryLabel,
skipLabel,
backLabel,
countedStepIdx,
countedStepsTotal,
waitingForTarget,
activeTour: readonly(activeTour),
startTour,
replayTour,
next,
back,
skip
}
})

View File

@@ -1,36 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveSteps } from './onboardingTours'
import type { CoachStep } from './onboardingTours'
function step(overrides: Partial<CoachStep> = {}): CoachStep {
return {
name: 'step',
placement: 'center',
...overrides
}
}
describe('resolveSteps', () => {
const isMounted = (mounted: boolean) => () => mounted
it('keeps a targetless step', () => {
const steps = [step()]
expect(resolveSteps(steps, isMounted(false))).toEqual(steps)
})
it('drops a step whose target is not mounted', () => {
const steps = [step({ coachId: 'app-run-button' })]
expect(resolveSteps(steps, isMounted(false))).toEqual([])
})
it('keeps a mounted step', () => {
const steps = [step({ coachId: 'app-run-button' })]
expect(resolveSteps(steps, isMounted(true))).toEqual(steps)
})
it('keeps a deferred step even before its target mounts', () => {
const steps = [step({ coachId: 'app-run-button', deferTarget: true })]
expect(resolveSteps(steps, isMounted(false))).toEqual(steps)
})
})

View File

@@ -1,92 +0,0 @@
export type EntryPath = 'appMode' | 'firstRun'
/** Setting holding the tours the user has completed or dismissed. */
export const TOUR_SEEN_SETTING = 'Comfy.OnboardingCoachmarks.Seen'
export type CoachPlacement =
| 'left'
| 'right'
| 'center'
| 'bottom'
/** Left of the target, vertically centred on it (clamps to the viewport edge). */
| 'leftCenter'
/** Sits on whichever horizontal side of the target has more room. */
| 'auto'
/** Every element a tour can point at; the e2e drift guard asserts each resolves. */
export const COACH_IDS = {
appRunButton: 'app-run-button',
inputsList: 'inputs-list',
outputs: 'outputs',
assetsPanel: 'assets-panel'
} as const
export type CoachId = (typeof COACH_IDS)[keyof typeof COACH_IDS]
export interface CoachStep {
/**
* Derives the step's translation keys:
* `onboardingCoachmarks.<tour>.<name>.title|body`, plus optional
* `primary`/`skip` button-label overrides.
*/
name: string
/** Element to spotlight (the first laid-out registered candidate wins). */
coachId?: CoachId
placement: CoachPlacement
/** Open this sidebar tab when the step starts (e.g. to reveal its target). */
openSidebarTab?: string
/** Target mounts later (e.g. a dialog); wait for it instead of dropping the step. */
deferTarget?: boolean
/** Renders the landing dialog instead of a spotlight. */
landing?: boolean
image?: string
}
/**
* Fixes the running step set (and so the step count) at tour start: drops steps
* whose own target isn't mounted, keeping targetless and deferred steps.
*/
export function resolveSteps(
steps: CoachStep[],
isMounted: (id: CoachId) => boolean
): CoachStep[] {
return steps.filter(
(s) => !s.coachId || s.deferTarget || isMounted(s.coachId)
)
}
export const TOURS: Partial<Record<EntryPath, CoachStep[]>> = {
appMode: [
{
name: 'landing',
landing: true,
placement: 'center',
image: '/assets/images/app-mode-landing.png'
},
{
name: 'inputs',
coachId: COACH_IDS.inputsList,
placement: 'auto',
deferTarget: true
},
{
name: 'run',
coachId: COACH_IDS.appRunButton,
placement: 'auto',
deferTarget: true
},
{
name: 'outputs',
coachId: COACH_IDS.outputs,
placement: 'leftCenter',
deferTarget: true
},
{
name: 'assets',
coachId: COACH_IDS.assetsPanel,
placement: 'auto',
deferTarget: true,
openSidebarTab: 'assets'
}
]
}

View File

@@ -1,61 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import { effectScope, ref } from 'vue'
import { clearCoachmarks, registerCoachmark } from './coachmarkRegistry'
import type { CoachId, CoachStep } from './onboardingTours'
import { useCoachmarkTarget } from './useCoachmarkTarget'
function step(coachId: CoachId): CoachStep {
return { name: 'step', placement: 'right', coachId }
}
function laidOut(): HTMLElement {
const el = document.createElement('div')
el.getBoundingClientRect = () => new DOMRect(10, 10, 80, 30)
return el
}
function hidden(): HTMLElement {
const el = document.createElement('div')
el.getBoundingClientRect = () => new DOMRect(0, 0, 0, 0)
return el
}
describe('useCoachmarkTarget', () => {
afterEach(clearCoachmarks)
function setup(coachId: CoachId) {
const scope = effectScope()
const stepRef = ref<CoachStep | null>(step(coachId))
const cardRef = ref<HTMLElement | null>(null)
const api = scope.run(() => useCoachmarkTarget(stepRef, cardRef))!
return { scope, api }
}
it('resolves the first laid-out candidate for the step target', () => {
const el = laidOut()
registerCoachmark('outputs', el)
const { scope, api } = setup('outputs')
expect(api.targetEl.value).toBe(el)
scope.stop()
})
it('skips a registered target that is not laid out', () => {
registerCoachmark('outputs', hidden())
const laid = laidOut()
registerCoachmark('outputs', laid)
const { scope, api } = setup('outputs')
expect(api.targetEl.value).toBe(laid)
scope.stop()
})
it('picks up a target that registers after the step starts', () => {
const { scope, api } = setup('outputs')
expect(api.targetEl.value).toBeNull()
const el = laidOut()
registerCoachmark('outputs', el)
expect(api.targetEl.value).toBe(el)
scope.stop()
})
})

View File

@@ -1,136 +0,0 @@
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue'
import type { Middleware, Placement, Rect } from '@floating-ui/vue'
import { useEventListener } from '@vueuse/core'
import { computed, ref, toValue, watch, watchEffect } from 'vue'
import type { MaybeRefOrGetter, Ref } from 'vue'
import { CARD_GAP, VIEWPORT_MARGIN, topSafeInset } from './coachmarkLayout'
import { coachmarkElements, isLaidOut } from './coachmarkRegistry'
import type { CoachPlacement, CoachStep } from './onboardingTours'
// A target animating in via CSS transform reports through neither scroll nor
// resize events; poll each frame until its rect holds still this long.
const MOTION_SETTLE_MS = 250
const PLACEMENT: Record<
Exclude<CoachPlacement, 'auto' | 'center'>,
Placement
> = {
left: 'left-start',
right: 'right-start',
leftCenter: 'left',
bottom: 'bottom'
}
function floatingPlacement(step: CoachStep | null): Placement {
const placement = step?.placement
if (!placement || placement === 'auto' || placement === 'center')
return 'right-start'
return PLACEMENT[placement]
}
// Surfaces the measured target rect so the spotlight can trace it.
const captureReference: Middleware = {
name: 'captureReference',
fn: (state) => ({ data: { rect: state.rects.reference } })
}
function middleware(step: CoachStep | null, topInset: number): Middleware[] {
const list: Middleware[] = [offset(CARD_GAP)]
if (!step?.placement || step.placement === 'auto') list.push(flip())
// shift only guards the main axis by default; crossAxis keeps vertically-
// centred placements (leftCenter) on-screen too.
list.push(
shift({
crossAxis: true,
padding: {
top: topInset,
left: VIEWPORT_MARGIN,
right: VIEWPORT_MARGIN,
bottom: CARD_GAP
}
}),
captureReference
)
return list
}
/**
* Locates a step's target in the registry and positions the card beside it with
* Floating UI, following the target until its rect settles.
*/
export function useCoachmarkTarget(
step: MaybeRefOrGetter<CoachStep | null>,
cardRef: Ref<HTMLElement | null>
) {
const candidateEls = computed<readonly HTMLElement[]>(() => {
const id = toValue(step)?.coachId
return id ? coachmarkElements(id) : []
})
const targetEl = computed<HTMLElement | null>(
() => candidateEls.value.find(isLaidOut) ?? null
)
// The top bar's height only changes on resize, so read it once and refresh
// then — Floating UI re-runs the middleware every frame while tracking motion.
const topInset = ref(topSafeInset())
useEventListener('resize', () => {
topInset.value = topSafeInset()
})
const { floatingStyles, middlewareData, isPositioned, update } = useFloating(
targetEl,
cardRef,
{
strategy: 'fixed',
transform: false,
placement: () => floatingPlacement(toValue(step)),
middleware: () => middleware(toValue(step), topInset.value)
}
)
const targetRect = computed<DOMRect | null>(() => {
const data = middlewareData.value.captureReference as
| { rect: Rect }
| undefined
const rect = data?.rect
if (!rect || rect.width === 0) return null
return new DOMRect(rect.x, rect.y, rect.width, rect.height)
})
const trackMotion = ref(false)
const rectKey = computed(() => {
const r = targetRect.value
return r ? `${r.x},${r.y},${r.width},${r.height}` : ''
})
watch(
() => toValue(step),
(s) => {
trackMotion.value = !!s?.deferTarget
},
{ immediate: true }
)
watch(rectKey, (_key, _prev, onCleanup) => {
if (!trackMotion.value) return
const timer = setTimeout(() => {
trackMotion.value = false
}, MOTION_SETTLE_MS)
onCleanup(() => clearTimeout(timer))
})
watchEffect((onCleanup) => {
const reference = targetEl.value
const floating = cardRef.value
if (!reference || !floating) return
onCleanup(
autoUpdate(reference, floating, update, {
animationFrame: trackMotion.value
})
)
})
return { targetEl, targetRect, floatingStyles, isPositioned }
}

View File

@@ -1,30 +0,0 @@
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useDesktopLayout } from '@/composables/useDesktopLayout'
import { useAppModeStore } from '@/stores/appModeStore'
import type { EntryPath } from './onboardingTours'
export interface TourTrigger {
holds: ComputedRef<boolean>
autoOpen: ComputedRef<boolean>
}
/** Each tour's auto-open condition, paired with its entry path. */
export function useTourTriggers(): [EntryPath, TourTrigger][] {
const { mode } = useAppMode()
const appModeStore = useAppModeStore()
const desktopLayout = useDesktopLayout()
const inAppMode = computed(() => desktopLayout.value && mode.value === 'app')
return [
[
'appMode',
{
holds: inAppMode,
autoOpen: computed(() => inAppMode.value && appModeStore.hasOutputs)
}
]
]
}

View File

@@ -1,73 +0,0 @@
import { cleanup, render } from '@testing-library/vue'
import { afterEach, describe, expect, it } from 'vitest'
import { defineComponent, h, nextTick, ref, withDirectives } from 'vue'
import { coachmarkElements } from './coachmarkRegistry'
import type { CoachId } from './onboardingTours'
import { vCoachmark } from './vCoachmark'
// Mounts a host element carrying `v-coachmark`, so the directive runs through
// Vue's real mount/update/unmount lifecycle rather than hand-called hooks.
function mountHost(initial: CoachId | null) {
const coachId = ref<CoachId | null>(initial)
const rev = ref(0)
const Host = defineComponent({
setup: () => () =>
withDirectives(
h('div', { 'data-testid': 'host', 'data-rev': rev.value }),
[[vCoachmark, coachId.value]]
)
})
return {
...render(Host),
coachId,
// Forces a re-render without touching the bound id, to exercise the
// no-op-update guard.
rerender: () => {
rev.value++
return nextTick()
}
}
}
describe('vCoachmark', () => {
afterEach(cleanup)
it('registers the element and mirrors the id to data-coach-id on mount', () => {
const { getByTestId } = mountHost('app-run-button')
const host = getByTestId('host')
expect(host.dataset.coachId).toBe('app-run-button')
expect(coachmarkElements('app-run-button')).toContain(host)
})
it('does not register an element bound to a falsy id', () => {
const { getByTestId } = mountHost(null)
expect(getByTestId('host').dataset.coachId).toBeUndefined()
expect(coachmarkElements('app-run-button')).toHaveLength(0)
})
it('moves the element to the new id when the binding changes', async () => {
const { getByTestId, coachId } = mountHost('app-run-button')
const host = getByTestId('host')
coachId.value = 'outputs'
await nextTick()
expect(host.dataset.coachId).toBe('outputs')
expect(coachmarkElements('app-run-button')).not.toContain(host)
expect(coachmarkElements('outputs')).toContain(host)
})
it('ignores a re-render that leaves the id unchanged', async () => {
const { getByTestId, rerender } = mountHost('app-run-button')
const host = getByTestId('host')
await rerender()
expect(coachmarkElements('app-run-button')).toEqual([host])
})
it('unregisters and clears data-coach-id on unmount', () => {
const { getByTestId, unmount } = mountHost('app-run-button')
const host = getByTestId('host')
unmount()
expect(host.dataset.coachId).toBeUndefined()
expect(coachmarkElements('app-run-button')).not.toContain(host)
})
})

View File

@@ -1,29 +0,0 @@
import type { Directive } from 'vue'
import { registerCoachmark, unregisterCoachmark } from './coachmarkRegistry'
import type { CoachId } from './onboardingTours'
// The element's `data-coach-id` is the record of what it is registered as.
function sync(el: HTMLElement, id: CoachId | undefined | null) {
const prev = el.dataset.coachId as CoachId | undefined
if (prev === id) return
if (prev) {
unregisterCoachmark(prev, el)
delete el.dataset.coachId
}
if (id) {
el.dataset.coachId = id
registerCoachmark(id, el)
}
}
/**
* Marks an element as a coach-mark target: registers it in the reactive
* registry and mirrors the id to `data-coach-id` for e2e locators. A falsy
* value is a no-op, so it can be bound to a conditional id.
*/
export const vCoachmark: Directive<HTMLElement, CoachId | undefined | null> = {
mounted: (el, { value }) => sync(el, value),
updated: (el, { value }) => sync(el, value),
unmounted: (el) => sync(el, null)
}

View File

@@ -107,6 +107,7 @@ export type RemoteConfig = {
manager_survey_url?: string
linear_toggle_enabled?: boolean
team_workspaces_enabled?: boolean
partner_node_governance_enabled?: boolean
user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean
free_tier_credits?: number
@@ -121,7 +122,6 @@ export type RemoteConfig = {
comfyhub_profile_gate_enabled?: boolean
unified_cloud_auth?: boolean
billing_control_enabled?: boolean
onboarding_tour_enabled?: boolean
sentry_dsn?: string
turnstile_sitekey?: string
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).

View File

@@ -4,7 +4,6 @@ import {
SUPPORTED_LOCALE_OPTIONS
} from '@/locales/localeConfig'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { TOUR_SEEN_SETTING } from '@/platform/onboarding/onboardingTours'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import type { ColorPalettes } from '@/schemas/colorPaletteSchema'
@@ -978,13 +977,6 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
versionAdded: '1.8.7'
},
{
id: TOUR_SEEN_SETTING,
name: 'Onboarding coachmark tours the user has already seen',
type: 'hidden',
defaultValue: [],
versionAdded: '1.48.0'
},
{
id: 'Comfy.InstalledVersion',
name: 'The frontend version that was running when the user first installed ComfyUI',

View File

@@ -17,8 +17,6 @@ import type {
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OnboardingTourMetadata,
OnboardingTourStage,
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
@@ -172,13 +170,6 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackSurvey?.(stage, responses))
}
trackOnboardingTour(
stage: OnboardingTourStage,
metadata: OnboardingTourMetadata
): void {
this.dispatch((provider) => provider.trackOnboardingTour?.(stage, metadata))
}
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
this.dispatch((provider) => provider.trackEmailVerification?.(stage))
}

View File

@@ -38,8 +38,6 @@ import type {
AuthMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
OnboardingTourMetadata,
OnboardingTourStage,
RunButtonProperties,
ShareFlowMetadata,
ShellLayoutMetadata,
@@ -462,42 +460,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
)
})
it.for<
[
OnboardingTourStage,
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
]
>([
['started', TelemetryEvents.ONBOARDING_TOUR_STARTED],
['step_shown', TelemetryEvents.ONBOARDING_TOUR_STEP_SHOWN],
['completed', TelemetryEvents.ONBOARDING_TOUR_COMPLETED],
['skipped', TelemetryEvents.ONBOARDING_TOUR_SKIPPED],
['run_triggered', TelemetryEvents.ONBOARDING_TOUR_RUN_TRIGGERED],
['upgrade_shown', TelemetryEvents.ONBOARDING_TOUR_UPGRADE_SHOWN],
['nudge_shown', TelemetryEvents.ONBOARDING_TOUR_NUDGE_SHOWN],
[
'explore_templates_clicked',
TelemetryEvents.ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED
]
])(
'trackOnboardingTour(%s) dispatches %s',
async ([stage, expectedEvent]) => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
const metadata: OnboardingTourMetadata = {
tour: 'appMode',
step_count: 6,
step_number: 2,
coach_id: 'app-run-button'
}
provider.trackOnboardingTour(stage, metadata)
expect(mockMixpanel.track).toHaveBeenCalledWith(expectedEvent, metadata)
}
)
it('omits share_id from existing Mixpanel events', async () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()

View File

@@ -20,8 +20,6 @@ import type {
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OnboardingTourMetadata,
OnboardingTourStage,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
@@ -46,7 +44,7 @@ import type {
} from '../../types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { OnboardingTourEvents, TelemetryEvents } from '../../types'
import { TelemetryEvents } from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
@@ -282,13 +280,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
}
trackOnboardingTour(
stage: OnboardingTourStage,
metadata: OnboardingTourMetadata
): void {
this.trackEvent(OnboardingTourEvents[stage], metadata)
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses

View File

@@ -4,7 +4,6 @@ import type { Ref } from 'vue'
import { nextTick, ref } from 'vue'
import { TelemetryEvents } from '../../types'
import type { OnboardingTourStage } from '../../types'
const hoisted = vi.hoisted(() => {
const mockCapture = vi.fn()
@@ -719,45 +718,6 @@ describe('PostHogTelemetryProvider', () => {
shellLayoutMetadata
)
})
it.for<
[
OnboardingTourStage,
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
]
>([
['started', TelemetryEvents.ONBOARDING_TOUR_STARTED],
['step_shown', TelemetryEvents.ONBOARDING_TOUR_STEP_SHOWN],
['completed', TelemetryEvents.ONBOARDING_TOUR_COMPLETED],
['skipped', TelemetryEvents.ONBOARDING_TOUR_SKIPPED],
['run_triggered', TelemetryEvents.ONBOARDING_TOUR_RUN_TRIGGERED],
['upgrade_shown', TelemetryEvents.ONBOARDING_TOUR_UPGRADE_SHOWN],
['nudge_shown', TelemetryEvents.ONBOARDING_TOUR_NUDGE_SHOWN],
[
'explore_templates_clicked',
TelemetryEvents.ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED
]
])(
'maps onboarding tour stage %s to %s',
async ([stage, expectedEvent]) => {
const provider = createProvider()
await vi.dynamicImportSettled()
const metadata = {
tour: 'appMode',
step_count: 6,
step_number: 2,
coach_id: 'app-run-button'
} as const
provider.trackOnboardingTour(stage, metadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
expectedEvent,
metadata
)
}
)
})
describe('survey tracking', () => {

View File

@@ -24,8 +24,6 @@ import type {
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OnboardingTourMetadata,
OnboardingTourStage,
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
@@ -52,11 +50,7 @@ import type {
WorkflowSavedMetadata,
WorkspaceInviteMetadata
} from '../../types'
import {
CANCELLATION_STAGE_EVENTS,
OnboardingTourEvents,
TelemetryEvents
} from '../../types'
import { CANCELLATION_STAGE_EVENTS, TelemetryEvents } from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
@@ -427,13 +421,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
}
trackOnboardingTour(
stage: OnboardingTourStage,
metadata: OnboardingTourMetadata
): void {
this.trackEvent(OnboardingTourEvents[stage], metadata)
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses

View File

@@ -94,57 +94,6 @@ export interface SurveyResponses {
usage?: string
}
export type OnboardingTourStage =
| 'started'
| 'step_shown'
| 'completed'
| 'skipped'
| 'run_triggered'
| 'upgrade_shown'
| 'nudge_shown'
| 'explore_templates_clicked'
export type OnboardingTourSkipReason =
| 'user'
| 'target_timeout'
| 'trigger_lost'
/**
* `step_number` is 1-based and matches the "Step N of M" indicator the user
* sees, with `step_count` as M. Both `step_number` and `coach_id` are absent
* for steps with no numbered spotlight (e.g. the landing). `skip_reason` is
* present only on the `skipped` stage. `step_count` is absent on `nudge_shown`
* and `explore_templates_clicked`, which fire outside the step sequence.
*/
export interface OnboardingTourMetadata {
tour: string
step_count?: number
step_number?: number
coach_id?: string
skip_reason?: OnboardingTourSkipReason
}
/** `shape` labels the role-derived sequence, not the template — `'other'` is the
* honest bucket for graphs the resolver handles best-effort but that aren't a
* named shape. */
export type OnboardingTourShape = 't2i' | 'i2v' | 'image-edit' | 'other'
export type OnboardingTourEntry =
| 'getting_started'
| 'share_url'
| 'template_url'
export type OnboardingTourStepKey = 'upload' | 'prompt' | 'run' | 'result'
export type OnboardingTourRunStatus = 'success' | 'error' | 'interrupted'
/** Reported only by the first-run tour. No field carries user content or a
* share id, so no PII. */
export interface FirstRunTourMetadata extends OnboardingTourMetadata {
template_id?: string
shape?: OnboardingTourShape
entry?: OnboardingTourEntry
step_key?: OnboardingTourStepKey
status?: OnboardingTourRunStatus
}
export interface SurveyResponsesNormalized extends SurveyResponses {
industry_normalized?: string
industry_raw?: string
@@ -635,12 +584,6 @@ export interface TelemetryProvider {
// Survey flow events
trackSurvey?(stage: 'opened' | 'submitted', responses?: SurveyResponses): void
// Onboarding coachmark tour events
trackOnboardingTour?(
stage: OnboardingTourStage,
metadata: OnboardingTourMetadata
): void
// Email verification events
trackEmailVerification?(stage: 'opened' | 'requested' | 'completed'): void
@@ -747,17 +690,6 @@ export const TelemetryEvents = {
USER_SURVEY_OPENED: 'app:user_survey_opened',
USER_SURVEY_SUBMITTED: 'app:user_survey_submitted',
// Onboarding Tour
ONBOARDING_TOUR_STARTED: 'app:onboarding_tour_started',
ONBOARDING_TOUR_STEP_SHOWN: 'app:onboarding_tour_step_shown',
ONBOARDING_TOUR_COMPLETED: 'app:onboarding_tour_completed',
ONBOARDING_TOUR_SKIPPED: 'app:onboarding_tour_skipped',
ONBOARDING_TOUR_RUN_TRIGGERED: 'app:onboarding_tour_run_triggered',
ONBOARDING_TOUR_UPGRADE_SHOWN: 'app:onboarding_tour_upgrade_shown',
ONBOARDING_TOUR_NUDGE_SHOWN: 'app:onboarding_tour_nudge_shown',
ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED:
'app:onboarding_tour_explore_templates_clicked',
// Email Verification
USER_EMAIL_VERIFY_OPENED: 'app:user_email_verify_opened',
USER_EMAIL_VERIFY_REQUESTED: 'app:user_email_verify_requested',
@@ -821,21 +753,6 @@ export const TelemetryEvents = {
export type TelemetryEventName =
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
export const OnboardingTourEvents: Record<
OnboardingTourStage,
TelemetryEventName
> = {
started: TelemetryEvents.ONBOARDING_TOUR_STARTED,
step_shown: TelemetryEvents.ONBOARDING_TOUR_STEP_SHOWN,
completed: TelemetryEvents.ONBOARDING_TOUR_COMPLETED,
skipped: TelemetryEvents.ONBOARDING_TOUR_SKIPPED,
run_triggered: TelemetryEvents.ONBOARDING_TOUR_RUN_TRIGGERED,
upgrade_shown: TelemetryEvents.ONBOARDING_TOUR_UPGRADE_SHOWN,
nudge_shown: TelemetryEvents.ONBOARDING_TOUR_NUDGE_SHOWN,
explore_templates_clicked:
TelemetryEvents.ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED
}
export const CANCELLATION_STAGE_EVENTS = {
flow_opened: TelemetryEvents.SUBSCRIPTION_CANCEL_FLOW_OPENED,
confirmed: TelemetryEvents.SUBSCRIPTION_CANCEL_CONFIRMED,
@@ -856,7 +773,6 @@ export type ExecutionTriggerSource =
export type TelemetryEventProperties =
| AuthMetadata
| AuthErrorMetadata
| OnboardingTourMetadata
| SurveyResponses
| TemplateMetadata
| ExecutionContext

View File

@@ -5,8 +5,6 @@ import { createApp, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { TemplateUrlLoadResult } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
import { useOnboardingEntryStore } from '../onboardingEntryStore'
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
import { useWorkflowPersistenceV2 } from './useWorkflowPersistenceV2'
@@ -60,17 +58,11 @@ vi.mock('@/platform/workflow/core/services/workflowService', () => ({
})
}))
const templateLoaderMocks = vi.hoisted(() => ({
loadTemplateFromUrl: vi.fn(
async () => ({ loaded: false }) as TemplateUrlLoadResult
)
}))
vi.mock(
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
() => ({
useTemplateUrlLoader: () => ({
loadTemplateFromUrl: templateLoaderMocks.loadTemplateFromUrl
loadTemplateFromUrl: vi.fn()
})
})
)
@@ -133,57 +125,7 @@ vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return onboardingMocks.isCloud
}
}))
const onboardingMocks = vi.hoisted(() => ({
isCloud: false,
onboardingTourEnabled: false,
isNewUser: null as boolean | null,
isSubscriptionEnabled: true,
isDesktop: true,
loadWorkflowTemplates: vi.fn(async () => {})
}))
vi.mock('@/composables/useDesktopLayout', () => ({
useDesktopLayout: () => ({
get value() {
return onboardingMocks.isDesktop
}
})
}))
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: () => ({
loadWorkflowTemplates: onboardingMocks.loadWorkflowTemplates
})
})
)
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get onboardingTourEnabled() {
return onboardingMocks.onboardingTourEnabled
}
}
})
}))
vi.mock('@/services/useNewUserService', () => ({
useNewUserService: () => ({
isNewUser: () => onboardingMocks.isNewUser
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isSubscriptionEnabled: () => onboardingMocks.isSubscriptionEnabled
})
isCloud: false
}))
vi.mock('../migration/migrateV1toV2', () => ({
@@ -264,12 +206,6 @@ describe('useWorkflowPersistenceV2', () => {
commandStoreMocks.execute.mockReset()
routeMocks.query = {}
preservedQueryMocks.payloads = {}
onboardingMocks.isCloud = false
onboardingMocks.onboardingTourEnabled = false
onboardingMocks.isNewUser = null
onboardingMocks.isSubscriptionEnabled = true
templateLoaderMocks.loadTemplateFromUrl.mockReset()
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({ loaded: false })
})
afterEach(() => {
@@ -681,156 +617,5 @@ describe('useWorkflowPersistenceV2', () => {
'Comfy.BrowseTemplates'
)
})
it('shows Getting Started instead of the templates browser for a flagged new user', async () => {
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = true
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(entryStore.shouldShowGettingStarted).toBe(true)
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('opens the templates browser off the Cloud build, matching the tour gate', async () => {
onboardingMocks.isCloud = false
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = true
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(entryStore.shouldShowGettingStarted).toBe(false)
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('prefetches templates when Getting Started is shown so its cards are ready', async () => {
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = true
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(onboardingMocks.loadWorkflowTemplates).toHaveBeenCalled()
})
it('opens the templates browser when the flag is on but the user is not new', async () => {
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = false
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(entryStore.shouldShowGettingStarted).toBe(false)
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('opens the templates browser when the flag is off', async () => {
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = false
onboardingMocks.isNewUser = true
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(entryStore.shouldShowGettingStarted).toBe(false)
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('opens the templates browser, not Getting Started, when subscriptions are disabled', async () => {
// The tour refuses when subscriptions are off, so the takeover must not show
// — otherwise it would dismiss into a bare canvas with no tour.
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = true
onboardingMocks.isSubscriptionEnabled = false
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(entryStore.shouldShowGettingStarted).toBe(false)
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('does not show Getting Started for a flagged new user arriving via a template URL', async () => {
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = true
routeMocks.query = { template: 'default-template-id' }
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(entryStore.shouldShowGettingStarted).toBe(false)
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
})
describe('loadTemplateFromUrlIfPresent', () => {
it('surfaces the validated template id the loader reports', async () => {
routeMocks.query = { template: 'image_z_image_turbo' }
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
loaded: true,
templateId: 'image_z_image_turbo'
})
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
await expect(loadTemplateFromUrlIfPresent()).resolves.toEqual({
loaded: true,
templateId: 'image_z_image_turbo'
})
})
it('reports not-loaded when the loader loads nothing', async () => {
routeMocks.query = {}
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
loaded: false
})
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
await expect(loadTemplateFromUrlIfPresent()).resolves.toEqual({
loaded: false
})
})
it('hydrates preserved template intent before delegating to the loader', async () => {
preservedQueryMocks.payloads.template = {
template: 'image_z_image_turbo'
}
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
loaded: true,
templateId: 'image_z_image_turbo'
})
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
await loadTemplateFromUrlIfPresent()
expect(templateLoaderMocks.loadTemplateFromUrl).toHaveBeenCalled()
})
})
})

View File

@@ -16,14 +16,11 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useDesktopLayout } from '@/composables/useDesktopLayout'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
@@ -31,17 +28,9 @@ import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { useNewUserService } from '@/services/useNewUserService'
import { PERSIST_DEBOUNCE_MS } from '../base/draftTypes'
import { clearAllV2Storage } from '../base/storageIO'
import { migrateV1toV2 } from '../migration/migrateV1toV2'
import type { OnboardingCandidateDeps } from '../onboardingEntryStore'
import {
isOnboardingCandidate,
useOnboardingEntryStore
} from '../onboardingEntryStore'
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
import { useWorkflowTabState } from './useWorkflowTabState'
import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader'
@@ -63,14 +52,6 @@ export function useWorkflowPersistenceV2() {
const draftStore = useWorkflowDraftStoreV2()
const tabState = useWorkflowTabState()
const toast = useToast()
const templatesStore = useWorkflowTemplatesStore()
const entryStore = useOnboardingEntryStore()
const onboardingDeps: OnboardingCandidateDeps = {
subscription: useSubscription(),
newUserService: useNewUserService(),
featureFlags: useFeatureFlags(),
desktop: useDesktopLayout()
}
const { onUserLogout } = useCurrentUser()
// Run migration on module load, passing clientId for tab state migration
@@ -198,12 +179,7 @@ export function useWorkflowPersistenceV2() {
await settingStore.set('Comfy.TutorialCompleted', true)
await useWorkflowService().loadBlankWorkflow()
if (!hasSharedWorkflowIntent() && !hasTemplateUrlIntent()) {
if (isOnboardingCandidate(onboardingDeps)) {
void templatesStore.loadWorkflowTemplates()
entryStore.showGettingStarted()
} else {
await useCommandStore().execute('Comfy.BrowseTemplates')
}
await useCommandStore().execute('Comfy.BrowseTemplates')
}
} else {
await comfyApp.loadGraphData()
@@ -247,10 +223,12 @@ export function useWorkflowPersistenceV2() {
}
const loadTemplateFromUrlIfPresent = async () => {
// Hydrate any preserved ?template= intent into the route first; the loader
// reads the route and returns the id only after validating it.
await ensureTemplateQueryFromIntent()
return templateUrlLoader.loadTemplateFromUrl()
const query = await ensureTemplateQueryFromIntent()
const hasTemplateUrl = query.template && typeof query.template === 'string'
if (hasTemplateUrl) {
await templateUrlLoader.loadTemplateFromUrl()
}
}
const loadSharedWorkflowFromUrlIfPresent = async () => {

View File

@@ -1,57 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { ComputedRef } from 'vue'
import type { useFeatureFlags } from '@/composables/useFeatureFlags'
import type { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import type { useNewUserService } from '@/services/useNewUserService'
export interface OnboardingCandidateDeps {
subscription: ReturnType<typeof useSubscription>
newUserService: ReturnType<typeof useNewUserService>
featureFlags: ReturnType<typeof useFeatureFlags>
desktop: ComputedRef<boolean>
}
/**
* Shared by the Getting Started screen and the tour: the screen dismisses even
* when the tour then declines, so a looser gate on either side strands the user
* on a bare canvas. Deps are resolved by callers during setup — this runs after
* an await, where a first composable call would have no injection context.
*/
export function isOnboardingCandidate({
subscription,
newUserService,
featureFlags,
desktop
}: OnboardingCandidateDeps): boolean {
if (!desktop.value) return false
if (!isCloud) return false
if (!subscription.isSubscriptionEnabled()) return false
if (newUserService.isNewUser() !== true) return false
if (!featureFlags.flags.onboardingTourEnabled) return false
return true
}
/**
* Seam between the persistence layer and the onboarding Getting Started screen.
*
* `loadDefaultWorkflow` decides when a fresh user should land on Getting
* Started, but the screen lives in `renderer/` and cannot be imported from this
* layer. Both sides share this flag: persistence turns it on; the
* renderer-mounted overlay shows itself while set and clears it on exit.
*/
export const useOnboardingEntryStore = defineStore('onboardingEntry', () => {
const shouldShowGettingStarted = ref(false)
function showGettingStarted() {
shouldShowGettingStarted.value = true
}
function dismissGettingStarted() {
shouldShowGettingStarted.value = false
}
return { shouldShowGettingStarted, showGettingStarted, dismissGettingStarted }
})

View File

@@ -97,51 +97,6 @@ describe('useTemplateUrlLoader', () => {
expect(mockLoadWorkflowTemplate).not.toHaveBeenCalled()
})
it('returns the loaded template id when the template loads successfully', async () => {
mockQueryParams = { template: 'flux_simple' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await expect(loadTemplateFromUrl()).resolves.toEqual({
loaded: true,
templateId: 'flux_simple'
})
})
it('returns not-loaded when no template param is present', async () => {
mockQueryParams = {}
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
})
it('returns not-loaded without a template id when the template fails to load', async () => {
mockQueryParams = { template: 'invalid-template' }
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
})
it('returns not-loaded when loading throws', async () => {
mockQueryParams = { template: 'flux_simple' }
mockLoadTemplates.mockRejectedValueOnce(new Error('Network error'))
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
})
it('returns not-loaded for an invalid template parameter', async () => {
mockQueryParams = { template: '../../../etc/passwd' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
})
it('loads template when query param is present', async () => {
mockQueryParams = { template: 'flux_simple' }

View File

@@ -10,11 +10,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useTemplateWorkflows } from './useTemplateWorkflows'
export interface TemplateUrlLoadResult {
loaded: boolean
templateId?: string
}
/**
* Composable for loading templates from URL query parameters
*
@@ -69,18 +64,18 @@ export function useTemplateUrlLoader() {
* Loads template from URL query parameters if present
* Handles errors internally and shows appropriate user feedback
*/
const loadTemplateFromUrl = async (): Promise<TemplateUrlLoadResult> => {
const loadTemplateFromUrl = async () => {
const templateParam = route.query.template
if (!templateParam || typeof templateParam !== 'string') {
return { loaded: false }
return
}
if (!isValidParameter(templateParam)) {
console.warn(
`[useTemplateUrlLoader] Invalid template parameter format: ${templateParam}`
)
return { loaded: false }
return
}
const sourceParam = (route.query.source as string | undefined) || 'default'
@@ -89,7 +84,7 @@ export function useTemplateUrlLoader() {
console.warn(
`[useTemplateUrlLoader] Invalid source parameter format: ${sourceParam}`
)
return { loaded: false }
return
}
const modeParam = route.query.mode as string | undefined
@@ -101,7 +96,7 @@ export function useTemplateUrlLoader() {
console.warn(
`[useTemplateUrlLoader] Invalid mode parameter format: ${modeParam}`
)
return { loaded: false }
return
}
if (modeParam && !isSupportedMode(modeParam)) {
@@ -126,16 +121,11 @@ export function useTemplateUrlLoader() {
templateName: templateParam
})
})
return { loaded: false }
}
if (modeParam === 'linear') {
} else if (modeParam === 'linear') {
// Set linear mode after successful template load
useTelemetry()?.trackEnterLinear({ source: 'template_url' })
canvasStore.linearMode = true
}
return { loaded: true, templateId: templateParam }
} catch (error) {
console.error(
'[useTemplateUrlLoader] Failed to load template from URL:',
@@ -146,7 +136,6 @@ export function useTemplateUrlLoader() {
summary: t('g.error'),
detail: t('g.errorLoadingTemplate')
})
return { loaded: false }
} finally {
cleanupUrlParams()
clearPreservedQuery(TEMPLATE_NAMESPACE)

View File

@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getPartnerNodePolicy,
PartnerNodePolicyApiError,
updatePartnerNodePolicy
} from '@/platform/workspace/api/partnerNodePolicyApi'
const mockFetchApi = vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: (...args: unknown[]) => mockFetchApi(...args)
}
}))
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(body), init)
}
describe('partnerNodePolicyApi', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('normalizes the configured policy response', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({
enforcement_enabled: true,
nodes: { AllowedNode: true, DisabledNode: false }
})
)
await expect(getPartnerNodePolicy()).resolves.toEqual({
enforcementEnabled: true,
nodes: { AllowedNode: true, DisabledNode: false }
})
expect(mockFetchApi).toHaveBeenCalledWith(
'/workspace/partner-node-policy',
{ cache: 'no-store' }
)
})
it('maps 404 to an unconfigured policy', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({}, { status: 404, statusText: 'Not Found' })
)
await expect(getPartnerNodePolicy()).resolves.toBeNull()
})
it('preserves non-404 status codes for policy decisions', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({}, { status: 503, statusText: 'Service Unavailable' })
)
await expect(getPartnerNodePolicy()).rejects.toEqual(
new PartnerNodePolicyApiError(503, 'Service Unavailable')
)
})
it('rejects malformed policy responses', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({ enforcement_enabled: 'yes', nodes: [] })
)
await expect(getPartnerNodePolicy()).rejects.toMatchObject({
name: 'ZodError'
})
})
it('serializes and validates a whole-policy update', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({
enforcement_enabled: false,
nodes: { AllowedNode: true, DisabledNode: true }
})
)
await expect(
updatePartnerNodePolicy({
enforcementEnabled: false,
nodes: { AllowedNode: true, DisabledNode: true }
})
).resolves.toEqual({
enforcementEnabled: false,
nodes: { AllowedNode: true, DisabledNode: true }
})
expect(mockFetchApi).toHaveBeenCalledWith(
'/workspace/partner-node-policy',
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enforcement_enabled: false,
nodes: { AllowedNode: true, DisabledNode: true }
})
}
)
})
it('preserves update failures for the editor', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({}, { status: 409, statusText: 'Conflict' })
)
await expect(
updatePartnerNodePolicy({ enforcementEnabled: false, nodes: {} })
).rejects.toEqual(new PartnerNodePolicyApiError(409, 'Conflict'))
})
it('rejects malformed update responses', async () => {
mockFetchApi.mockResolvedValue(
jsonResponse({ enforcement_enabled: false, nodes: [] })
)
await expect(
updatePartnerNodePolicy({ enforcementEnabled: false, nodes: {} })
).rejects.toMatchObject({ name: 'ZodError' })
})
})

View File

@@ -0,0 +1,67 @@
import { z } from 'zod'
import { api } from '@/scripts/api'
const PARTNER_NODE_POLICY_PATH = '/workspace/partner-node-policy'
const partnerNodePolicyResponseSchema = z.object({
enforcement_enabled: z.boolean(),
nodes: z.record(z.string(), z.boolean())
})
export type PartnerNodePolicyResponse = z.infer<
typeof partnerNodePolicyResponseSchema
>
export interface PartnerNodePolicy {
enforcementEnabled: boolean
nodes: Readonly<Record<string, boolean>>
}
export class PartnerNodePolicyApiError extends Error {
constructor(
public readonly status: number,
message: string
) {
super(message)
this.name = 'PartnerNodePolicyApiError'
}
}
function parsePartnerNodePolicy(data: unknown): PartnerNodePolicy {
const policy = partnerNodePolicyResponseSchema.parse(data)
return {
enforcementEnabled: policy.enforcement_enabled,
nodes: policy.nodes
}
}
function throwResponseError(response: Response): never {
throw new PartnerNodePolicyApiError(response.status, response.statusText)
}
export async function getPartnerNodePolicy(): Promise<PartnerNodePolicy | null> {
const response = await api.fetchApi(PARTNER_NODE_POLICY_PATH, {
cache: 'no-store'
})
if (response.status === 404) return null
if (!response.ok) throwResponseError(response)
return parsePartnerNodePolicy(await response.json())
}
export async function updatePartnerNodePolicy(
policy: PartnerNodePolicy
): Promise<PartnerNodePolicy> {
const response = await api.fetchApi(PARTNER_NODE_POLICY_PATH, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enforcement_enabled: policy.enforcementEnabled,
nodes: policy.nodes
})
})
if (!response.ok) throwResponseError(response)
return parsePartnerNodePolicy(await response.json())
}

View File

@@ -0,0 +1,270 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { defineComponent, nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
import type { PartnerNodePolicyStatus } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import PartnerNodeAllowlistPanel from './PartnerNodeAllowlistPanel.vue'
const mockSavePolicy = vi.fn()
const mockLoadPolicy = vi.fn()
const mockToastAdd = vi.fn()
const state = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
governedWorkspaceId: ref<string | null>('workspace-one'),
partnerNodes: ref([
{ id: 'FluxFill', name: 'Flux Fill', provider: 'BFL' },
{ id: 'FluxExpand', name: 'Flux Expand', provider: 'BFL' },
{ id: 'VeoVideo', name: 'Veo Video', provider: 'Google' }
]),
policy: ref<PartnerNodePolicy | null>({
enforcementEnabled: true,
nodes: {
FluxFill: false,
FluxExpand: true,
VeoVideo: true,
RetiredNode: false
}
}),
status: ref<PartnerNodePolicyStatus>('configured')
}
})
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
storeToRefs: (store: Record<string, unknown>) => store
}
})
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
usePartnerNodeGovernanceStore: () => ({
...state,
savePolicy: mockSavePolicy,
loadPolicy: mockLoadPolicy
})
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: mockToastAdd })
}))
const ToggleSwitchStub = defineComponent({
name: 'ToggleSwitch',
props: {
modelValue: Boolean,
disabled: Boolean,
ariaLabel: String
},
emits: ['update:modelValue'],
template: `
<button
role="switch"
:aria-checked="modelValue"
:aria-label="ariaLabel"
:disabled="disabled"
@click="$emit('update:modelValue', !modelValue)"
/>
`
})
const SearchInputStub = defineComponent({
name: 'SearchInput',
props: {
modelValue: { type: String, required: true },
placeholder: String,
disabled: Boolean
},
emits: ['update:modelValue'],
template: `
<input
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
@input="$emit('update:modelValue', $event.target.value)"
/>
`
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderPanel() {
return render(PartnerNodeAllowlistPanel, {
global: {
plugins: [i18n],
stubs: {
ToggleSwitch: ToggleSwitchStub,
SearchInput: SearchInputStub
}
}
})
}
describe('PartnerNodeAllowlistPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
state.governedWorkspaceId.value = 'workspace-one'
state.partnerNodes.value = [
{ id: 'FluxFill', name: 'Flux Fill', provider: 'BFL' },
{ id: 'FluxExpand', name: 'Flux Expand', provider: 'BFL' },
{ id: 'VeoVideo', name: 'Veo Video', provider: 'Google' }
]
state.policy.value = {
enforcementEnabled: true,
nodes: {
FluxFill: false,
FluxExpand: true,
VeoVideo: true,
RetiredNode: false
}
}
state.status.value = 'configured'
mockSavePolicy.mockResolvedValue(true)
})
it('groups catalog nodes and reflects the configured allowlist', () => {
renderPanel()
expect(screen.getByText('BFL')).toBeInTheDocument()
expect(screen.getByText('Google')).toBeInTheDocument()
expect(
screen.getByRole('switch', {
name: 'Enforce partner node allowlist'
})
).toBeChecked()
expect(
screen.getByRole('switch', { name: 'Allow Flux Fill' })
).not.toBeChecked()
expect(
screen.getByRole('switch', { name: 'Allow Flux Expand' })
).toBeChecked()
expect(screen.getByText('1 of 2 allowed')).toBeInTheDocument()
})
it('saves one whole policy while preserving enforcement and hidden entries', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('switch', { name: 'Allow Flux Fill' }))
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(mockSavePolicy).toHaveBeenCalledWith({
enforcementEnabled: true,
nodes: {
FluxFill: true,
FluxExpand: true,
VeoVideo: true,
RetiredNode: false
}
})
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'Partner node policy saved',
life: 2000
})
})
it('saves enforcement with the existing allowlist', async () => {
const user = userEvent.setup()
state.policy.value = {
enforcementEnabled: false,
nodes: {
FluxFill: false,
FluxExpand: true,
VeoVideo: true,
RetiredNode: false
}
}
renderPanel()
await user.click(
screen.getByRole('switch', {
name: 'Enforce partner node allowlist'
})
)
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(mockSavePolicy).toHaveBeenCalledWith({
enforcementEnabled: true,
nodes: {
FluxFill: false,
FluxExpand: true,
VeoVideo: true,
RetiredNode: false
}
})
})
it('treats an unconfigured workspace as allow-all draft', async () => {
state.policy.value = null
state.status.value = 'unconfigured'
renderPanel()
await nextTick()
expect(
screen.getByRole('switch', {
name: 'Enforce partner node allowlist'
})
).not.toBeChecked()
const nodeToggles = screen.getAllByRole('switch', { name: /^Allow / })
expect(nodeToggles).toHaveLength(3)
for (const toggle of nodeToggles) {
expect(toggle).toBeChecked()
}
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
})
it('filters by provider and node identity', async () => {
const user = userEvent.setup()
renderPanel()
await user.type(
screen.getByPlaceholderText('Search partner nodes...'),
'veo'
)
expect(screen.getByText('Veo Video')).toBeInTheDocument()
expect(screen.queryByText('Flux Fill')).not.toBeInTheDocument()
expect(screen.getByText('Google')).toBeInTheDocument()
})
it('offers retry when the policy is unavailable', async () => {
const user = userEvent.setup()
state.status.value = 'unavailable'
renderPanel()
await user.click(screen.getByRole('button', { name: 'Try again' }))
expect(mockLoadPolicy).toHaveBeenCalledOnce()
expect(screen.getByRole('alert')).toHaveTextContent(
'Partner node policy could not be loaded.'
)
})
it('keeps draft changes and surfaces a save failure', async () => {
const user = userEvent.setup()
mockSavePolicy.mockRejectedValue(new Error('Conflict'))
renderPanel()
const toggle = screen.getByRole('switch', { name: 'Allow Flux Fill' })
await user.click(toggle)
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(toggle).toBeChecked()
expect(screen.getByRole('alert')).toHaveTextContent(
'Partner node policy could not be saved.'
)
})
})

View File

@@ -0,0 +1,286 @@
<template>
<div class="grow overflow-auto pt-6">
<div
class="flex size-full min-h-0 flex-col gap-5 rounded-2xl border border-interface-stroke p-6"
>
<div class="flex items-start gap-6">
<div class="min-w-0 flex-1">
<h2 class="m-0 text-base font-semibold text-base-foreground">
{{ $t('workspacePanel.allowlist.title') }}
</h2>
<p class="mt-1 mb-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.allowlist.description') }}
</p>
</div>
<SearchInput
v-model="searchQuery"
:placeholder="$t('workspacePanel.allowlist.searchPlaceholder')"
size="lg"
class="w-64 shrink-0"
:disabled="!isReady"
/>
</div>
<div
v-if="isReady"
class="flex min-h-14 items-center gap-4 rounded-xl border border-interface-stroke/60 px-4 py-3"
>
<div class="min-w-0 flex-1">
<div class="text-sm text-base-foreground">
{{ $t('workspacePanel.allowlist.enforcement.title') }}
</div>
<div class="mt-0.5 text-xs text-muted-foreground">
{{ $t('workspacePanel.allowlist.enforcement.description') }}
</div>
</div>
<ToggleSwitch
:model-value="draftEnforcementEnabled"
:disabled="isSaving"
:aria-label="$t('workspacePanel.allowlist.enforcement.toggle')"
@update:model-value="setEnforcementEnabled"
/>
</div>
<div
v-if="status === 'loading'"
role="status"
class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"
>
<i class="icon-[lucide--loader-circle] size-4 animate-spin" />
{{ $t('workspacePanel.allowlist.loading') }}
</div>
<div
v-else-if="status === 'error' || status === 'unavailable'"
role="alert"
class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"
>
<span>{{ $t('workspacePanel.allowlist.loadError') }}</span>
<Button variant="muted-textonly" @click="loadPolicy">
{{ $t('workspacePanel.allowlist.retry') }}
</Button>
</div>
<div
v-else-if="isReady && partnerNodes.length === 0"
class="flex flex-1 items-center justify-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.allowlist.empty') }}
</div>
<div v-else-if="isReady" class="min-h-0 flex-1 overflow-y-auto">
<div
v-if="groups.length === 0"
class="flex min-h-32 items-center justify-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.allowlist.noMatches') }}
</div>
<template v-else>
<section
v-for="group in groups"
:key="group.provider"
class="mb-5 last:mb-0"
>
<div
class="mb-2 flex items-center justify-between text-xs font-medium text-muted-foreground"
>
<span>{{ group.provider }}</span>
<span>
{{
$t('workspacePanel.allowlist.allowedCount', {
enabled: group.enabledCount,
total: group.nodes.length
})
}}
</span>
</div>
<div
class="overflow-hidden rounded-xl border border-interface-stroke/60"
>
<div
v-for="node in group.nodes"
:key="node.id"
class="flex min-h-14 items-center gap-4 border-b border-interface-stroke/40 px-4 last:border-b-0"
>
<div class="min-w-0 flex-1">
<div class="truncate text-sm text-base-foreground">
{{ node.name }}
</div>
<div
v-if="node.name !== node.id"
class="truncate text-xs text-muted-foreground"
>
{{ node.id }}
</div>
</div>
<ToggleSwitch
:model-value="draftNodes[node.id]"
:disabled="isSaving"
:aria-label="
$t('workspacePanel.allowlist.nodeToggle', {
name: node.name
})
"
@update:model-value="
(enabled: boolean) => setNodeEnabled(node.id, enabled)
"
/>
</div>
</div>
</section>
</template>
</div>
<div v-if="isReady" class="flex min-h-10 items-center justify-end gap-3">
<span v-if="saveError" role="alert" class="text-destructive text-sm">
{{ $t('workspacePanel.allowlist.saveError') }}
</span>
<Button
variant="primary"
size="lg"
:disabled="!hasChanges"
:loading="isSaving"
@click="save"
>
{{ $t('g.save') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import type { PartnerNodeCatalogItem } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
interface PartnerNodeGroup {
provider: string
nodes: PartnerNodeCatalogItem[]
enabledCount: number
}
const { t } = useI18n()
const governanceStore = usePartnerNodeGovernanceStore()
const { governedWorkspaceId, partnerNodes, policy, status } =
storeToRefs(governanceStore)
const toastStore = useToastStore()
const searchQuery = ref('')
const draftNodes = ref<Record<string, boolean>>({})
const draftEnforcementEnabled = ref(false)
const isSaving = ref(false)
const saveError = ref(false)
const isReady = computed(
() => status.value === 'configured' || status.value === 'unconfigured'
)
function originalNodeValue(nodeId: string): boolean {
return policy.value ? policy.value.nodes[nodeId] === true : true
}
function resetDraft(): void {
draftEnforcementEnabled.value = policy.value?.enforcementEnabled ?? false
draftNodes.value = Object.fromEntries(
partnerNodes.value.map((node) => [node.id, originalNodeValue(node.id)])
)
saveError.value = false
}
watch([governedWorkspaceId, partnerNodes, policy], resetDraft, {
immediate: true
})
const hasChanges = computed(
() =>
draftEnforcementEnabled.value !==
(policy.value?.enforcementEnabled ?? false) ||
partnerNodes.value.some(
(node) => draftNodes.value[node.id] !== originalNodeValue(node.id)
)
)
const groups = computed<PartnerNodeGroup[]>(() => {
const query = searchQuery.value.trim().toLocaleLowerCase()
const byProvider = new Map<string, PartnerNodeCatalogItem[]>()
for (const node of partnerNodes.value) {
if (
query &&
![node.name, node.id, node.provider].some((value) =>
value.toLocaleLowerCase().includes(query)
)
) {
continue
}
const nodes = byProvider.get(node.provider) ?? []
nodes.push(node)
byProvider.set(node.provider, nodes)
}
return [...byProvider.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([provider, nodes]) => {
const sortedNodes = nodes.sort((left, right) =>
left.name.localeCompare(right.name)
)
return {
provider,
nodes: sortedNodes,
enabledCount: sortedNodes.filter(
(node) => draftNodes.value[node.id] === true
).length
}
})
})
function setNodeEnabled(nodeId: string, enabled: boolean): void {
draftNodes.value[nodeId] = enabled
saveError.value = false
}
function setEnforcementEnabled(enabled: boolean): void {
draftEnforcementEnabled.value = enabled
saveError.value = false
}
async function save(): Promise<void> {
if (!hasChanges.value || isSaving.value) return
isSaving.value = true
saveError.value = false
try {
const applied = await governanceStore.savePolicy({
enforcementEnabled: draftEnforcementEnabled.value,
nodes: {
...(policy.value?.nodes ?? {}),
...draftNodes.value
}
})
if (!applied) return
toastStore.add({
severity: 'success',
summary: t('workspacePanel.allowlist.saved'),
life: 2000
})
} catch {
saveError.value = true
} finally {
isSaving.value = false
}
}
function loadPolicy(): void {
void governanceStore.loadPolicy()
}
</script>

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/vue'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -9,12 +10,19 @@ import WorkspacePanelContent from './WorkspacePanelContent.vue'
const mockFetchMembers = vi.fn()
const mockFetchPendingInvites = vi.fn()
const { mockMembers, mockWorkspaceType } = vi.hoisted(() => {
const {
mockGovernedWorkspaceId,
mockMembers,
mockWorkspaceRole,
mockWorkspaceType
} = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
mockGovernedWorkspaceId: ref<string | null>('workspace-one'),
mockMembers: ref<WorkspaceMember[]>([]),
mockWorkspaceRole: ref<'owner' | 'member'>('owner'),
mockWorkspaceType: ref<'personal' | 'team'>('team')
}
})
@@ -41,16 +49,20 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
})
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
useWorkspaceUI: () => ({
workspaceType: mockWorkspaceType,
workspaceRole: ref('owner')
workspaceRole: mockWorkspaceRole
})
}
})
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
usePartnerNodeGovernanceStore: () => ({
governedWorkspaceId: mockGovernedWorkspaceId
})
}))
vi.mock(
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
() => ({
@@ -75,6 +87,11 @@ vi.mock(
})
)
vi.mock(
'@/platform/workspace/components/dialogs/settings/PartnerNodeAllowlistPanel.vue',
() => ({ default: { template: '<div />' } })
)
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -94,8 +111,9 @@ function createMember(id: string): WorkspaceMember {
}
}
function renderComponent() {
function renderComponent(defaultTab?: string) {
return render(WorkspacePanelContent, {
props: { defaultTab },
global: {
plugins: [i18n],
stubs: { WorkspaceProfilePic: true }
@@ -107,6 +125,8 @@ describe('WorkspacePanelContent billing banner', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.value = []
mockGovernedWorkspaceId.value = 'workspace-one'
mockWorkspaceRole.value = 'owner'
mockWorkspaceType.value = 'team'
})
@@ -126,6 +146,8 @@ describe('WorkspacePanelContent members tab label', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.value = []
mockGovernedWorkspaceId.value = 'workspace-one'
mockWorkspaceRole.value = 'owner'
mockWorkspaceType.value = 'team'
})
@@ -156,3 +178,56 @@ describe('WorkspacePanelContent members tab label', () => {
expect(mockFetchPendingInvites).toHaveBeenCalled()
})
})
describe('WorkspacePanelContent allowlist tab', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGovernedWorkspaceId.value = 'workspace-one'
mockWorkspaceRole.value = 'owner'
mockWorkspaceType.value = 'team'
})
it('shows the allowlist for an eligible workspace owner', () => {
renderComponent()
expect(
screen.getByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
).toBeInTheDocument()
})
it('hides the allowlist from workspace members', () => {
mockWorkspaceRole.value = 'member'
renderComponent()
expect(
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
).toBeNull()
})
it('hides the allowlist when governance is ineligible', () => {
mockGovernedWorkspaceId.value = null
renderComponent()
expect(
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
).toBeNull()
})
it('falls back to a valid tab when allowlist access is lost', async () => {
renderComponent('allowlist')
expect(
screen.getByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
).toHaveAttribute('aria-selected', 'true')
mockWorkspaceRole.value = 'member'
await nextTick()
expect(
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
).toBeNull()
expect(
screen.getByRole('tab', { name: 'workspacePanel.tabs.planCredits' })
).toHaveAttribute('aria-selected', 'true')
})
})

View File

@@ -39,6 +39,18 @@
: $t('workspacePanel.members.header')
}}
</TabsTrigger>
<TabsTrigger
v-if="showAllowlistTab"
value="allowlist"
:class="
cn(
tabTriggerBase,
activeTab === 'allowlist' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{ $t('workspacePanel.tabs.allowlist') }}
</TabsTrigger>
</TabsList>
<BillingStatusBanner class="mt-4" />
@@ -49,21 +61,26 @@
<TabsContent value="members" class="mt-4">
<MembersPanelContent :key="workspaceRole" />
</TabsContent>
<TabsContent v-if="showAllowlistTab" value="allowlist" class="mt-4">
<PartnerNodeAllowlistPanel />
</TabsContent>
</TabsRoot>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import PartnerNodeAllowlistPanel from '@/platform/workspace/components/dialogs/settings/PartnerNodeAllowlistPanel.vue'
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
@@ -83,9 +100,19 @@ const { workspaceName, members } = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { workspaceType, workspaceRole } = useWorkspaceUI()
const { governedWorkspaceId } = storeToRefs(usePartnerNodeGovernanceStore())
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
const showAllowlistTab = computed(
() => !!governedWorkspaceId.value && workspaceRole.value === 'owner'
)
const activeTab = ref(defaultTab)
watch(showAllowlistTab, (isVisible) => {
if (!isVisible && activeTab.value === 'allowlist') {
activeTab.value = defaultTab === 'allowlist' ? 'plan' : defaultTab
}
})
// Per design, the tab counts members only when there is more than the owner
const showMembersTabCount = computed(
() => !isPersonalWorkspace.value && members.value.length > 1

View File

@@ -0,0 +1,411 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type * as PartnerNodePolicyApi from '@/platform/workspace/api/partnerNodePolicyApi'
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
import { PartnerNodePolicyApiError } from '@/platform/workspace/api/partnerNodePolicyApi'
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
const mockGetPartnerNodePolicy = vi.hoisted(() => vi.fn())
const mockUpdatePartnerNodePolicy = vi.hoisted(() => vi.fn())
const mockFlags = vi.hoisted(() => ({
teamWorkspacesEnabled: true,
partnerNodeGovernanceEnabled: true
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags })
}))
vi.mock(
'@/platform/workspace/api/partnerNodePolicyApi',
async (importOriginal) => {
const actual = await importOriginal<typeof PartnerNodePolicyApi>()
return {
...actual,
getPartnerNodePolicy: mockGetPartnerNodePolicy,
updatePartnerNodePolicy: mockUpdatePartnerNodePolicy
}
}
)
function nodeDef(
name: string,
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef {
return {
name,
display_name: `Display ${name}`,
category: 'partner/image/Provider',
python_module: 'comfy_api_nodes.provider',
description: '',
input: {},
output: [],
output_is_list: [],
output_name: [],
output_node: false,
api_node: true,
...overrides
}
}
function activateWorkspace(id: string, type: 'personal' | 'team' = 'team') {
const store = useTeamWorkspaceStore()
store.workspaces = [
{
id,
name: id,
type,
role: 'owner',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
isSubscribed: false,
subscriptionPlan: null,
subscriptionTier: null,
members: [],
pendingInvites: []
}
]
store.activeWorkspaceId = id
}
async function createLoadedStore() {
const store = usePartnerNodeGovernanceStore()
await vi.waitFor(() => expect(store.status).not.toBe('loading'))
return store
}
describe('partnerNodeGovernanceStore', () => {
let store: ReturnType<typeof usePartnerNodeGovernanceStore> | undefined
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
mockFlags.teamWorkspacesEnabled = true
mockFlags.partnerNodeGovernanceEnabled = true
mockGetPartnerNodePolicy.mockResolvedValue(null)
mockUpdatePartnerNodePolicy.mockImplementation(
async (policy: PartnerNodePolicy) => policy
)
activateWorkspace('workspace-one')
useNodeDefStore().updateNodeDefs([
nodeDef('AllowedNode'),
nodeDef('DisabledNode'),
nodeDef('UnreviewedNode'),
nodeDef('CoreNode', {
api_node: false,
category: 'sampling',
python_module: 'nodes'
})
])
})
afterEach(() => {
delete LiteGraph.registered_node_types.LegacyGovernedNode
store?.$dispose()
store = undefined
})
it('composes partner-node catalog metadata from object info', async () => {
useNodeDefStore().updateNodeDefs([
nodeDef('PartnerNode', {
display_name: 'Partner Node',
category: 'partner/video/Acme'
}),
nodeDef('CoreNode', { api_node: false })
])
store = await createLoadedStore()
expect(store.partnerNodes).toEqual([
{ id: 'PartnerNode', name: 'Partner Node', provider: 'Acme' }
])
})
it('treats 404 as unconfigured and allows every node', async () => {
store = await createLoadedStore()
expect(store.status).toBe('unconfigured')
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('does not block nodes while enforcement is off', async () => {
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: false,
nodes: { AllowedNode: true, DisabledNode: false }
} satisfies PartnerNodePolicy)
store = await createLoadedStore()
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('allows only explicit true rules while enforcement is on', async () => {
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: true,
nodes: { AllowedNode: true, DisabledNode: false }
} satisfies PartnerNodePolicy)
store = await createLoadedStore()
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
expect(store.isNodeDisabled('DisabledNode')).toBe(true)
expect(store.isNodeDisabled('UnreviewedNode')).toBe(true)
expect(store.isNodeDisabled('CoreNode')).toBe(false)
})
it('filters disabled nodes out of discovery', async () => {
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: true,
nodes: { AllowedNode: true, DisabledNode: false }
} satisfies PartnerNodePolicy)
store = await createLoadedStore()
const nodeDefStore = useNodeDefStore()
expect(nodeDefStore.visibleNodeDefs.map((node) => node.name)).toEqual([
'AllowedNode',
'CoreNode'
])
expect(nodeDefStore.nodeSearchService.searchNode('DisabledNode')).toEqual(
[]
)
})
it('keeps the litegraph legacy menu in sync with policy changes', async () => {
class LegacyGovernedNode extends LGraphNode {}
LiteGraph.registered_node_types.LegacyGovernedNode = LegacyGovernedNode
useNodeDefStore().addNodeDef(nodeDef('LegacyGovernedNode'))
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: true,
nodes: { LegacyGovernedNode: false }
} satisfies PartnerNodePolicy)
store = await createLoadedStore()
expect(LegacyGovernedNode.skip_list).toBe(true)
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: false,
nodes: { LegacyGovernedNode: false }
} satisfies PartnerNodePolicy)
await store.loadPolicy()
await nextTick()
expect(LegacyGovernedNode.skip_list).toBe(false)
})
it('fails closed for a 503 from an enforcing workspace', async () => {
mockGetPartnerNodePolicy.mockRejectedValue(
new PartnerNodePolicyApiError(503, 'Service Unavailable')
)
store = await createLoadedStore()
expect(store.status).toBe('unavailable')
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
expect(store.isNodeDisabled('CoreNode')).toBe(false)
})
it('stays fail-closed while retrying an unavailable policy', async () => {
mockGetPartnerNodePolicy.mockRejectedValueOnce(
new PartnerNodePolicyApiError(503, 'Service Unavailable')
)
store = await createLoadedStore()
let rejectRetry!: (error: Error) => void
mockGetPartnerNodePolicy.mockReturnValueOnce(
new Promise((_, reject) => {
rejectRetry = reject
})
)
const retry = store.loadPolicy()
expect(store.status).toBe('unavailable')
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
rejectRetry(new Error('Network error'))
await retry
expect(store.status).toBe('unavailable')
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
})
it('preserves the last enforcing policy after a generic refresh error', async () => {
mockGetPartnerNodePolicy.mockResolvedValue({
enforcementEnabled: true,
nodes: { AllowedNode: true, DisabledNode: false }
} satisfies PartnerNodePolicy)
store = await createLoadedStore()
mockGetPartnerNodePolicy.mockRejectedValue(new Error('Network error'))
await store.loadPolicy()
expect(store.status).toBe('error')
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
expect(store.isNodeDisabled('DisabledNode')).toBe(true)
})
it('stays inactive when partner-node governance is disabled', async () => {
mockFlags.partnerNodeGovernanceEnabled = false
store = usePartnerNodeGovernanceStore()
await nextTick()
expect(mockGetPartnerNodePolicy).not.toHaveBeenCalled()
expect(store.status).toBe('inactive')
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('stays inactive in a personal workspace', async () => {
activateWorkspace('personal-workspace', 'personal')
store = usePartnerNodeGovernanceStore()
await nextTick()
expect(mockGetPartnerNodePolicy).not.toHaveBeenCalled()
expect(store.status).toBe('inactive')
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('ignores a stale response after switching workspaces', async () => {
let resolveFirst!: (policy: PartnerNodePolicy) => void
mockGetPartnerNodePolicy
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirst = resolve
})
)
.mockResolvedValueOnce({
enforcementEnabled: true,
nodes: { DisabledNode: true }
} satisfies PartnerNodePolicy)
store = usePartnerNodeGovernanceStore()
await vi.waitFor(() =>
expect(mockGetPartnerNodePolicy).toHaveBeenCalledTimes(1)
)
activateWorkspace('workspace-two')
await vi.waitFor(() => expect(store?.status).toBe('configured'))
resolveFirst({
enforcementEnabled: true,
nodes: { DisabledNode: false }
})
await nextTick()
expect(store.governedWorkspaceId).toBe('workspace-two')
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
})
it('saves and installs the validated whole policy', async () => {
store = await createLoadedStore()
const nextPolicy = {
enforcementEnabled: false,
nodes: { AllowedNode: true, DisabledNode: true }
} satisfies PartnerNodePolicy
await expect(store.savePolicy(nextPolicy)).resolves.toBe(true)
expect(mockUpdatePartnerNodePolicy).toHaveBeenCalledWith(nextPolicy)
expect(store.policy).toEqual(nextPolicy)
expect(store.status).toBe('configured')
})
it('does not install a save response after switching workspaces', async () => {
store = await createLoadedStore()
let resolveSave!: (policy: PartnerNodePolicy) => void
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
new Promise((resolve) => {
resolveSave = resolve
})
)
const save = store.savePolicy({
enforcementEnabled: false,
nodes: { AllowedNode: true }
})
activateWorkspace('workspace-two')
await vi.waitFor(() =>
expect(store?.governedWorkspaceId).toBe('workspace-two')
)
resolveSave({
enforcementEnabled: false,
nodes: { AllowedNode: true }
})
await expect(save).resolves.toBe(false)
await vi.waitFor(() => expect(store?.status).toBe('unconfigured'))
expect(store.policy).toBeNull()
})
it('does not install a save response after switching away and back', async () => {
store = await createLoadedStore()
let resolveSave!: (policy: PartnerNodePolicy) => void
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
new Promise((resolve) => {
resolveSave = resolve
})
)
const save = store.savePolicy({
enforcementEnabled: true,
nodes: { AllowedNode: true }
})
activateWorkspace('workspace-two')
await vi.waitFor(() =>
expect(store?.governedWorkspaceId).toBe('workspace-two')
)
activateWorkspace('workspace-one')
await vi.waitFor(() =>
expect(store?.governedWorkspaceId).toBe('workspace-one')
)
resolveSave({
enforcementEnabled: true,
nodes: { AllowedNode: true }
})
await expect(save).resolves.toBe(false)
await vi.waitFor(() => expect(store?.status).toBe('unconfigured'))
expect(store.policy).toBeNull()
})
it('ignores a stale save rejection after switching workspaces', async () => {
store = await createLoadedStore()
let rejectSave!: (error: Error) => void
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
new Promise((_, reject) => {
rejectSave = reject
})
)
const save = store.savePolicy({
enforcementEnabled: true,
nodes: { AllowedNode: true }
})
activateWorkspace('workspace-two')
await vi.waitFor(() =>
expect(store?.governedWorkspaceId).toBe('workspace-two')
)
rejectSave(new Error('Conflict'))
await expect(save).resolves.toBe(false)
expect(store.error).toBeNull()
})
it('refuses to save before governance is ready', async () => {
mockFlags.partnerNodeGovernanceEnabled = false
store = usePartnerNodeGovernanceStore()
await nextTick()
await expect(
store.savePolicy({ enforcementEnabled: false, nodes: {} })
).rejects.toThrow('Partner node governance is not ready')
expect(mockUpdatePartnerNodePolicy).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,213 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch, watchEffect } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
getPartnerNodePolicy,
PartnerNodePolicyApiError,
updatePartnerNodePolicy
} from '@/platform/workspace/api/partnerNodePolicyApi'
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
export interface PartnerNodeCatalogItem {
id: string
name: string
provider: string
}
export type PartnerNodePolicyStatus =
| 'inactive'
| 'loading'
| 'unconfigured'
| 'configured'
| 'unavailable'
| 'error'
const DISCOVERY_FILTER_ID = 'workspace.partner-node-governance'
export const usePartnerNodeGovernanceStore = defineStore(
'partnerNodeGovernance',
() => {
const { flags } = useFeatureFlags()
const workspaceStore = useTeamWorkspaceStore()
const nodeDefStore = useNodeDefStore()
const policy = shallowRef<PartnerNodePolicy | null>(null)
const policyWorkspaceId = ref<string | null>(null)
const status = ref<PartnerNodePolicyStatus>('inactive')
const error = shallowRef<Error | null>(null)
let loadVersion = 0
let workspaceVersion = 0
const governedWorkspaceId = computed(() => {
const workspace = workspaceStore.activeWorkspace
return flags.teamWorkspacesEnabled &&
flags.partnerNodeGovernanceEnabled &&
workspace?.type === 'team'
? workspace.id
: null
})
const partnerNodes = computed<PartnerNodeCatalogItem[]>(() =>
Object.values(nodeDefStore.nodeDefsByName)
.filter((nodeDef) => nodeDef.api_node)
.map((nodeDef) => ({
id: nodeDef.name,
name: nodeDef.display_name || nodeDef.name,
provider:
nodeDef.category.split('/')[2] ||
nodeDef.category ||
nodeDef.python_module
}))
)
function isNodeDisabled(nodeType: string): boolean {
if (!nodeDefStore.nodeDefsByName[nodeType]?.api_node) return false
const workspaceId = governedWorkspaceId.value
if (!workspaceId || policyWorkspaceId.value !== workspaceId) return false
if (status.value === 'unavailable') return true
return (
policy.value?.enforcementEnabled === true &&
policy.value.nodes[nodeType] !== true
)
}
nodeDefStore.registerNodeDefFilter({
id: DISCOVERY_FILTER_ID,
name: 'Workspace partner-node governance',
predicate: (nodeDef) => !isNodeDisabled(nodeDef.name)
})
const legacyHiddenNodeTypes = new Set<string>()
watchEffect(() => {
const showDevOnly = nodeDefStore.showDevOnly
for (const nodeDef of Object.values(nodeDefStore.nodeDefsByName)) {
if (!nodeDef.api_node) continue
const nodeType = LiteGraph.registered_node_types[nodeDef.name]
if (!nodeType) continue
if (isNodeDisabled(nodeDef.name)) {
if (!nodeType.skip_list) {
nodeType.skip_list = true
legacyHiddenNodeTypes.add(nodeDef.name)
}
continue
}
if (legacyHiddenNodeTypes.delete(nodeDef.name)) {
nodeType.skip_list = nodeDef.dev_only && !showDevOnly
}
}
})
async function loadPolicy(): Promise<void> {
const workspaceId = governedWorkspaceId.value
const version = ++loadVersion
if (!workspaceId) {
policy.value = null
policyWorkspaceId.value = null
status.value = 'inactive'
error.value = null
return
}
const workspaceChanged = policyWorkspaceId.value !== workspaceId
if (workspaceChanged) {
policy.value = null
policyWorkspaceId.value = workspaceId
status.value = 'loading'
} else if (status.value !== 'unavailable') {
status.value = 'loading'
}
error.value = null
try {
const nextPolicy = await getPartnerNodePolicy()
if (
version !== loadVersion ||
governedWorkspaceId.value !== workspaceId
) {
return
}
policy.value = nextPolicy
status.value = nextPolicy ? 'configured' : 'unconfigured'
} catch (loadError) {
if (
version !== loadVersion ||
governedWorkspaceId.value !== workspaceId
) {
return
}
error.value =
loadError instanceof Error
? loadError
: new Error('Failed to load partner node policy')
if (
loadError instanceof PartnerNodePolicyApiError &&
loadError.status === 503
) {
policy.value = null
status.value = 'unavailable'
return
}
if (status.value !== 'unavailable') status.value = 'error'
}
}
async function savePolicy(nextPolicy: PartnerNodePolicy): Promise<boolean> {
const workspaceId = governedWorkspaceId.value
const version = workspaceVersion
if (!workspaceId || policyWorkspaceId.value !== workspaceId) {
throw new Error('Partner node governance is not ready')
}
let savedPolicy: PartnerNodePolicy
try {
savedPolicy = await updatePartnerNodePolicy(nextPolicy)
} catch (saveError) {
if (
workspaceVersion !== version ||
governedWorkspaceId.value !== workspaceId
) {
return false
}
throw saveError
}
if (
workspaceVersion !== version ||
governedWorkspaceId.value !== workspaceId
) {
return false
}
policy.value = savedPolicy
policyWorkspaceId.value = workspaceId
status.value = 'configured'
error.value = null
return true
}
watch(
governedWorkspaceId,
() => {
workspaceVersion++
void loadPolicy()
},
{ immediate: true, flush: 'sync' }
)
return {
policy,
status,
error,
governedWorkspaceId,
partnerNodes,
isNodeDisabled,
loadPolicy,
savePolicy
}
}
)

View File

@@ -1,37 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import FirstRunTourNudge from './FirstRunTourNudge.vue'
import { useFirstRunTourStore } from './firstRunTourStore'
const SAMPLE_IMAGE =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200"><rect width="100%" height="100%" fill="%234f46e5"/></svg>'
)
const meta: Meta<typeof FirstRunTourNudge> = {
title: 'Renderer/OnboardingTour/FirstRunTourNudge',
component: FirstRunTourNudge,
parameters: {
layout: 'fullscreen',
backgrounds: { default: 'dark' }
},
decorators: [
() => {
const store = useFirstRunTourStore()
store.resultMedia = { url: SAMPLE_IMAGE, kind: 'image' }
store.showNudge()
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { FirstRunTourNudge },
template: '<FirstRunTourNudge />'
})
}

View File

@@ -1,242 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
const mocks = vi.hoisted(() => ({
show: vi.fn(),
trackOnboardingTour: vi.fn()
}))
// A reactive backing so the component's modal-close watch actually fires, the
// same way the real store's `isDialogOpen` reads a reactive dialog stack.
const openDialogs = ref<string[]>([])
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
useWorkflowTemplateSelectorDialog: () => ({ show: mocks.show })
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackOnboardingTour: mocks.trackOnboardingTour })
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
isDialogOpen: (key: string) => openDialogs.value.includes(key)
})
}))
vi.mock('@/scripts/app', () => ({ app: { canvas: null } }))
import FirstRunTourNudge from './FirstRunTourNudge.vue'
import { useFirstRunTourStore } from './firstRunTourStore'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderNudge() {
// No appear delay: these assert what the nudge shows, not when it fades in.
return render(FirstRunTourNudge, {
props: { appearDelayMs: 0 },
global: { plugins: [i18n] }
})
}
const nudgeTitle = enMessages.onboardingTour.nudge.title
describe('FirstRunTourNudge', () => {
let store: ReturnType<typeof useFirstRunTourStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useFirstRunTourStore()
mocks.show.mockReset()
mocks.trackOnboardingTour.mockReset()
openDialogs.value = []
})
afterEach(() => {
vi.clearAllMocks()
})
it('renders nothing until the nudge is requested', () => {
renderNudge()
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
})
it('shows the post-run prompt once the store requests it', async () => {
renderNudge()
store.showNudge()
expect(await screen.findByText(nudgeTitle)).toBeInTheDocument()
})
describe('appear delay', () => {
afterEach(() => {
vi.useRealTimers()
})
it('holds the nudge back so the fresh result gets a beat on its own', async () => {
vi.useFakeTimers()
render(FirstRunTourNudge, {
props: { appearDelayMs: 2000 },
global: { plugins: [i18n] }
})
store.showNudge()
await vi.advanceTimersByTimeAsync(1999)
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
await vi.advanceTimersByTimeAsync(1)
expect(screen.getByText(nudgeTitle)).toBeInTheDocument()
})
it('never appears when the nudge is withdrawn inside the delay', async () => {
// Dismissing during the wait must cancel the pending appearance, not fire late.
vi.useFakeTimers()
render(FirstRunTourNudge, {
props: { appearDelayMs: 2000 },
global: { plugins: [i18n] }
})
store.showNudge()
await vi.advanceTimersByTimeAsync(1000)
store.dismissNudge()
await vi.advanceTimersByTimeAsync(5000)
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
})
it('reports the nudge as shown only when it actually appears', async () => {
vi.useFakeTimers()
render(FirstRunTourNudge, {
props: { appearDelayMs: 2000 },
global: { plugins: [i18n] }
})
store.showNudge()
await vi.advanceTimersByTimeAsync(1000)
expect(mocks.trackOnboardingTour).not.toHaveBeenCalledWith(
'nudge_shown',
expect.anything()
)
await vi.advanceTimersByTimeAsync(1000)
expect(mocks.trackOnboardingTour).toHaveBeenCalledExactlyOnceWith(
'nudge_shown',
expect.objectContaining({ tour: 'firstRun' })
)
})
})
it('leads with the generated image when the result media is an image', async () => {
renderNudge()
store.resultMedia = { url: 'result.png', kind: 'image' }
store.showNudge()
const image = await screen.findByRole('img', { name: nudgeTitle })
expect(image).toHaveAttribute('src', 'result.png')
})
it('leads with a looping video when the result media is a video', async () => {
renderNudge()
store.resultMedia = { url: 'result.mp4', kind: 'video' }
store.showNudge()
const video = await screen.findByTestId('onboarding-nudge-video')
expect(video).toHaveAttribute('src', 'result.mp4')
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('opens the template library and reports the click on Explore templates', async () => {
renderNudge()
store.showNudge()
const user = userEvent.setup()
await user.click(
await screen.findByRole('button', {
name: enMessages.onboardingTour.nudge.explore
})
)
expect(mocks.show).toHaveBeenCalledOnce()
expect(mocks.trackOnboardingTour).toHaveBeenCalledWith(
'explore_templates_clicked',
expect.objectContaining({ tour: 'firstRun' })
)
})
it('permanently dismisses on Not now and does not resurface', async () => {
renderNudge()
store.showNudge()
const user = userEvent.setup()
await user.click(
await screen.findByRole('button', {
name: enMessages.onboardingTour.nudge.dismiss
})
)
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
// A later trigger must not bring it back this session.
store.showNudge()
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
})
it('defers past the open upgrade modal and surfaces once it closes', async () => {
openDialogs.value = ['free-tier-info']
// showNudge while the modal is open must defer, not overlap the paywall.
store.showNudge()
renderNudge()
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
openDialogs.value = []
expect(await screen.findByText(nudgeTitle)).toBeInTheDocument()
})
it('stays hidden on modal close when the nudge was never requested', async () => {
openDialogs.value = ['subscription-required']
renderNudge()
openDialogs.value = []
await nextTick()
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
})
it('surfaces the deferred nudge only once across repeated modal cycles', async () => {
openDialogs.value = ['free-tier-info']
store.showNudge()
renderNudge()
const user = userEvent.setup()
openDialogs.value = []
await user.click(
await screen.findByRole('button', {
name: enMessages.onboardingTour.nudge.dismiss
})
)
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
// A second upgrade-modal cycle must not resurface the dismissed nudge:
// the arm was consumed on the first surfacing and dismissal is permanent.
openDialogs.value = ['free-tier-info']
await nextTick()
openDialogs.value = []
await nextTick()
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
})
})

View File

@@ -1,120 +0,0 @@
<template>
<div
v-if="shown"
role="status"
class="pointer-events-auto fixed right-0 bottom-0 z-1000 flex w-80 animate-in flex-col overflow-hidden rounded-tl-xl border-t border-l border-border-default/50 bg-base-background shadow-lg duration-500 fade-in-0"
>
<div class="relative h-50 w-full bg-secondary-background">
<video
v-if="media?.kind === 'video'"
:src="media.url"
data-testid="onboarding-nudge-video"
class="size-full object-cover"
autoplay
muted
loop
playsinline
/>
<img
v-else
:src="media?.url ?? FALLBACK_MEDIA"
:alt="t('onboardingTour.nudge.title')"
class="size-full object-cover"
/>
<Button
class="absolute top-2 right-2 size-8 opacity-50 hover:opacity-100"
variant="secondary"
size="icon"
:aria-label="t('g.close')"
@click="store.dismissNudge()"
>
<i class="icon-[lucide--x] size-4" aria-hidden="true" />
</Button>
</div>
<div
class="flex flex-col gap-2 border-t border-border-default px-4 pt-6 pb-4"
>
<p class="m-0 text-sm/5 font-bold text-base-foreground">
{{ t('onboardingTour.nudge.title') }}
</p>
<p class="m-0 text-sm text-muted-foreground">
{{ t('onboardingTour.nudge.body') }}
</p>
</div>
<div class="flex items-center justify-end gap-4 px-4 pb-4">
<Button
variant="link"
size="unset"
class="h-6 text-sm font-normal"
@click="store.dismissNudge()"
>
{{ t('onboardingTour.nudge.dismiss') }}
</Button>
<Button
variant="inverted"
size="lg"
class="font-normal"
@click="onExplore"
>
{{ t('onboardingTour.nudge.explore') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { trackFirstRunTour } from './firstRunTourTelemetry'
import { isUpgradeModalOpen, useFirstRunTourStore } from './firstRunTourStore'
const FALLBACK_MEDIA = '/assets/images/og-image.png'
/** Delayed, so the fresh result is seen before the nudge fades in over it. */
const { appearDelayMs = 1500 } = defineProps<{ appearDelayMs?: number }>()
const { t } = useI18n()
const store = useFirstRunTourStore()
const { resultMedia: media } = storeToRefs(store)
const shouldShow = computed(() => store.shouldShowNudge)
const shown = ref(false)
const { start: startAppearDelay, stop: cancelAppearDelay } = useTimeoutFn(
() => {
shown.value = true
trackFirstRunTour('nudge_shown')
},
() => appearDelayMs,
{ immediate: false }
)
watch(shouldShow, (visible) => {
cancelAppearDelay()
shown.value = false
if (visible) startAppearDelay()
})
// The run-step gate arms the nudge behind the upgrade modal; surface it only once
// that modal closes, so the two never overlap.
const upgradeModalOpen = computed(() => isUpgradeModalOpen())
watch(upgradeModalOpen, (open, wasOpen) => {
if (wasOpen && !open && store.nudgeArmed) store.showNudge()
})
function onExplore() {
useWorkflowTemplateSelectorDialog().show('command')
trackFirstRunTour('explore_templates_clicked')
store.dismissNudge()
}
</script>

View File

@@ -1,84 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { toNodeId } from '@/types/nodeId'
import FirstRunTourOverlay from './FirstRunTourOverlay.vue'
import { useFirstRunTourStore } from './firstRunTourStore'
import type { TourStep } from './tourSequence'
interface StoryArgs {
stepIndex: number
}
const SAMPLE_IMAGE =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160"><rect width="100%" height="100%" fill="%234f46e5"/></svg>'
)
const steps: TourStep[] = [
{ kind: 'upload', nodeId: toNodeId(1) },
{
kind: 'prompt',
nodeId: null,
prompt: {
subgraphNodeId: toNodeId(2),
innerNodeId: toNodeId(3),
widgetName: 'text',
portFallback: 'prompt'
}
},
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(4), mediaKind: 'image' }
]
const meta: Meta<StoryArgs> = {
title: 'Renderer/OnboardingTour/FirstRunTourOverlay',
component: FirstRunTourOverlay,
parameters: {
layout: 'fullscreen',
backgrounds: { default: 'dark' }
},
argTypes: {
stepIndex: { control: { type: 'number', min: 0, max: 3 } }
},
decorators: [
(_, context) => {
const store = useFirstRunTourStore()
store.isActive = true
store.steps = steps
store.stepIndex = context.args.stepIndex
const activeStep = steps[context.args.stepIndex]
store.resultMedia =
activeStep?.kind === 'result'
? { url: SAMPLE_IMAGE, kind: activeStep.mediaKind ?? 'image' }
: null
// Spotlight geometry needs a live litegraph canvas (absent in Storybook),
// so holes are exercised by the unit test; this catalogs the coach-mark.
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
const render: Story['render'] = () => ({
components: { FirstRunTourOverlay },
template: '<FirstRunTourOverlay />'
})
export const FirstStep: Story = {
args: { stepIndex: 0 },
render
}
export const MultiReveal: Story = {
args: { stepIndex: 1 },
render
}
export const LastStep: Story = {
args: { stepIndex: 3 },
render
}

View File

@@ -1,558 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { toNodeId } from '@/types/nodeId'
import type * as adapterModule from './canvasSpotlightAdapter'
import type { ScreenRect } from './canvasSpotlightAdapter'
import type { TourStep } from './tourSequence'
type AdapterModule = typeof adapterModule
const mocks = vi.hoisted(() => ({
maskRects: [] as ScreenRect[],
// Optional per-node-id rects: when set, `maskRectsFor` returns one rect per id
// that has an entry, so the revealed (holes) and spotlit (rings) calls resolve
// by which ids they carry — not by how many. Falls back to `maskRects`.
rectsById: null as Record<string, ScreenRect> | null,
focusNodes: vi.fn(),
// A camera already at rest: these assert what a step renders, not the settle
// machine (covered against the real `trackSettle` in the adapter's own tests).
transformKey: 'settled' as string | null,
viewport: { left: 0, top: 0, width: 1440, height: 900 } as ScreenRect,
controller: {
end: vi.fn(),
back: vi.fn(),
advance: vi.fn()
}
}))
// Only the litegraph-backed reads are stubbed; the pure geometry helpers stay real,
// so a test that pans a target off screen exercises the same code prod does.
vi.mock('./canvasSpotlightAdapter', async (importOriginal) => ({
...(await importOriginal<AdapterModule>()),
// Zero so a step's copy is gated only on the camera settling, never on a wait.
TOUR_FOCUS_DURATION_MS: 0,
// Return a fresh array (like prod) so a caller pushing to it can't grow the mock.
maskRectsFor: (ids: unknown[]) =>
mocks.rectsById === null
? [...mocks.maskRects]
: (ids as { toString(): string }[])
.map((id) => mocks.rectsById?.[String(id)])
.filter((r): r is ScreenRect => r !== undefined),
focusNodes: mocks.focusNodes,
canvasViewport: () => mocks.viewport,
canvasTransformKey: () => mocks.transformKey,
canvasElement: () => null
}))
vi.mock('./useFirstRunTourController', () => ({
useFirstRunTourController: () => mocks.controller
}))
vi.mock('@/scripts/app', () => ({ app: { canvas: null } }))
import FirstRunTourOverlay from './FirstRunTourOverlay.vue'
import { useFirstRunTourStore } from './firstRunTourStore'
import enMessages from '@/locales/en/main.json'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const promptStep: TourStep = {
kind: 'prompt',
nodeId: null,
prompt: {
subgraphNodeId: toNodeId(2),
innerNodeId: toNodeId(3),
widgetName: 'text',
portFallback: 'prompt'
}
}
const runStep: TourStep = { kind: 'run', nodeId: null }
const videoResultStep: TourStep = {
kind: 'result',
nodeId: toNodeId(4),
mediaKind: 'video'
}
const imageResultStep: TourStep = {
kind: 'result',
nodeId: toNodeId(4),
mediaKind: 'image'
}
function rect(left: number): ScreenRect {
return { left, top: 0, width: 100, height: 50 }
}
function renderOverlay() {
return render(FirstRunTourOverlay, { global: { plugins: [i18n] } })
}
describe('FirstRunTourOverlay', () => {
let store: ReturnType<typeof useFirstRunTourStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useFirstRunTourStore()
mocks.maskRects = []
mocks.rectsById = null
mocks.transformKey = 'settled'
mocks.viewport = { left: 0, top: 0, width: 1440, height: 900 }
mocks.focusNodes.mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})
it('renders nothing while the tour is idle', () => {
renderOverlay()
expect(screen.queryByRole('dialog')).toBeNull()
})
it('names the dialog for assistive technology when active', async () => {
store.isActive = true
store.steps = [promptStep, runStep]
renderOverlay()
expect(
await screen.findByRole('dialog', { name: 'Getting started tour' })
).toBeInTheDocument()
})
it('draws a focus ring for each spotlit node when active', async () => {
mocks.maskRects = [rect(0), rect(200)]
store.isActive = true
store.steps = [promptStep, runStep]
store.stepIndex = 0
renderOverlay()
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(2)
})
})
it('rings only the current step, not every revealed node', async () => {
// At the prompt step both the upload node (1) and the prompt host (2) are
// revealed, but only the prompt host is spotlit. Keying rects by id proves
// the ring set is the current step's targets, not the accumulated reveals.
const uploadStep: TourStep = { kind: 'upload', nodeId: toNodeId(1) }
mocks.rectsById = { '1': rect(0), '2': rect(200) }
store.isActive = true
store.steps = [uploadStep, promptStep, runStep]
store.stepIndex = 1
renderOverlay()
await vi.waitFor(() => {
// Holes cover both revealed nodes (1 & 2); rings cover only spotlit node 2.
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
})
})
it('shows the step counter derived from stepIndex and the step total', async () => {
store.isActive = true
store.steps = [promptStep, runStep, videoResultStep]
store.stepIndex = 1
renderOverlay()
expect(await screen.findByText('2 of 3')).toBeInTheDocument()
})
it.for([
{
name: 'renders the t2i prompt copy',
shape: 't2i',
body: 'Your image gets built from this description. Try anything you like.'
},
{
name: 'renders the i2v prompt copy',
shape: 'i2v',
body: 'The image sets the scene. This tells it what happens next.'
},
{
name: 'renders the image-edit prompt copy',
shape: 'image-edit',
body: 'Your image stays as it is. Describe what you want different.'
},
{
name: 'renders the other prompt copy',
shape: 'other',
body: 'This is your prompt. Change it to change your result.'
}
] as const)('$name', async ({ shape, body }) => {
store.isActive = true
store.steps = [{ ...promptStep, shape }, runStep]
renderOverlay()
expect(await screen.findByText(body)).toBeInTheDocument()
})
it.for([
{
name: 'renders the i2v upload copy',
shape: 'i2v',
body: 'This picture is your first frame. Swap in your own whenever you like.'
},
{
name: 'renders the image-edit upload copy',
shape: 'image-edit',
body: "This is the picture you'll edit. Swap in your own whenever you like."
},
{
name: 'renders the other upload copy',
shape: 'other',
body: 'This image feeds the workflow. Swap in your own whenever you like.'
}
] as const)('$name', async ({ shape, body }) => {
store.isActive = true
store.steps = [{ kind: 'upload', nodeId: toNodeId(1), shape }, runStep]
renderOverlay()
expect(await screen.findByText(body)).toBeInTheDocument()
})
it('renders video result copy for a video sink once the media is captured', async () => {
store.isActive = true
store.steps = [runStep, videoResultStep]
store.stepIndex = 1
store.resultMedia = { url: 'blob:video-output', kind: 'video' }
renderOverlay()
expect(
await screen.findByText(
'Your new video lands right here — ready to download or share.'
)
).toBeInTheDocument()
})
it('keeps result media out of the coach-mark (it lands on the canvas node)', async () => {
store.isActive = true
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
store.resultMedia = { url: 'blob:image-output', kind: 'image' }
renderOverlay()
await screen.findByText(
'Your new image lands right here — ready to download or share.'
)
expect(screen.queryByRole('img')).toBeNull()
expect(screen.queryByTestId('onboarding-result-video')).toBeNull()
})
it('ends the tour with skip when the Skip control is pressed', async () => {
store.isActive = true
store.steps = [promptStep, runStep]
const user = userEvent.setup()
renderOverlay()
await user.click(await screen.findByRole('button', { name: 'Skip' }))
expect(mocks.controller.end).toHaveBeenCalledWith('skip')
})
it('advances on Next before the last step', async () => {
store.isActive = true
store.steps = [promptStep, runStep, videoResultStep]
store.stepIndex = 0
const user = userEvent.setup()
renderOverlay()
await user.click(await screen.findByRole('button', { name: 'Next' }))
expect(mocks.controller.advance).toHaveBeenCalledOnce()
})
it('shows Complete and finishes the tour on the last step', async () => {
store.isActive = true
store.steps = [runStep, videoResultStep]
store.stepIndex = 1
const user = userEvent.setup()
renderOverlay()
await user.click(await screen.findByRole('button', { name: 'Finish' }))
expect(mocks.controller.end).toHaveBeenCalledWith('done')
})
it('hides Back on the first step', async () => {
store.isActive = true
store.steps = [promptStep, runStep, videoResultStep]
store.stepIndex = 0
renderOverlay()
await screen.findByRole('button', { name: 'Next' })
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull()
})
it('shows Back after the first step and goes back on press', async () => {
store.isActive = true
store.steps = [promptStep, runStep, videoResultStep]
store.stepIndex = 1
const user = userEvent.setup()
renderOverlay()
await user.click(await screen.findByRole('button', { name: 'Back' }))
expect(mocks.controller.back).toHaveBeenCalledOnce()
})
it('points the decorative agent cursor at the focused target', async () => {
mocks.maskRects = [rect(0)]
store.isActive = true
store.steps = [promptStep, runStep]
renderOverlay()
const cursor = await screen.findByTestId('onboarding-cursor')
expect(cursor).toHaveAttribute('aria-hidden', 'true')
})
it('hides the cursor but keeps the coach-mark and Skip reachable with no target', async () => {
mocks.maskRects = []
store.isActive = true
store.steps = [promptStep, runStep]
renderOverlay()
expect(await screen.findByRole('button', { name: 'Skip' })).toBeVisible()
expect(screen.queryByTestId('onboarding-cursor')).toBeNull()
})
it('offers no Next escape on the Run step (the run is the only way forward)', async () => {
store.isActive = true
store.steps = [promptStep, runStep, imageResultStep]
store.stepIndex = 1
renderOverlay()
await screen.findByText('Press Run to start generating your result')
expect(screen.queryByRole('button', { name: 'Next' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Finish' })).toBeNull()
// Skip and Back remain so the user is never trapped.
expect(screen.getByRole('button', { name: 'Skip' })).toBeVisible()
expect(screen.getByRole('button', { name: 'Back' })).toBeVisible()
})
it('lights the action bar on the Result step without ringing it', async () => {
const actionbar = document.createElement('div')
actionbar.setAttribute('data-testid', 'comfy-actionbar')
actionbar.getBoundingClientRect = () =>
({ left: 5, top: 5, width: 80, height: 8 }) as DOMRect
document.body.append(actionbar)
mocks.maskRects = [rect(0)] // the sink node
store.isActive = true
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
try {
renderOverlay()
// Holes: sink node + action bar. Ring: the sink node only.
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-hole')).toHaveLength(2)
})
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
} finally {
actionbar.remove()
}
})
it('spotlights only the sink on the Result step when no run bar is present', async () => {
mocks.maskRects = [rect(0)]
store.isActive = true
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
renderOverlay()
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
})
})
it('keeps the result copy and shows a generating indicator while the run is live', async () => {
store.isActive = true
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
store.resultMedia = null
store.runFinished = false
renderOverlay()
expect(
await screen.findByText(
'Your new image lands right here — ready to download or share.'
)
).toBeInTheDocument()
expect(await screen.findByText('Generating…')).toBeInTheDocument()
})
it('stops generating once the run reports, even if no media was captured', async () => {
// The capture polls the sink and gives up silently on timeout. Hanging the
// spinner off the media left it running forever when no URL ever landed.
store.isActive = true
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
store.resultMedia = null
store.runFinished = true
renderOverlay()
await screen.findByText(
'Your new image lands right here — ready to download or share.'
)
expect(screen.queryByText('Generating…')).toBeNull()
})
it('stops generating once the media lands', async () => {
store.isActive = true
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
store.resultMedia = { url: 'blob:result', kind: 'image' }
store.runFinished = true
renderOverlay()
await screen.findByText(
'Your new image lands right here — ready to download or share.'
)
expect(screen.queryByText('Generating…')).toBeNull()
})
it('hides Skip and Back on the Result step so Complete is the only exit', async () => {
store.isActive = true
store.steps = [promptStep, runStep, imageResultStep]
store.stepIndex = 2
renderOverlay()
expect(await screen.findByRole('button', { name: 'Finish' })).toBeVisible()
expect(screen.queryByRole('button', { name: 'Skip' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull()
})
it('hides the ring and holds the coach-mark when the target is panned off screen', async () => {
// The user can pan the spotlit node out of view mid-step. The ring must not be
// drawn off screen, and the mark must hold rather than chase a target that
// isn't there.
mocks.maskRects = [rect(0)]
store.isActive = true
store.steps = [promptStep, runStep]
store.stepIndex = 0
renderOverlay()
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
})
mocks.maskRects = [{ left: -900, top: -900, width: 100, height: 50 }]
await vi.waitFor(() => {
expect(screen.queryByTestId('onboarding-spotlight')).toBeNull()
})
expect(screen.queryByTestId('onboarding-cursor')).toBeNull()
const mark = screen.getByTestId('onboarding-coach-mark')
expect(mark.style.top).toBe('50%')
expect(mark.style.left).toBe('50%')
})
it('keeps the coach-mark on screen when the target fills the viewport', async () => {
// A zoomed-in node can be larger than the canvas region; the mark must still be
// fully placed inside it rather than spilling off an edge.
mocks.viewport = { left: 0, top: 40, width: 800, height: 600 }
mocks.maskRects = [{ left: 0, top: 40, width: 800, height: 600 }]
store.isActive = true
store.steps = [promptStep, runStep]
store.stepIndex = 0
renderOverlay()
const mark = await screen.findByTestId('onboarding-coach-mark')
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
})
const top = Number.parseFloat(mark.style.top)
const left = Number.parseFloat(mark.style.left)
expect(top).toBeGreaterThanOrEqual(mocks.viewport.top)
expect(left).toBeGreaterThanOrEqual(mocks.viewport.left)
})
it('zooms in once, then pans without re-zooming on later steps', async () => {
// Reserving room on the first framing sizes the node so the mark fits beside
// it; later steps pass none, which pans at the scale already reached.
mocks.maskRects = [rect(0)]
store.isActive = true
store.steps = [promptStep, runStep, imageResultStep]
store.stepIndex = 0
renderOverlay()
await vi.waitFor(() => expect(mocks.focusNodes).toHaveBeenCalled())
expect(mocks.focusNodes.mock.calls[0][1]).toEqual(
expect.objectContaining({ width: expect.any(Number) })
)
mocks.focusNodes.mockClear()
store.stepIndex = 2
await vi.waitFor(() => expect(mocks.focusNodes).toHaveBeenCalled())
expect(mocks.focusNodes.mock.calls[0][1]).toBeUndefined()
})
it('does not move the camera on the Run step, which points at the toolbar', async () => {
store.isActive = true
store.steps = [promptStep, runStep]
store.stepIndex = 0
renderOverlay()
await vi.waitFor(() => expect(mocks.focusNodes).toHaveBeenCalled())
mocks.focusNodes.mockClear()
store.stepIndex = 1
await screen.findByText('Press Run to start generating your result')
expect(mocks.focusNodes).not.toHaveBeenCalled()
})
it('spotlights the toolbar Run button on the Run step', async () => {
const runButton = document.createElement('div')
runButton.setAttribute('data-testid', 'queue-button')
runButton.getBoundingClientRect = () =>
({ left: 10, top: 20, width: 40, height: 16 }) as DOMRect
document.body.append(runButton)
store.isActive = true
store.steps = [runStep]
try {
renderOverlay()
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
})
} finally {
runButton.remove()
}
})
})

View File

@@ -1,441 +0,0 @@
<template>
<Teleport v-if="isActive" to="body">
<div
class="pointer-events-none fixed inset-0 z-3000"
role="dialog"
:aria-label="t('onboardingTour.overlayLabel')"
>
<svg class="absolute inset-0 size-full" aria-hidden="true">
<defs>
<mask id="onboarding-tour-spotlight">
<rect width="100%" height="100%" fill="white" />
<rect
v-for="(hole, i) in visibleHoleRects"
:key="i"
data-testid="onboarding-hole"
:x="hole.left"
:y="hole.top"
:width="hole.width"
:height="hole.height"
rx="12"
fill="black"
/>
</mask>
</defs>
<rect
width="100%"
height="100%"
:class="
cn(
'fill-base-background/95',
!reduceMotion && 'transition-opacity duration-500 ease-out',
revealed ? 'opacity-100' : 'opacity-0'
)
"
mask="url(#onboarding-tour-spotlight)"
/>
</svg>
<div
v-for="(hole, i) in visibleSpotRects"
:key="i"
data-testid="onboarding-spotlight"
:class="
cn(
'absolute border-node-component-outline',
!reduceMotion && 'transition-opacity duration-300 ease-out',
revealed ? 'opacity-100' : 'opacity-0',
isRunStep ? 'rounded-lg border' : 'rounded-xl border-2'
)
"
:style="ringStyle(hole)"
/>
<div
ref="bubbleRef"
data-testid="onboarding-coach-mark"
:class="
cn(
'absolute w-full max-w-xs',
!reduceMotion &&
(markGlides
? 'transition-[top,left,opacity] ease-in-out'
: 'transition-opacity duration-300 ease-out'),
copyVisible
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0'
)
"
:style="bubbleStyle"
tabindex="-1"
aria-live="polite"
>
<i
v-if="cursorEdgeClass"
data-testid="onboarding-cursor"
:class="
cn(
'absolute icon-[lucide--mouse-pointer-2] size-4 text-base-foreground drop-shadow-md',
cursorEdgeClass
)
"
aria-hidden="true"
/>
<span
v-if="isGenerating"
class="absolute top-4 right-4 z-10 flex items-center gap-1.5 text-xs text-muted-foreground"
>
<DotSpinner :size="12" />
{{ t('onboardingTour.generating') }}
</span>
<CoachmarkCard
:subtitle="
t('onboardingTour.stepCounter', {
current: stepIndex + 1,
total: totalSteps
})
"
:title="t(copy.title)"
:message="t(copy.body)"
>
<template #actions>
<Button
v-if="!isResultStep"
variant="textonly"
size="md"
class="font-normal"
@click="controller.end('skip')"
>
{{ t('onboardingTour.skip') }}
</Button>
<div class="ml-auto flex items-center gap-2">
<Button
v-if="stepIndex > 0 && !isResultStep"
variant="secondary"
size="md"
class="gap-1 border border-muted-background px-3 py-2 font-normal"
@click="controller.back()"
>
<i
class="icon-[lucide--arrow-left] size-4"
aria-hidden="true"
/>
{{ t('onboardingTour.back') }}
</Button>
<Button
v-if="showNextButton"
variant="inverted"
size="md"
class="gap-1 px-3 py-2 font-normal"
@click="onNext"
>
<i
v-if="isLastStep"
class="icon-[lucide--check] size-4"
aria-hidden="true"
/>
{{
isLastStep
? t('onboardingTour.complete')
: t('onboardingTour.next')
}}
<i
v-if="!isLastStep"
class="icon-[lucide--arrow-right] size-4"
aria-hidden="true"
/>
</Button>
</div>
</template>
</CoachmarkCard>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import {
autoUpdate,
flip,
hide,
offset,
shift,
useFloating
} from '@floating-ui/vue'
import type { VirtualElement } from '@floating-ui/vue'
import {
useElementBounding,
usePreferredReducedMotion,
useResizeObserver
} from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import DotSpinner from '@/components/common/DotSpinner.vue'
import Button from '@/components/ui/button/Button.vue'
import CoachmarkCard from '@/platform/onboarding/CoachmarkCard.vue'
import {
COACH_MARK_GAP,
canvasElement,
focusNodes
} from './canvasSpotlightAdapter'
import type { ScreenRect } from './canvasSpotlightAdapter'
import { MARK_GLIDE_MS, useTourChoreography } from './useTourChoreography'
import { useFirstRunTourController } from './useFirstRunTourController'
import { useFirstRunTourStore } from './firstRunTourStore'
import { useTourSpotlightRects } from './useTourSpotlightRects'
import type { TourStep } from './tourSequence'
const VIEWPORT_MARGIN = 12
const { t } = useI18n()
const store = useFirstRunTourStore()
const controller = useFirstRunTourController()
const {
isActive,
stepIndex,
revealedNodeIds,
spotlitNodeIds,
currentStep,
totalSteps,
resultMedia,
runFinished,
tourRunId
} = storeToRefs(store)
const isLastStep = computed(() => stepIndex.value >= totalSteps.value - 1)
const isRunStep = computed(() => currentStep.value?.kind === 'run')
const isResultStep = computed(() => currentStep.value?.kind === 'result')
/** Gated on the run reporting, not on media: a timed-out capture must not spin forever. */
const isGenerating = computed(
() => isResultStep.value && !runFinished.value && !resultMedia.value
)
/** The Run step advances on click, so it offers no Next. */
const showNextButton = computed(() => !isRunStep.value)
function stepCopyKey(step: TourStep): { title: string; body: string } {
const base = `onboardingTour.step.${step.kind}`
switch (step.kind) {
case 'upload':
case 'prompt': {
const shape = step.shape ?? 'other'
return { title: `${base}.${shape}.title`, body: `${base}.${shape}.body` }
}
case 'run':
return { title: `${base}.title`, body: `${base}.body` }
case 'result': {
const media = step.mediaKind ?? 'image'
return { title: `${base}.${media}.title`, body: `${base}.${media}.body` }
}
}
}
const copy = computed(() =>
currentStep.value ? stepCopyKey(currentStep.value) : { title: '', body: '' }
)
function onNext() {
if (isLastStep.value) {
controller.end('done')
} else {
void controller.advance()
}
}
// Called at setup, not inside the computed: a computed getter has no effect scope,
// so the matchMedia listener VueUse registers there would never be disposed.
const preferredMotion = usePreferredReducedMotion()
const reduceMotion = computed(() => preferredMotion.value === 'reduce')
const bubbleRef = useTemplateRef<HTMLElement>('bubbleRef')
const { width: bubbleWidth, height: bubbleHeight } =
useElementBounding(bubbleRef)
/** True once the tour has zoomed in; later steps pan at that scale. */
const hasZoomed = ref(false)
/**
* Frame the current step, reserving the mark's real footprint on the first framing
* so the node is sized to leave room for it. Later steps pan at that scale.
*/
function frameTarget() {
const reserve = {
width: bubbleWidth.value + COACH_MARK_GAP,
height: bubbleHeight.value + COACH_MARK_GAP
}
focusNodes([...spotlitNodeIds.value], hasZoomed.value ? undefined : reserve)
hasZoomed.value = true
}
const choreography = useTourChoreography({
reduceMotion,
frameTarget,
isStatic: isRunStep
})
const { revealed, copyVisible, markGlides } = choreography
const rects = useTourSpotlightRects({
isRunStep,
isResultStep,
revealedNodeIds,
spotlitNodeIds,
onFrame: choreography.sampleFrame
})
const { focusRect } = rects
/** Holes are cut only once the steps begin, so the intro preview reads undimmed. */
const visibleHoleRects = computed(() =>
revealed.value ? rects.holeRects.value : []
)
const visibleSpotRects = computed(() =>
revealed.value ? rects.visibleSpotRects.value : []
)
// A canvas node has no DOM element; one stable VirtualElement reads the live spotlit
// rect so autoUpdate tracks the camera each frame without re-initialising during motion.
const referenceEl: VirtualElement = {
getBoundingClientRect: () => {
const rect = focusRect.value
return rect
? new DOMRect(rect.left, rect.top, rect.width, rect.height)
: new DOMRect()
}
}
const reference = computed<VirtualElement | null>(() =>
focusRect.value ? referenceEl : null
)
const { floatingStyles, middlewareData, placement } = useFloating(
reference,
bubbleRef,
{
strategy: 'fixed',
// Position via top/left, not transform, so the mark's glide transition animates.
transform: false,
// The Run button lives in the top toolbar, so its mark sits below it.
placement: () => (isRunStep.value ? 'bottom' : 'right-start'),
middleware: [
offset(COACH_MARK_GAP),
flip({ fallbackPlacements: ['left-start', 'bottom', 'top'] }),
shift({ crossAxis: true, padding: VIEWPORT_MARGIN }),
hide()
],
whileElementsMounted: (ref_, floating, update) =>
autoUpdate(ref_, floating, update, { animationFrame: true })
}
)
/** True while the target is clipped out of view; the ring hides and the mark recentres. */
const targetHidden = computed(
() =>
!reference.value || (middlewareData.value.hide?.referenceHidden ?? false)
)
watch(
[isActive, stepIndex, tourRunId],
([active, , runId], previous) => {
if (!active) {
choreography.endTour()
hasZoomed.value = false
return
}
// A fresh tour, not a step change. Keyed to the run id rather than an
// idle→active edge: `start()` passes through idle within one tick, so the
// watcher only ever sees active→active and would carry the last tour's zoom in.
if (previous?.[2] === runId) {
choreography.resetStep()
choreography.beginStep()
return
}
hasZoomed.value = false
choreography.openTour()
},
{ immediate: true }
)
watch(
[isActive, revealedNodeIds, spotlitNodeIds, currentStep],
([active]) => (active ? rects.start() : rects.stop()),
{ immediate: true }
)
// animateToBounds captures the canvas size when the tween starts, so a resize
// mid-flight lands the camera short. Re-frame against the new size.
useResizeObserver(
computed(() => (isActive.value ? canvasElement() : null)),
() => {
if (!isActive.value || choreography.isAwaitingSettle()) return
if (!revealed.value || isRunStep.value || !hasZoomed.value) return
focusNodes([...spotlitNodeIds.value])
}
)
// Routing away mid-tour must end the tour, so the controller's grace timer can't
// outlive the UI it drives.
onUnmounted(() => {
choreography.clearTimers()
if (isActive.value) controller.end('skip')
})
/** Px the node ring sits outside the box, mirroring litegraph's selection overlay. */
const NODE_RING_OFFSET = 3
/** The node ring frames the box from outside; the Run button ring hugs it tightly. */
function ringStyle(rect: ScreenRect) {
const offset = isRunStep.value ? 0 : NODE_RING_OFFSET
return {
left: `${rect.left - offset}px`,
top: `${rect.top - offset}px`,
width: `${rect.width + offset * 2}px`,
height: `${rect.height + offset * 2}px`
}
}
// Shares MARK_GLIDE_MS with the camera delay, so the mark lands before it starts.
const bubbleStyle = computed(() => {
const glide = markGlides.value
? { transitionDuration: `${MARK_GLIDE_MS}ms` }
: {}
if (targetHidden.value) {
return {
...glide,
position: 'fixed' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}
}
return { ...glide, ...floatingStyles.value }
})
/** Floats the cursor mid-gap on the target-facing edge, tip rotated toward the node. */
const CURSOR_EDGE_CLASS: Record<'top' | 'bottom' | 'left' | 'right', string> = {
top: '-top-7 left-1/2 -translate-x-1/2 rotate-45',
bottom: '-bottom-7 left-1/2 -translate-x-1/2 -rotate-[135deg]',
left: '-left-7 top-1/2 -translate-y-1/2 -rotate-45',
right: '-right-7 top-1/2 -translate-y-1/2 rotate-[135deg]'
}
// The card sits on one side of the target; the cursor points back from the opposite edge.
const OPPOSITE_EDGE: Record<string, 'top' | 'bottom' | 'left' | 'right'> = {
top: 'bottom',
bottom: 'top',
left: 'right',
right: 'left'
}
const cursorEdgeClass = computed(() => {
if (targetHidden.value) return ''
const side = placement.value.split('-')[0]
const edge = OPPOSITE_EDGE[side]
return edge ? CURSOR_EDGE_CLASS[edge] : ''
})
</script>

View File

@@ -1,92 +0,0 @@
<template>
<div
v-if="skeleton"
:data-testid="testid"
class="relative aspect-square overflow-hidden rounded-2xl"
>
<Skeleton class="absolute inset-0 rounded-2xl" />
<Skeleton class="absolute inset-x-4 bottom-4 h-4 w-2/3 rounded-md" />
</div>
<div
v-else
role="button"
:tabindex="loading ? -1 : 0"
:aria-disabled="loading"
:data-testid="testid"
:aria-label="title"
:aria-busy="loading"
class="group/card focus-visible:ring-ring relative aspect-square cursor-pointer overflow-hidden rounded-2xl focus-visible:ring-1 focus-visible:outline-none"
@click="onSelect"
@keydown.enter.prevent="onSelect"
@keydown.space.prevent="onSelect"
>
<LazyImage
:src="imageSrc"
:alt="title"
image-class="size-full object-cover opacity-60 transition-all duration-300 ease-out group-hover/card:scale-105 group-hover/card:opacity-100"
/>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-0 bg-linear-to-b from-black/40 via-transparent via-50% to-black/40 transition-opacity duration-300 ease-out group-hover/card:opacity-60"
/>
<div
v-if="badgeIcon"
aria-hidden="true"
data-testid="getting-started-card-badge"
class="pointer-events-none absolute top-3 right-3 flex size-7 items-center justify-center rounded-full bg-black/50 text-base-foreground backdrop-blur-sm"
>
<i :class="cn(badgeIcon, 'size-4')" />
</div>
<h3
class="absolute inset-x-0 bottom-0 m-0 truncate p-4 text-sm font-semibold text-base-foreground drop-shadow-md"
:title
>
{{ title }}
</h3>
<div
v-if="loading"
aria-live="polite"
class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-base-background/70 backdrop-blur-md"
>
<DotSpinner :size="24" />
<span class="text-xs font-medium text-base-foreground/80">
{{ t('onboardingTour.preparing') }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import DotSpinner from '@/components/common/DotSpinner.vue'
import LazyImage from '@/components/common/LazyImage.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
const {
imageSrc = '',
title = '',
loading = false,
skeleton = false,
badgeIcon = '',
testid
} = defineProps<{
imageSrc?: string
title?: string
loading?: boolean
skeleton?: boolean
badgeIcon?: string
testid?: string
}>()
const emit = defineEmits<{ select: [] }>()
const { t } = useI18n()
function onSelect() {
if (loading) return
emit('select')
}
</script>

View File

@@ -1,34 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { useOnboardingEntryStore } from '@/platform/workflow/persistence/onboardingEntryStore'
import GettingStartedScreen from './GettingStartedScreen.vue'
const meta: Meta<typeof GettingStartedScreen> = {
title: 'Renderer/OnboardingTour/GettingStartedScreen',
component: GettingStartedScreen,
parameters: {
layout: 'fullscreen',
backgrounds: { default: 'dark' }
},
decorators: [
() => {
useOnboardingEntryStore().showGettingStarted()
// Template cards need the served template package; without a backend the
// curated lookups return nothing, so this catalogs the screen chrome
// (heading, tabs, placeholders). Card rendering is covered by the unit
// test.
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
const render: Story['render'] = () => ({
components: { GettingStartedScreen },
template: '<GettingStartedScreen />'
})
export const Default: Story = { render }

View File

@@ -1,290 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
const mocks = vi.hoisted(() => ({
loadWorkflowTemplate: vi.fn(async () => true),
getTemplateThumbnailUrl: vi.fn(() => 'thumb.jpg'),
getTemplateTitle: vi.fn(
(template: TemplateInfo) => template.title ?? template.name
),
controllerBeginTour: vi.fn(async () => {}),
templatesByName: new Map<string, TemplateInfo>(),
templatesLoaded: true,
loadWorkflowTemplates: vi.fn(async () => {})
}))
function makeTemplate(name: string, title: string): TemplateInfo {
return {
name,
title,
mediaType: 'image',
mediaSubtype: 'jpg',
description: ''
}
}
const CARD_IDS = [
'image_krea2_turbo_t2i',
'image_z_image_turbo',
'video_ltx2_3_i2v',
'video_wan2_2_14B_i2v'
] as const
vi.mock(
'@/platform/workflow/templates/composables/useTemplateWorkflows',
() => ({
useTemplateWorkflows: () => ({
loadWorkflowTemplate: mocks.loadWorkflowTemplate,
getTemplateThumbnailUrl: mocks.getTemplateThumbnailUrl,
getTemplateTitle: mocks.getTemplateTitle
})
})
)
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: () => ({
getTemplateByName: (name: string) => mocks.templatesByName.get(name),
get isLoaded() {
return mocks.templatesLoaded
},
loadWorkflowTemplates: mocks.loadWorkflowTemplates
})
})
)
vi.mock('./useFirstRunTourController', () => ({
useFirstRunTourController: () => ({ beginTour: mocks.controllerBeginTour })
}))
import GettingStartedScreen from './GettingStartedScreen.vue'
import { tutorialCards } from './tutorialCards'
import { useOnboardingEntryStore } from '@/platform/workflow/persistence/onboardingEntryStore'
import enMessages from '@/locales/en/main.json'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderScreen() {
return render(GettingStartedScreen, { global: { plugins: [i18n] } })
}
describe('GettingStartedScreen', () => {
let store: ReturnType<typeof useOnboardingEntryStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useOnboardingEntryStore()
store.showGettingStarted()
mocks.loadWorkflowTemplate.mockClear()
mocks.controllerBeginTour.mockClear()
mocks.loadWorkflowTemplates.mockClear()
mocks.templatesLoaded = true
mocks.templatesByName.clear()
CARD_IDS.forEach((id, i) =>
mocks.templatesByName.set(id, makeTemplate(id, `Template ${i + 1}`))
)
})
it('renders the four curated template cards on the Templates tab', () => {
renderScreen()
for (const id of CARD_IDS) {
expect(screen.getByTestId(`getting-started-card-${id}`)).toBeTruthy()
}
})
it('loads the templates store when opened unloaded so cards can resolve', () => {
mocks.templatesLoaded = false
renderScreen()
expect(mocks.loadWorkflowTemplates).toHaveBeenCalledOnce()
})
it('does not reload the templates store when it is already loaded', () => {
mocks.templatesLoaded = true
renderScreen()
expect(mocks.loadWorkflowTemplates).not.toHaveBeenCalled()
})
it('does not load the templates store while the screen is hidden', () => {
store.dismissGettingStarted()
mocks.templatesLoaded = false
renderScreen()
expect(mocks.loadWorkflowTemplates).not.toHaveBeenCalled()
})
it('loads the template then starts the tour when a card is picked', async () => {
const user = userEvent.setup()
renderScreen()
await user.click(
screen.getByTestId('getting-started-card-image_z_image_turbo')
)
expect(mocks.loadWorkflowTemplate).toHaveBeenCalledWith(
'image_z_image_turbo',
'default'
)
expect(mocks.controllerBeginTour).toHaveBeenCalledWith({
templateId: 'image_z_image_turbo'
})
const loadOrder = mocks.loadWorkflowTemplate.mock.invocationCallOrder[0]
const startOrder = mocks.controllerBeginTour.mock.invocationCallOrder[0]
expect(loadOrder).toBeLessThan(startOrder)
expect(store.shouldShowGettingStarted).toBe(false)
})
it('marks the picked card busy while the template loads and the tour prepares', async () => {
let resolveLoad: (loaded: boolean) => void = () => {}
mocks.loadWorkflowTemplate.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveLoad = resolve
})
)
const user = userEvent.setup()
renderScreen()
const cardId = 'getting-started-card-image_z_image_turbo'
await user.click(screen.getByTestId(cardId))
await vi.waitFor(() =>
expect(screen.getByTestId(cardId)).toHaveAttribute('aria-busy', 'true')
)
resolveLoad(true)
})
it('keeps the screen up and does not start the tour when loading fails', async () => {
mocks.loadWorkflowTemplate.mockResolvedValueOnce(false)
const user = userEvent.setup()
renderScreen()
await user.click(
screen.getByTestId('getting-started-card-video_ltx2_3_i2v')
)
expect(mocks.loadWorkflowTemplate).toHaveBeenCalled()
expect(mocks.controllerBeginTour).not.toHaveBeenCalled()
expect(store.shouldShowGettingStarted).toBe(true)
})
it('does not start the tour or dismiss when loading rejects', async () => {
mocks.loadWorkflowTemplate.mockRejectedValueOnce(new Error('load failed'))
const user = userEvent.setup()
renderScreen()
await user.click(
screen.getByTestId('getting-started-card-video_wan2_2_14B_i2v')
)
expect(mocks.controllerBeginTour).not.toHaveBeenCalled()
expect(store.shouldShowGettingStarted).toBe(true)
})
it('does not offer the Import workflow tab', () => {
renderScreen()
expect(screen.queryByRole('tab', { name: /import workflow/i })).toBeNull()
expect(screen.getAllByRole('tab')).toHaveLength(2)
})
it('shows a message instead of endless skeletons when the catalog load fails', async () => {
mocks.templatesLoaded = false
mocks.loadWorkflowTemplates.mockRejectedValueOnce(new Error('offline'))
vi.spyOn(console, 'error').mockImplementation(() => {})
renderScreen()
await vi.waitFor(() =>
expect(
screen.getByText(enMessages.onboardingTour.gettingStarted.loadFailed)
).toBeTruthy()
)
expect(
screen.queryAllByTestId('getting-started-card-skeleton')
).toHaveLength(0)
})
it('shows skeleton cards instead of an empty grid while templates load', () => {
mocks.templatesLoaded = false
renderScreen()
expect(screen.getAllByTestId('getting-started-card-skeleton')).toHaveLength(
CARD_IDS.length
)
expect(
screen.queryByTestId('getting-started-card-image_z_image_turbo')
).toBeNull()
})
it('opens the tutorial docs in a new tab when a tutorial card is picked', async () => {
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
const user = userEvent.setup()
renderScreen()
await user.click(screen.getByRole('tab', { name: /tutorials/i }))
await user.click(
screen.getByTestId('getting-started-tutorial-text-to-image')
)
expect(openSpy).toHaveBeenCalledWith(
'https://docs.comfy.org/tutorials/basic/text-to-image',
'_blank',
'noopener,noreferrer'
)
expect(mocks.controllerBeginTour).not.toHaveBeenCalled()
expect(store.shouldShowGettingStarted).toBe(true)
openSpy.mockRestore()
})
it('renders a card per tutorial on the Tutorials tab', async () => {
const user = userEvent.setup()
renderScreen()
await user.click(screen.getByRole('tab', { name: /tutorials/i }))
for (const tutorial of tutorialCards) {
expect(
screen.getByTestId(`getting-started-tutorial-${tutorial.id}`)
).toBeTruthy()
}
expect(
screen.queryByTestId('getting-started-card-image_z_image_turbo')
).toBeNull()
})
it('badges tutorial cards so they read apart from the template cards', async () => {
const user = userEvent.setup()
renderScreen()
expect(screen.queryByTestId('getting-started-card-badge')).toBeNull()
await user.click(screen.getByRole('tab', { name: /tutorials/i }))
expect(screen.getAllByTestId('getting-started-card-badge')).toHaveLength(
tutorialCards.length
)
})
it('does not render when the entry flag is off', () => {
store.dismissGettingStarted()
renderScreen()
expect(screen.queryByRole('dialog')).toBeNull()
})
})

View File

@@ -1,215 +0,0 @@
<template>
<Teleport v-if="visible" to="body">
<FocusScope as-child trapped loop>
<div
ref="screenRef"
class="fixed inset-0 z-2000 flex flex-col items-center justify-center bg-base-background px-8 focus:outline-none"
role="dialog"
aria-modal="true"
:aria-label="t('onboardingTour.gettingStarted.screenLabel')"
tabindex="-1"
>
<div class="flex w-full max-w-5xl flex-col items-center gap-8">
<div class="flex flex-col items-center gap-3">
<h1
class="m-0 text-center text-[2.5rem]/11 font-medium text-base-foreground"
>
{{ t('onboardingTour.gettingStarted.title') }}
</h1>
<p class="m-0 text-center text-base/5 text-muted-foreground">
{{ t('onboardingTour.gettingStarted.subtitle') }}
</p>
</div>
<TabList
v-model="activeTab"
class="w-auto gap-1 rounded-full border border-border-subtle p-0.5"
:aria-label="t('onboardingTour.gettingStarted.tabsLabel')"
>
<Tab
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
class="gap-2.5 rounded-full px-3 text-xs font-medium text-base-foreground hover:opacity-100 data-[state=active]:bg-tertiary-background data-[state=inactive]:opacity-70"
>
<i :class="cn(tab.icon, 'size-3.5')" aria-hidden="true" />
{{ t(tab.labelKey) }}
</Tab>
</TabList>
<div class="w-full">
<TabPanel
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
:model-value="activeTab"
>
<div
v-if="tab.value === 'templates'"
class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4"
>
<template v-if="templatesStore.isLoaded">
<GettingStartedTemplateCard
v-for="template in cards"
:key="template.name"
:template
:loading="loadingTemplateId === template.name"
class="min-w-0"
@select="onSelectTemplate"
/>
</template>
<p
v-else-if="loadFailed"
class="col-span-full py-16 text-center text-sm text-muted-foreground"
>
{{ t('onboardingTour.gettingStarted.loadFailed') }}
</p>
<template v-else>
<GettingStartedCard
v-for="id in CURATED_TEMPLATE_IDS"
:key="id"
skeleton
testid="getting-started-card-skeleton"
class="min-w-0"
/>
</template>
</div>
<div
v-else-if="tab.value === 'tutorials'"
class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4"
>
<GettingStartedCard
v-for="tutorial in tutorialCards"
:key="tutorial.id"
:image-src="tutorialThumbnail(tutorial.thumbnailTemplate)"
:title="t(tutorial.titleKey)"
:badge-icon="TUTORIAL_BADGE_ICON"
:testid="`getting-started-tutorial-${tutorial.id}`"
class="min-w-0"
@select="openTutorial(tutorial.url)"
/>
</div>
</TabPanel>
</div>
</div>
</div>
</FocusScope>
</Teleport>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { FocusScope } from 'reka-ui'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import TabPanel from '@/components/tab/TabPanel.vue'
import { useOnboardingEntryStore } from '@/platform/workflow/persistence/onboardingEntryStore'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import GettingStartedCard from './GettingStartedCard.vue'
import GettingStartedTemplateCard from './GettingStartedTemplateCard.vue'
import type { TutorialCard } from './tutorialCards'
import {
CURATED_TEMPLATE_IDS,
TUTORIAL_BADGE_ICON,
tutorialCards
} from './tutorialCards'
import { useFirstRunTourController } from './useFirstRunTourController'
type TabValue = 'templates' | 'tutorials'
const tabs = [
{
value: 'templates' as const,
labelKey: 'onboardingTour.gettingStarted.tabs.templates',
icon: 'icon-[lucide--layout-template]'
},
{
value: 'tutorials' as const,
labelKey: 'onboardingTour.gettingStarted.tabs.tutorials',
icon: 'icon-[lucide--tv-minimal-play]'
}
]
const { t } = useI18n()
const entryStore = useOnboardingEntryStore()
const { shouldShowGettingStarted: visible } = storeToRefs(entryStore)
const templatesStore = useWorkflowTemplatesStore()
const { loadWorkflowTemplate, getTemplateThumbnailUrl } = useTemplateWorkflows()
const controller = useFirstRunTourController()
const activeTab = ref<TabValue>('templates')
const loadingTemplateId = ref<string | null>(null)
const loadFailed = ref(false)
const screenRef = useTemplateRef<HTMLElement>('screenRef')
const cards = computed(() =>
CURATED_TEMPLATE_IDS.map((id) => templatesStore.getTemplateByName(id)).filter(
(template) => template !== undefined
)
)
// Cards and per-card loads no-op until the templates store is loaded; nothing
// else loads it on this path, so load it (and focus the takeover) on open.
watch(
visible,
(isVisible) => {
if (!isVisible) return
if (!templatesStore.isLoaded) {
loadFailed.value = false
templatesStore.loadWorkflowTemplates().catch((error: unknown) => {
loadFailed.value = true
console.error('Failed to load onboarding templates:', error)
})
}
void nextTick(() => screenRef.value?.focus())
},
{ immediate: true }
)
function tutorialThumbnail(id: TutorialCard['thumbnailTemplate']) {
const template = templatesStore.getTemplateByName(id)
return template ? getTemplateThumbnailUrl(template, 'default') : ''
}
function openTutorial(url: string) {
window.open(url, '_blank', 'noopener,noreferrer')
}
async function onSelectTemplate(id: string) {
if (loadingTemplateId.value) return
// Hold the screen up until the tour overlay covers it, so the canvas never flashes bare.
loadingTemplateId.value = id
try {
let loaded = false
try {
loaded = await loadWorkflowTemplate(id, 'default')
} catch (error) {
console.error('Failed to load onboarding template:', error)
}
if (!loaded) return
try {
// Dismiss even on a silent no-start; only a throw keeps the screen up to retry.
const started = await controller.beginTour({ templateId: id })
if (!started) {
console.warn('Onboarding tour did not start for template:', id)
}
entryStore.dismissGettingStarted()
} catch (error) {
console.error('Failed to start onboarding tour:', error)
}
} finally {
loadingTemplateId.value = null
}
}
</script>

View File

@@ -1,32 +0,0 @@
<template>
<GettingStartedCard
:image-src="thumbnailSrc"
:title
:loading
:testid="`getting-started-card-${template.name}`"
@select="emit('select', template.name)"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import GettingStartedCard from './GettingStartedCard.vue'
const { template, loading = false } = defineProps<{
template: TemplateInfo
loading?: boolean
}>()
const emit = defineEmits<{ select: [id: string] }>()
const { getTemplateThumbnailUrl, getTemplateTitle } = useTemplateWorkflows()
const thumbnailSrc = computed(() =>
getTemplateThumbnailUrl(template, 'default')
)
const title = computed(() => getTemplateTitle(template, 'default'))
</script>

View File

@@ -1,27 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import {
zComfyWorkflow,
zComfyWorkflow1
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { CuratedTemplateId } from '../roleResolver'
const TEMPLATES_DIR = path.join(import.meta.dirname, 'templates')
/** Loads a curated template's real workflow, validated so a bad fixture fails loudly. */
export function loadTemplateWorkflow(id: CuratedTemplateId): ComfyWorkflowJSON {
const file = path.join(TEMPLATES_DIR, `${id}.json`)
const data: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
const schema =
(data as { version?: number }).version === 1
? zComfyWorkflow1
: zComfyWorkflow
const result = schema.safeParse(data)
if (!result.success) {
throw new Error(`Invalid template fixture "${id}": ${result.error.message}`)
}
return result.data
}

View File

@@ -1,374 +0,0 @@
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeId } from '@/types/nodeId'
import { toNodeId } from '@/types/nodeId'
const mocks = vi.hoisted(() => ({
canvas: null as unknown,
currentGraph: null as LGraph | null
}))
vi.mock('@/scripts/app', () => ({
app: {
get canvas() {
return mocks.canvas
}
}
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
get currentGraph() {
return mocks.currentGraph
}
})
}))
import {
INITIAL_SETTLE,
TOUR_FOCUS_DURATION_MS,
canvasTransformValid,
focusFillFor,
focusNodes,
maskRectsFor,
nodeClientRect,
nodesPresent,
rectIntersectsViewport,
trackSettle
} from './canvasSpotlightAdapter'
function fakeCanvas(options: {
offset?: [number, number]
scale?: number
clientLeft?: number
clientTop?: number
}) {
const { offset = [0, 0], scale = 1, clientLeft = 0, clientTop = 0 } = options
return {
canvas: {
getBoundingClientRect: () =>
({ left: clientLeft, top: clientTop }) as DOMRect
},
ds: { offset, scale }
}
}
function fakeNode(
id: number,
boundingRect: [number, number, number, number]
): LGraphNode {
return fromAny<LGraphNode, unknown>({ id: toNodeId(id), boundingRect })
}
describe('canvasSpotlightAdapter', () => {
beforeEach(() => {
mocks.canvas = fakeCanvas({})
mocks.currentGraph = null
})
afterEach(() => {
vi.clearAllMocks()
})
describe('nodeClientRect', () => {
it('maps graph coords to client coords at scale 1, no offset', () => {
mocks.canvas = fakeCanvas({ clientLeft: 100, clientTop: 50 })
const node = fakeNode(1, [10, 20, 200, 80])
expect(nodeClientRect(node)).toEqual({
left: 110,
top: 70,
width: 200,
height: 80
})
})
it('applies pan offset and zoom scale', () => {
mocks.canvas = fakeCanvas({
clientLeft: 0,
clientTop: 0,
offset: [5, 10],
scale: 2
})
const node = fakeNode(1, [10, 20, 100, 40])
// left = 0 + (10 + 5) * 2 = 30; top = (20 + 10) * 2 = 60; size scaled ×2
expect(nodeClientRect(node)).toEqual({
left: 30,
top: 60,
width: 200,
height: 80
})
})
it('returns null when the canvas is unavailable', () => {
mocks.canvas = null
expect(nodeClientRect(fakeNode(1, [0, 0, 10, 10]))).toBeNull()
})
})
describe('maskRectsFor', () => {
it('resolves node ids through the current graph, hugging each node box', () => {
const node = fakeNode(7, [0, 0, 100, 50])
mocks.currentGraph = fromPartial<LGraph>({
getNodeById: (id: NodeId) => (id === toNodeId(7) ? node : null)
})
const rects = maskRectsFor([toNodeId(7)])
expect(rects).toHaveLength(1)
expect(rects[0]).toEqual({ left: 0, top: 0, width: 100, height: 50 })
})
it('drops ids that do not resolve, never throwing', () => {
mocks.currentGraph = fromPartial<LGraph>({ getNodeById: () => null })
expect(maskRectsFor([toNodeId(99)])).toEqual([])
})
it('drops ids that resolve to undefined without reading boundingRect', () => {
// getNodeById returns undefined (not null) for ids absent from the graph —
// e.g. a nested executing node — which must not crash on `.boundingRect`.
mocks.currentGraph = fromPartial<LGraph>({
getNodeById: () => undefined as unknown as null
})
expect(() => maskRectsFor([toNodeId(99)])).not.toThrow()
expect(maskRectsFor([toNodeId(99)])).toEqual([])
})
it('returns empty when there is no current graph', () => {
mocks.currentGraph = null
expect(maskRectsFor([toNodeId(1)])).toEqual([])
})
})
describe('focusNodes', () => {
it('pans to the resolved nodes bounds without re-zooming when no room is reserved', () => {
const animate = vi.fn()
const node = fakeNode(3, [0, 0, 100, 100])
mocks.canvas = { ...fakeCanvas({}), animateToBounds: animate }
mocks.currentGraph = fromPartial<LGraph>({
getNodeById: (id: NodeId) => (id === toNodeId(3) ? node : null)
})
focusNodes([toNodeId(3)])
// createBounds pads the [0,0,100,100] node by 10 on every side; zoom 0 keeps
// the current scale, so later steps pan rather than re-zoom.
expect(animate).toHaveBeenCalledWith([-10, -10, 120, 120], {
zoom: 0,
duration: TOUR_FOCUS_DURATION_MS
})
})
it('does nothing when no nodes resolve', () => {
const animate = vi.fn()
mocks.canvas = { ...fakeCanvas({}), animateToBounds: animate }
mocks.currentGraph = fromPartial<LGraph>({ getNodeById: () => null })
focusNodes([toNodeId(99)])
expect(animate).not.toHaveBeenCalled()
})
})
describe('nodesPresent', () => {
it('is true only when every id resolves on the current graph', () => {
const node = fakeNode(7, [0, 0, 10, 10])
mocks.currentGraph = fromPartial<LGraph>({
getNodeById: (id: NodeId) => (id === toNodeId(7) ? node : null)
})
expect(nodesPresent([toNodeId(7)])).toBe(true)
expect(nodesPresent([toNodeId(7), toNodeId(8)])).toBe(false)
})
it('is false when there is no current graph', () => {
mocks.currentGraph = null
expect(nodesPresent([toNodeId(1)])).toBe(false)
})
})
describe('rectIntersectsViewport', () => {
const viewport = { left: 0, top: 100, width: 1000, height: 800 }
it.for([
['fully inside', { left: 200, top: 200, width: 50, height: 50 }, true],
[
'straddling an edge',
{ left: -20, top: 200, width: 50, height: 50 },
true
],
[
'above the region',
{ left: 200, top: 10, width: 50, height: 50 },
false
],
[
'past the right',
{ left: 1000, top: 200, width: 50, height: 50 },
false
],
[
'below the region',
{ left: 200, top: 900, width: 50, height: 50 },
false
]
] as const)('is %s => %s', ([, rect, expected]) => {
expect(rectIntersectsViewport(rect, viewport)).toBe(expected)
})
it('respects the region origin, not the window', () => {
// Sits under a 100px top bar: inside the window, outside the canvas region.
const underTopBar = { left: 200, top: 20, width: 50, height: 50 }
expect(rectIntersectsViewport(underTopBar, viewport)).toBe(false)
})
})
describe('trackSettle', () => {
const feed = (keys: (string | null)[]) =>
keys.reduce((state, key) => trackSettle(state, key), INITIAL_SETTLE)
it('does not settle while the transform keeps changing', () => {
// A camera mid-tween reports a new transform every frame.
expect(feed(['a', 'b', 'c', 'd']).settled).toBe(false)
})
it('settles once the transform holds still for two frames', () => {
expect(feed(['a', 'a']).settled).toBe(false)
expect(feed(['a', 'a', 'a']).settled).toBe(true)
})
it('un-settles when the user grabs the canvas after it landed', () => {
const landed = feed(['a', 'a', 'a'])
expect(landed.settled).toBe(true)
expect(trackSettle(landed, 'b').settled).toBe(false)
})
it('treats an absent transform as settled — there is no camera to wait for', () => {
expect(trackSettle(INITIAL_SETTLE, null).settled).toBe(true)
})
it('does not settle on a tween that pauses on one value for a single frame', () => {
// Two overlapping tweens can repeat a rounded value for one frame; requiring
// two consecutive matches keeps that from reading as a landing.
expect(feed(['a', 'b', 'b', 'c']).settled).toBe(false)
})
})
describe('focusFillFor', () => {
const viewport = { left: 0, top: 0, width: 1500, height: 900 }
const reserve = { width: 360, height: 300 }
/** Litegraph's own fit (DragAndScale), so we assert the scale it will land on. */
const scaleFor = (
fill: number,
bounds: { width: number; height: number }
) =>
Math.min(
(fill * viewport.width) / Math.max(bounds.width, 300),
(fill * viewport.height) / Math.max(bounds.height, 300),
10
)
it('caps a tiny node at the same scale a roomy one gets, never zooming further', () => {
// Both fit with room to spare, so both land on the ceiling.
const tiny = { width: 60, height: 40 }
const roomy = { width: 400, height: 300 }
const tinyScale = scaleFor(focusFillFor(tiny, viewport, reserve), tiny)
const roomyScale = scaleFor(focusFillFor(roomy, viewport, reserve), roomy)
expect(tinyScale).toBeCloseTo(roomyScale, 2)
// And the cap holds the node near life size, not blown up.
expect(tinyScale).toBeLessThanOrEqual(1.2)
})
it('shrinks a tall node until the mark still has room beside it', () => {
const tall = { width: 400, height: 1600 }
const scale = scaleFor(focusFillFor(tall, viewport, reserve), tall)
expect(scale).toBeLessThan(1)
// The node leaves a full mark's width of the region free.
expect(tall.width * scale).toBeLessThanOrEqual(
viewport.width - reserve.width
)
expect(tall.height * scale).toBeLessThanOrEqual(viewport.height)
})
it('keeps a wide node on screen by stacking the mark below it', () => {
const wide = { width: 2400, height: 300 }
const scale = scaleFor(focusFillFor(wide, viewport, reserve), wide)
expect(wide.width * scale).toBeLessThanOrEqual(viewport.width)
expect(wide.height * scale).toBeLessThanOrEqual(
viewport.height - reserve.height
)
})
it('leaves the mark room for any node, without a floor forcing overflow', () => {
const nodes = [
{ width: 240, height: 140 },
{ width: 500, height: 650 },
{ width: 600, height: 1100 },
{ width: 800, height: 1600 },
{ width: 3000, height: 2400 }
]
for (const node of nodes) {
const scale = scaleFor(focusFillFor(node, viewport, reserve), node)
const fitsBeside =
node.width * scale <= viewport.width - reserve.width &&
node.height * scale <= viewport.height
const fitsBelow =
node.width * scale <= viewport.width &&
node.height * scale <= viewport.height - reserve.height
expect(fitsBeside || fitsBelow).toBe(true)
}
})
it('adapts to the region it is given, not a fixed screen size', () => {
const node = { width: 500, height: 400 }
const small = { left: 0, top: 0, width: 800, height: 600 }
const large = { left: 0, top: 0, width: 2560, height: 1400 }
const smallScale =
(focusFillFor(node, small, reserve) * small.width) /
Math.max(node.width, 300)
const largeScale =
(focusFillFor(node, large, reserve) * large.width) /
Math.max(node.width, 300)
expect(smallScale).toBeLessThan(largeScale)
})
})
describe('canvasTransformValid', () => {
it('is true for a finite positive transform', () => {
mocks.canvas = fakeCanvas({ scale: 1, offset: [0, 0] })
expect(canvasTransformValid()).toBe(true)
})
it('is false when the canvas is absent', () => {
mocks.canvas = null
expect(canvasTransformValid()).toBe(false)
})
it('is false for a zero or non-finite scale', () => {
mocks.canvas = fakeCanvas({ scale: 0 })
expect(canvasTransformValid()).toBe(false)
mocks.canvas = fakeCanvas({ scale: Number.NaN })
expect(canvasTransformValid()).toBe(false)
})
it('is false for a non-finite offset', () => {
mocks.canvas = fakeCanvas({ offset: [Number.NaN, 0] })
expect(canvasTransformValid()).toBe(false)
})
})
})

View File

@@ -1,233 +0,0 @@
import { createBounds } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { Point } from '@/lib/litegraph/src/interfaces'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { NodeId } from '@/types/nodeId'
/** The toolbar Run button (queue on desktop, subscribe-to-run on cloud). */
export const RUN_BUTTON_SELECTOR =
'[data-testid="queue-button"], [data-testid="subscribe-to-run-button"]'
/** The floating action bar the Run button sits in. */
export const ACTIONBAR_SELECTOR = '[data-testid="comfy-actionbar"]'
/** A rectangle in client (viewport) coordinates. */
export interface ScreenRect {
left: number
top: number
width: number
height: number
}
interface Size {
width: number
height: number
}
/** Whether any part of `rect` is inside `viewport`. */
export function rectIntersectsViewport(
rect: ScreenRect,
viewport: ScreenRect
): boolean {
return (
rect.left < viewport.left + viewport.width &&
rect.left + rect.width > viewport.left &&
rect.top < viewport.top + viewport.height &&
rect.top + rect.height > viewport.top
)
}
/** Space between the coach-mark and the target it points at. */
export const COACH_MARK_GAP = 40
interface CanvasFrame {
left: number
top: number
offset: [number, number]
scale: number
}
/** Client-space origin + transform of the litegraph canvas, or null if absent. */
function canvasFrame(): CanvasFrame | null {
const canvas = app.canvas
if (!canvas) return null
const rect = canvas.canvas.getBoundingClientRect()
const { offset, scale } = canvas.ds
return { left: rect.left, top: rect.top, offset, scale }
}
/**
* The region the overlay may draw in: the canvas's client rect, not the window.
* Measuring the canvas excludes the toolbar and panels without assuming their size.
* Null when the canvas is absent or unlaid-out.
*/
export function canvasViewport(): ScreenRect | null {
const canvas = app.canvas
if (!canvas) return null
const { left, top, width, height } = canvas.canvas.getBoundingClientRect()
if (!(width > 0) || !(height > 0)) return null
return { left, top, width, height }
}
function toClient(frame: CanvasFrame, [x, y]: Point): Point {
return [
frame.left + (x + frame.offset[0]) * frame.scale,
frame.top + (y + frame.offset[1]) * frame.scale
]
}
/**
* Whether the canvas has a usable transform yet — a finite positive scale and
* finite offset. Used to gate the tour start until the just-loaded graph has
* actually laid out, so the spotlight never paints against a stale/absent frame.
*/
export function canvasTransformValid(): boolean {
const frame = canvasFrame()
return (
frame !== null &&
Number.isFinite(frame.scale) &&
frame.scale > 0 &&
Number.isFinite(frame.offset[0]) &&
Number.isFinite(frame.offset[1])
)
}
/** A node's bounding box in client coordinates, or null if the canvas is absent. */
export function nodeClientRect(node: LGraphNode): ScreenRect | null {
const frame = canvasFrame()
if (!frame) return null
const [bx, by, bw, bh] = node.boundingRect
const [left, top] = toClient(frame, [bx, by])
return { left, top, width: bw * frame.scale, height: bh * frame.scale }
}
function resolveNodes(nodeIds: NodeId[]): LGraphNode[] {
const graph = useCanvasStore().currentGraph
if (!graph) return []
return nodeIds
.map((id) => graph.getNodeById(id))
.filter((node): node is LGraphNode => node != null)
}
/** Whether every id resolves to a node on the live graph (vacuously true if none). */
export function nodesPresent(nodeIds: NodeId[]): boolean {
return resolveNodes(nodeIds).length === nodeIds.length
}
/** Client rects for the revealed nodes, hugging each node box; unresolvable ids are dropped. */
export function maskRectsFor(nodeIds: NodeId[]): ScreenRect[] {
return resolveNodes(nodeIds)
.map((node) => nodeClientRect(node))
.filter((rect): rect is ScreenRect => rect !== null)
}
/** Duration of the tour's framing animation; bounds the overlay's wait for it. */
export const TOUR_FOCUS_DURATION_MS = 450
/** Never magnify past this: the aim is a node that reads, not one that dominates. */
const MAX_FOCUS_SCALE = 0.6
/**
* The fill fraction that frames `bounds` with room left for a `reserve`-sized
* coach-mark, in the units `animateToBounds` expects.
*
* The mark sits beside the node *or* below it, never both, so each arrangement is
* costed separately and the roomier one wins. Deliberately unfloored: forcing a
* minimum scale is what pushes a big node past the viewport and strands the mark.
*/
export function focusFillFor(
bounds: Size,
viewport: ScreenRect,
reserve: Size
): number {
const width = Math.max(bounds.width, 1)
const height = Math.max(bounds.height, 1)
const freeWidth = viewport.width - COACH_MARK_GAP * 2
const freeHeight = viewport.height - COACH_MARK_GAP * 2
const beside = Math.min(
(freeWidth - reserve.width) / width,
freeHeight / height
)
const below = Math.min(
freeWidth / width,
(freeHeight - reserve.height) / height
)
const scale = Math.min(Math.max(beside, below), MAX_FOCUS_SCALE)
// Invert litegraph's fit: it takes scale = min(fillX, fillY), each solved as
// (fill * side) / max(bound, 300). To land on `scale`, the binding (smaller)
// axis must equal it — so take the larger of the two per-axis fills.
return Math.max(
(scale * Math.max(bounds.width, 300)) / viewport.width,
(scale * Math.max(bounds.height, 300)) / viewport.height
)
}
/**
* Frame the view around the given nodes. No-op when none resolve.
*
* @param reserve Space to leave for the coach-mark. Omit to pan at the current
* scale, so the tour zooms once and only pans after.
*/
export function focusNodes(nodeIds: NodeId[], reserve?: Size): void {
const nodes = resolveNodes(nodeIds)
if (nodes.length === 0) return
const bounds = createBounds(nodes)
if (!bounds) return
const viewport = canvasViewport()
const zoom =
reserve && viewport
? focusFillFor({ width: bounds[2], height: bounds[3] }, viewport, reserve)
: 0
app.canvas?.animateToBounds(bounds, {
zoom,
duration: TOUR_FOCUS_DURATION_MS
})
}
/** The canvas element, or null when it is absent — for observing its size. */
export function canvasElement(): HTMLCanvasElement | null {
return app.canvas?.canvas ?? null
}
/** Consecutive identical frames that mean the camera has stopped. */
const SETTLE_FRAMES = 2
/** How many frames the transform has held still, and whether that means settled. */
export interface SettleState {
key: string | null
frames: number
settled: boolean
}
export const INITIAL_SETTLE: SettleState = {
key: null,
frames: 0,
settled: false
}
/**
* Fold one frame's transform into the settle state. `animateToBounds` reports no
* completion and cannot be cancelled, so an unchanged transform is the only honest
* "camera stopped" signal. An absent transform counts as settled: no camera to wait for.
*/
export function trackSettle(
state: SettleState,
key: string | null
): SettleState {
if (key === null) return { key, frames: 0, settled: true }
if (key !== state.key) return { key, frames: 0, settled: false }
const frames = state.frames + 1
return { key, frames, settled: frames >= SETTLE_FRAMES }
}
/** The canvas transform as a comparable string, or null when the canvas is absent. */
export function canvasTransformKey(): string | null {
const frame = canvasFrame()
if (!frame) return null
return `${frame.scale}:${frame.offset[0]}:${frame.offset[1]}`
}

View File

@@ -1,387 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { toNodeId } from '@/types/nodeId'
const restoreView = vi.hoisted(() => vi.fn())
vi.mock('./subgraphNavigation', () => ({ restoreView }))
const resolveTourRoles = vi.hoisted(() => vi.fn())
vi.mock('./roleResolution', () => ({ resolveTourRoles }))
const sinkNode = vi.hoisted(() => ({}) as object)
const resolveNode = vi.hoisted(() => vi.fn())
vi.mock('@/utils/litegraphUtil', () => ({ resolveNode }))
const getNodeImageUrls = vi.hoisted(() => vi.fn())
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({ getNodeImageUrls })
}))
// Reactive so `until(sinkUrl)` wakes on a real dependency change rather than
// relying on incidental re-polling of a plain mock.
const sinkOutput = ref<string[] | undefined>(['blob:sink-output'])
const isDialogOpen = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ isDialogOpen })
}))
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useFirstRunTourStore } from './firstRunTourStore'
import type { ResolvedRoles } from './tourSequence'
const workflow = {} as ComfyWorkflowJSON
const i2vRoles: ResolvedRoles = {
source: { nodeId: toNodeId(97) },
prompt: {
subgraphNodeId: toNodeId(10),
innerNodeId: toNodeId(93),
widgetName: 'text',
portFallback: 'prompt'
},
engine: { nodeId: toNodeId(86) },
sink: { nodeId: toNodeId(108) },
mediaKind: 'video'
}
const t2iRoles: ResolvedRoles = {
source: null,
prompt: {
subgraphNodeId: toNodeId(10),
innerNodeId: toNodeId(27),
widgetName: 'text',
portFallback: 'prompt'
},
engine: { nodeId: toNodeId(3) },
sink: { nodeId: toNodeId(9) },
mediaKind: 'image'
}
describe('firstRunTourStore', () => {
let store: ReturnType<typeof useFirstRunTourStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useFirstRunTourStore()
resolveTourRoles.mockReset()
restoreView.mockReset()
resolveNode.mockReset()
getNodeImageUrls.mockReset()
resolveNode.mockReturnValue(sinkNode)
sinkOutput.value = ['blob:sink-output']
getNodeImageUrls.mockImplementation(() => sinkOutput.value)
isDialogOpen.mockReset()
isDialogOpen.mockReturnValue(false)
})
it('prepare() on I2V roles builds [Upload, Prompt, Run, Result] and reveals the source', () => {
resolveTourRoles.mockReturnValue(i2vRoles)
store.prepare(workflow)
expect(resolveTourRoles).toHaveBeenCalledWith(workflow, undefined)
expect(store.stepIndex).toBe(0)
expect(store.steps.map((s) => s.kind)).toEqual([
'upload',
'prompt',
'run',
'result'
])
expect([...store.revealedNodeIds]).toEqual([toNodeId(97)])
})
it('prepare() passes the templateId through to the resolver', () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.prepare(workflow, 'image_z_image_turbo')
expect(resolveTourRoles).toHaveBeenCalledWith(
workflow,
'image_z_image_turbo'
)
})
it('prepare() on T2I roles omits the Upload step', () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.prepare(workflow)
expect(store.steps.map((s) => s.kind)).toEqual(['prompt', 'run', 'result'])
// T2I opens on the prompt step, revealing its collapsed subgraph host.
expect([...store.revealedNodeIds]).toEqual([toNodeId(10)])
})
it('reveals accumulate as the step index advances so the graph builds up', () => {
resolveTourRoles.mockReturnValue(i2vRoles)
store.prepare(workflow)
store.stepIndex = 1 // prompt (nodeId null, reveals the subgraph host)
expect([...store.revealedNodeIds]).toEqual([toNodeId(97), toNodeId(10)])
store.stepIndex = 2 // run (no node)
expect([...store.revealedNodeIds]).toEqual([toNodeId(97), toNodeId(10)])
})
it('collapses to only the sink on the Result step', () => {
resolveTourRoles.mockReturnValue(i2vRoles)
store.prepare(workflow)
store.stepIndex = 3 // result
// The final step focuses solely on the generated output, hiding the rest.
expect([...store.revealedNodeIds]).toEqual([toNodeId(108)])
})
it('spotlitNodeIds tracks only the current step while reveals accumulate', () => {
resolveTourRoles.mockReturnValue(i2vRoles)
store.prepare(workflow)
// Upload step: source revealed and spotlit.
expect([...store.revealedNodeIds]).toEqual([toNodeId(97)])
expect([...store.spotlitNodeIds]).toEqual([toNodeId(97)])
store.stepIndex = 1 // prompt (reveals the subgraph host)
// Reveals accumulate; the spotlight narrows to just the prompt host.
expect([...store.revealedNodeIds]).toEqual([toNodeId(97), toNodeId(10)])
expect([...store.spotlitNodeIds]).toEqual([toNodeId(10)])
store.stepIndex = 2 // run (no node)
expect([...store.spotlitNodeIds]).toEqual([])
store.stepIndex = 3 // result (sink)
// Result collapses to only the sink for both spotlight and reveal.
expect([...store.spotlitNodeIds]).toEqual([toNodeId(108)])
expect([...store.revealedNodeIds]).toEqual([toNodeId(108)])
})
it('recomputes the prior reveal set when the step index steps back', () => {
resolveTourRoles.mockReturnValue(i2vRoles)
store.prepare(workflow)
store.stepIndex = 1
store.stepIndex = 0
expect([...store.revealedNodeIds]).toEqual([toNodeId(97)])
})
it('end() restores the view and resets to idle', () => {
resolveTourRoles.mockReturnValue(i2vRoles)
store.prepare(workflow)
store.stepIndex = 1
store.end()
expect(restoreView).toHaveBeenCalledOnce()
expect(store.isActive).toBe(false)
expect(store.stepIndex).toBe(0)
expect(store.resolvedRoles).toBeNull()
expect(store.steps).toEqual([])
expect(store.revealedNodeIds.size).toBe(0)
})
it('degrades to a lone Run step when prompt and sink are unresolved', () => {
resolveTourRoles.mockReturnValue({ ...t2iRoles, prompt: null, sink: null })
store.prepare(workflow)
// No prompt AND no sink → still builds the always-present Run step rather than crashing.
expect(store.steps.map((s) => s.kind)).toEqual(['run'])
})
it('captureResultMedia() records the sink output URL with the resolved media kind', async () => {
resolveTourRoles.mockReturnValue(i2vRoles)
store.prepare(workflow)
store.isActive = true
await store.captureResultMedia()
expect(resolveNode).toHaveBeenCalledWith(toNodeId(108))
expect(getNodeImageUrls).toHaveBeenCalledWith(sinkNode)
expect(store.resultMedia).toEqual({
url: 'blob:sink-output',
kind: 'video'
})
})
it('captureResultMedia() waits for the URL to appear before recording it', async () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.prepare(workflow)
store.isActive = true
sinkOutput.value = undefined
const pending = store.captureResultMedia()
// The cloud queue refresh fills the output just after execution_success.
sinkOutput.value = ['blob:late-output']
await pending
expect(store.resultMedia).toEqual({
url: 'blob:late-output',
kind: 'image'
})
})
it('captureResultMedia() is a no-op while the tour is idle', async () => {
await store.captureResultMedia()
expect(resolveNode).not.toHaveBeenCalled()
expect(store.resultMedia).toBeNull()
})
it('captureResultMedia() discards the URL if the tour ends mid-wait', async () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.prepare(workflow)
store.isActive = true
sinkOutput.value = undefined
// The tour ends (user skips) during the wait; the URL then resolves, but the
// post-await guard must drop it rather than record into a dead tour.
const pending = store.captureResultMedia()
store.end()
sinkOutput.value = ['blob:late-output']
await pending
expect(store.resultMedia).toBeNull()
})
it('captureResultMedia() discards the URL if a newer tour started mid-wait', async () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.prepare(workflow)
store.isActive = true
sinkOutput.value = undefined
// The original tour's capture is still waiting when a fresh tour starts
// (prepare bumps tourRunId). The late URL must not write into the new run.
const pending = store.captureResultMedia()
store.prepare(workflow)
store.isActive = true
sinkOutput.value = ['blob:late-output']
await pending
expect(store.resultMedia).toBeNull()
})
it('captureResultMedia() is idempotent once the media is set', async () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.prepare(workflow)
store.isActive = true
await store.captureResultMedia()
sinkOutput.value = ['blob:second-run']
await store.captureResultMedia()
// A second success must not overwrite the first captured result.
expect(store.resultMedia).toEqual({
url: 'blob:sink-output',
kind: 'image'
})
})
it('captureResultMedia() gives up after the timeout without recording', async () => {
vi.useFakeTimers()
resolveTourRoles.mockReturnValue(t2iRoles)
store.prepare(workflow)
store.isActive = true
sinkOutput.value = undefined
const pending = store.captureResultMedia()
await vi.runAllTimersAsync()
await pending
expect(store.resultMedia).toBeNull()
vi.useRealTimers()
})
it('prepare() clears a previous tours finished run so the next one can generate', () => {
// The flag drives the Result step's "Generating…"; carried over, a second tour
// would show its result as already done.
resolveTourRoles.mockReturnValue(t2iRoles)
store.prepare(workflow)
store.runFinished = true
store.prepare(workflow)
expect(store.runFinished).toBe(false)
})
it('showNudge() surfaces the post-run nudge', () => {
expect(store.shouldShowNudge).toBe(false)
store.showNudge()
expect(store.shouldShowNudge).toBe(true)
})
it('keeps the nudge visible after the tour ends so it outlives the run', () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.prepare(workflow)
store.showNudge()
store.end()
expect(store.shouldShowNudge).toBe(true)
})
it('keeps the captured media after the tour ends so the nudge still shows it', () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.prepare(workflow)
store.resultMedia = { url: 'blob:first-run', kind: 'image' }
store.showNudge()
store.end()
expect(store.resultMedia).toEqual({ url: 'blob:first-run', kind: 'image' })
})
it('defers the nudge while the upgrade modal is open, then surfaces it on close', () => {
isDialogOpen.mockReturnValue(true)
store.showNudge()
// Held back so it never overlaps the paywall; the arm flag drives the retry.
expect(store.shouldShowNudge).toBe(false)
expect(store.nudgeArmed).toBe(true)
// The end of the tour must not lose the deferred nudge.
store.end()
expect(store.nudgeArmed).toBe(true)
isDialogOpen.mockReturnValue(false)
store.showNudge()
expect(store.shouldShowNudge).toBe(true)
expect(store.nudgeArmed).toBe(false)
})
it('dismissNudge() hides it and blocks any later re-trigger this session', () => {
store.showNudge()
expect(store.shouldShowNudge).toBe(true)
store.dismissNudge()
expect(store.shouldShowNudge).toBe(false)
store.showNudge()
expect(store.shouldShowNudge).toBe(false)
})
it('prepare() resets the nudge lifecycle for a fresh tour', () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.showNudge()
store.dismissNudge()
store.resultMedia = { url: 'blob:prior-run', kind: 'image' }
store.prepare(workflow)
expect(store.shouldShowNudge).toBe(false)
expect(store.nudgeArmed).toBe(false)
// The prior run's media is cleared with the rest of the nudge lifecycle.
expect(store.resultMedia).toBeNull()
// Dismissal from the prior tour no longer blocks the new one.
store.showNudge()
expect(store.shouldShowNudge).toBe(true)
})
})

View File

@@ -1,170 +0,0 @@
import { until } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { UPGRADE_DIALOG_KEYS } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { useDialogStore } from '@/stores/dialogStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/types/nodeId'
import { resolveNode } from '@/utils/litegraphUtil'
import { resolveTourRoles } from './roleResolution'
import { restoreView } from './subgraphNavigation'
import { sequenceBuilder } from './tourSequence'
import type { MediaKind, ResolvedRoles, TourStep } from './tourSequence'
export type TourEndReason = 'done' | 'skip' | 'error'
export interface ResultMedia {
url: string
kind: MediaKind
}
const RESULT_MEDIA_TIMEOUT_MS = 8000
export function isUpgradeModalOpen(): boolean {
const dialogStore = useDialogStore()
return UPGRADE_DIALOG_KEYS.some((key) => dialogStore.isDialogOpen(key))
}
/** A step's target node, plus the prompt's collapsed host (the subgraph is never entered). */
function stepTargets(step: TourStep): NodeId[] {
const ids: NodeId[] = []
if (step.nodeId !== null) ids.push(step.nodeId)
if (step.prompt) ids.push(step.prompt.subgraphNodeId)
return ids
}
/** First-run tour view state; the coachmark engine owns the sequence, the controller mirrors its position here. */
export const useFirstRunTourStore = defineStore('firstRunTour', () => {
const isActive = ref(false)
const stepIndex = ref(0)
const steps = ref<TourStep[]>([])
const resolvedRoles = ref<ResolvedRoles | null>(null)
const resultMedia = ref<ResultMedia | null>(null)
/** Set on any run outcome; drives "Generating…" so a timed-out capture doesn't spin forever. */
const runFinished = ref(false)
/** Bumped once per tour so the overlay can tell a restart from a step change. */
const tourRunId = ref(0)
const shouldShowNudge = ref(false)
/** Set when `showNudge` defers behind the upgrade modal; the modal-close watch drains it. */
const nudgeArmed = ref(false)
/** "Not now" latches this so no later trigger resurfaces the nudge this session. */
const nudgeDismissed = ref(false)
const currentStep = computed<TourStep | null>(
() => steps.value[stepIndex.value] ?? null
)
const totalSteps = computed(() => steps.value.length)
const spotlitNodeIds = computed<Set<NodeId>>(() => {
const step = currentStep.value
return step ? new Set(stepTargets(step)) : new Set()
})
// Steps up to `stepIndex` accumulate — except Result, which collapses to just the sink.
const revealedNodeIds = computed<Set<NodeId>>(() => {
const step = currentStep.value
if (step?.kind === 'result' && step.nodeId !== null) {
return new Set([step.nodeId])
}
const revealed = new Set<NodeId>()
for (const prior of steps.value.slice(0, stepIndex.value + 1)) {
for (const id of stepTargets(prior)) revealed.add(id)
}
return revealed
})
function prepare(workflow: ComfyWorkflowJSON, templateId?: string) {
resetNudge()
stepIndex.value = 0
const roles = resolveTourRoles(workflow, templateId)
resolvedRoles.value = roles
steps.value = sequenceBuilder(roles)
tourRunId.value += 1
}
// The URL lands just after the run (the cloud queue refresh fetches it), so wait rather than drop it.
async function captureResultMedia() {
if (!isActive.value || resultMedia.value) return
const roles = resolvedRoles.value
const sink = roles?.sink
if (!sink) return
const runId = tourRunId.value
const sinkUrl = () => {
const node = resolveNode(sink.nodeId)
return node
? (useNodeOutputStore().getNodeImageUrls(node)?.[0] ?? '')
: ''
}
const url = await until(sinkUrl).toMatch((value) => value.length > 0, {
timeout: RESULT_MEDIA_TIMEOUT_MS,
throwOnTimeout: false
})
// A newer tour may have started during the wait; don't write into it.
if (!url || !isActive.value || tourRunId.value !== runId) return
resultMedia.value = { url, kind: roles.mediaKind }
}
// Defer behind the upgrade modal so the two never overlap; dismissal wins over both.
function showNudge() {
if (nudgeDismissed.value) return
if (isUpgradeModalOpen()) {
nudgeArmed.value = true
return
}
nudgeArmed.value = false
shouldShowNudge.value = true
}
function dismissNudge() {
shouldShowNudge.value = false
nudgeDismissed.value = true
}
// Nudge state outlives the tour, so only a fresh tour clears it.
function resetNudge() {
shouldShowNudge.value = false
nudgeArmed.value = false
nudgeDismissed.value = false
resultMedia.value = null
runFinished.value = false
}
function reset() {
isActive.value = false
stepIndex.value = 0
steps.value = []
resolvedRoles.value = null
}
function end() {
restoreView()
reset()
}
return {
isActive,
stepIndex,
steps,
currentStep,
totalSteps,
resolvedRoles,
revealedNodeIds,
spotlitNodeIds,
resultMedia,
runFinished,
tourRunId,
shouldShowNudge,
nudgeArmed,
prepare,
captureResultMedia,
showNudge,
dismissNudge,
end
}
})

View File

@@ -1,16 +0,0 @@
import { useTelemetry } from '@/platform/telemetry'
import type {
FirstRunTourMetadata,
OnboardingTourStage
} from '@/platform/telemetry/types'
/** Distinguishes this tour from the App Mode coachmarks on the shared events. */
const TOUR = 'firstRun'
export function trackFirstRunTour(
stage: OnboardingTourStage,
metadata: Omit<FirstRunTourMetadata, 'tour'> = {}
): void {
const payload: FirstRunTourMetadata = { tour: TOUR, ...metadata }
useTelemetry()?.trackOnboardingTour(stage, payload)
}

View File

@@ -1,72 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
const resolveRoles = vi.hoisted(() => vi.fn())
vi.mock('./roleResolver', () => ({ resolveRoles }))
interface FakeNodeDef {
output_node: boolean
outputs: { type: string }[]
}
const nodeDefsByName = vi.hoisted(() => ({}) as Record<string, FakeNodeDef>)
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({ nodeDefsByName })
}))
import { resolveTourRoles } from './roleResolution'
type Lookup = (
type: string
) => { isOutputNode: boolean; producesVideo: boolean } | null
const workflow = {} as ComfyWorkflowJSON
function capturedLookup(): Lookup {
return resolveRoles.mock.calls[0][2] as Lookup
}
describe('resolveTourRoles', () => {
beforeEach(() => {
resolveRoles.mockReset()
for (const key of Object.keys(nodeDefsByName)) delete nodeDefsByName[key]
})
it('forwards the workflow and templateId to resolveRoles', () => {
resolveTourRoles(workflow, 'image_z_image_turbo')
expect(resolveRoles).toHaveBeenCalledWith(
workflow,
'image_z_image_turbo',
expect.any(Function)
)
})
it('injects a lookup that rejects prototype keys and maps registry defs', () => {
// The lookup indexes `nodeDefsByName` by an attacker-craftable node type, so
// it must reject prototype members and only report real registry defs.
nodeDefsByName.SaveVideo = {
output_node: true,
outputs: [{ type: 'VIDEO' }]
}
nodeDefsByName.SaveImage = {
output_node: true,
outputs: [{ type: 'IMAGE' }]
}
resolveTourRoles(workflow)
const lookup = capturedLookup()
expect(lookup('toString')).toBeNull()
expect(lookup('constructor')).toBeNull()
expect(lookup('unregistered')).toBeNull()
expect(lookup('SaveVideo')).toEqual({
isOutputNode: true,
producesVideo: true
})
expect(lookup('SaveImage')).toEqual({
isOutputNode: true,
producesVideo: false
})
})
})

View File

@@ -1,35 +0,0 @@
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { resolveRoles } from './roleResolver'
import type { NodeDefLookup } from './roleResolver'
import type { ResolvedRoles } from './tourSequence'
/** Output types that mark a registry sink as producing video rather than an image. */
const VIDEO_OUTPUT_TYPES = new Set(['VIDEO', 'VHS_VIDEOINFO'])
/** Reads the node registry so the resolver can widen sink detection to custom output nodes. */
const nodeDefLookup: NodeDefLookup = (type) => {
const defs = useNodeDefStore().nodeDefsByName
if (!Object.hasOwn(defs, type)) return null
const def = defs[type]
return {
isOutputNode: def.output_node,
producesVideo: def.outputs.some((output) =>
VIDEO_OUTPUT_TYPES.has(output.type)
)
}
}
/**
* Resolve tour roles with the live node registry injected, so registry-only
* custom sinks are detected. The single entry point the store and the readiness
* gate share, so both see the same roles (the gate reads the live graph the
* spotlight reads — they must not diverge on which sink exists).
*/
export function resolveTourRoles(
workflow: ComfyWorkflowJSON,
templateId?: string
): ResolvedRoles {
return resolveRoles(workflow, templateId, nodeDefLookup)
}

View File

@@ -1,601 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { toNodeId } from '@/types/nodeId'
import { loadTemplateWorkflow } from './__fixtures__/loadTemplateWorkflow'
import {
fromWorkflowJson,
resolveRoles,
templateOverrides
} from './roleResolver'
import type { CuratedTemplateId } from './roleResolver'
interface CuratedExpectation {
sourceId: number | null
promptInnerId: number
promptWidget: string
engineId: number
sinkId: number
mediaKind: 'image' | 'video'
}
const CURATED: Record<CuratedTemplateId, CuratedExpectation> = {
image_krea2_turbo_t2i: {
sourceId: null,
promptInnerId: 19,
promptWidget: 'value',
engineId: 3,
sinkId: 29,
mediaKind: 'image'
},
image_z_image_turbo: {
sourceId: null,
promptInnerId: 27,
promptWidget: 'text',
engineId: 3,
sinkId: 9,
mediaKind: 'image'
},
video_ltx2_3_i2v: {
sourceId: 269,
promptInnerId: 319,
promptWidget: 'value',
engineId: 283,
sinkId: 75,
mediaKind: 'video'
},
video_wan2_2_14B_i2v: {
sourceId: 97,
promptInnerId: 93,
promptWidget: 'text',
engineId: 86,
sinkId: 108,
mediaKind: 'video'
},
flux_kontext_dev_basic: {
sourceId: 190,
promptInnerId: 6,
promptWidget: 'text',
engineId: 31,
sinkId: 136,
mediaKind: 'image'
}
}
const curatedIds = Object.keys(CURATED) as CuratedTemplateId[]
describe('resolveRoles — curated templates, heuristics only (no override)', () => {
it.for(curatedIds)('resolves %s from its real JSON', (id) => {
const expected = CURATED[id]
const roles = resolveRoles(loadTemplateWorkflow(id))
if (expected.sourceId === null) {
expect(roles.source).toBeNull()
} else {
expect(roles.source?.nodeId).toBe(toNodeId(expected.sourceId))
}
expect(roles.prompt?.innerNodeId).toBe(toNodeId(expected.promptInnerId))
expect(roles.prompt?.widgetName).toBe(expected.promptWidget)
expect(roles.engine?.nodeId).toBe(toNodeId(expected.engineId))
expect(roles.sink?.nodeId).toBe(toNodeId(expected.sinkId))
expect(roles.mediaKind).toBe(expected.mediaKind)
})
it('rejects the internal-fed decoy CLIPTextEncode (krea 6, ltx 303)', () => {
expect(
resolveRoles(loadTemplateWorkflow('image_krea2_turbo_t2i')).prompt
?.innerNodeId
).not.toBe(toNodeId(6))
expect(
resolveRoles(loadTemplateWorkflow('video_ltx2_3_i2v')).prompt?.innerNodeId
).not.toBe(toNodeId(303))
})
it('picks the boundary-fed user prompt over a sibling system prompt (krea)', () => {
const roles = resolveRoles(loadTemplateWorkflow('image_krea2_turbo_t2i'))
expect(roles.prompt?.innerNodeId).toBe(toNodeId(19))
})
it('picks the positive CLIPTextEncode over the negative one (wan)', () => {
const roles = resolveRoles(loadTemplateWorkflow('video_wan2_2_14B_i2v'))
expect(roles.prompt?.innerNodeId).toBe(toNodeId(93))
})
it('picks one source when several LoadImage exist (flux)', () => {
const roles = resolveRoles(loadTemplateWorkflow('flux_kontext_dev_basic'))
expect(roles.source?.nodeId).toBe(toNodeId(190))
})
it('exposes the subgraph node id and prompt port for fallback', () => {
const zImage = resolveRoles(loadTemplateWorkflow('image_z_image_turbo'))
expect(zImage.prompt?.subgraphNodeId).toBe(toNodeId(57))
expect(zImage.prompt?.portFallback).toBe('text')
const ltx = resolveRoles(loadTemplateWorkflow('video_ltx2_3_i2v'))
expect(ltx.prompt?.portFallback).toBe('value')
})
})
describe('templateOverrides pin the heuristic result', () => {
it('matches the heuristic result for every curated id', () => {
for (const id of curatedIds) {
const roles = resolveRoles(loadTemplateWorkflow(id))
const pin = templateOverrides[id]
expect(pin.promptNodeId).toBe(roles.prompt?.innerNodeId)
expect(pin.engineNodeId).toBe(roles.engine?.nodeId)
expect(pin.sinkNodeId).toBe(roles.sink?.nodeId)
expect(pin.mediaKind).toBe(roles.mediaKind)
if (roles.source) expect(pin.sourceNodeId).toBe(roles.source.nodeId)
}
})
it('override sink, engine, and media win over the heuristics on the graph', () => {
const pin = templateOverrides.image_z_image_turbo
const roles = resolveRoles(
workflow([
node(900, 'SaveVideo', {
inputs: [{ name: 'video', type: 'VIDEO', link: 1 }]
})
]),
'image_z_image_turbo'
)
expect(roles.sink?.nodeId).toBe(pin.sinkNodeId)
expect(roles.engine?.nodeId).toBe(pin.engineNodeId)
expect(roles.mediaKind).toBe(pin.mediaKind)
expect(roles.sink?.nodeId).not.toBe(toNodeId(900))
expect(roles.mediaKind).not.toBe('video')
})
it('degrades the prompt to null when the pinned inner node is absent', () => {
const roles = resolveRoles(
workflow([
node(900, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 1 }]
})
]),
'image_z_image_turbo'
)
expect(roles.prompt).toBeNull()
})
it('spotlights the subgraph host, not the pinned inner node, for a nested prompt', () => {
const roles = resolveRoles(
loadTemplateWorkflow('flux_kontext_dev_basic'),
'flux_kontext_dev_basic'
)
expect(roles.prompt?.innerNodeId).toBe(toNodeId(6))
expect(roles.prompt?.subgraphNodeId).toBe(toNodeId(192))
})
it('ignores an unknown template id and falls back to heuristics', () => {
const withUnknown = resolveRoles(
loadTemplateWorkflow('image_z_image_turbo'),
'not_a_real_template'
)
const heuristic = resolveRoles(loadTemplateWorkflow('image_z_image_turbo'))
expect(withUnknown).toEqual(heuristic)
})
})
function workflow(nodes: unknown[], links: unknown[] = []): ComfyWorkflowJSON {
return {
version: 0.4,
nodes,
links,
extra: {}
} as unknown as ComfyWorkflowJSON
}
function node(
id: number,
type: string,
extra: Record<string, unknown> = {}
): Record<string, unknown> {
return {
id,
type,
pos: [0, 0],
size: [1, 1],
flags: {},
order: id,
mode: 0,
properties: {},
...extra
}
}
function cte(id: number, conditioningLink: number, text: string) {
return node(id, 'CLIPTextEncode', {
inputs: [{ name: 'text', type: 'STRING' }],
outputs: [
{ name: 'CONDITIONING', type: 'CONDITIONING', links: [conditioningLink] }
],
widgets_values: [text]
})
}
describe('resolveRoles — arbitrary (non-curated) graphs', () => {
it('resolves a top-level T2I, choosing the positive prompt by conditioning', () => {
const roles = resolveRoles(
workflow(
[
cte(6, 4, 'a red fox'),
cte(7, 6, 'blurry'),
node(3, 'KSampler', {
inputs: [
{ name: 'positive', type: 'CONDITIONING', link: 4 },
{ name: 'negative', type: 'CONDITIONING', link: 6 }
]
}),
node(9, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 8 }]
})
],
[
[4, 6, 0, 3, 1, 'CONDITIONING'],
[6, 7, 0, 3, 2, 'CONDITIONING'],
[8, 3, 0, 9, 0, 'IMAGE']
]
)
)
expect(roles.source).toBeNull()
expect(roles.prompt?.innerNodeId).toBe(toNodeId(6))
expect(roles.prompt?.widgetName).toBe('text')
expect(roles.engine?.nodeId).toBe(toNodeId(3))
expect(roles.sink?.nodeId).toBe(toNodeId(9))
expect(roles.mediaKind).toBe('image')
})
it('resolves a top-level I2V with a video sink', () => {
const roles = resolveRoles(
workflow(
[
node(1, 'LoadImage', {
outputs: [{ name: 'IMAGE', type: 'IMAGE', links: [10] }]
}),
cte(2, 11, 'dancing'),
node(3, 'KSamplerAdvanced', {
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 11 }]
}),
node(4, 'SaveVideo', {
inputs: [{ name: 'video', type: 'VIDEO', link: 12 }]
})
],
[
[10, 1, 0, 3, 3, 'IMAGE'],
[11, 2, 0, 3, 1, 'CONDITIONING'],
[12, 3, 0, 4, 0, 'VIDEO']
]
)
)
expect(roles.source?.nodeId).toBe(toNodeId(1))
expect(roles.prompt?.innerNodeId).toBe(toNodeId(2))
expect(roles.engine?.nodeId).toBe(toNodeId(3))
expect(roles.sink?.nodeId).toBe(toNodeId(4))
expect(roles.mediaKind).toBe('video')
})
it('prefers a Save sink over a Preview sink', () => {
const roles = resolveRoles(
workflow(
[
cte(6, 4, 'x'),
node(3, 'KSampler', {
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 4 }]
}),
node(9, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 8 }]
}),
node(19, 'PreviewImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 20 }]
})
],
[
[4, 6, 0, 3, 1, 'CONDITIONING'],
[8, 3, 0, 9, 0, 'IMAGE'],
[20, 3, 0, 19, 0, 'IMAGE']
]
)
)
expect(roles.sink?.nodeId).toBe(toNodeId(9))
})
it('resolves top-level prompt and engine past an unrelated subgraph', () => {
const subgraphId = '22222222-2222-2222-2222-222222222222'
const graph = {
...workflow(
[
node(1, subgraphId, { inputs: [] }),
cte(6, 4, 'a red fox'),
node(3, 'KSampler', {
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 4 }]
}),
node(9, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 8 }]
})
],
[
[4, 6, 0, 3, 1, 'CONDITIONING'],
[8, 3, 0, 9, 0, 'IMAGE']
]
),
definitions: {
subgraphs: [
{
id: subgraphId,
inputs: [],
nodes: [node(10, 'VAEDecode', { inputs: [] })],
links: []
}
]
}
} as unknown as ComfyWorkflowJSON
const roles = resolveRoles(graph)
expect(roles.prompt?.innerNodeId).toBe(toNodeId(6))
expect(roles.engine?.nodeId).toBe(toNodeId(3))
expect(roles.sink?.nodeId).toBe(toNodeId(9))
})
})
describe('fromWorkflowJson normalizes the serialized shape', () => {
it('reads tuple links at the top level', () => {
const graph = fromWorkflowJson(
workflow(
[
node(1, 'KSampler', {
outputs: [{ name: 'LATENT', type: 'LATENT', links: [5] }]
}),
node(2, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 5 }]
})
],
[[5, 1, 0, 2, 0, 'IMAGE']]
)
)
expect(graph.nodes[0].hasOutgoingLinks).toBe(true)
expect(graph.nodes[1].inputs[0].origin).toEqual({
kind: 'node',
nodeId: toNodeId(1),
slot: 0
})
})
it('reads object links and the boundary origin inside subgraphs', () => {
const graph = fromWorkflowJson(loadTemplateWorkflow('image_z_image_turbo'))
const subgraph = graph.subgraphs[0]
const clip = subgraph.nodes.find((n) => n.id === toNodeId(27))
const textInput = clip?.inputs.find((i) => i.name === 'text')
expect(textInput?.origin).toEqual({ kind: 'boundary', slot: 0 })
})
it('flags the hosting subgraph node', () => {
const graph = fromWorkflowJson(loadTemplateWorkflow('image_z_image_turbo'))
const host = graph.nodes.find((n) => n.subgraphId !== null)
expect(host?.subgraphId).toBe(graph.subgraphs[0].id)
})
})
describe('resolveRoles — graceful degradation for any input', () => {
it('returns all-null roles for a graph with no sink or prompt', () => {
const roles = resolveRoles(
workflow([node(1, 'MarkdownNote'), node(2, 'Reroute')])
)
expect(roles.source).toBeNull()
expect(roles.prompt).toBeNull()
expect(roles.engine).toBeNull()
expect(roles.sink).toBeNull()
expect(roles.mediaKind).toBe('image')
})
it('does not throw on an empty graph', () => {
expect(() => resolveRoles(workflow([]))).not.toThrow()
})
it('resolves the sink even when the prompt is missing', () => {
const roles = resolveRoles(
workflow([
node(9, 'SaveVideo', {
inputs: [{ name: 'video', type: 'VIDEO', link: 1 }]
})
])
)
expect(roles.sink?.nodeId).toBe(toNodeId(9))
expect(roles.mediaKind).toBe('video')
expect(roles.prompt).toBeNull()
})
it('leaves the prompt null when a subgraph port leads to no editable node', () => {
const subgraphId = '11111111-1111-1111-1111-111111111111'
const graph = {
...workflow([
node(1, subgraphId, { inputs: [] }),
node(2, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 5 }]
})
]),
definitions: {
subgraphs: [
{
id: subgraphId,
inputs: [{ name: 'text', type: 'STRING', label: 'prompt' }],
nodes: [node(10, 'VAEDecode', { inputs: [] })],
links: []
}
]
}
} as unknown as ComfyWorkflowJSON
const roles = resolveRoles(graph)
expect(roles.prompt).toBeNull()
expect(roles.sink?.nodeId).toBe(toNodeId(2))
})
it('skips malformed link entries without throwing', () => {
const graph = {
...workflow(
[
node(9, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 8 }]
})
],
[null, 'garbage', [8, 3, 0, 9, 0, 'IMAGE']]
)
} as unknown as ComfyWorkflowJSON
expect(() => resolveRoles(graph)).not.toThrow()
expect(resolveRoles(graph).sink?.nodeId).toBe(toNodeId(9))
})
it('prefers a terminal Save sink over a Save that feeds onward', () => {
const roles = resolveRoles(
workflow(
[
node(1, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 4 }],
outputs: [{ name: 'IMAGE', type: 'IMAGE', links: [5] }]
}),
node(2, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 6 }]
})
],
[
[5, 1, 0, 2, 0, 'IMAGE'],
[6, 1, 0, 2, 0, 'IMAGE']
]
)
)
expect(roles.sink?.nodeId).toBe(toNodeId(2))
})
})
describe('resolveRoles — hostile node types do not match prototype members', () => {
it('does not treat a node typed "constructor" as a sink', () => {
const roles = resolveRoles(
workflow([node(1, 'constructor', { inputs: [] })])
)
expect(roles.sink).toBeNull()
expect(roles.mediaKind).toBe('image')
})
it('ignores a template id that names a prototype member', () => {
const heuristic = resolveRoles(loadTemplateWorkflow('image_z_image_turbo'))
for (const id of ['constructor', 'toString', 'hasOwnProperty']) {
expect(
resolveRoles(loadTemplateWorkflow('image_z_image_turbo'), id)
).toEqual(heuristic)
}
})
})
describe('resolveRoles — registry-backed sink fallback', () => {
// A custom save node outside the hardcoded SINK_MEDIA list, only recognizable
// as a sink through the injected registry lookup.
const customSinkGraph = workflow([
node(9, 'MyCustomVideoSave', {
inputs: [{ name: 'video', type: 'VIDEO', link: 1 }]
})
])
const lookup = (type: string) =>
type === 'MyCustomVideoSave'
? { isOutputNode: true, producesVideo: true }
: null
it('resolves a custom output node as the sink when the type list misses', () => {
const roles = resolveRoles(customSinkGraph, undefined, lookup)
expect(roles.sink?.nodeId).toBe(toNodeId(9))
expect(roles.mediaKind).toBe('video')
})
it('leaves the sink null for the same graph without a registry lookup', () => {
const roles = resolveRoles(customSinkGraph)
expect(roles.sink).toBeNull()
expect(roles.mediaKind).toBe('image')
})
it('does not use the fallback when a known sink type already matches', () => {
// A registry that would (wrongly) label the KSampler an output node must not
// steal the sink from the real SaveImage.
const greedyLookup = () => ({ isOutputNode: true, producesVideo: false })
const roles = resolveRoles(
workflow(
[
node(3, 'KSampler', {
outputs: [{ name: 'LATENT', type: 'LATENT', links: [8] }]
}),
node(9, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 8 }]
})
],
[[8, 3, 0, 9, 0, 'IMAGE']]
),
undefined,
greedyLookup
)
expect(roles.sink?.nodeId).toBe(toNodeId(9))
})
it('ignores a registry output node that still feeds downstream', () => {
// Only terminal output nodes are sinks; one with outgoing links is not.
const roles = resolveRoles(
workflow(
[
node(9, 'MyCustomVideoSave', {
inputs: [{ name: 'video', type: 'VIDEO', link: 1 }],
outputs: [{ name: 'VIDEO', type: 'VIDEO', links: [2] }]
}),
node(10, 'MyCustomVideoSave', {
inputs: [{ name: 'video', type: 'VIDEO', link: 2 }]
})
],
[[2, 9, 0, 10, 0, 'VIDEO']]
),
undefined,
lookup
)
expect(roles.sink?.nodeId).toBe(toNodeId(10))
})
it('does not treat a non-output registry node as a sink', () => {
const roles = resolveRoles(
workflow([node(9, 'MyCustomImageSave', { inputs: [] })]),
undefined,
(type: string) =>
type === 'MyCustomImageSave'
? { isOutputNode: false, producesVideo: false }
: null
)
// isOutputNode:false → not a sink at all, so it stays null.
expect(roles.sink).toBeNull()
})
it('infers image media for an accepted output node with no video output', () => {
const roles = resolveRoles(
workflow([node(9, 'MyCustomImageSave', { inputs: [] })]),
undefined,
(type: string) =>
type === 'MyCustomImageSave'
? { isOutputNode: true, producesVideo: false }
: null
)
expect(roles.sink?.nodeId).toBe(toNodeId(9))
expect(roles.mediaKind).toBe('image')
})
})

View File

@@ -1,544 +0,0 @@
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/types/nodeId'
import { toNodeId } from '@/types/nodeId'
import type {
MediaKind,
NodeRole,
PromptRole,
ResolvedRoles
} from './tourSequence'
/**
* A minimal, faithful view of the parts of a workflow the resolver reads,
* projected from a serialized `ComfyWorkflowJSON` (see {@link fromWorkflowJson}).
* Keeping the resolver on this view makes it pure and testable against real
* template JSON without instantiating a live graph.
*/
export interface WorkflowGraph {
nodes: WorkflowGraphNode[]
subgraphs: WorkflowSubgraph[]
}
interface WorkflowGraphNode {
id: NodeId
type: string
inputs: WorkflowInput[]
/** True when this node hosts a subgraph, keyed to a {@link WorkflowSubgraph}. */
subgraphId: string | null
hasOutgoingLinks: boolean
}
interface WorkflowInput {
name: string
/** Origin of the link feeding this input, or null when driven by its widget. */
origin: WorkflowLinkOrigin | null
}
interface WorkflowSubgraph {
id: string
nodes: WorkflowGraphNode[]
exposedInputs: ExposedInput[]
}
interface ExposedInput {
name: string
type: string
label: string | null
slot: number
}
/** Where a link originates: an inner node, or the subgraph's input boundary. */
type WorkflowLinkOrigin =
| { kind: 'node'; nodeId: NodeId; slot: number }
| { kind: 'boundary'; slot: number }
/**
* Sink node types that terminate a generation, and the media each yields. A Map
* (not an object) so an attacker-crafted `node.type` like `"constructor"` can't
* match a prototype member and smuggle a non-`MediaKind` value through.
*/
const SINK_MEDIA = new Map<string, MediaKind>([
['SaveImage', 'image'],
['PreviewImage', 'image'],
['SaveAnimatedWEBP', 'image'],
['SaveVideo', 'video'],
['VHS_VideoCombine', 'video'],
['SaveWEBM', 'video']
])
const IMAGE_SOURCE_TYPES = new Set([
'LoadImage',
'LoadImageMask',
'LoadImageOutput'
])
const SAMPLER_TYPES = new Set([
'KSampler',
'KSamplerAdvanced',
'SamplerCustom',
'SamplerCustomAdvanced'
])
/**
* A read of the node registry, injected so the resolver stays pure and testable
* without a live `nodeDefStore`. Returns null for unknown types.
*/
export type NodeDefLookup = (
type: string
) => { isOutputNode: boolean; producesVideo: boolean } | null
/** Exposed-port names/labels that mark the editable prompt boundary. */
const PROMPT_PORT_NAMES = new Set(['prompt', 'text', 'value'])
const CLIP_TEXT_ENCODE = 'CLIPTextEncode'
const PROMPT_PRIMITIVE = 'PrimitiveStringMultiline'
/** Pinned node ids for a curated template — a confidence layer over heuristics. */
interface TemplateOverride {
sourceNodeId?: NodeId
promptNodeId: NodeId
engineNodeId: NodeId
sinkNodeId: NodeId
mediaKind: MediaKind
}
export type CuratedTemplateId =
| 'image_krea2_turbo_t2i'
| 'image_z_image_turbo'
| 'video_ltx2_3_i2v'
| 'video_wan2_2_14B_i2v'
| 'flux_kontext_dev_basic'
export const templateOverrides: Record<CuratedTemplateId, TemplateOverride> = {
image_krea2_turbo_t2i: {
promptNodeId: toNodeId(19),
engineNodeId: toNodeId(3),
sinkNodeId: toNodeId(29),
mediaKind: 'image'
},
image_z_image_turbo: {
promptNodeId: toNodeId(27),
engineNodeId: toNodeId(3),
sinkNodeId: toNodeId(9),
mediaKind: 'image'
},
video_ltx2_3_i2v: {
sourceNodeId: toNodeId(269),
promptNodeId: toNodeId(319),
engineNodeId: toNodeId(283),
sinkNodeId: toNodeId(75),
mediaKind: 'video'
},
video_wan2_2_14B_i2v: {
sourceNodeId: toNodeId(97),
promptNodeId: toNodeId(93),
engineNodeId: toNodeId(86),
sinkNodeId: toNodeId(108),
mediaKind: 'video'
},
flux_kontext_dev_basic: {
sourceNodeId: toNodeId(190),
promptNodeId: toNodeId(6),
engineNodeId: toNodeId(31),
sinkNodeId: toNodeId(136),
mediaKind: 'image'
}
}
interface SinkMatch {
node: WorkflowGraphNode
mediaKind: MediaKind
}
function findSink(
graph: WorkflowGraph,
nodeDefLookup: NodeDefLookup
): SinkMatch | null {
const candidates = graph.nodes.flatMap((node) => {
const mediaKind = SINK_MEDIA.get(node.type)
return mediaKind ? [{ node, mediaKind }] : []
})
if (candidates.length === 0) return findRegistrySink(graph, nodeDefLookup)
const isSave = (match: SinkMatch) => match.node.type.startsWith('Save')
const [best] = candidates.sort((a, b) => {
if (isSave(a) !== isSave(b)) return isSave(a) ? -1 : 1
if (a.node.hasOutgoingLinks !== b.node.hasOutgoingLinks) {
return a.node.hasOutgoingLinks ? 1 : -1
}
return 0
})
return best
}
/**
* Fallback when no known save/preview type matches: a terminal node the registry
* marks as an output node. Widens sink detection to custom save nodes on
* arbitrary share/`?template=` workflows, where the hardcoded type list misses.
*/
function findRegistrySink(
graph: WorkflowGraph,
nodeDefLookup: NodeDefLookup
): SinkMatch | null {
const node = graph.nodes.find((n) => {
if (n.hasOutgoingLinks) return false
return nodeDefLookup(n.type)?.isOutputNode === true
})
if (!node) return null
const producesVideo = nodeDefLookup(node.type)?.producesVideo === true
return { node, mediaKind: producesVideo ? 'video' : 'image' }
}
function findSource(graph: WorkflowGraph): WorkflowGraphNode | null {
return graph.nodes.find((n) => IMAGE_SOURCE_TYPES.has(n.type)) ?? null
}
function firstSampler(nodes: WorkflowGraphNode[]): WorkflowGraphNode | null {
return nodes.find((n) => SAMPLER_TYPES.has(n.type)) ?? null
}
function subgraphHost(
graph: WorkflowGraph
): { node: WorkflowGraphNode; subgraph: WorkflowSubgraph } | null {
for (const node of graph.nodes) {
if (!node.subgraphId) continue
const subgraph = graph.subgraphs.find((s) => s.id === node.subgraphId)
if (subgraph) return { node, subgraph }
}
return null
}
function promptPort(subgraph: WorkflowSubgraph): ExposedInput | null {
const strings = subgraph.exposedInputs.filter((p) => p.type === 'STRING')
const named = strings.find(
(p) => PROMPT_PORT_NAMES.has(p.label ?? '') || PROMPT_PORT_NAMES.has(p.name)
)
return named ?? strings[0] ?? null
}
/** Inner node whose named widget-input is fed from the given boundary port. */
function boundaryFedNode(
subgraph: WorkflowSubgraph,
portSlot: number,
widgetName: string
): WorkflowGraphNode | null {
return (
subgraph.nodes.find((node) =>
node.inputs.some(
(input) =>
input.name === widgetName &&
input.origin?.kind === 'boundary' &&
input.origin.slot === portSlot
)
) ?? null
)
}
function resolveSubgraphPrompt(
subgraphNodeId: NodeId,
subgraph: WorkflowSubgraph
): PromptRole | null {
const port = promptPort(subgraph)
if (!port) return null
const primitive = boundaryFedNode(subgraph, port.slot, 'value')
if (primitive?.type === PROMPT_PRIMITIVE) {
return {
subgraphNodeId,
innerNodeId: primitive.id,
widgetName: 'value',
portFallback: port.name
}
}
const clip = boundaryFedNode(subgraph, port.slot, 'text')
if (clip?.type === CLIP_TEXT_ENCODE) {
return {
subgraphNodeId,
innerNodeId: clip.id,
widgetName: 'text',
portFallback: port.name
}
}
return null
}
/**
* The CLIPTextEncode feeding the first sampler's positive input, else the first
* CLIPTextEncode anywhere (best-effort for graphs with no wired sampler).
*/
function positivePromptNode(graph: WorkflowGraph): WorkflowGraphNode | null {
const sampler = firstSampler(graph.nodes)
const positive = sampler?.inputs.find((i) => i.name === 'positive')
if (positive?.origin?.kind === 'node') {
const origin = positive.origin
const feeder = graph.nodes.find((n) => n.id === origin.nodeId)
if (feeder?.type === CLIP_TEXT_ENCODE) return feeder
}
return graph.nodes.find((n) => n.type === CLIP_TEXT_ENCODE) ?? null
}
function resolveTopLevelPrompt(graph: WorkflowGraph): PromptRole | null {
const node = positivePromptNode(graph)
if (!node) return null
return {
subgraphNodeId: node.id,
innerNodeId: node.id,
widgetName: 'text',
portFallback: null
}
}
function toNodeRole(nodeId: NodeId): NodeRole {
return { nodeId }
}
/**
* The pinned override for a curated template, if one exists. `Object.hasOwn`
* guards against a `?template=` id like `"constructor"` matching a prototype
* member and yielding a non-override value.
*/
function overrideFor(templateId: string | undefined): TemplateOverride | null {
if (!templateId || !Object.hasOwn(templateOverrides, templateId)) return null
return templateOverrides[templateId as CuratedTemplateId]
}
/**
* The root-graph node to spotlight for a prompt whose editable widget lives on
* `innerNodeId`: the node itself when it sits on the root graph, else the
* collapsed host of the subgraph that contains it. Null when the id belongs to
* no known node — the tour never enters a subgraph, so an inner id can't be a
* spotlight target.
*/
function promptHostId(
graph: WorkflowGraph,
innerNodeId: NodeId
): NodeId | null {
if (graph.nodes.some((n) => n.id === innerNodeId)) return innerNodeId
for (const node of graph.nodes) {
if (!node.subgraphId) continue
const subgraph = graph.subgraphs.find((s) => s.id === node.subgraphId)
if (subgraph?.nodes.some((n) => n.id === innerNodeId)) return node.id
}
return null
}
/**
* Build the prompt role from the override's pinned inner node. `subgraphNodeId`
* (the spotlight target) is resolved to a root-graph node so it never points at
* a raw inner node; widget/port detail comes from the heuristic only when it
* resolved the same inner node. Prompt degrades to null if the inner id is
* unreachable.
*/
function overridePrompt(
graph: WorkflowGraph,
override: TemplateOverride,
heuristicPrompt: PromptRole | null
): PromptRole | null {
const subgraphNodeId = promptHostId(graph, override.promptNodeId)
if (subgraphNodeId === null) return null
const sameInner = heuristicPrompt?.innerNodeId === override.promptNodeId
return {
subgraphNodeId,
innerNodeId: override.promptNodeId,
widgetName: sameInner ? heuristicPrompt.widgetName : 'text',
portFallback: sameInner ? heuristicPrompt.portFallback : null
}
}
function applyOverride(
graph: WorkflowGraph,
override: TemplateOverride,
heuristicPrompt: PromptRole | null
): ResolvedRoles {
return {
source: override.sourceNodeId ? toNodeRole(override.sourceNodeId) : null,
prompt: overridePrompt(graph, override, heuristicPrompt),
engine: toNodeRole(override.engineNodeId),
sink: toNodeRole(override.sinkNodeId),
mediaKind: override.mediaKind
}
}
/**
* Inspects a loaded workflow and returns the roles the tour targets. Works on
* any graph — curated templates, arbitrary share/`?template=` workflows, and
* custom user templates alike — via structural heuristics. Never throws; any
* role that cannot be resolved is left null so the sequence builder degrades
* gracefully. When `templateId` names a curated template, its pinned ids take
* precedence over the heuristics.
*/
export function resolveRoles(
workflow: ComfyWorkflowJSON,
templateId?: string,
nodeDefLookup: NodeDefLookup = () => null
): ResolvedRoles {
const graph = fromWorkflowJson(workflow)
const host = subgraphHost(graph)
// Fall back to the root graph so an unrelated subgraph can't null out its roles.
const subgraphPrompt = host
? resolveSubgraphPrompt(host.node.id, host.subgraph)
: null
const prompt = subgraphPrompt ?? resolveTopLevelPrompt(graph)
const override = overrideFor(templateId)
if (override) return applyOverride(graph, override, prompt)
const sink = findSink(graph, nodeDefLookup)
const source = findSource(graph)
const engine =
(host ? firstSampler(host.subgraph.nodes) : null) ??
firstSampler(graph.nodes)
return {
source: source ? toNodeRole(source.id) : null,
prompt,
engine: engine ? toNodeRole(engine.id) : null,
sink: sink ? toNodeRole(sink.node.id) : null,
mediaKind: sink?.mediaKind ?? 'image'
}
}
// --- Projection from serialized ComfyWorkflowJSON onto the resolver view. ---
interface RawNode {
id: number | string
type?: string
inputs?: RawInput[]
outputs?: { links?: number[] | null }[]
}
interface RawInput {
name?: string
link?: number | null
}
interface RawExposedInput {
name?: string
type?: string
label?: string | null
}
interface RawSubgraph {
id: string
nodes?: RawNode[]
links?: unknown
inputs?: RawExposedInput[]
}
/**
* The parts of a serialized workflow this resolver reads. `ComfyWorkflowJSON`'s
* own type does not statically expose `definitions.subgraphs[].nodes/links`
* (its recursive subgraph type omits them), so the projection asserts this
* narrower view once at {@link fromWorkflowJson} and re-parses links loosely.
*/
interface RawWorkflow {
nodes?: RawNode[]
links?: unknown
definitions?: { subgraphs?: RawSubgraph[] }
}
interface RawLink {
id: number
originId: NodeId
originSlot: number
targetId: NodeId
targetSlot: number
}
const SUBGRAPH_INPUT_ORIGIN = '-10'
function readLink(raw: unknown): RawLink | null {
if (Array.isArray(raw) && raw.length >= 6) {
return {
id: Number(raw[0]),
originId: toNodeId(String(raw[1])),
originSlot: Number(raw[2]),
targetId: toNodeId(String(raw[3])),
targetSlot: Number(raw[4])
}
}
if (raw && typeof raw === 'object' && 'origin_id' in raw) {
const linkObject = raw as Record<string, unknown>
return {
id: Number(linkObject.id),
originId: toNodeId(String(linkObject.origin_id)),
originSlot: Number(linkObject.origin_slot),
targetId: toNodeId(String(linkObject.target_id)),
targetSlot: Number(linkObject.target_slot)
}
}
return null
}
function readLinks(raw: unknown): RawLink[] {
if (!Array.isArray(raw)) return []
return raw.map(readLink).filter((l): l is RawLink => l !== null)
}
function originFor(
input: RawInput,
links: RawLink[]
): WorkflowLinkOrigin | null {
if (typeof input.link !== 'number') return null
const link = links.find((l) => l.id === input.link)
if (!link) return null
if (String(link.originId) === SUBGRAPH_INPUT_ORIGIN) {
return { kind: 'boundary', slot: link.originSlot }
}
return { kind: 'node', nodeId: link.originId, slot: link.originSlot }
}
function readNode(
raw: RawNode,
links: RawLink[],
subgraphIds: Set<string>
): WorkflowGraphNode {
const id = toNodeId(raw.id)
const type = raw.type ?? ''
return {
id,
type,
subgraphId: subgraphIds.has(type) ? type : null,
hasOutgoingLinks: (raw.outputs ?? []).some(
(o) => (o.links?.length ?? 0) > 0
),
inputs: (raw.inputs ?? []).map((input) => ({
name: input.name ?? '',
origin: originFor(input, links)
}))
}
}
/** Narrows the loosely-typed serialized workflow into the resolver's view. */
export function fromWorkflowJson(workflow: ComfyWorkflowJSON): WorkflowGraph {
const raw: RawWorkflow = workflow
const definitions = raw.definitions?.subgraphs ?? []
const subgraphIds = new Set(definitions.map((d) => d.id))
const topLinks = readLinks(raw.links)
const subgraphs: WorkflowSubgraph[] = definitions.map((def) => {
const innerLinks = readLinks(def.links)
return {
id: def.id,
nodes: (def.nodes ?? []).map((n) => readNode(n, innerLinks, subgraphIds)),
exposedInputs: (def.inputs ?? []).map((input, slot) => ({
name: input.name ?? '',
type: input.type ?? '',
label: input.label ?? null,
slot
}))
}
})
return {
nodes: (raw.nodes ?? []).map((n) => readNode(n, topLinks, subgraphIds)),
subgraphs
}
}

View File

@@ -1,41 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const canvasObj = {
setGraph: vi.fn()
}
const mocks = vi.hoisted(() => ({
canvas: null as { setGraph: ReturnType<typeof vi.fn> } | null,
rootGraph: { id: 'root' }
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: mocks.canvas })
}))
vi.mock('@/scripts/app', () => ({
app: {
get rootGraph() {
return mocks.rootGraph
}
}
}))
import { restoreView } from './subgraphNavigation'
describe('subgraphNavigation', () => {
beforeEach(() => {
canvasObj.setGraph.mockReset()
mocks.canvas = canvasObj
})
it('returns the canvas to the root graph', () => {
restoreView()
expect(canvasObj.setGraph).toHaveBeenCalledWith(mocks.rootGraph)
})
it('does not throw without a canvas', () => {
mocks.canvas = null
expect(() => restoreView()).not.toThrow()
})
})

View File

@@ -1,11 +0,0 @@
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
/**
* Return the canvas to the root graph. Called defensively before each step in
* case the user opened a subgraph manually — view-only, no graph mutation
* (ADR-0008). The tour itself never enters a subgraph.
*/
export function restoreView() {
useCanvasStore().canvas?.setGraph(app.rootGraph)
}

View File

@@ -1,132 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveSteps } from '@/platform/onboarding/onboardingTours'
import { toNodeId } from '@/types/nodeId'
import type { ResolvedRoles } from './tourSequence'
import { sequenceBuilder, shapeOf, toCoachSteps } from './tourSequence'
function nodeRole(id: number) {
return { nodeId: toNodeId(id) }
}
function promptRole(subgraphId: number, innerId: number) {
return {
subgraphNodeId: toNodeId(subgraphId),
innerNodeId: toNodeId(innerId),
widgetName: 'text',
portFallback: 'prompt'
}
}
const i2vRoles: ResolvedRoles = {
source: nodeRole(97),
prompt: promptRole(129, 93),
engine: nodeRole(86),
sink: nodeRole(108),
mediaKind: 'video'
}
const t2iRoles: ResolvedRoles = {
source: null,
prompt: promptRole(57, 27),
engine: nodeRole(3),
sink: nodeRole(9),
mediaKind: 'image'
}
describe('sequenceBuilder', () => {
it('builds Upload → Prompt → Run → Result for an I2V graph', () => {
const steps = sequenceBuilder(i2vRoles)
expect(steps.map((s) => s.kind)).toEqual([
'upload',
'prompt',
'run',
'result'
])
})
it('omits the Upload step when there is no source (T2I)', () => {
const steps = sequenceBuilder(t2iRoles)
expect(steps.map((s) => s.kind)).toEqual(['prompt', 'run', 'result'])
})
it('targets the source node on the Upload step', () => {
const upload = sequenceBuilder(i2vRoles).find((s) => s.kind === 'upload')
expect(upload?.nodeId).toBe(toNodeId(97))
})
it('carries the prompt path on the Prompt step', () => {
const prompt = sequenceBuilder(t2iRoles).find((s) => s.kind === 'prompt')
expect(prompt?.prompt).toEqual(promptRole(57, 27))
})
it('carries the sink and media kind on the Result step', () => {
const result = sequenceBuilder(i2vRoles).find((s) => s.kind === 'result')
expect(result?.nodeId).toBe(toNodeId(108))
expect(result?.mediaKind).toBe('video')
})
it('omits the Prompt step when the prompt role is unresolved', () => {
const steps = sequenceBuilder({ ...t2iRoles, prompt: null })
expect(steps.map((s) => s.kind)).toEqual(['run', 'result'])
})
it('omits the Result step when the sink role is unresolved', () => {
const steps = sequenceBuilder({ ...t2iRoles, sink: null })
expect(steps.map((s) => s.kind)).toEqual(['prompt', 'run'])
})
it('always includes the Run step, which targets no node', () => {
const run = sequenceBuilder(t2iRoles).find((s) => s.kind === 'run')
expect(run).toBeDefined()
expect(run?.nodeId).toBeNull()
})
it('tags the Prompt and Upload steps with the shape that picks their copy', () => {
const steps = sequenceBuilder(i2vRoles)
expect(steps.find((s) => s.kind === 'upload')?.shape).toBe('i2v')
expect(steps.find((s) => s.kind === 'prompt')?.shape).toBe('i2v')
})
})
describe('shapeOf', () => {
it('is t2i when nothing feeds the prompt', () => {
expect(shapeOf(t2iRoles)).toBe('t2i')
})
it('is i2v when a source image drives video', () => {
expect(shapeOf(i2vRoles)).toBe('i2v')
})
it('is image-edit when a source image drives an image', () => {
// The prompt edits the picture rather than describing a new one, so this
// must not collapse into t2i: the copy would tell the wrong story.
expect(shapeOf({ ...i2vRoles, mediaKind: 'image' })).toBe('image-edit')
})
it('is other when the tour cannot tell what the workflow does', () => {
expect(shapeOf({ ...t2iRoles, sink: null })).toBe('other')
expect(shapeOf({ ...t2iRoles, prompt: null })).toBe('other')
})
})
describe('toCoachSteps', () => {
it('yields one targetless coach step per sequence step so indices stay aligned', () => {
const steps = sequenceBuilder(i2vRoles)
const coach = toCoachSteps(steps)
expect(coach).toHaveLength(steps.length)
expect(coach.every((s) => !s.coachId && !s.landing)).toBe(true)
expect(resolveSteps(coach, () => false)).toHaveLength(steps.length)
})
})

View File

@@ -1,104 +0,0 @@
import type { CoachStep } from '@/platform/onboarding/onboardingTours'
import type {
OnboardingTourShape,
OnboardingTourStepKey
} from '@/platform/telemetry/types'
import type { NodeId } from '@/types/nodeId'
/**
* The kind of media a resolved sink produces, used to pick the Result step's
* renderer (image vs video player) and to shape telemetry.
*/
export type MediaKind = 'image' | 'video'
/**
* How to reach the editable prompt widget. The widget is (in every known
* template) nested inside a subgraph node, so the resolver returns the path to
* it plus the subgraph's exposed `prompt` input port as a fallback spotlight
* target when programmatic focus of the inner widget fails.
*/
export interface PromptRole {
subgraphNodeId: NodeId
innerNodeId: NodeId
widgetName: string
/** Name of the exposed input port on the collapsed subgraph node. */
portFallback: string | null
}
/** A resolved graph node targeted by a tour step. */
export interface NodeRole {
nodeId: NodeId
}
/**
* The roles the resolver extracts from a loaded graph. Any role may be null
* when it cannot be resolved; the sequence builder omits the corresponding
* step rather than failing (graceful degradation).
*/
export interface ResolvedRoles {
/** Top-level input image node (absent for text-to-image → Upload step skipped). */
source: NodeRole | null
prompt: PromptRole | null
engine: NodeRole | null
sink: NodeRole | null
mediaKind: MediaKind
}
/** Step kinds are the telemetry step keys, so the two can never drift. */
type TourStepKind = OnboardingTourStepKey
/**
* What the workflow does, derived from the roles that resolved. Drives both the
* step copy (the prompt means something different in each) and telemetry tags.
*/
export function shapeOf(roles: ResolvedRoles): OnboardingTourShape {
if (!roles.prompt || !roles.sink) return 'other'
if (!roles.source) return 't2i'
return roles.mediaKind === 'video' ? 'i2v' : 'image-edit'
}
export interface TourStep {
kind: TourStepKind
/** Spotlight target; null for Run, which points at the toolbar button. */
nodeId: NodeId | null
/** The subgraph-aware path to the editable prompt widget. */
prompt?: PromptRole
/** Picks the Result renderer. */
mediaKind?: MediaKind
/** Picks the Upload/Prompt copy. */
shape?: OnboardingTourShape
}
/**
* Turns resolved roles into the ordered step list by shape: an Upload step only
* when a source image resolved, then always Prompt → Run → Result. A role that
* failed to resolve omits its step instead of crashing.
*/
export function sequenceBuilder(roles: ResolvedRoles): TourStep[] {
const steps: TourStep[] = []
const shape = shapeOf(roles)
if (roles.source) {
steps.push({ kind: 'upload', nodeId: roles.source.nodeId, shape })
}
if (roles.prompt) {
steps.push({ kind: 'prompt', nodeId: null, prompt: roles.prompt, shape })
}
steps.push({ kind: 'run', nodeId: null })
if (roles.sink) {
steps.push({
kind: 'result',
nodeId: roles.sink.nodeId,
mediaKind: roles.mediaKind
})
}
return steps
}
/** Targetless coach steps, so `resolveSteps` keeps all and indices stay 1:1 with `steps`. */
export function toCoachSteps(steps: TourStep[]): CoachStep[] {
return steps.map(
(step): CoachStep => ({ name: step.kind, placement: 'center' })
)
}

View File

@@ -1,56 +0,0 @@
import type { CuratedTemplateId } from './roleResolver'
/**
* The curated templates the Getting Started screen loads. Tutorial thumbnails are
* drawn from this set so they only ever reference a template that resolves.
*/
export const CURATED_TEMPLATE_IDS = [
'image_krea2_turbo_t2i',
'image_z_image_turbo',
'video_ltx2_3_i2v',
'video_wan2_2_14B_i2v'
] as const satisfies readonly CuratedTemplateId[]
type LoadedTemplateId = (typeof CURATED_TEMPLATE_IDS)[number]
/**
* Tutorial cards on the Getting Started "Tutorials" tab. Each reuses a loaded
* template's server-served thumbnail for its image and opens `url` in a new tab.
* Thumbnails are offset from the Templates tab's order so the two grids don't
* read as the same four images in the same places.
*/
export interface TutorialCard {
id: string
titleKey: string
url: string
thumbnailTemplate: LoadedTemplateId
}
export const TUTORIAL_BADGE_ICON = 'icon-[lucide--graduation-cap]'
export const tutorialCards: readonly TutorialCard[] = [
{
id: 'interface-overview',
titleKey: 'onboardingTour.gettingStarted.tutorials.interfaceOverview',
url: 'https://docs.comfy.org/interface/overview',
thumbnailTemplate: 'image_krea2_turbo_t2i'
},
{
id: 'text-to-image',
titleKey: 'onboardingTour.gettingStarted.tutorials.textToImage',
url: 'https://docs.comfy.org/tutorials/basic/text-to-image',
thumbnailTemplate: 'video_ltx2_3_i2v'
},
{
id: 'image-to-image',
titleKey: 'onboardingTour.gettingStarted.tutorials.imageToImage',
url: 'https://docs.comfy.org/tutorials/basic/image-to-image',
thumbnailTemplate: 'video_wan2_2_14B_i2v'
},
{
id: 'inpaint',
titleKey: 'onboardingTour.gettingStarted.tutorials.inpaint',
url: 'https://docs.comfy.org/tutorials/basic/inpaint',
thumbnailTemplate: 'image_z_image_turbo'
}
]

View File

@@ -1,929 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { effectScope, nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OnboardingTourStage } from '@/platform/telemetry/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { toNodeId } from '@/types/nodeId'
import type * as CanvasSpotlightAdapter from './canvasSpotlightAdapter'
import type { PromptRole, ResolvedRoles, TourStep } from './tourSequence'
const activeState = {} as ComfyWorkflowJSON
const promptRole: PromptRole = {
subgraphNodeId: toNodeId(10),
innerNodeId: toNodeId(27),
widgetName: 'text',
portFallback: 'prompt'
}
const imageEditRoles: ResolvedRoles = {
source: { nodeId: toNodeId(1) },
prompt: promptRole,
engine: { nodeId: toNodeId(3) },
sink: { nodeId: toNodeId(9) },
mediaKind: 'image'
}
const mocks = vi.hoisted(() => ({
isCloud: true,
isSubscriptionEnabled: vi.fn(() => true),
isNewUser: vi.fn<() => boolean | null>(() => true),
onboardingTourEnabled: true,
isDesktop: true,
tutorialCompleted: false as boolean,
resolveTourRoles: vi.fn(),
restoreView: vi.fn(),
nodesPresent: vi.fn(() => true),
canvasTransformValid: vi.fn(() => true),
activeWorkflowState: {} as ComfyWorkflowJSON | null,
storePrepare: vi.fn(),
storeEnd: vi.fn(),
engineStartTour: vi.fn(),
engineNext: vi.fn(),
engineBack: vi.fn(),
engineSkip: vi.fn(),
steps: [] as TourStep[],
stepIndex: { value: 0 },
isActive: { value: false },
engineActiveTour: { value: null as string | null },
resolvedRoles: { value: null as ResolvedRoles | null },
telemetry: {
trackOnboardingTour: vi.fn()
},
hasFunds: true as boolean,
showSubscriptionDialog: vi.fn(),
storeShowNudge: vi.fn(),
storeCaptureResultMedia: vi.fn()
}))
const upgradeModalOpen = await vi.hoisted(async () => {
const { ref } = await import('vue')
return ref(false)
})
/** Captures the api-bus handlers the controller registers so tests can dispatch a specific run outcome. */
type ApiEventHandler = (event: CustomEvent) => void
const apiEventHandlers = vi.hoisted(() => new Map<string, ApiEventHandler>())
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn((event: string, handler: ApiEventHandler) => {
apiEventHandlers.set(event, handler)
}),
removeEventListener: vi.fn((event: string) => {
apiEventHandlers.delete(event)
})
}
}))
function emitExecution(event: string) {
apiEventHandlers.get(event)?.(new CustomEvent(event, { detail: {} }))
}
/** Simulate a run finishing successfully. */
async function runJob() {
emitExecution('execution_success')
await nextTick()
}
/** Simulate a run ending with a non-success outcome. */
async function failJob(event: 'execution_error' | 'execution_interrupted') {
emitExecution(event)
await nextTick()
}
/** Click the toolbar Run button (the Run step's advance trigger). */
function clickRunButton() {
const button = document.createElement('button')
button.setAttribute('data-testid', 'queue-button')
document.body.append(button)
button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
button.remove()
}
/** Click an element that is not the Run button (must not advance the step). */
function clickElsewhere() {
const el = document.createElement('div')
document.body.append(el)
el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
el.remove()
}
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mocks.isCloud
}
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isSubscriptionEnabled: mocks.isSubscriptionEnabled
})
}))
vi.mock('@/services/useNewUserService', () => ({
useNewUserService: () => ({ isNewUser: mocks.isNewUser })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get onboardingTourEnabled() {
return mocks.onboardingTourEnabled
}
}
})
}))
vi.mock('@/composables/useDesktopLayout', () => ({
useDesktopLayout: () => ({
get value() {
return mocks.isDesktop
}
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.TutorialCompleted' ? mocks.tutorialCompleted : undefined,
set: vi.fn()
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
subscription: {
get value() {
return { hasFunds: mocks.hasFunds }
}
},
showSubscriptionDialog: mocks.showSubscriptionDialog
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
isDialogOpen: () => upgradeModalOpen.value
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: {
get activeState() {
return mocks.activeWorkflowState
}
}
})
}))
vi.mock('./subgraphNavigation', () => ({
restoreView: mocks.restoreView
}))
vi.mock('./roleResolution', () => ({
resolveTourRoles: mocks.resolveTourRoles
}))
vi.mock('./canvasSpotlightAdapter', async (importOriginal) => ({
...(await importOriginal<typeof CanvasSpotlightAdapter>()),
nodesPresent: mocks.nodesPresent,
canvasTransformValid: mocks.canvasTransformValid
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => mocks.telemetry
}))
vi.mock('./firstRunTourStore', () => ({
isUpgradeModalOpen: () => upgradeModalOpen.value,
useFirstRunTourStore: () => ({
prepare: mocks.storePrepare,
end: mocks.storeEnd,
showNudge: mocks.storeShowNudge,
captureResultMedia: mocks.storeCaptureResultMedia,
runFinished: false,
get isActive() {
return mocks.isActive.value
},
set isActive(value: boolean) {
mocks.isActive.value = value
},
get stepIndex() {
return mocks.stepIndex.value
},
set stepIndex(value: number) {
mocks.stepIndex.value = value
},
get steps() {
return mocks.steps
},
get currentStep() {
return mocks.steps[mocks.stepIndex.value] ?? null
},
get totalSteps() {
return mocks.steps.length
},
get resolvedRoles() {
return mocks.resolvedRoles.value
}
})
}))
// The coachmark engine sequences both tours; here it drives the shared step index.
vi.mock('@/platform/onboarding/onboardingTourStore', () => ({
useOnboardingTourStore: () => ({
startTour: (...args: unknown[]) => {
mocks.engineStartTour(...args)
mocks.engineActiveTour.value = 'firstRun'
mocks.stepIndex.value = 0
},
next: () => {
mocks.engineNext()
mocks.stepIndex.value += 1
},
back: () => {
mocks.engineBack()
if (mocks.stepIndex.value > 0) mocks.stepIndex.value -= 1
},
skip: () => {
mocks.engineSkip()
mocks.engineActiveTour.value = null
},
get activeTour() {
return mocks.engineActiveTour.value
},
get countedStepIdx() {
return mocks.stepIndex.value
}
})
}))
import { useFirstRunTourController } from './useFirstRunTourController'
/** The metadata reported for one stage of the shared onboarding-tour event. */
function tourReports(stage: OnboardingTourStage) {
return mocks.telemetry.trackOnboardingTour.mock.calls
.filter(([reported]) => reported === stage)
.map(([, metadata]) => metadata)
}
describe('useFirstRunTourController.shouldStartTour', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mocks.isCloud = true
mocks.isSubscriptionEnabled.mockReturnValue(true)
mocks.isNewUser.mockReturnValue(true)
mocks.onboardingTourEnabled = true
mocks.isDesktop = true
mocks.tutorialCompleted = false
mocks.activeWorkflowState = activeState
mocks.steps = []
mocks.stepIndex.value = 0
})
afterEach(() => {
vi.clearAllMocks()
})
it('starts when every condition holds', () => {
expect(useFirstRunTourController().shouldStartTour()).toBe(true)
})
it('does not start off the Cloud build', () => {
mocks.isCloud = false
expect(useFirstRunTourController().shouldStartTour()).toBe(false)
})
it('does not start when subscription mode is off', () => {
mocks.isSubscriptionEnabled.mockReturnValue(false)
expect(useFirstRunTourController().shouldStartTour()).toBe(false)
})
it('does not start for a returning user', () => {
mocks.isNewUser.mockReturnValue(false)
expect(useFirstRunTourController().shouldStartTour()).toBe(false)
})
it('does not start before the new-user verdict is known', () => {
mocks.isNewUser.mockReturnValue(null)
expect(useFirstRunTourController().shouldStartTour()).toBe(false)
})
it('does not start when the feature flag is off', () => {
mocks.onboardingTourEnabled = false
expect(useFirstRunTourController().shouldStartTour()).toBe(false)
})
it('does not start on a narrow (mobile) viewport', () => {
mocks.isDesktop = false
expect(useFirstRunTourController().shouldStartTour()).toBe(false)
})
it('still starts when the tutorial flag is already set but the user is new', () => {
mocks.tutorialCompleted = true
mocks.isNewUser.mockReturnValue(true)
expect(useFirstRunTourController().shouldStartTour()).toBe(true)
})
})
describe('useFirstRunTourController.start', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mocks.isCloud = true
mocks.isSubscriptionEnabled.mockReturnValue(true)
mocks.isNewUser.mockReturnValue(true)
mocks.onboardingTourEnabled = true
mocks.isDesktop = true
mocks.tutorialCompleted = false
mocks.activeWorkflowState = activeState
mocks.storePrepare.mockReset()
mocks.storeEnd.mockReset()
mocks.engineNext.mockReset()
mocks.steps = []
mocks.stepIndex.value = 0
mocks.isActive.value = true
mocks.resolvedRoles.value = imageEditRoles
mocks.hasFunds = true
mocks.showSubscriptionDialog.mockReset()
upgradeModalOpen.value = false
mocks.storeShowNudge.mockReset()
mocks.telemetry.trackOnboardingTour.mockReset()
mocks.storeCaptureResultMedia.mockReset()
apiEventHandlers.clear()
})
afterEach(() => {
useFirstRunTourController().end('skip')
vi.useRealTimers()
vi.clearAllMocks()
})
it('does not start the store when gating fails', async () => {
mocks.isNewUser.mockReturnValue(false)
await useFirstRunTourController().start()
expect(mocks.storePrepare).not.toHaveBeenCalled()
})
it('serializes the active workflow and starts the store', async () => {
mocks.steps = [{ kind: 'run', nodeId: null }]
await useFirstRunTourController().start('image_z_image_turbo')
expect(mocks.storePrepare).toHaveBeenCalledWith(
activeState,
'image_z_image_turbo'
)
expect(mocks.telemetry.trackOnboardingTour).toHaveBeenCalledWith(
'started',
expect.objectContaining({
tour: 'firstRun',
template_id: 'image_z_image_turbo',
shape: 'image-edit'
})
)
})
it('reports the resolved shape (t2i has no source image)', async () => {
mocks.steps = [{ kind: 'run', nodeId: null }]
mocks.resolvedRoles.value = { ...imageEditRoles, source: null }
await useFirstRunTourController().start()
expect(mocks.telemetry.trackOnboardingTour).toHaveBeenCalledWith(
'started',
expect.objectContaining({ shape: 't2i' })
)
})
it('defaults the entry to getting_started when none is passed', async () => {
mocks.steps = [{ kind: 'run', nodeId: null }]
await useFirstRunTourController().start('image_z_image_turbo')
expect(mocks.telemetry.trackOnboardingTour).toHaveBeenCalledWith(
'started',
expect.objectContaining({ entry: 'getting_started' })
)
})
it('threads the template_url entry for a ?template= arrival', async () => {
mocks.steps = [{ kind: 'run', nodeId: null }]
await useFirstRunTourController().start(
'image_z_image_turbo',
'template_url'
)
expect(mocks.telemetry.trackOnboardingTour).toHaveBeenCalledWith(
'started',
expect.objectContaining({
template_id: 'image_z_image_turbo',
entry: 'template_url'
})
)
})
it('threads the share_url entry with no template id for a ?share= arrival', async () => {
mocks.steps = [{ kind: 'run', nodeId: null }]
mocks.resolvedRoles.value = { ...imageEditRoles, prompt: null, sink: null }
await useFirstRunTourController().start(undefined, 'share_url')
expect(mocks.storePrepare).toHaveBeenCalledWith(activeState, undefined)
expect(mocks.telemetry.trackOnboardingTour).toHaveBeenCalledWith(
'started',
expect.objectContaining({
template_id: undefined,
shape: 'other',
entry: 'share_url'
})
)
})
it('ends the tour when no active workflow is present', async () => {
mocks.activeWorkflowState = null
await useFirstRunTourController().start()
expect(mocks.storePrepare).not.toHaveBeenCalled()
expect(mocks.storeEnd).toHaveBeenCalled()
})
it('reports completion when the tour finishes', () => {
mocks.steps = [{ kind: 'run', nodeId: null }]
useFirstRunTourController().end('done')
expect(tourReports('completed')).toHaveLength(1)
})
it('spotlights the collapsed port on the prompt step without entering a subgraph', async () => {
mocks.steps = [{ kind: 'prompt', nodeId: null, prompt: promptRole }]
await useFirstRunTourController().start()
// The tour never opens the subgraph; the prompt step restores the root view
// so the collapsed host's exposed port stays spotlit.
expect(mocks.restoreView).toHaveBeenCalled()
})
it('never auto-advances a step — the user always drives Next', async () => {
vi.useFakeTimers()
// Even a purely informational step (upload) must not advance on its own.
mocks.steps = [
{ kind: 'upload', nodeId: toNodeId(1) },
{ kind: 'prompt', nodeId: null, prompt: promptRole }
]
await useFirstRunTourController().start()
await vi.advanceTimersByTimeAsync(60000)
expect(mocks.engineNext).not.toHaveBeenCalled()
})
it('gates a no-funds user at the Run step: upgrade modal, nudge, end', async () => {
mocks.hasFunds = false
mocks.steps = [{ kind: 'run', nodeId: null }]
// The gate opens the real upgrade modal, so the paywall watcher fires too;
// ending must stay one-shot across both paths.
mocks.showSubscriptionDialog.mockImplementation(() => {
upgradeModalOpen.value = true
})
await useFirstRunTourController().start('image_z_image_turbo')
await nextTick()
expect(mocks.showSubscriptionDialog).toHaveBeenCalledWith({
reason: 'out_of_credits'
})
expect(mocks.telemetry.trackOnboardingTour).toHaveBeenCalledWith(
'upgrade_shown',
expect.objectContaining({ template_id: 'image_z_image_turbo' })
)
expect(mocks.storeShowNudge).toHaveBeenCalledOnce()
expect(mocks.storeEnd).toHaveBeenCalledOnce()
expect(tourReports('completed')).toHaveLength(1)
expect(tourReports('run_triggered')).toHaveLength(0)
})
it('gates via store.end when the user cannot fund a run', async () => {
mocks.hasFunds = false
mocks.steps = [{ kind: 'run', nodeId: null }]
await useFirstRunTourController().start()
expect(mocks.storeEnd).toHaveBeenCalled()
})
it('lets a funded user reach the Run step without reporting a run yet', async () => {
mocks.hasFunds = true
mocks.steps = [{ kind: 'run', nodeId: null }]
await useFirstRunTourController().start()
expect(mocks.showSubscriptionDialog).not.toHaveBeenCalled()
expect(mocks.storeEnd).not.toHaveBeenCalled()
// Merely arriving at the Run step is not a run — telemetry waits for a real
// execution so nothing can fabricate an activation event.
expect(tourReports('run_triggered')).toHaveLength(0)
})
it('ends the tour when the upgrade modal opens mid-run so it never hangs', async () => {
mocks.hasFunds = true
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useFirstRunTourController().start()
expect(mocks.storeEnd).not.toHaveBeenCalled()
// A no-subscription run is blocked by the paywall before it queues, so no
// execution event fires; the opening modal must end the tour on its own.
upgradeModalOpen.value = true
await nextTick()
expect(mocks.storeEnd).toHaveBeenCalled()
expect(mocks.storeShowNudge).toHaveBeenCalled()
})
it('reports the run only when a run actually completes', async () => {
mocks.hasFunds = true
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useFirstRunTourController().start()
// Merely starting the tour reports nothing — the run must finish first.
expect(tourReports('run_triggered')).toHaveLength(0)
await runJob()
expect(mocks.storeCaptureResultMedia).toHaveBeenCalledOnce()
expect(mocks.telemetry.trackOnboardingTour).toHaveBeenCalledWith(
'run_triggered',
expect.objectContaining({ status: 'success' })
)
})
it('captures the media when the run reports after the click advanced to Result', async () => {
// Production ordering: the Run click advances synchronously, so the execution
// event lands while Result is current. `advance` is a spy, so move it by hand.
mocks.hasFunds = true
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useFirstRunTourController().start()
mocks.stepIndex.value = 1
await runJob()
expect(mocks.storeCaptureResultMedia).toHaveBeenCalledOnce()
expect(mocks.telemetry.trackOnboardingTour).toHaveBeenCalledWith(
'run_triggered',
expect.objectContaining({ status: 'success' })
)
})
it('ignores execution events that arrive before the Run step', async () => {
// A run kicked off from an earlier step must not resolve the Result or report.
mocks.hasFunds = true
mocks.steps = [
{ kind: 'prompt', nodeId: null },
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useFirstRunTourController().start()
mocks.stepIndex.value = 0
await runJob()
expect(mocks.storeCaptureResultMedia).not.toHaveBeenCalled()
expect(tourReports('run_triggered')).toHaveLength(0)
})
it('reports the run at most once across repeated runs', async () => {
mocks.hasFunds = true
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useFirstRunTourController().start()
await runJob()
await runJob()
expect(tourReports('run_triggered')).toHaveLength(1)
})
it('reports the true status and skips media capture when a run errors', async () => {
mocks.hasFunds = true
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useFirstRunTourController().start()
await failJob('execution_error')
expect(mocks.storeCaptureResultMedia).not.toHaveBeenCalled()
expect(mocks.telemetry.trackOnboardingTour).toHaveBeenCalledWith(
'run_triggered',
expect.objectContaining({ status: 'error' })
)
})
it('reports the true status and skips media capture when a run is interrupted', async () => {
mocks.hasFunds = true
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useFirstRunTourController().start()
await failJob('execution_interrupted')
expect(mocks.storeCaptureResultMedia).not.toHaveBeenCalled()
expect(mocks.telemetry.trackOnboardingTour).toHaveBeenCalledWith(
'run_triggered',
expect.objectContaining({ status: 'interrupted' })
)
})
it('completes the tour when a run finishes on the terminal Run step', async () => {
// No sink resolved → Run is the last step; a finished run must fire Completed.
mocks.hasFunds = true
mocks.steps = [{ kind: 'run', nodeId: null }]
await useFirstRunTourController().start('image_z_image_turbo')
await runJob()
expect(tourReports('completed')).toHaveLength(1)
})
it('does not click-advance the terminal Run step out from under the run outcome', async () => {
mocks.hasFunds = true
mocks.steps = [{ kind: 'run', nodeId: null }]
await useFirstRunTourController().start('image_z_image_turbo')
clickRunButton()
await runJob()
expect(mocks.engineNext).not.toHaveBeenCalled()
expect(tourReports('completed')).toHaveLength(1)
expect(mocks.storeShowNudge).toHaveBeenCalledOnce()
expect(apiEventHandlers.size).toBe(0)
})
it('does not complete on run when a Result step follows', async () => {
mocks.hasFunds = true
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useFirstRunTourController().start('image_z_image_turbo')
await runJob()
expect(tourReports('completed')).toHaveLength(0)
})
it('advances off the Run step the instant the Run button is clicked', async () => {
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useFirstRunTourController().start()
expect(mocks.engineNext).not.toHaveBeenCalled()
clickRunButton()
// The click alone advances — no waiting for the run to finish.
expect(mocks.engineNext).toHaveBeenCalledOnce()
})
it('does not advance when a click misses the Run button', async () => {
// The listener must discriminate on the selector, not advance on any click.
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useFirstRunTourController().start()
clickElsewhere()
expect(mocks.engineNext).not.toHaveBeenCalled()
})
it('captures the media when the run finishes (not the step advance)', async () => {
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useFirstRunTourController().start()
await runJob()
expect(mocks.storeCaptureResultMedia).toHaveBeenCalledOnce()
})
it('still advances after the launching component unmounts', async () => {
// start() runs inside the Getting Started screen's setup, which unmounts right
// after; the Run-click listener must survive that teardown.
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
const launchingScope = effectScope()
await launchingScope.run(async () => {
await useFirstRunTourController().start()
})
launchingScope.stop() // the Getting Started screen unmounts
clickRunButton()
expect(mocks.engineNext).toHaveBeenCalledOnce()
})
it('does not advance on the Result step when a run completes', async () => {
// Started already on Result: no Run-click listener is bound, so a completing
// run resolves media but never advances the step.
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
mocks.stepIndex.value = 1
await useFirstRunTourController().start()
await runJob()
expect(mocks.engineNext).not.toHaveBeenCalled()
})
it('only gates when the funds check reaches the Run step', async () => {
mocks.hasFunds = false
mocks.steps = [
{ kind: 'prompt', nodeId: null, prompt: promptRole },
{ kind: 'run', nodeId: null }
]
await useFirstRunTourController().start()
// First step is Prompt, not Run — no gate yet.
expect(mocks.showSubscriptionDialog).not.toHaveBeenCalled()
})
})
describe('useFirstRunTourController post-run nudge', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mocks.isCloud = true
mocks.isSubscriptionEnabled.mockReturnValue(true)
mocks.isNewUser.mockReturnValue(true)
mocks.onboardingTourEnabled = true
mocks.activeWorkflowState = activeState
mocks.storePrepare.mockReset()
mocks.storeShowNudge.mockReset()
mocks.steps = [{ kind: 'run', nodeId: null }]
mocks.stepIndex.value = 0
mocks.isActive.value = true
mocks.hasFunds = true
apiEventHandlers.clear()
})
afterEach(() => {
useFirstRunTourController().end('skip')
vi.clearAllMocks()
})
it('surfaces the nudge on the first completed run after the tour starts', async () => {
await useFirstRunTourController().start('image_z_image_turbo')
await runJob()
expect(mocks.storeShowNudge).toHaveBeenCalledOnce()
})
it('surfaces the nudge only once even across repeated runs', async () => {
await useFirstRunTourController().start('image_z_image_turbo')
await runJob()
await runJob()
expect(mocks.storeShowNudge).toHaveBeenCalledOnce()
})
it('does not surface the nudge before any tour has started', async () => {
// No tour is live, so no run watcher is registered; a stray job completion
// must not pop the nudge.
await runJob()
expect(mocks.storeShowNudge).not.toHaveBeenCalled()
})
})
describe('useFirstRunTourController.beginTour', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mocks.isCloud = true
mocks.isSubscriptionEnabled.mockReturnValue(true)
mocks.isNewUser.mockReturnValue(true)
mocks.onboardingTourEnabled = true
mocks.activeWorkflowState = activeState
mocks.resolveTourRoles.mockReturnValue(imageEditRoles)
mocks.nodesPresent.mockReturnValue(true)
mocks.canvasTransformValid.mockReturnValue(true)
mocks.storePrepare.mockReset()
mocks.steps = [{ kind: 'run', nodeId: null }]
mocks.stepIndex.value = 0
mocks.isActive.value = false
mocks.resolvedRoles.value = imageEditRoles
mocks.hasFunds = true
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0)
return 0
})
apiEventHandlers.clear()
})
afterEach(() => {
useFirstRunTourController().end('skip')
vi.unstubAllGlobals()
vi.useRealTimers()
vi.clearAllMocks()
})
it('starts the tour once the live graph holds the resolved nodes', async () => {
const started = await useFirstRunTourController().beginTour({
templateId: 'image_z_image_turbo'
})
expect(started).toBe(true)
expect(mocks.storePrepare).toHaveBeenCalledWith(
activeState,
'image_z_image_turbo'
)
expect(mocks.telemetry.trackOnboardingTour).toHaveBeenCalledWith(
'started',
expect.objectContaining({
template_id: 'image_z_image_turbo',
entry: 'getting_started'
})
)
})
it('threads the share_url entry through when launched from a URL', async () => {
await useFirstRunTourController().beginTour({ entry: 'share_url' })
expect(mocks.telemetry.trackOnboardingTour).toHaveBeenCalledWith(
'started',
expect.objectContaining({ entry: 'share_url' })
)
})
it('does not start when gating fails', async () => {
mocks.isNewUser.mockReturnValue(false)
const started = await useFirstRunTourController().beginTour({
templateId: 'x'
})
expect(started).toBe(false)
expect(mocks.storePrepare).not.toHaveBeenCalled()
})
it('does not start a second tour while one is already active', async () => {
mocks.isActive.value = true
const started = await useFirstRunTourController().beginTour({
templateId: 'x'
})
expect(started).toBe(false)
expect(mocks.storePrepare).not.toHaveBeenCalled()
})
it('starts degraded rather than trapping when the graph never becomes ready', async () => {
vi.useFakeTimers()
mocks.nodesPresent.mockReturnValue(false)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const pending = useFirstRunTourController().beginTour({ templateId: 'x' })
await vi.runAllTimersAsync()
await pending
expect(mocks.storePrepare).toHaveBeenCalled()
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
})

View File

@@ -1,356 +0,0 @@
import { createSharedComposable, until, useEventListener } from '@vueuse/core'
import { computed, effectScope, ref, watch } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useDesktopLayout } from '@/composables/useDesktopLayout'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useOnboardingTourStore } from '@/platform/onboarding/onboardingTourStore'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { api } from '@/scripts/api'
import type {
OnboardingTourEntry,
OnboardingTourRunStatus,
OnboardingTourShape
} from '@/platform/telemetry/types'
import type { OnboardingCandidateDeps } from '@/platform/workflow/persistence/onboardingEntryStore'
import { isOnboardingCandidate } from '@/platform/workflow/persistence/onboardingEntryStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useNewUserService } from '@/services/useNewUserService'
import type { NodeId } from '@/types/nodeId'
import {
RUN_BUTTON_SELECTOR,
canvasTransformValid,
nodesPresent
} from './canvasSpotlightAdapter'
import { trackFirstRunTour } from './firstRunTourTelemetry'
import { resolveTourRoles } from './roleResolution'
import { isUpgradeModalOpen, useFirstRunTourStore } from './firstRunTourStore'
import type { TourEndReason } from './firstRunTourStore'
import { restoreView } from './subgraphNavigation'
import { shapeOf, toCoachSteps } from './tourSequence'
import type { ResolvedRoles } from './tourSequence'
/** Budget for the serialized snapshot to appear (guards the URL blank-load race). */
const ACTIVE_STATE_TIMEOUT_MS = 1500
/** Budget for the live canvas graph to contain the resolved role nodes. */
const READY_TIMEOUT_MS = 3000
/** Top-level nodes the sequence actually spotlights (upload, prompt host, sink). */
function roleAnchorIds(roles: ResolvedRoles): NodeId[] {
return [
roles.source?.nodeId,
roles.prompt?.subgraphNodeId,
roles.sink?.nodeId
].filter((id): id is NodeId => id != null)
}
/** The live graph holds the resolved anchors and the canvas has a usable transform. */
function liveGraphReady(roles: ResolvedRoles): boolean {
return nodesPresent(roleAnchorIds(roles)) && canvasTransformValid()
}
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()))
}
interface BeginTourOptions {
templateId?: string
entry?: OnboardingTourEntry
}
function _useFirstRunTourController() {
const store = useFirstRunTourStore()
const engine = useOnboardingTourStore()
const onboardingDeps: OnboardingCandidateDeps = {
subscription: useSubscription(),
newUserService: useNewUserService(),
featureFlags: useFeatureFlags(),
desktop: useDesktopLayout()
}
/** True while the loader is up: template chosen, tour not yet started. */
const isPreparing = ref(false)
/** Retained from `start` so completion/skip telemetry carries the same tags. */
let activeTemplateId: string | undefined
let activeShape: OnboardingTourShape = 'other'
/** Guards RunTriggered against re-firing on a second run. */
let runReported = false
/** Stops the per-tour run watcher; set while a tour is live. */
let stopRunListener: (() => void) | undefined
/** Stops the Run-button click listener; set only while on the Run step. */
let stopRunClick: (() => void) | undefined
/** Guards the post-run outcome so only the first finished run acts. */
let runOutcomeHandled = false
/** True when the sequence has no step after Run, so a finished run ends the tour. */
function runIsTerminalStep(): boolean {
return store.steps.at(-1)?.kind === 'run'
}
/**
* Handle the run finishing: capture the media on success, report the real status,
* and surface the nudge. When Run is the last step, this completes the tour;
* otherwise the Result step does.
*/
function handleRunOutcome(status: OnboardingTourRunStatus) {
// The Run click advances to Result before the run reports, so accept either.
const step = store.currentStep?.kind
if (step !== 'run' && step !== 'result') return
if (runOutcomeHandled) return
runOutcomeHandled = true
store.runFinished = true
if (status === 'success') void store.captureResultMedia()
reportRunTriggered(status)
if (runIsTerminalStep()) {
end('done')
} else {
store.showNudge()
}
}
/**
* Watch for the run to finish, keyed to the real execution outcome rather than the
* `activeJobId` reset, which also fires on failure. Owned by a detached scope:
* `start()` runs in the Getting Started screen's setup, which unmounts right after,
* so a component-scoped listener would die before the user reaches Run.
*/
function listenForFirstRun() {
stopRunListener?.()
runOutcomeHandled = false
const scope = effectScope(true)
scope.run(() => {
useEventListener(api, 'execution_success', () =>
handleRunOutcome('success')
)
useEventListener(api, 'execution_error', () => handleRunOutcome('error'))
useEventListener(api, 'execution_interrupted', () =>
handleRunOutcome('interrupted')
)
// A no-subscription run is blocked by the paywall before it queues, so no
// execution event ever fires; end the tour when that modal opens so it
// doesn't hang on the Result step. The nudge defers itself until it closes.
const upgradeModalOpen = computed(() => isUpgradeModalOpen())
watch(upgradeModalOpen, (open) => {
if (open && store.isActive) end('done')
})
})
stopRunListener = () => scope.stop()
}
/**
* Advance on the Run click rather than on execution completion, which left the mark
* stuck on Run. Capture-phase, so it fires despite the scrim above the toolbar.
*/
function listenForRunClick() {
stopRunClick?.()
const onClick = (event: MouseEvent) => {
const target = event.target
if (target instanceof Element && target.closest(RUN_BUTTON_SELECTOR))
void advance()
}
document.addEventListener('click', onClick, true)
stopRunClick = () => document.removeEventListener('click', onClick, true)
}
function reportRunTriggered(status: OnboardingTourRunStatus) {
if (runReported) return
runReported = true
trackFirstRunTour('run_triggered', {
template_id: activeTemplateId,
shape: activeShape,
status
})
}
function shouldStartTour(): boolean {
return isOnboardingCandidate(onboardingDeps)
}
/** Mirror the engine's position into our view store (the engine owns the sequence). */
function syncFromEngine() {
store.isActive = engine.activeTour === 'firstRun'
store.stepIndex = engine.countedStepIdx
}
/**
* Serialize the loaded graph and run the tour on it. Aborts cleanly if gating fails
* or no workflow is active.
*
* @param entry Where the tour was launched from, for telemetry.
*/
async function start(
templateId?: string,
entry: OnboardingTourEntry = 'getting_started'
) {
if (!shouldStartTour()) return
// The engine runs one tour at a time; don't preempt one already in progress.
if (engine.activeTour) return
const workflow = useWorkflowStore().activeWorkflow?.activeState
if (!workflow) {
store.end()
return
}
store.prepare(workflow, templateId)
engine.startTour('firstRun', {
force: true,
definition: toCoachSteps(store.steps)
})
if (engine.activeTour !== 'firstRun') return
syncFromEngine()
activeTemplateId = templateId
const roles = store.resolvedRoles
activeShape = roles ? shapeOf(roles) : 'other'
runReported = false
listenForFirstRun()
trackFirstRunTour('started', {
template_id: activeTemplateId,
shape: activeShape,
entry
})
await enterStep()
}
/**
* Shared entry point for the Getting Started card and the URL loaders. Waits until
* the template is present on the live canvas before starting, so the overlay never
* paints over an unrendered graph. On timeout it starts degraded rather than trap.
*/
async function beginTour({
templateId,
entry = 'getting_started'
}: BeginTourOptions = {}): Promise<boolean> {
if (!shouldStartTour()) return false
if (isPreparing.value || store.isActive) return false
isPreparing.value = true
try {
const workflowStore = useWorkflowStore()
// Guards the URL path, where loadBlankWorkflow() precedes the template load.
await until(() => workflowStore.activeWorkflow?.activeState).toBeTruthy({
timeout: ACTIVE_STATE_TIMEOUT_MS,
throwOnTimeout: false
})
const workflow = workflowStore.activeWorkflow?.activeState
if (!workflow) return false
// Resolve from the same snapshot start() uses, then hold until the live
// graph agrees — the spotlight reads the live graph, so they must match.
const roles = resolveTourRoles(workflow, templateId)
const ready = await until(() => liveGraphReady(roles)).toBe(true, {
timeout: READY_TIMEOUT_MS,
throwOnTimeout: false
})
if (!ready) {
console.warn(
'[onboardingTour] canvas readiness timed out; starting degraded',
{ templateId }
)
}
await nextFrame()
await start(templateId, entry)
await nextFrame()
return store.isActive
} finally {
isPreparing.value = false
}
}
/** UX-only paywall check; real enforcement is server-side. Never gate on signup method. */
function hasFunds(): boolean {
return useBillingContext().subscription.value?.hasFunds === true
}
/**
* Gate the Run step: no-funds users get the upgrade modal and the tour ends, with
* the nudge deferring itself until that modal closes.
*
* @returns True when gated.
*/
function gateRunStep(): boolean {
if (hasFunds()) return false
useBillingContext().showSubscriptionDialog({ reason: 'out_of_credits' })
trackFirstRunTour('upgrade_shown', { template_id: activeTemplateId })
end('done')
return true
}
/** Set up the current step: gate the run, arm the run-click, spotlight the prompt. */
async function enterStep() {
stopRunClick?.()
stopRunClick = undefined
const step = store.currentStep
if (!step) return
if (step.kind === 'run') {
if (gateRunStep()) return
// A terminal Run has no next step; let the run outcome end the tour.
if (!runIsTerminalStep()) listenForRunClick()
}
// Defensive: the tour never enters a subgraph, but the user may have opened one.
restoreView()
trackFirstRunTour('step_shown', {
template_id: activeTemplateId,
step_key: step.kind,
step_number: store.stepIndex + 1,
step_count: store.totalSteps
})
}
async function advance() {
engine.next()
syncFromEngine()
await enterStep()
}
async function back() {
engine.back()
syncFromEngine()
await enterStep()
}
function end(reason: TourEndReason) {
if (!store.isActive) return
stopRunListener?.()
stopRunListener = undefined
stopRunClick?.()
stopRunClick = undefined
if (reason === 'skip') {
const step = store.currentStep
if (step) {
trackFirstRunTour('skipped', {
template_id: activeTemplateId,
step_key: step.kind,
step_number: store.stepIndex + 1,
step_count: store.totalSteps
})
}
} else if (reason === 'done') {
trackFirstRunTour('completed', {
template_id: activeTemplateId,
shape: activeShape
})
}
engine.skip()
store.showNudge()
store.end()
}
return { shouldStartTour, beginTour, start, advance, back, end }
}
export const useFirstRunTourController = createSharedComposable(
_useFirstRunTourController
)

Some files were not shown because too many files have changed in this diff Show More