mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 17:28:58 +00:00
Compare commits
7 Commits
feat/onboa
...
codex/part
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0af560cc | ||
|
|
cbdacb3316 | ||
|
|
4e27f203c9 | ||
|
|
9459aa1b04 | ||
|
|
71f37c4daf | ||
|
|
8938305de7 | ||
|
|
a4ffecd49d |
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
126
browser_tests/tests/dialogs/partnerNodeAllowlist.spec.ts
Normal file
126
browser_tests/tests/dialogs/partnerNodeAllowlist.spec.ts
Normal 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[])
|
||||
})
|
||||
})
|
||||
97
browser_tests/tests/partnerNodeGovernanceDiscovery.spec.ts
Normal file
97
browser_tests/tests/partnerNodeGovernanceDiscovery.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
6
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
<Panel
|
||||
ref="panelRef"
|
||||
data-testid="comfy-actionbar"
|
||||
class="pointer-events-auto"
|
||||
:style="style"
|
||||
:class="panelClass"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
34
src/extensions/core/cloudPartnerNodeGovernance.test.ts
Normal file
34
src/extensions/core/cloudPartnerNodeGovernance.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
10
src/extensions/core/cloudPartnerNodeGovernance.ts
Normal file
10
src/extensions/core/cloudPartnerNodeGovernance.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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' }
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
121
src/platform/workspace/api/partnerNodePolicyApi.test.ts
Normal file
121
src/platform/workspace/api/partnerNodePolicyApi.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
67
src/platform/workspace/api/partnerNodePolicyApi.ts
Normal file
67
src/platform/workspace/api/partnerNodePolicyApi.ts
Normal 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())
|
||||
}
|
||||
@@ -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.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
411
src/platform/workspace/stores/partnerNodeGovernanceStore.test.ts
Normal file
411
src/platform/workspace/stores/partnerNodeGovernanceStore.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
213
src/platform/workspace/stores/partnerNodeGovernanceStore.ts
Normal file
213
src/platform/workspace/stores/partnerNodeGovernanceStore.ts
Normal 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
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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 />'
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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]}`
|
||||
}
|
||||
@@ -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 tour’s 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user