mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 01:07:56 +00:00
Compare commits
57 Commits
feat/color
...
feat/onboa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a2c4819ad | ||
|
|
897e92f434 | ||
|
|
f524683f3c | ||
|
|
1d2ac9ca27 | ||
|
|
d23a00b5cb | ||
|
|
ea15f1af8a | ||
|
|
6709d27c71 | ||
|
|
2697a43f78 | ||
|
|
d5fd3d10aa | ||
|
|
d1d55585f9 | ||
|
|
98c654df20 | ||
|
|
5bce9c4874 | ||
|
|
13fd07201c | ||
|
|
62806d5ebd | ||
|
|
dff76dbc9d | ||
|
|
66b9f329ce | ||
|
|
cb1be63fb6 | ||
|
|
3f5b71a472 | ||
|
|
7df585e606 | ||
|
|
f1f7c53fbe | ||
|
|
25ce1ebbe4 | ||
|
|
97308500e9 | ||
|
|
ccb3cd428b | ||
|
|
9a71b54d05 | ||
|
|
61756e977b | ||
|
|
7630cc9314 | ||
|
|
bb39a51d46 | ||
|
|
76abe8eb3f | ||
|
|
9bcfda88f6 | ||
|
|
e7cdfc8c35 | ||
|
|
e97746fd16 | ||
|
|
549200a76c | ||
|
|
96c2ae1182 | ||
|
|
2312b213ce | ||
|
|
ce8b107322 | ||
|
|
831813a9db | ||
|
|
64d10da9d7 | ||
|
|
3f84d4f5f2 | ||
|
|
5383e23d24 | ||
|
|
9b8dd27f3d | ||
|
|
a818b7eee8 | ||
|
|
87d0a110cd | ||
|
|
b65da23915 | ||
|
|
07356e3253 | ||
|
|
51182127f3 | ||
|
|
fdfa9882b1 | ||
|
|
88cd848245 | ||
|
|
5dbb560ef0 | ||
|
|
f973626ebc | ||
|
|
e9729ca272 | ||
|
|
c51f963ef2 | ||
|
|
5e23f76642 | ||
|
|
f642384674 | ||
|
|
f80deb9655 | ||
|
|
75af0430fc | ||
|
|
cc74e1dc65 | ||
|
|
989773995a |
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -6,3 +6,6 @@ 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
|
||||
|
||||
@@ -10,11 +10,12 @@ const PAYMENT_STATUSES = ['success', 'failed'] as const
|
||||
const LOCALE_PREFIXES = LOCALES.map((locale) =>
|
||||
locale === DEFAULT_LOCALE ? '' : `/${locale}`
|
||||
)
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
|
||||
LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set([
|
||||
...LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
|
||||
)
|
||||
)
|
||||
),
|
||||
'/individual-submission'
|
||||
])
|
||||
|
||||
function isExcludedFromSitemap(page: string): boolean {
|
||||
const pathname = new URL(page).pathname.replace(/\/$/, '')
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { href, label } = defineProps<{
|
||||
href: string
|
||||
label: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-2 flex justify-center">
|
||||
<Button as="a" :href variant="default" size="lg">
|
||||
{{ label }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,12 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
const { title } = defineProps<{ title: string }>()
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { title, class: className } = defineProps<{
|
||||
title: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex items-center justify-center px-6 pt-20 pb-16 lg:pt-32 lg:pb-24"
|
||||
>
|
||||
<h1 class="text-primary-comfy-canvas text-4xl font-light lg:text-6xl">
|
||||
<h1
|
||||
:class="
|
||||
cn(
|
||||
'text-4xl font-light text-primary-comfy-canvas lg:text-6xl',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
60
apps/website/src/pages/individual-submission.astro
Normal file
60
apps/website/src/pages/individual-submission.astro
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import PlansPricingCta from '../components/individual-submission/PlansPricingCta.vue'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import { getRoutes } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Thanks for reaching out - Comfy"
|
||||
description="Thanks for reaching out. Based on what you shared, one of our self-serve plans is probably a better fit."
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="Thanks for reaching out." class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>
|
||||
Based on what you shared, one of our self-serve plans is probably a
|
||||
better fit.
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Standard</strong
|
||||
>,
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Creator</strong
|
||||
>, and
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Pro</strong
|
||||
> for individual creators, plus our new
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Teams</strong
|
||||
> plan for multiple users under shared billing.
|
||||
</p>
|
||||
|
||||
<PlansPricingCta
|
||||
href={routes.cloudPricing}
|
||||
label="See plans and pricing →"
|
||||
/>
|
||||
|
||||
<p class="text-primary-warm-gray mt-8 text-sm">
|
||||
Still think you have an enterprise need?<br /> We're happy to assist. Email: <a
|
||||
href="mailto:gtm-team@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>gtm-team@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
Need help with something else? Email: <a
|
||||
href="mailto:support@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>support@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
57
apps/website/src/pages/zh-CN/individual-submission.astro
Normal file
57
apps/website/src/pages/zh-CN/individual-submission.astro
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PlansPricingCta from '../../components/individual-submission/PlansPricingCta.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
import { getRoutes } from '../../config/routes'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="感谢您的联系 - Comfy"
|
||||
description="感谢您的联系。根据您提供的信息,我们的自助服务套餐之一可能更适合您。"
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="感谢您的联系。" class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>
|
||||
根据您提供的信息,我们的自助服务套餐之一可能更适合您。面向个人创作者的
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Standard</strong
|
||||
>、
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Creator</strong
|
||||
>
|
||||
和
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Pro</strong
|
||||
>,以及我们全新的
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Teams</strong
|
||||
> 套餐,可让多位用户共享账单。
|
||||
</p>
|
||||
|
||||
<PlansPricingCta href={routes.cloudPricing} label="查看套餐与价格 →" />
|
||||
|
||||
<p class="text-primary-warm-gray mt-8 text-sm">
|
||||
仍然认为您有企业级需求?我们很乐意为您提供帮助。邮箱:<a
|
||||
href="mailto:gtm-team@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>gtm-team@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
需要其他方面的帮助?邮箱:<a
|
||||
href="mailto:support@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>support@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
@@ -542,6 +543,8 @@ 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
|
||||
|
||||
77
browser_tests/fixtures/components/Tour.ts
Normal file
77
browser_tests/fixtures/components/Tour.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
11
browser_tests/fixtures/tourFixture.ts
Normal file
11
browser_tests/fixtures/tourFixture.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
135
browser_tests/tests/tour.spec.ts
Normal file
135
browser_tests/tests/tour.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
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.page.getByRole('button', { name: 'Media Assets' }).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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -72,6 +72,7 @@
|
||||
"@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:",
|
||||
|
||||
@@ -40,6 +40,11 @@ export type ComfyDesktop2TelemetryProperties = Record<
|
||||
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
|
||||
>
|
||||
|
||||
export type ComfyDesktop2FirebaseAuthState =
|
||||
| { status: 'pending' }
|
||||
| { status: 'signed_out' }
|
||||
| { status: 'signed_in'; userId: string }
|
||||
|
||||
export interface ComfyDesktop2TerminalBridge {
|
||||
subscribe(installationId?: string): Promise<TerminalRestore>
|
||||
unsubscribe(installationId?: string): Promise<void>
|
||||
@@ -60,6 +65,7 @@ export interface ComfyDesktop2LogsBridge {
|
||||
|
||||
export interface ComfyDesktop2TelemetryBridge {
|
||||
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
|
||||
reportFirebaseAuthState?(state: ComfyDesktop2FirebaseAuthState): void
|
||||
}
|
||||
|
||||
export interface ComfyDesktop2Bridge {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-desktop-bridge-types",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
|
||||
"homepage": "https://comfy.org",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -102,6 +102,10 @@
|
||||
--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,6 +30,9 @@ 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
|
||||
@@ -468,6 +471,9 @@ 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,6 +18,7 @@ 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
|
||||
|
||||
BIN
public/assets/images/app-mode-landing.png
Normal file
BIN
public/assets/images/app-mode-landing.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 281 KiB |
@@ -15,6 +15,7 @@ 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,6 +11,7 @@
|
||||
|
||||
<Panel
|
||||
ref="panelRef"
|
||||
data-testid="comfy-actionbar"
|
||||
class="pointer-events-auto"
|
||||
:style="style"
|
||||
:class="panelClass"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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
|
||||
@@ -9,7 +13,7 @@ import type { Directive } from 'vue'
|
||||
// renderers share one stacking sequence: whichever dialog opens last wins.
|
||||
export const vRekaZIndex: Directive<HTMLElement> = {
|
||||
mounted(el) {
|
||||
ZIndex.set('modal', el, 1700)
|
||||
ZIndex.set(MODAL_Z_KEY, el, MODAL_Z_BASE)
|
||||
},
|
||||
beforeUnmount(el) {
|
||||
ZIndex.clear(el)
|
||||
|
||||
@@ -180,6 +180,7 @@ 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'
|
||||
@@ -504,6 +505,9 @@ 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.
|
||||
@@ -561,11 +565,38 @@ onMounted(async () => {
|
||||
// Restore saved workflow and workflow tabs state
|
||||
await workflowPersistence.initializeWorkflow()
|
||||
await workflowPersistence.restoreWorkflowTabsState()
|
||||
await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||
templateFromUrl = await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||
} finally {
|
||||
workspaceStore.spinner = false
|
||||
}
|
||||
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
|
||||
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' })
|
||||
}
|
||||
|
||||
comfyApp.canvas.onSelectionChange = useChainCallback(
|
||||
comfyApp.canvas.onSelectionChange,
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
<template>
|
||||
<div role="tablist" class="flex w-full items-center gap-2">
|
||||
<div role="tablist" :class="cn('flex w-full items-center gap-2', className)">
|
||||
<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) {
|
||||
|
||||
6
src/composables/useDesktopLayout.ts
Normal file
6
src/composables/useDesktopLayout.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
|
||||
/** `md`+ viewport — the width the onboarding tours need to place their coach-marks. */
|
||||
export function useDesktopLayout() {
|
||||
return useBreakpoints(breakpointsTailwind).greaterOrEqual('md')
|
||||
}
|
||||
@@ -33,7 +33,8 @@ export enum ServerFeatureFlag {
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile',
|
||||
ONBOARDING_TOUR_ENABLED = 'onboarding_tour_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,6 +209,13 @@ export function useFeatureFlags() {
|
||||
remoteConfig.value.signup_turnstile,
|
||||
'off'
|
||||
)
|
||||
},
|
||||
get onboardingTourEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.ONBOARDING_TOUR_ENABLED,
|
||||
remoteConfig.value.onboarding_tour_enabled,
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2317,6 +2317,90 @@
|
||||
"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.",
|
||||
"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",
|
||||
@@ -4548,5 +4632,37 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
configValueOrDefault,
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
import { syncHostUserIdWithFirebaseAuth } from '@/platform/telemetry/hostUserIdSync'
|
||||
import '@/lib/litegraph/public/css/litegraph.css'
|
||||
import router from '@/router'
|
||||
import { isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
@@ -141,6 +142,10 @@ app
|
||||
modules: [VueFireAuth()]
|
||||
})
|
||||
|
||||
if (isCloud && hasHostTelemetryBridge) {
|
||||
syncHostUserIdWithFirebaseAuth()
|
||||
}
|
||||
|
||||
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
|
||||
flushProxyWidgetMigration({
|
||||
hostNode,
|
||||
|
||||
@@ -13,6 +13,9 @@ 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
|
||||
/**
|
||||
|
||||
67
src/platform/onboarding/CoachmarkCard.test.ts
Normal file
67
src/platform/onboarding/CoachmarkCard.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
50
src/platform/onboarding/CoachmarkCard.vue
Normal file
50
src/platform/onboarding/CoachmarkCard.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<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>
|
||||
67
src/platform/onboarding/CoachmarkLanding.test.ts
Normal file
67
src/platform/onboarding/CoachmarkLanding.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
120
src/platform/onboarding/CoachmarkLanding.vue
Normal file
120
src/platform/onboarding/CoachmarkLanding.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<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>
|
||||
127
src/platform/onboarding/TourOverlay.test.ts
Normal file
127
src/platform/onboarding/TourOverlay.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
46
src/platform/onboarding/TourOverlay.vue
Normal file
46
src/platform/onboarding/TourOverlay.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<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>
|
||||
147
src/platform/onboarding/TourSpotlight.test.ts
Normal file
147
src/platform/onboarding/TourSpotlight.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
239
src/platform/onboarding/TourSpotlight.vue
Normal file
239
src/platform/onboarding/TourSpotlight.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<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="!waitingForTarget"
|
||||
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>
|
||||
72
src/platform/onboarding/coachmarkLayout.test.ts
Normal file
72
src/platform/onboarding/coachmarkLayout.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
55
src/platform/onboarding/coachmarkLayout.ts
Normal file
55
src/platform/onboarding/coachmarkLayout.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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
|
||||
}
|
||||
157
src/platform/onboarding/coachmarkRegistry.test.ts
Normal file
157
src/platform/onboarding/coachmarkRegistry.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
84
src/platform/onboarding/coachmarkRegistry.ts
Normal file
84
src/platform/onboarding/coachmarkRegistry.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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()
|
||||
}
|
||||
516
src/platform/onboarding/onboardingTourStore.test.ts
Normal file
516
src/platform/onboarding/onboardingTourStore.test.ts
Normal file
@@ -0,0 +1,516 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
238
src/platform/onboarding/onboardingTourStore.ts
Normal file
238
src/platform/onboarding/onboardingTourStore.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
36
src/platform/onboarding/onboardingTours.test.ts
Normal file
36
src/platform/onboarding/onboardingTours.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
92
src/platform/onboarding/onboardingTours.ts
Normal file
92
src/platform/onboarding/onboardingTours.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
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'
|
||||
}
|
||||
]
|
||||
}
|
||||
61
src/platform/onboarding/useCoachmarkTarget.test.ts
Normal file
61
src/platform/onboarding/useCoachmarkTarget.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
136
src/platform/onboarding/useCoachmarkTarget.ts
Normal file
136
src/platform/onboarding/useCoachmarkTarget.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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 }
|
||||
}
|
||||
30
src/platform/onboarding/useTourTriggers.ts
Normal file
30
src/platform/onboarding/useTourTriggers.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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)
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
73
src/platform/onboarding/vCoachmark.test.ts
Normal file
73
src/platform/onboarding/vCoachmark.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
29
src/platform/onboarding/vCoachmark.ts
Normal file
29
src/platform/onboarding/vCoachmark.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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)
|
||||
}
|
||||
@@ -116,6 +116,7 @@ export type RemoteConfig = {
|
||||
comfyhub_profile_gate_enabled?: boolean
|
||||
unified_cloud_auth?: boolean
|
||||
consolidated_billing_enabled?: boolean
|
||||
onboarding_tour_enabled?: boolean
|
||||
sentry_dsn?: string
|
||||
turnstile_sitekey?: string
|
||||
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).
|
||||
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
@@ -977,6 +978,13 @@ 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,6 +17,8 @@ import type {
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
OnboardingTourMetadata,
|
||||
OnboardingTourStage,
|
||||
SearchQueryMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
@@ -170,6 +172,13 @@ 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))
|
||||
}
|
||||
|
||||
183
src/platform/telemetry/hostUserIdSync.test.ts
Normal file
183
src/platform/telemetry/hostUserIdSync.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as VueModule from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
type MockAuthStore = {
|
||||
isInitialized: boolean
|
||||
currentUser: { uid: string } | null
|
||||
}
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
authStore: null as unknown as MockAuthStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', async () => {
|
||||
const { reactive } = await vi.importActual<typeof VueModule>('vue')
|
||||
hoisted.authStore = reactive<MockAuthStore>({
|
||||
isInitialized: false,
|
||||
currentUser: null
|
||||
})
|
||||
return { useAuthStore: () => hoisted.authStore }
|
||||
})
|
||||
|
||||
import { syncHostUserIdWithFirebaseAuth } from './hostUserIdSync'
|
||||
|
||||
const stopHandles: Array<() => void> = []
|
||||
|
||||
function installTelemetryBridge() {
|
||||
const reportFirebaseAuthState = vi.fn()
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => false,
|
||||
Telemetry: {
|
||||
capture: vi.fn(),
|
||||
reportFirebaseAuthState
|
||||
}
|
||||
}
|
||||
return { reportFirebaseAuthState }
|
||||
}
|
||||
|
||||
function startSync(): void {
|
||||
const stop = syncHostUserIdWithFirebaseAuth()
|
||||
if (stop) stopHandles.push(stop)
|
||||
}
|
||||
|
||||
describe('host user ID sync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hoisted.authStore.isInitialized = false
|
||||
hoisted.authStore.currentUser = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
while (stopHandles.length) stopHandles.pop()?.()
|
||||
delete window.__comfyDesktop2
|
||||
})
|
||||
|
||||
it('waits for Firebase auth initialization before reporting a user', async () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
startSync()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'pending'
|
||||
})
|
||||
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
|
||||
|
||||
hoisted.authStore.isInitialized = true
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'signed_in',
|
||||
userId: 'firebase-user-a'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a restored Firebase session immediately', () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
hoisted.authStore.isInitialized = true
|
||||
|
||||
startSync()
|
||||
|
||||
expect(reportFirebaseAuthState.mock.calls).toEqual([
|
||||
[{ status: 'pending' }],
|
||||
[{ status: 'signed_in', userId: 'firebase-user-a' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('reports an initially signed-out Firebase session', () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
hoisted.authStore.isInitialized = true
|
||||
|
||||
startSync()
|
||||
|
||||
expect(reportFirebaseAuthState.mock.calls).toEqual([
|
||||
[{ status: 'pending' }],
|
||||
[{ status: 'signed_out' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('reports signed out when Firebase finishes initialization', async () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
startSync()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
|
||||
|
||||
hoisted.authStore.isInitialized = true
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'signed_out'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports account switches, logout, and subsequent login', async () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
hoisted.authStore.isInitialized = true
|
||||
startSync()
|
||||
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-b' }
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'signed_in',
|
||||
userId: 'firebase-user-b'
|
||||
})
|
||||
|
||||
hoisted.authStore.currentUser = null
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'signed_out'
|
||||
})
|
||||
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-c' }
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState.mock.calls).toEqual([
|
||||
[{ status: 'pending' }],
|
||||
[{ status: 'signed_in', userId: 'firebase-user-a' }],
|
||||
[{ status: 'signed_in', userId: 'firebase-user-b' }],
|
||||
[{ status: 'signed_out' }],
|
||||
[{ status: 'signed_in', userId: 'firebase-user-c' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('does not report again when Firebase replaces the user object with the same UID', async () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
hoisted.authStore.isInitialized = true
|
||||
startSync()
|
||||
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState.mock.calls).toEqual([
|
||||
[{ status: 'pending' }],
|
||||
[{ status: 'signed_in', userId: 'firebase-user-a' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('does not let a host reporting failure interrupt Firebase state sync', async () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
reportFirebaseAuthState.mockImplementationOnce(() => {
|
||||
throw new Error('host unavailable')
|
||||
})
|
||||
|
||||
expect(() => startSync()).not.toThrow()
|
||||
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
hoisted.authStore.isInitialized = true
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'signed_in',
|
||||
userId: 'firebase-user-a'
|
||||
})
|
||||
})
|
||||
})
|
||||
49
src/platform/telemetry/hostUserIdSync.ts
Normal file
49
src/platform/telemetry/hostUserIdSync.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { watch } from 'vue'
|
||||
import type { WatchStopHandle } from 'vue'
|
||||
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
function safelyReportFirebaseAuthState(report: () => void): void {
|
||||
try {
|
||||
report()
|
||||
} catch {
|
||||
// A host bridge failure must not block renderer startup or Firebase auth.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the Desktop main-process telemetry identity aligned with Firebase auth.
|
||||
* Must run after Pinia and VueFire are installed.
|
||||
*/
|
||||
export function syncHostUserIdWithFirebaseAuth(): WatchStopHandle | undefined {
|
||||
const telemetry = window.__comfyDesktop2?.Telemetry
|
||||
if (!telemetry) return
|
||||
|
||||
// Register this Cloud renderer before Firebase resolves. Desktop may host
|
||||
// multiple Cloud main frames whose isolated browser partitions have
|
||||
// different auth states, so main owns all cross-WebContents arbitration.
|
||||
safelyReportFirebaseAuthState(() =>
|
||||
telemetry.reportFirebaseAuthState?.({ status: 'pending' })
|
||||
)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
return watch(
|
||||
() =>
|
||||
authStore.isInitialized
|
||||
? (authStore.currentUser?.uid ?? null)
|
||||
: undefined,
|
||||
(userId) => {
|
||||
if (userId === undefined) return
|
||||
|
||||
safelyReportFirebaseAuthState(() =>
|
||||
telemetry.reportFirebaseAuthState?.(
|
||||
userId === null
|
||||
? { status: 'signed_out' }
|
||||
: { status: 'signed_in', userId }
|
||||
)
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const analytics = {
|
||||
identify: vi.fn(),
|
||||
track: vi.fn(),
|
||||
identify: vi.fn().mockResolvedValue(undefined),
|
||||
page: vi.fn(),
|
||||
track: vi.fn().mockResolvedValue(undefined),
|
||||
reset: vi.fn(),
|
||||
register: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
let resolvedCb: ((user: { id: string }) => void) | undefined
|
||||
let logoutCb: (() => void) | undefined
|
||||
const resolvedUserInfo = { value: null as { id: string } | null }
|
||||
return {
|
||||
analytics,
|
||||
load: vi.fn(() => analytics),
|
||||
inAppPlugin: vi.fn(() => ({ name: 'Customer.io In-App Plugin' })),
|
||||
userEmail: { value: null as string | null },
|
||||
onUserResolved: vi.fn((cb: (user: { id: string }) => void) => {
|
||||
resolvedCb = cb
|
||||
if (resolvedUserInfo.value) cb(resolvedUserInfo.value)
|
||||
}),
|
||||
onUserLogout: vi.fn((cb: () => void) => {
|
||||
logoutCb = cb
|
||||
}),
|
||||
resolveUser: (id: string) => resolvedCb?.({ id }),
|
||||
logoutUser: () => logoutCb?.()
|
||||
resolvedUserInfo,
|
||||
resolveUser: (id: string) => {
|
||||
resolvedUserInfo.value = { id }
|
||||
resolvedCb?.({ id })
|
||||
},
|
||||
logoutUser: () => {
|
||||
resolvedUserInfo.value = null
|
||||
logoutCb?.()
|
||||
},
|
||||
resetCallbacks: () => {
|
||||
resolvedCb = undefined
|
||||
logoutCb = undefined
|
||||
resolvedUserInfo.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -31,6 +47,8 @@ vi.mock('@customerio/cdp-analytics-browser', () => ({
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
userEmail: hoisted.userEmail,
|
||||
resolvedUserInfo: hoisted.resolvedUserInfo,
|
||||
onUserResolved: hoisted.onUserResolved,
|
||||
onUserLogout: hoisted.onUserLogout
|
||||
})
|
||||
@@ -54,14 +72,32 @@ function createProvider(
|
||||
return new CustomerIoTelemetryProvider()
|
||||
}
|
||||
|
||||
function createDeferred() {
|
||||
let resolve = () => {}
|
||||
const promise = new Promise<void>((complete) => {
|
||||
resolve = complete
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('CustomerIoTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hoisted.resetCallbacks()
|
||||
hoisted.load.mockReturnValue(hoisted.analytics)
|
||||
hoisted.analytics.identify.mockResolvedValue(undefined)
|
||||
hoisted.analytics.track.mockResolvedValue(undefined)
|
||||
hoisted.analytics.reset.mockReset().mockResolvedValue(undefined)
|
||||
hoisted.analytics.register.mockResolvedValue(undefined)
|
||||
hoisted.userEmail.value = null
|
||||
window.__CONFIG__ = {} as typeof window.__CONFIG__
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('loads the client and registers the in-app plugin with the site id', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
@@ -73,6 +109,89 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
expect(hoisted.analytics.register).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the current page after registering the in-app plugin', async () => {
|
||||
const provider = createProvider()
|
||||
provider.trackPageView('workflow_editor', {
|
||||
path: 'https://cloud.comfy.org/'
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledWith()
|
||||
expect(hoisted.analytics.register.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.analytics.page.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('queues page views until the in-app plugin is registered', async () => {
|
||||
let resolveRegistration: (() => void) | undefined
|
||||
const registration = new Promise<void>((resolve) => {
|
||||
resolveRegistration = resolve
|
||||
})
|
||||
hoisted.analytics.register.mockReturnValue(registration)
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackPageView('workflow_editor', {
|
||||
path: 'https://cloud.comfy.org/'
|
||||
})
|
||||
expect(hoisted.analytics.page).not.toHaveBeenCalled()
|
||||
|
||||
resolveRegistration?.()
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
|
||||
)
|
||||
})
|
||||
|
||||
it('reports client-side route changes', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.page).not.toHaveBeenCalled()
|
||||
|
||||
provider.trackPageView('workflow_editor', {
|
||||
path: 'https://cloud.comfy.org/'
|
||||
})
|
||||
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('continues tracking events and page views when the in-app plugin fails to register', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const registrationError = new Error('in-app setup failed')
|
||||
hoisted.analytics.register.mockRejectedValue(registrationError)
|
||||
const provider = createProvider()
|
||||
provider.trackWorkflowExecution()
|
||||
provider.trackPageView('workflow_editor', {
|
||||
path: 'https://cloud.comfy.org/'
|
||||
})
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'execution_start',
|
||||
SOURCE
|
||||
)
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Failed to initialize Customer.io in-app plugin:',
|
||||
registrationError
|
||||
)
|
||||
|
||||
provider.trackAddApiCreditButtonClicked()
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:add_api_credit_button_clicked',
|
||||
SOURCE
|
||||
)
|
||||
)
|
||||
provider.trackPageView('settings', {
|
||||
path: 'https://cloud.comfy.org/settings'
|
||||
})
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not initialize without a write key', async () => {
|
||||
const provider = createProvider({ customer_io: { site_id: SITE_ID } })
|
||||
await vi.dynamicImportSettled()
|
||||
@@ -89,13 +208,19 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
expect(hoisted.load).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('identifies the person by uid only on auth resolve', async () => {
|
||||
it('identifies the resolved user with uid and email traits', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.userEmail.value = 'user@example.com'
|
||||
hoisted.resolveUser('test-uid-7f3a9c')
|
||||
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith('test-uid-7f3a9c')
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith(
|
||||
'test-uid-7f3a9c',
|
||||
{ email: 'user@example.com' }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('identifies with the configured user_id override without waiting for auth', async () => {
|
||||
@@ -108,8 +233,54 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith('forced-uid')
|
||||
expect(hoisted.onUserResolved).not.toHaveBeenCalled()
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith(
|
||||
'forced-uid',
|
||||
undefined
|
||||
)
|
||||
expect(hoisted.onUserResolved).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('identifies a restored session with the configured user id once', async () => {
|
||||
hoisted.userEmail.value = 'restored@example.com'
|
||||
hoisted.resolvedUserInfo.value = { id: 'resolved-uid' }
|
||||
|
||||
createProvider({
|
||||
customer_io: {
|
||||
write_key: WRITE_KEY,
|
||||
site_id: SITE_ID,
|
||||
user_id: 'forced-uid'
|
||||
}
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith('forced-uid', {
|
||||
email: 'restored@example.com'
|
||||
})
|
||||
})
|
||||
|
||||
it('re-identifies with the configured user id after logout and re-login', async () => {
|
||||
createProvider({
|
||||
customer_io: {
|
||||
write_key: WRITE_KEY,
|
||||
site_id: SITE_ID,
|
||||
user_id: 'forced-uid'
|
||||
}
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.logoutUser()
|
||||
hoisted.userEmail.value = 'returning@example.com'
|
||||
hoisted.resolveUser('resolved-uid')
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'forced-uid',
|
||||
{ email: 'returning@example.com' }
|
||||
)
|
||||
)
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('identifies before flushing events buffered before the SDK loads', async () => {
|
||||
@@ -129,13 +300,99 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
expect(identifyOrder).toBeLessThan(trackOrder)
|
||||
})
|
||||
|
||||
it('restores the resolved user after flushing an older auth event', async () => {
|
||||
let activeUser: string | null = null
|
||||
const trackedUsers: Array<[string, string | null]> = []
|
||||
hoisted.analytics.identify.mockImplementation((userId: string) => {
|
||||
activeUser = userId
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.analytics.track.mockImplementation((event: string) => {
|
||||
trackedUsers.push([event, activeUser])
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.userEmail.value = 'current@example.com'
|
||||
hoisted.resolvedUserInfo.value = { id: 'current-uid' }
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'queued-uid',
|
||||
email: 'queued@example.com'
|
||||
})
|
||||
provider.trackWorkflowExecution()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify.mock.calls).toEqual([
|
||||
['current-uid', { email: 'current@example.com' }],
|
||||
['queued-uid', { email: 'queued@example.com' }],
|
||||
['current-uid', { email: 'current@example.com' }]
|
||||
])
|
||||
)
|
||||
expect(activeUser).toBe('current-uid')
|
||||
expect(trackedUsers).toEqual([
|
||||
['app:user_auth_completed', 'queued-uid'],
|
||||
['execution_start', 'current-uid']
|
||||
])
|
||||
})
|
||||
|
||||
it('resets identity after flushing auth for a signed-out user', async () => {
|
||||
let activeUser: string | null = null
|
||||
let trackedUser: string | null = null
|
||||
hoisted.analytics.identify.mockImplementation((userId: string) => {
|
||||
activeUser = userId
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.analytics.track.mockImplementation(() => {
|
||||
trackedUser = activeUser
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.analytics.reset.mockImplementation(() => {
|
||||
activeUser = null
|
||||
})
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'queued-uid',
|
||||
email: 'queued@example.com'
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
await vi.waitFor(() => expect(hoisted.analytics.reset).toHaveBeenCalled())
|
||||
expect(trackedUser).toBe('queued-uid')
|
||||
expect(activeUser).toBeNull()
|
||||
})
|
||||
|
||||
it('resets on logout', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.logoutUser()
|
||||
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
|
||||
)
|
||||
})
|
||||
|
||||
it('continues tracking after reset fails', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
hoisted.analytics.reset.mockRejectedValueOnce(new Error('reset failed'))
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.logoutUser()
|
||||
provider.trackWorkflowExecution()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'execution_start',
|
||||
SOURCE
|
||||
)
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to process Customer.io operation:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
const DIRECT_EVENTS: Array<{
|
||||
@@ -189,6 +446,7 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
invoke: (p) =>
|
||||
p.trackShareFlow({
|
||||
step: 'dialog_opened',
|
||||
share_id: 'share-1',
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false
|
||||
}),
|
||||
@@ -209,10 +467,280 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
|
||||
invoke(provider)
|
||||
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(event, expected)
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(event, expected)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('awaits auth identification before tracking without raw identifiers', async () => {
|
||||
const identifyResult = createDeferred()
|
||||
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com',
|
||||
share_id: 'share-1'
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith('uid-1', {
|
||||
email: 'person@example.com'
|
||||
})
|
||||
expect(hoisted.analytics.track).not.toHaveBeenCalled()
|
||||
|
||||
identifyResult.resolve()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{
|
||||
...SOURCE,
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'uid-1'
|
||||
}
|
||||
)
|
||||
)
|
||||
expect(hoisted.analytics.identify.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.analytics.track.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses matching resolved-user identification for auth delivery', async () => {
|
||||
const identifyResult = createDeferred()
|
||||
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.userEmail.value = 'person@example.com'
|
||||
hoisted.resolveUser('uid-1')
|
||||
provider.trackAuth({
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(hoisted.analytics.track).not.toHaveBeenCalled()
|
||||
identifyResult.resolve()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{ ...SOURCE, user_id: 'uid-1' }
|
||||
)
|
||||
)
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('tracks auth before resetting identity on logout', async () => {
|
||||
const identifyResult = createDeferred()
|
||||
const trackResult = createDeferred()
|
||||
let activeUser: string | null = null
|
||||
let trackedUser: string | null = null
|
||||
hoisted.analytics.identify.mockImplementationOnce((userId: string) => {
|
||||
activeUser = userId
|
||||
return identifyResult.promise
|
||||
})
|
||||
hoisted.analytics.track.mockImplementationOnce(() => {
|
||||
trackedUser = activeUser
|
||||
return trackResult.promise
|
||||
})
|
||||
hoisted.analytics.reset.mockImplementationOnce(() => {
|
||||
activeUser = null
|
||||
})
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
hoisted.logoutUser()
|
||||
identifyResult.resolve()
|
||||
|
||||
await vi.waitFor(() => expect(hoisted.analytics.reset).toHaveBeenCalled())
|
||||
expect(trackedUser).toBe('uid-1')
|
||||
expect(hoisted.analytics.track.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.analytics.reset.mock.invocationCallOrder[0]
|
||||
)
|
||||
trackResult.resolve()
|
||||
})
|
||||
|
||||
it('restores signed-out identity when auth delivery follows logout', async () => {
|
||||
let activeUser: string | null = null
|
||||
let trackedUser: string | null = null
|
||||
hoisted.analytics.identify.mockImplementation((userId: string) => {
|
||||
activeUser = userId
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.analytics.track.mockImplementation(() => {
|
||||
trackedUser = activeUser
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.analytics.reset.mockImplementation(() => {
|
||||
activeUser = null
|
||||
})
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.userEmail.value = 'person@example.com'
|
||||
hoisted.resolveUser('uid-1')
|
||||
hoisted.logoutUser()
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
expect(trackedUser).toBe('uid-1')
|
||||
expect(activeUser).toBeNull()
|
||||
expect(hoisted.analytics.track.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.analytics.reset.mock.invocationCallOrder[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('restores a configured identity after tracking auth with the Firebase uid', async () => {
|
||||
const provider = createProvider({
|
||||
customer_io: {
|
||||
write_key: WRITE_KEY,
|
||||
site_id: SITE_ID,
|
||||
user_id: 'forced-uid'
|
||||
}
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
hoisted.analytics.identify.mockClear()
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'firebase-uid',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify.mock.calls).toEqual([
|
||||
['firebase-uid', { email: 'person@example.com' }],
|
||||
['forced-uid', undefined]
|
||||
])
|
||||
)
|
||||
expect(hoisted.analytics.track.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.analytics.identify.mock.invocationCallOrder[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('does not reset identity when login resolution follows auth tracking', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ user_id: 'uid-1', email: 'person@example.com' })
|
||||
hoisted.userEmail.value = 'person@example.com'
|
||||
hoisted.resolveUser('uid-1')
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{ ...SOURCE, user_id: 'uid-1' }
|
||||
)
|
||||
)
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
|
||||
expect(hoisted.analytics.reset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not stall later events when identification never settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
hoisted.analytics.identify.mockReturnValueOnce(new Promise(() => {}))
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ user_id: 'uid-1', email: 'person@example.com' })
|
||||
provider.trackWorkflowExecution()
|
||||
expect(hoisted.analytics.track).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track.mock.calls).toEqual([
|
||||
['app:user_auth_completed', { ...SOURCE, user_id: 'uid-1' }],
|
||||
['execution_start', SOURCE]
|
||||
])
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to identify Customer.io user:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks auth after identifying a user without an email', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ user_id: 'uid-without-email' })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith(
|
||||
'uid-without-email',
|
||||
undefined
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{ ...SOURCE, user_id: 'uid-without-email' }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks auth without identifying when user_id is absent', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ method: 'google', email: 'person@example.com' })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{ ...SOURCE, method: 'google' }
|
||||
)
|
||||
)
|
||||
expect(hoisted.analytics.identify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks auth after identification fails', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
hoisted.analytics.identify.mockRejectedValueOnce(
|
||||
new Error('identify failed')
|
||||
)
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{ ...SOURCE, user_id: 'uid-1' }
|
||||
)
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to identify Customer.io user:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('flushes events buffered before load once, in order', async () => {
|
||||
const provider = createProvider()
|
||||
provider.trackWorkflowExecution()
|
||||
@@ -227,6 +755,67 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('waits for queued auth identification before later events', async () => {
|
||||
const identifyResult = createDeferred()
|
||||
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
|
||||
const provider = createProvider()
|
||||
provider.trackAuth({
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
provider.trackWorkflowExecution()
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.track).not.toHaveBeenCalled()
|
||||
identifyResult.resolve()
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track.mock.calls).toEqual([
|
||||
['app:user_auth_completed', { ...SOURCE, user_id: 'uid-1' }],
|
||||
['execution_start', SOURCE]
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('does not wait for event delivery before handing off later events', async () => {
|
||||
const trackResult = createDeferred()
|
||||
hoisted.analytics.track.mockReturnValueOnce(trackResult.promise)
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackWorkflowExecution()
|
||||
provider.trackAddApiCreditButtonClicked()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track.mock.calls).toEqual([
|
||||
['execution_start', SOURCE],
|
||||
['app:add_api_credit_button_clicked', SOURCE]
|
||||
])
|
||||
)
|
||||
trackResult.resolve()
|
||||
})
|
||||
|
||||
it('snapshots resolved user email before queued identification', async () => {
|
||||
const identifyResult = createDeferred()
|
||||
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ user_id: 'blocking-uid' })
|
||||
hoisted.userEmail.value = 'first@example.com'
|
||||
hoisted.resolveUser('resolved-uid')
|
||||
hoisted.userEmail.value = 'second@example.com'
|
||||
identifyResult.resolve()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'resolved-uid',
|
||||
{ email: 'first@example.com' }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('disables tracking when the SDK fails to load', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
hoisted.load.mockImplementation(() => {
|
||||
@@ -253,12 +842,12 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
provider.trackWorkflowExecution()
|
||||
provider.trackAddApiCreditButtonClicked()
|
||||
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledTimes(2)
|
||||
await vi.waitFor(() =>
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to track Customer.io event:',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to track Customer.io event:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { AnalyticsBrowser } from '@customerio/cdp-analytics-browser'
|
||||
import { omit, withTimeout } from 'es-toolkit'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import type { AuthUserInfo } from '@/types/authTypes'
|
||||
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
PageViewMetadata,
|
||||
ShareFlowMetadata,
|
||||
SubscriptionMetadata,
|
||||
TelemetryEventProperties,
|
||||
@@ -16,9 +19,17 @@ import type {
|
||||
|
||||
export const EVENT_SOURCE = 'web-sdk'
|
||||
|
||||
const SDK_OPERATION_TIMEOUT_MS = 10_000
|
||||
|
||||
interface QueuedEvent {
|
||||
event: string
|
||||
properties: Record<string, unknown>
|
||||
identity?: CustomerIoIdentity
|
||||
}
|
||||
|
||||
interface CustomerIoIdentity {
|
||||
userId: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +42,12 @@ interface QueuedEvent {
|
||||
export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
private analytics: AnalyticsBrowser | null = null
|
||||
private isEnabled = true
|
||||
private isPageViewTrackingReady = false
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private pageViewQueued = false
|
||||
private identifiedUser: CustomerIoIdentity | null = null
|
||||
private sessionIdentity: CustomerIoIdentity | null = null
|
||||
private operationQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor() {
|
||||
const {
|
||||
@@ -39,6 +55,7 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
site_id: siteId,
|
||||
user_id: userIdOverride
|
||||
} = window.__CONFIG__?.customer_io ?? {}
|
||||
this.sessionIdentity = userIdOverride ? { userId: userIdOverride } : null
|
||||
if (!writeKey || !siteId) {
|
||||
this.isEnabled = false
|
||||
return
|
||||
@@ -47,7 +64,7 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
void import('@customerio/cdp-analytics-browser')
|
||||
.then(({ AnalyticsBrowser, InAppPlugin }) => {
|
||||
const analytics = AnalyticsBrowser.load({ writeKey })
|
||||
void analytics.register(
|
||||
const inAppRegistration = analytics.register(
|
||||
InAppPlugin({
|
||||
siteId,
|
||||
events: null,
|
||||
@@ -60,14 +77,40 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
this.analytics = analytics
|
||||
|
||||
const currentUser = useCurrentUser()
|
||||
if (userIdOverride) {
|
||||
void analytics.identify(userIdOverride)
|
||||
} else {
|
||||
currentUser.onUserResolved((user) => void analytics.identify(user.id))
|
||||
const identifyResolvedUser = (user: AuthUserInfo) => {
|
||||
const identity = {
|
||||
userId: userIdOverride || user.id,
|
||||
email: currentUser.userEmail.value || undefined
|
||||
}
|
||||
this.sessionIdentity = identity
|
||||
return this.enqueueOperation(() => this.identify(identity))
|
||||
}
|
||||
currentUser.onUserLogout(() => void analytics.reset())
|
||||
|
||||
this.flushQueue()
|
||||
if (userIdOverride && !currentUser.resolvedUserInfo.value) {
|
||||
void this.enqueueOperation(() =>
|
||||
this.identify({ userId: userIdOverride })
|
||||
)
|
||||
}
|
||||
currentUser.onUserResolved((user) => {
|
||||
void identifyResolvedUser(user)
|
||||
})
|
||||
currentUser.onUserLogout(() => {
|
||||
this.sessionIdentity = null
|
||||
void this.enqueueOperation(() => this.resetIdentity())
|
||||
})
|
||||
|
||||
void this.flushQueue()
|
||||
void inAppRegistration
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to initialize Customer.io in-app plugin:',
|
||||
error
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isPageViewTrackingReady = true
|
||||
this.flushPageView()
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load Customer.io:', error)
|
||||
@@ -76,32 +119,134 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
})
|
||||
}
|
||||
|
||||
private send(event: string, properties: Record<string, unknown>): void {
|
||||
void this.analytics?.track(event, properties)?.catch((error) => {
|
||||
console.error('Failed to track Customer.io event:', error)
|
||||
private enqueueOperation(
|
||||
operation: () => Promise<void> | void
|
||||
): Promise<void> {
|
||||
this.operationQueue = this.operationQueue.then(operation).catch((error) => {
|
||||
console.error('Failed to process Customer.io operation:', error)
|
||||
})
|
||||
return this.operationQueue
|
||||
}
|
||||
|
||||
private track(event: string, metadata?: TelemetryEventProperties): void {
|
||||
private async resetIdentity(): Promise<void> {
|
||||
this.identifiedUser = null
|
||||
const analytics = this.analytics
|
||||
if (!analytics) return
|
||||
await withTimeout(async () => {
|
||||
await analytics.reset()
|
||||
}, SDK_OPERATION_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private async restoreSessionIdentity(): Promise<void> {
|
||||
if (this.sessionIdentity) {
|
||||
await this.identify(this.sessionIdentity)
|
||||
} else {
|
||||
await this.resetIdentity()
|
||||
}
|
||||
}
|
||||
|
||||
private async identify(identity: CustomerIoIdentity): Promise<void> {
|
||||
const analytics = this.analytics
|
||||
if (!analytics) return
|
||||
|
||||
if (
|
||||
this.identifiedUser?.userId === identity.userId &&
|
||||
this.identifiedUser.email === identity.email
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.identifiedUser = identity
|
||||
try {
|
||||
await withTimeout(async () => {
|
||||
await analytics.identify(
|
||||
identity.userId,
|
||||
identity.email ? { email: identity.email } : undefined
|
||||
)
|
||||
}, SDK_OPERATION_TIMEOUT_MS)
|
||||
} catch (error) {
|
||||
this.identifiedUser = null
|
||||
console.error('Failed to identify Customer.io user:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private async send(
|
||||
event: string,
|
||||
properties: Record<string, unknown>,
|
||||
identity?: CustomerIoIdentity
|
||||
): Promise<void> {
|
||||
const analytics = this.analytics
|
||||
if (!analytics) return
|
||||
|
||||
if (identity) await this.identify(identity)
|
||||
|
||||
void analytics.track(event, properties).catch((error) => {
|
||||
console.error('Failed to track Customer.io event:', error)
|
||||
})
|
||||
|
||||
if (identity) await this.restoreSessionIdentity()
|
||||
}
|
||||
|
||||
private track(
|
||||
event: string,
|
||||
metadata?: TelemetryEventProperties,
|
||||
identity?: CustomerIoIdentity
|
||||
): void {
|
||||
if (!this.isEnabled) return
|
||||
const properties = { ...metadata, event_source: EVENT_SOURCE }
|
||||
if (this.analytics) {
|
||||
this.send(event, properties)
|
||||
void this.enqueueOperation(() => this.send(event, properties, identity))
|
||||
} else {
|
||||
this.eventQueue.push({ event, properties })
|
||||
this.eventQueue.push({ event, properties, identity })
|
||||
}
|
||||
}
|
||||
|
||||
private flushQueue(): void {
|
||||
private async flushQueue(): Promise<void> {
|
||||
if (!this.analytics) return
|
||||
for (const { event, properties } of this.eventQueue) {
|
||||
this.send(event, properties)
|
||||
}
|
||||
const queue = this.eventQueue
|
||||
this.eventQueue = []
|
||||
await this.enqueueOperation(async () => {
|
||||
for (const { event, properties, identity } of queue) {
|
||||
await this.send(event, properties, identity)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private sendPageView(): void {
|
||||
void this.analytics?.page()?.catch((error) => {
|
||||
console.error('Failed to track Customer.io page view:', error)
|
||||
})
|
||||
}
|
||||
|
||||
private flushPageView(): void {
|
||||
if (!this.isPageViewTrackingReady || !this.pageViewQueued) {
|
||||
return
|
||||
}
|
||||
this.pageViewQueued = false
|
||||
this.sendPageView()
|
||||
}
|
||||
|
||||
trackPageView(_pageName: string, _properties?: PageViewMetadata): void {
|
||||
if (!this.isEnabled) return
|
||||
if (!this.isPageViewTrackingReady) {
|
||||
this.pageViewQueued = true
|
||||
return
|
||||
}
|
||||
this.sendPageView()
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
this.track(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
|
||||
const identity = metadata.user_id
|
||||
? {
|
||||
userId: metadata.user_id,
|
||||
email: metadata.email || undefined
|
||||
}
|
||||
: undefined
|
||||
this.track(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
omit(metadata, ['email', 'share_id']),
|
||||
identity
|
||||
)
|
||||
}
|
||||
|
||||
trackSubscription(
|
||||
@@ -137,6 +282,6 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
|
||||
trackShareFlow(metadata: ShareFlowMetadata): void {
|
||||
this.track(TelemetryEvents.SHARE_FLOW, metadata)
|
||||
this.track(TelemetryEvents.SHARE_FLOW, omit(metadata, ['share_id']))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ import type {
|
||||
AuthMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
OnboardingTourMetadata,
|
||||
OnboardingTourStage,
|
||||
RunButtonProperties,
|
||||
ShareFlowMetadata,
|
||||
ShellLayoutMetadata,
|
||||
@@ -460,6 +462,35 @@ 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]
|
||||
])(
|
||||
'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,6 +20,8 @@ import type {
|
||||
HelpResourceClickedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
OnboardingTourMetadata,
|
||||
OnboardingTourStage,
|
||||
PageVisibilityMetadata,
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
@@ -44,7 +46,7 @@ import type {
|
||||
} from '../../types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import { OnboardingTourEvents, TelemetryEvents } from '../../types'
|
||||
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
|
||||
|
||||
const DEFAULT_DISABLED_EVENTS = [
|
||||
@@ -280,6 +282,13 @@ 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,6 +4,7 @@ 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()
|
||||
@@ -718,6 +719,38 @@ 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]
|
||||
])(
|
||||
'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,6 +24,8 @@ import type {
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
OnboardingTourMetadata,
|
||||
OnboardingTourStage,
|
||||
SearchQueryMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
@@ -50,7 +52,11 @@ import type {
|
||||
WorkflowSavedMetadata,
|
||||
WorkspaceInviteMetadata
|
||||
} from '../../types'
|
||||
import { CANCELLATION_STAGE_EVENTS, TelemetryEvents } from '../../types'
|
||||
import {
|
||||
CANCELLATION_STAGE_EVENTS,
|
||||
OnboardingTourEvents,
|
||||
TelemetryEvents
|
||||
} from '../../types'
|
||||
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
|
||||
|
||||
const DEFAULT_DISABLED_EVENTS = [
|
||||
@@ -421,6 +427,13 @@ 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
|
||||
|
||||
@@ -93,6 +93,57 @@ 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
|
||||
@@ -583,6 +634,12 @@ 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
|
||||
|
||||
@@ -689,6 +746,17 @@ 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',
|
||||
@@ -752,6 +820,21 @@ 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,
|
||||
@@ -772,6 +855,7 @@ export type ExecutionTriggerSource =
|
||||
export type TelemetryEventProperties =
|
||||
| AuthMetadata
|
||||
| AuthErrorMetadata
|
||||
| OnboardingTourMetadata
|
||||
| SurveyResponses
|
||||
| TemplateMetadata
|
||||
| ExecutionContext
|
||||
|
||||
@@ -5,6 +5,8 @@ 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'
|
||||
|
||||
@@ -58,11 +60,17 @@ 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: vi.fn()
|
||||
loadTemplateFromUrl: templateLoaderMocks.loadTemplateFromUrl
|
||||
})
|
||||
})
|
||||
)
|
||||
@@ -125,7 +133,57 @@ vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
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
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../migration/migrateV1toV2', () => ({
|
||||
@@ -206,6 +264,12 @@ 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(() => {
|
||||
@@ -617,5 +681,156 @@ 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,11 +16,14 @@ 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'
|
||||
@@ -28,9 +31,17 @@ 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'
|
||||
@@ -52,6 +63,14 @@ 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
|
||||
@@ -179,7 +198,12 @@ export function useWorkflowPersistenceV2() {
|
||||
await settingStore.set('Comfy.TutorialCompleted', true)
|
||||
await useWorkflowService().loadBlankWorkflow()
|
||||
if (!hasSharedWorkflowIntent() && !hasTemplateUrlIntent()) {
|
||||
await useCommandStore().execute('Comfy.BrowseTemplates')
|
||||
if (isOnboardingCandidate(onboardingDeps)) {
|
||||
void templatesStore.loadWorkflowTemplates()
|
||||
entryStore.showGettingStarted()
|
||||
} else {
|
||||
await useCommandStore().execute('Comfy.BrowseTemplates')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await comfyApp.loadGraphData()
|
||||
@@ -223,12 +247,10 @@ export function useWorkflowPersistenceV2() {
|
||||
}
|
||||
|
||||
const loadTemplateFromUrlIfPresent = async () => {
|
||||
const query = await ensureTemplateQueryFromIntent()
|
||||
const hasTemplateUrl = query.template && typeof query.template === 'string'
|
||||
|
||||
if (hasTemplateUrl) {
|
||||
await templateUrlLoader.loadTemplateFromUrl()
|
||||
}
|
||||
// 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 loadSharedWorkflowFromUrlIfPresent = async () => {
|
||||
|
||||
57
src/platform/workflow/persistence/onboardingEntryStore.ts
Normal file
57
src/platform/workflow/persistence/onboardingEntryStore.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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,6 +97,51 @@ 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,6 +10,11 @@ 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
|
||||
*
|
||||
@@ -64,18 +69,18 @@ export function useTemplateUrlLoader() {
|
||||
* Loads template from URL query parameters if present
|
||||
* Handles errors internally and shows appropriate user feedback
|
||||
*/
|
||||
const loadTemplateFromUrl = async () => {
|
||||
const loadTemplateFromUrl = async (): Promise<TemplateUrlLoadResult> => {
|
||||
const templateParam = route.query.template
|
||||
|
||||
if (!templateParam || typeof templateParam !== 'string') {
|
||||
return
|
||||
return { loaded: false }
|
||||
}
|
||||
|
||||
if (!isValidParameter(templateParam)) {
|
||||
console.warn(
|
||||
`[useTemplateUrlLoader] Invalid template parameter format: ${templateParam}`
|
||||
)
|
||||
return
|
||||
return { loaded: false }
|
||||
}
|
||||
|
||||
const sourceParam = (route.query.source as string | undefined) || 'default'
|
||||
@@ -84,7 +89,7 @@ export function useTemplateUrlLoader() {
|
||||
console.warn(
|
||||
`[useTemplateUrlLoader] Invalid source parameter format: ${sourceParam}`
|
||||
)
|
||||
return
|
||||
return { loaded: false }
|
||||
}
|
||||
|
||||
const modeParam = route.query.mode as string | undefined
|
||||
@@ -96,7 +101,7 @@ export function useTemplateUrlLoader() {
|
||||
console.warn(
|
||||
`[useTemplateUrlLoader] Invalid mode parameter format: ${modeParam}`
|
||||
)
|
||||
return
|
||||
return { loaded: false }
|
||||
}
|
||||
|
||||
if (modeParam && !isSupportedMode(modeParam)) {
|
||||
@@ -121,11 +126,16 @@ export function useTemplateUrlLoader() {
|
||||
templateName: templateParam
|
||||
})
|
||||
})
|
||||
} else if (modeParam === 'linear') {
|
||||
return { loaded: false }
|
||||
}
|
||||
|
||||
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:',
|
||||
@@ -136,6 +146,7 @@ export function useTemplateUrlLoader() {
|
||||
summary: t('g.error'),
|
||||
detail: t('g.errorLoadingTemplate')
|
||||
})
|
||||
return { loaded: false }
|
||||
} finally {
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(TEMPLATE_NAMESPACE)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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 />'
|
||||
})
|
||||
}
|
||||
242
src/renderer/extensions/firstRunTour/FirstRunTourNudge.test.ts
Normal file
242
src/renderer/extensions/firstRunTour/FirstRunTourNudge.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
120
src/renderer/extensions/firstRunTour/FirstRunTourNudge.vue
Normal file
120
src/renderer/extensions/firstRunTour/FirstRunTourNudge.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<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>
|
||||
@@ -0,0 +1,84 @@
|
||||
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
|
||||
}
|
||||
558
src/renderer/extensions/firstRunTour/FirstRunTourOverlay.test.ts
Normal file
558
src/renderer/extensions/firstRunTour/FirstRunTourOverlay.test.ts
Normal file
@@ -0,0 +1,558 @@
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
441
src/renderer/extensions/firstRunTour/FirstRunTourOverlay.vue
Normal file
441
src/renderer/extensions/firstRunTour/FirstRunTourOverlay.vue
Normal file
@@ -0,0 +1,441 @@
|
||||
<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>
|
||||
91
src/renderer/extensions/firstRunTour/GettingStartedCard.vue
Normal file
91
src/renderer/extensions/firstRunTour/GettingStartedCard.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<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"
|
||||
: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>
|
||||
@@ -0,0 +1,34 @@
|
||||
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 }
|
||||
@@ -0,0 +1,274 @@
|
||||
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 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()
|
||||
})
|
||||
})
|
||||
203
src/renderer/extensions/firstRunTour/GettingStartedScreen.vue
Normal file
203
src/renderer/extensions/firstRunTour/GettingStartedScreen.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<Teleport v-if="visible" to="body">
|
||||
<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-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>
|
||||
<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>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
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 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) {
|
||||
templatesStore.loadWorkflowTemplates().catch((error: unknown) => {
|
||||
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
|
||||
// Keep the screen up (the card shows a spinner) through the load and the
|
||||
// tour's readiness gate: a failed load leaves the user here to pick again,
|
||||
// and the tour overlay takes over on top before the screen is dismissed, 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 {
|
||||
await controller.beginTour({ templateId: id })
|
||||
entryStore.dismissGettingStarted()
|
||||
} catch (error) {
|
||||
// Keep the screen up so the user can retry rather than landing on a
|
||||
// half-started tour behind a dismissed takeover.
|
||||
console.error('Failed to start onboarding tour:', error)
|
||||
}
|
||||
} finally {
|
||||
loadingTemplateId.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
<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>
|
||||
@@ -0,0 +1,17 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { ComfyWorkflowJSON } 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 serialized workflow from the committed
|
||||
* fixtures. Used to prove the resolver's heuristics against ground truth.
|
||||
*/
|
||||
export function loadTemplateWorkflow(id: CuratedTemplateId): ComfyWorkflowJSON {
|
||||
const file = path.join(TEMPLATES_DIR, `${id}.json`)
|
||||
return JSON.parse(fs.readFileSync(file, 'utf-8')) as ComfyWorkflowJSON
|
||||
}
|
||||
1378
src/renderer/extensions/firstRunTour/__fixtures__/templates/flux_kontext_dev_basic.json
generated
Normal file
1378
src/renderer/extensions/firstRunTour/__fixtures__/templates/flux_kontext_dev_basic.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
2066
src/renderer/extensions/firstRunTour/__fixtures__/templates/image_krea2_turbo_t2i.json
generated
Normal file
2066
src/renderer/extensions/firstRunTour/__fixtures__/templates/image_krea2_turbo_t2i.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
1064
src/renderer/extensions/firstRunTour/__fixtures__/templates/image_z_image_turbo.json
generated
Normal file
1064
src/renderer/extensions/firstRunTour/__fixtures__/templates/image_z_image_turbo.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
5061
src/renderer/extensions/firstRunTour/__fixtures__/templates/video_ltx2_3_i2v.json
generated
Normal file
5061
src/renderer/extensions/firstRunTour/__fixtures__/templates/video_ltx2_3_i2v.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
3154
src/renderer/extensions/firstRunTour/__fixtures__/templates/video_wan2_2_14B_i2v.json
generated
Normal file
3154
src/renderer/extensions/firstRunTour/__fixtures__/templates/video_wan2_2_14B_i2v.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,374 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
233
src/renderer/extensions/firstRunTour/canvasSpotlightAdapter.ts
Normal file
233
src/renderer/extensions/firstRunTour/canvasSpotlightAdapter.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
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]}`
|
||||
}
|
||||
363
src/renderer/extensions/firstRunTour/firstRunTourStore.test.ts
Normal file
363
src/renderer/extensions/firstRunTour/firstRunTourStore.test.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
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 })
|
||||
}))
|
||||
|
||||
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)
|
||||
getNodeImageUrls.mockReturnValue(['blob:sink-output'])
|
||||
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
|
||||
// The cloud queue refresh fills the output just after execution_success.
|
||||
getNodeImageUrls.mockReturnValueOnce(undefined)
|
||||
getNodeImageUrls.mockReturnValue(['blob:late-output'])
|
||||
|
||||
await store.captureResultMedia()
|
||||
|
||||
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
|
||||
getNodeImageUrls.mockReturnValueOnce(undefined)
|
||||
getNodeImageUrls.mockReturnValue(['blob:late-output'])
|
||||
|
||||
// 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()
|
||||
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()
|
||||
getNodeImageUrls.mockReturnValue(['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
|
||||
getNodeImageUrls.mockReturnValue(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)
|
||||
})
|
||||
})
|
||||
167
src/renderer/extensions/firstRunTour/firstRunTourStore.ts
Normal file
167
src/renderer/extensions/firstRunTour/firstRunTourStore.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
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 sink = resolvedRoles.value?.sink
|
||||
if (!sink) return
|
||||
|
||||
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
|
||||
})
|
||||
if (!url || !isActive.value) return
|
||||
|
||||
resultMedia.value = { url, kind: resolvedRoles.value?.mediaKind ?? 'image' }
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
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)
|
||||
}
|
||||
72
src/renderer/extensions/firstRunTour/roleResolution.test.ts
Normal file
72
src/renderer/extensions/firstRunTour/roleResolution.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
35
src/renderer/extensions/firstRunTour/roleResolution.ts
Normal file
35
src/renderer/extensions/firstRunTour/roleResolution.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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)
|
||||
}
|
||||
564
src/renderer/extensions/firstRunTour/roleResolver.test.ts
Normal file
564
src/renderer/extensions/firstRunTour/roleResolver.test.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
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))
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
540
src/renderer/extensions/firstRunTour/roleResolver.ts
Normal file
540
src/renderer/extensions/firstRunTour/roleResolver.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
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)
|
||||
|
||||
const prompt = host
|
||||
? resolveSubgraphPrompt(host.node.id, host.subgraph)
|
||||
: resolveTopLevelPrompt(graph)
|
||||
|
||||
const override = overrideFor(templateId)
|
||||
if (override) return applyOverride(graph, override, prompt)
|
||||
|
||||
const sink = findSink(graph, nodeDefLookup)
|
||||
const source = findSource(graph)
|
||||
const engine = firstSampler(host ? host.subgraph.nodes : 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
11
src/renderer/extensions/firstRunTour/subgraphNavigation.ts
Normal file
11
src/renderer/extensions/firstRunTour/subgraphNavigation.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
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)
|
||||
}
|
||||
132
src/renderer/extensions/firstRunTour/tourSequence.test.ts
Normal file
132
src/renderer/extensions/firstRunTour/tourSequence.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
104
src/renderer/extensions/firstRunTour/tourSequence.ts
Normal file
104
src/renderer/extensions/firstRunTour/tourSequence.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
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' })
|
||||
)
|
||||
}
|
||||
56
src/renderer/extensions/firstRunTour/tutorialCards.ts
Normal file
56
src/renderer/extensions/firstRunTour/tutorialCards.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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'
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,922 @@
|
||||
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 () => {
|
||||
await useFirstRunTourController().beginTour({
|
||||
templateId: 'image_z_image_turbo'
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
await useFirstRunTourController().beginTour({ templateId: 'x' })
|
||||
|
||||
expect(mocks.storePrepare).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start a second tour while one is already active', async () => {
|
||||
mocks.isActive.value = true
|
||||
|
||||
await useFirstRunTourController().beginTour({ templateId: 'x' })
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,355 @@
|
||||
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 = {}) {
|
||||
if (!shouldStartTour()) return
|
||||
if (isPreparing.value || store.isActive) return
|
||||
|
||||
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
|
||||
|
||||
// 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()
|
||||
} 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
|
||||
)
|
||||
201
src/renderer/extensions/firstRunTour/useTourChoreography.test.ts
Normal file
201
src/renderer/extensions/firstRunTour/useTourChoreography.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type * as adapterModule from './canvasSpotlightAdapter'
|
||||
import { TOUR_FOCUS_DURATION_MS } from './canvasSpotlightAdapter'
|
||||
|
||||
type AdapterModule = typeof adapterModule
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
transformKey: 'a' as string | null
|
||||
}))
|
||||
|
||||
vi.mock('./canvasSpotlightAdapter', async (importOriginal) => ({
|
||||
...(await importOriginal<AdapterModule>()),
|
||||
canvasTransformKey: () => mocks.transformKey
|
||||
}))
|
||||
|
||||
import { MARK_GLIDE_MS, useTourChoreography } from './useTourChoreography'
|
||||
|
||||
const INTRO_PREVIEW_MS = 500
|
||||
const SETTLE_WATCHDOG_MS = TOUR_FOCUS_DURATION_MS + 200
|
||||
|
||||
function setup(options: { reduceMotion?: boolean; isStatic?: boolean } = {}) {
|
||||
const frameTarget = vi.fn()
|
||||
const choreography = useTourChoreography({
|
||||
reduceMotion: ref(options.reduceMotion ?? false),
|
||||
isStatic: ref(options.isStatic ?? false),
|
||||
frameTarget
|
||||
})
|
||||
return { ...choreography, frameTarget }
|
||||
}
|
||||
|
||||
/** Drive the rAF sampler until the real trackSettle sees a still transform. */
|
||||
function sampleUntilSettled(sampleFrame: () => void) {
|
||||
for (let i = 0; i < 4; i++) sampleFrame()
|
||||
}
|
||||
|
||||
describe('useTourChoreography', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.transformKey = 'a'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('holds the framing until the mark has glided', () => {
|
||||
const { beginStep, frameTarget } = setup()
|
||||
|
||||
beginStep()
|
||||
|
||||
expect(frameTarget).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(MARK_GLIDE_MS)
|
||||
expect(frameTarget).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reveals the copy once the canvas transform holds still', () => {
|
||||
const { beginStep, sampleFrame, copyVisible, cameraSettled } = setup()
|
||||
|
||||
beginStep()
|
||||
vi.advanceTimersByTime(MARK_GLIDE_MS)
|
||||
expect(copyVisible.value).toBe(false)
|
||||
|
||||
sampleUntilSettled(sampleFrame)
|
||||
|
||||
expect(copyVisible.value).toBe(true)
|
||||
expect(cameraSettled.value).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the copy hidden while the camera is still moving', () => {
|
||||
const { beginStep, sampleFrame, copyVisible } = setup()
|
||||
|
||||
beginStep()
|
||||
vi.advanceTimersByTime(MARK_GLIDE_MS)
|
||||
for (const key of ['b', 'c', 'd', 'e']) {
|
||||
mocks.transformKey = key
|
||||
sampleFrame()
|
||||
}
|
||||
|
||||
expect(copyVisible.value).toBe(false)
|
||||
})
|
||||
|
||||
it('releases the copy on the watchdog when the camera never settles', () => {
|
||||
const { beginStep, sampleFrame, copyVisible } = setup()
|
||||
|
||||
beginStep()
|
||||
vi.advanceTimersByTime(MARK_GLIDE_MS)
|
||||
mocks.transformKey = 'moving'
|
||||
sampleFrame()
|
||||
expect(copyVisible.value).toBe(false)
|
||||
|
||||
vi.advanceTimersByTime(SETTLE_WATCHDOG_MS)
|
||||
|
||||
expect(copyVisible.value).toBe(true)
|
||||
})
|
||||
|
||||
it('unpins the mark whenever the canvas moves under it', () => {
|
||||
const { beginStep, sampleFrame, markGlides } = setup()
|
||||
|
||||
beginStep()
|
||||
expect(markGlides.value).toBe(true)
|
||||
|
||||
mocks.transformKey = 'moved'
|
||||
sampleFrame()
|
||||
|
||||
expect(markGlides.value).toBe(false)
|
||||
})
|
||||
|
||||
it('frames a static step immediately and shows its copy without waiting', () => {
|
||||
const { beginStep, frameTarget, copyVisible } = setup({ isStatic: true })
|
||||
|
||||
beginStep()
|
||||
|
||||
expect(copyVisible.value).toBe(true)
|
||||
expect(frameTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('previews the workflow undimmed before dimming to the first step', () => {
|
||||
const { openTour, revealed, frameTarget } = setup()
|
||||
|
||||
openTour()
|
||||
expect(revealed.value).toBe(false)
|
||||
|
||||
vi.advanceTimersByTime(INTRO_PREVIEW_MS)
|
||||
|
||||
expect(revealed.value).toBe(true)
|
||||
// The first step frames without a glide delay: there is no prior mark to move.
|
||||
expect(frameTarget).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('skips the preview and the glide when motion is reduced', () => {
|
||||
const { openTour, revealed, copyVisible, frameTarget } = setup({
|
||||
reduceMotion: true
|
||||
})
|
||||
|
||||
openTour()
|
||||
|
||||
expect(revealed.value).toBe(true)
|
||||
expect(copyVisible.value).toBe(true)
|
||||
expect(frameTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the copy again on a step change', () => {
|
||||
const { beginStep, sampleFrame, resetStep, copyVisible, cameraSettled } =
|
||||
setup()
|
||||
|
||||
beginStep()
|
||||
vi.advanceTimersByTime(MARK_GLIDE_MS)
|
||||
sampleUntilSettled(sampleFrame)
|
||||
expect(copyVisible.value).toBe(true)
|
||||
|
||||
resetStep()
|
||||
|
||||
expect(copyVisible.value).toBe(false)
|
||||
expect(cameraSettled.value).toBe(false)
|
||||
})
|
||||
|
||||
it('drops a pending framing when the step is reset before it runs', () => {
|
||||
const { beginStep, resetStep, frameTarget } = setup()
|
||||
|
||||
beginStep()
|
||||
resetStep()
|
||||
vi.advanceTimersByTime(MARK_GLIDE_MS + SETTLE_WATCHDOG_MS)
|
||||
|
||||
expect(frameTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops sampling for a settle once the step is reset', () => {
|
||||
const { beginStep, resetStep, isAwaitingSettle } = setup()
|
||||
|
||||
beginStep()
|
||||
expect(isAwaitingSettle()).toBe(true)
|
||||
|
||||
resetStep()
|
||||
|
||||
expect(isAwaitingSettle()).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves no timer able to reveal a scrim after the tour ends', () => {
|
||||
const { openTour, endTour, revealed } = setup()
|
||||
|
||||
openTour()
|
||||
endTour()
|
||||
vi.advanceTimersByTime(INTRO_PREVIEW_MS + SETTLE_WATCHDOG_MS)
|
||||
|
||||
expect(revealed.value).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves no watchdog able to fire after the tour ends', () => {
|
||||
const { beginStep, endTour, copyVisible } = setup()
|
||||
|
||||
beginStep()
|
||||
vi.advanceTimersByTime(MARK_GLIDE_MS)
|
||||
endTour()
|
||||
vi.advanceTimersByTime(SETTLE_WATCHDOG_MS)
|
||||
|
||||
expect(copyVisible.value).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
141
src/renderer/extensions/firstRunTour/useTourChoreography.ts
Normal file
141
src/renderer/extensions/firstRunTour/useTourChoreography.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import {
|
||||
INITIAL_SETTLE,
|
||||
TOUR_FOCUS_DURATION_MS,
|
||||
canvasTransformKey,
|
||||
trackSettle
|
||||
} from './canvasSpotlightAdapter'
|
||||
import type { SettleState } from './canvasSpotlightAdapter'
|
||||
|
||||
/** Undimmed preview of the whole workflow before the tour dims to the first target. */
|
||||
const INTRO_PREVIEW_MS = 500
|
||||
/** The camera waits this out, so the mark's glide and the framing never run at once. */
|
||||
export const MARK_GLIDE_MS = 400
|
||||
/** A camera that never settles (the user keeps panning) must not strand the copy. */
|
||||
const SETTLE_WATCHDOG_MS = TOUR_FOCUS_DURATION_MS + 200
|
||||
|
||||
interface ChoreographyOptions {
|
||||
reduceMotion: Ref<boolean>
|
||||
/** Frame the current step's target. Called once the mark has finished gliding. */
|
||||
frameTarget: () => void
|
||||
/** True when the step points at the toolbar, so the camera must not move. */
|
||||
isStatic: Ref<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequences a step's motion so only one thing moves at a time: the mark glides, the
|
||||
* camera frames it, then the copy fades in once the transform holds still.
|
||||
*
|
||||
* The camera is watched rather than counted out: `animateToBounds` reports no
|
||||
* completion and cannot be cancelled, so an unchanged transform is the only honest
|
||||
* "it stopped" signal. A watchdog bounds that wait.
|
||||
*/
|
||||
export function useTourChoreography({
|
||||
reduceMotion,
|
||||
frameTarget,
|
||||
isStatic
|
||||
}: ChoreographyOptions) {
|
||||
/** Scrim and rings are drawn; false during the intro preview. */
|
||||
const revealed = ref(false)
|
||||
const copyVisible = ref(false)
|
||||
/** True once this step's framing is final, so the mark's side can latch. */
|
||||
const cameraSettled = ref(false)
|
||||
/** Pinned while the canvas moves, so the mark rides it rather than chasing it. */
|
||||
const markGlides = ref(false)
|
||||
|
||||
let timers: ReturnType<typeof setTimeout>[] = []
|
||||
let watchdogTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let settle: SettleState = INITIAL_SETTLE
|
||||
let awaitingSettle = false
|
||||
let lastTransformKey: string | null = null
|
||||
|
||||
function after(ms: number, run: () => void) {
|
||||
timers.push(setTimeout(run, ms))
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
for (const timer of timers) clearTimeout(timer)
|
||||
timers = []
|
||||
if (watchdogTimer !== null) clearTimeout(watchdogTimer)
|
||||
watchdogTimer = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels only the watchdog, never a pending framing: on a step whose transform is
|
||||
* already at rest the camera settles before it has moved, and the framing must still run.
|
||||
*/
|
||||
function showCopy() {
|
||||
awaitingSettle = false
|
||||
if (watchdogTimer !== null) clearTimeout(watchdogTimer)
|
||||
watchdogTimer = null
|
||||
cameraSettled.value = true
|
||||
copyVisible.value = true
|
||||
}
|
||||
|
||||
function sampleFrame() {
|
||||
const key = canvasTransformKey()
|
||||
if (key !== lastTransformKey) {
|
||||
lastTransformKey = key
|
||||
markGlides.value = false
|
||||
}
|
||||
if (!awaitingSettle) return
|
||||
settle = trackSettle(settle, key)
|
||||
if (settle.settled) showCopy()
|
||||
}
|
||||
|
||||
function beginStep({ glideFirst = true } = {}) {
|
||||
revealed.value = true
|
||||
|
||||
if (isStatic.value || reduceMotion.value) {
|
||||
markGlides.value = !reduceMotion.value
|
||||
showCopy()
|
||||
return
|
||||
}
|
||||
|
||||
markGlides.value = true
|
||||
awaitingSettle = true
|
||||
settle = INITIAL_SETTLE
|
||||
|
||||
const delay = glideFirst ? MARK_GLIDE_MS : 0
|
||||
if (delay === 0) frameTarget()
|
||||
else after(delay, frameTarget)
|
||||
watchdogTimer = setTimeout(showCopy, delay + SETTLE_WATCHDOG_MS)
|
||||
}
|
||||
|
||||
function resetStep() {
|
||||
clearTimers()
|
||||
awaitingSettle = false
|
||||
copyVisible.value = false
|
||||
cameraSettled.value = false
|
||||
}
|
||||
|
||||
/** Open a fresh tour on the undimmed workflow before dimming to the first target. */
|
||||
function openTour() {
|
||||
resetStep()
|
||||
revealed.value = false
|
||||
if (reduceMotion.value) return beginStep({ glideFirst: false })
|
||||
after(INTRO_PREVIEW_MS, () => beginStep({ glideFirst: false }))
|
||||
}
|
||||
|
||||
function endTour() {
|
||||
resetStep()
|
||||
revealed.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
revealed,
|
||||
copyVisible,
|
||||
cameraSettled,
|
||||
markGlides,
|
||||
/** True while the camera is still being waited on, so a resize must not re-frame. */
|
||||
isAwaitingSettle: () => awaitingSettle,
|
||||
sampleFrame,
|
||||
beginStep,
|
||||
resetStep,
|
||||
openTour,
|
||||
endTour,
|
||||
clearTimers
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user