Compare commits

...

57 Commits

Author SHA1 Message Date
Maanil Verma
2a2c4819ad Merge branch 'main' into feat/onboarding-getting-started 2026-07-17 00:39:17 +05:30
Maanil Verma
897e92f434 Merge remote-tracking branch 'origin/main' into feat/onboarding-getting-started 2026-07-17 00:31:31 +05:30
steven-comfy
f524683f3c feat: add hidden /individual-submission page for sales-form redirects (#13697)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds `/individual-submission`, a hidden `noindex` page intended as the
post-submit redirect target for the HubSpot sales form when the visitor
selects the "individual" option. The page thanks the visitor, points
them at the self-serve plans on `/cloud/pricing`, and offers a
`mailto:gtm-team@comfy.org` fallback for enterprise inquiries.

## Design

Mirrors the `terms-of-service.astro` template — `BaseLayout` +
`HeroSection` — so typography and spacing feel native to the marketing
site. Plan names (**Standard**, **Creator**, **Pro**, **Teams**) are
highlighted with the yellow-italic accent used elsewhere on the site.

## Hidden-page mechanics

Follows the pattern already used for `/terms-of-service` and
`/payment/*`:

- `noindex` meta tag on the page
- Path added to `SITEMAP_EXCLUDED_PATHNAMES` in `astro.config.ts`
- English-only, with a `/zh-CN/individual-submission` →
`/individual-submission` redirect (matches `termsOfService` handling)
- Not linked from `routes.ts`, `HeaderMain`, or `SiteFooter`

## Next step (not in this PR)

Someone with HubSpot access needs to configure the form (portal
`244637579`, form `94e05eab-1373-47f7-ab5e-d84f9e6aa262`) to redirect to
`https://comfy.org/individual-submission` when the "individual" option
is selected. Happy to add a JS-side fallback in a follow-up PR if
HubSpot's conditional redirects aren't a fit.

## Verification

- `pnpm typecheck` → 0 errors (10 pre-existing hints unrelated to this
change)
- `pnpm build` → 499 pages built; `/individual-submission/` and
`/zh-CN/individual-submission/` (redirect stub) emitted; sitemap
excludes both
- Playwright preview at 1280×900 and 390×844 — hero title, prose,
plan-name highlighting, CTA button, and mailto link all render as
expected; no console errors; `whitespace-nowrap` keeps the email address
on one line on mobile
- `/review` (oracle) on the previous revision: 0 findings; only change
since is the URL rename
- `/zh-CN/individual-submission` redirect verified in preview

## Screenshots

![Desktop view of /individual-submission: centered hero title 'Thanks
for reaching out.', body copy with Standard/Creator/Pro/Teams in yellow
italic, yellow 'See plans and pricing' CTA button, and a smaller mailto
fallback
line.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4ce277dd60743a347e517f79e6707eef6b43ec5fb8f54a92ea8c52d3858fb258/pr-images/1784158692177-e7fe7ed9-8844-4625-9823-1dd719173c73.png)

![Mobile 390px view of /individual-submission: same layout stacks
cleanly, gtm-team@comfy.org email stays on one line, no horizontal
overflow.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4ce277dd60743a347e517f79e6707eef6b43ec5fb8f54a92ea8c52d3858fb258/pr-images/1784158692523-e1af7deb-1379-4280-9bcc-0c602bb12aff.png)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: imick-io <michael@concreo.io>
Co-authored-by: Michael B <michael@imick.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:49:30 +00:00
Maanil Verma
1d2ac9ca27 feat(onboarding): Getting Started screen and tour mounts
- Getting Started screen with curated template cards
- mount the first-run overlay, nudge, and Getting Started screen in GraphView
2026-07-16 23:50:46 +05:30
Maanil Verma
d23a00b5cb feat(onboarding): first-run canvas tour
- residual view store + controller bridging the shared coachmark engine
- overlay: multi-hole scrim, camera choreography, Floating UI placement, shared
  CoachmarkCard, result media and generating states
- post-run nudge with deferral behind the upgrade modal
- first-run telemetry funnel; e2e coverage
2026-07-16 23:49:20 +05:30
Maanil Verma
ea15f1af8a feat(onboarding): resolve tour roles from the loaded workflow
- roleResolver extracts source/prompt/engine/sink roles (subgraph-aware)
- sequenceBuilder turns roles into the ordered step list by shape
- toCoachSteps projects steps onto the shared engine (targetless, 1:1 aligned)
- subgraph navigation restores the root view
- vendored workflow_templates fixtures (marked generated) as resolver ground truth
2026-07-16 23:48:11 +05:30
Maanil Verma
6709d27c71 feat(onboarding): shared contracts for the first-run tour
- onboarding_tour feature flag (remoteConfig + useFeatureFlags)
- fold first-run telemetry events into the shared onboarding schema
- useDesktopLayout composable (shared by the tour gate and app-mode triggers)
- expose UPGRADE_DIALOG_KEYS; thread templateId through useTemplateUrlLoader
- data-testids on the action bar and tab list; onboarding candidate predicate
2026-07-16 23:36:11 +05:30
Maanil Verma
2697a43f78 feat(onboarding): canvas spotlight adapter for the first-run tour
Client-space geometry for canvas-anchored tour targets: node rects, camera
framing, mask rects, and transform-settle tracking.
2026-07-16 23:34:43 +05:30
Maanil Verma
d5fd3d10aa feat(onboarding): generalize the coachmark engine for a second consumer
- EntryPath adds 'firstRun'; TOURS is Partial so an injected tour needs no static entry
- startTour accepts a precomputed step definition; activeTour is exposed read-only
- engine telemetry and TourOverlay cover only registry (TOURS) tours; injected tours self-render and self-report
- TourOverlay.test mock provides activeTour for the new gate (assertions unchanged)
2026-07-16 21:40:28 +05:30
Benjamin Lu
d1d55585f9 [GTM-278] Report Firebase auth state to Desktop telemetry (#13687)
## Summary

Reports each hosted Cloud renderer's declarative Firebase auth state to
Desktop. Desktop remains the sole owner of PostHog identity and
arbitrates all live `WebContents` before changing the process-global
person.

Linear: [GTM-278](https://linear.app/comfyorg/issue/GTM-278) · Parent:
[GTM-93](https://linear.app/comfyorg/issue/GTM-93)

## Changes

- reports `pending` as soon as host auth synchronization starts
- waits for Firebase initialization, then reports `signed_out` or
`signed_in` with the Firebase UID
- reports restored sessions, logout, and direct account switches through
the same declarative state contract
- makes repeated same-UID Firebase updates a no-op
- adds the optional `reportFirebaseAuthState` contract to the Desktop
bridge types
- keeps host bridge failures from interrupting renderer startup or
Firebase auth

## Ownership and privacy

- the frontend never calls PostHog `identify`, `alias`, bind, or unbind
APIs
- browser PostHog provider behavior is unchanged
- the auth-state message is local to Desktop; Desktop #1272 applies its
own telemetry-consent gate before any PostHog identify or emission
- `enable_telemetry` controls only the frontend `HostTelemetrySink`
(renderer event forwarding), so this observer deliberately does not
share that rollout flag
- no canonical `comfy_user_id`, `/api/user` lookup, Cloud API
dependency, Redis state, token redemption, or website transport change
is added

## Review and rollout order

Review [Comfy-Desktop
#1272](https://github.com/Comfy-Org/Comfy-Desktop/pull/1272) first
because it defines the trusted state-reporting API and consensus policy.
Deploy this frontend first: the method is optional and therefore a no-op
on older Desktop builds. Then release Desktop #1272, and only afterward
deploy [comfy-router
#34](https://github.com/Comfy-Org/comfy-router/pull/34).

Auth [Comfy-Desktop
#1222](https://github.com/Comfy-Org/Comfy-Desktop/pull/1222) must not
become an identity authority; with Desktop #1272 its legacy imperative
bind is inert and the post-reload Firebase report is the source of
truth.

## Testing

- `pnpm test:unit src/platform/telemetry/hostUserIdSync.test.ts
src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts`
— 53 tests passed
- `pnpm typecheck`
- targeted Oxlint and ESLint
- targeted `oxfmt --check`
- `pnpm knip`
- pre-commit typecheck/lint/format hooks
- pre-push `knip`
- `git diff --check`

No user-facing UI changes.
2026-07-16 04:22:41 +00:00
Benjamin Lu
98c654df20 fix: report page views to Customer.io in-app SDK (#13560)
## Summary

Report the current URL to Customer.io after its in-app plugin
initializes and on every client-side navigation, allowing queued web
in-app messages to satisfy page rules and render.

## Changes

- **What**: Wait for the in-app plugin to register, flush an initial
URL-based `page()` call, and consume the router's existing page-view
dispatches for subsequent SPA navigation.
- **Tests**: Cover initial page reporting, plugin-registration ordering,
and client-side route changes.

## Review Focus

Customer.io intentionally receives `page()` without an explicit page
name. Passing the telemetry page title would make the in-app SDK match
page rules against that title instead of the full URL; the live campaign
requires `contains cloud.comfy.org`.

Root cause: the production SDK debugger showed a correctly identified
user and active SSE connection, but `Route: NONE` and no eligible
messages because the provider did not implement `trackPageView()`.

Complements #13556, which addresses identity stitching for existing
email-keyed Customer.io profiles.

Validation: targeted unit tests (21/21), `pnpm typecheck`, targeted
ESLint and oxfmt checks, and the pre-push `knip` gate.
2026-07-16 02:02:59 +00:00
Benjamin Lu
5bce9c4874 fix: sequence Customer.io identification before auth events (#13677)
We need this fix urgently, blocking all welcome emails and fixing
several vectors.

## Summary

Identify Customer.io users with Firebase UID and email traits, then wait
for that profile update before sending `app:user_auth_completed` so
Welcome Onboarding entrants satisfy the Valid Email Address segment.
Preserve the correct session or anonymous identity after the auth event
is handed to the SDK.

## Changes

- **Auth delivery ordering**: Queue auth events with their Firebase
identity and send the event only after Customer.io `identify(uid, {
email })` settles. Missing-email identities fall back to UID-only
identification, and rejected identifies are logged without suppressing
auth telemetry.
- **Identity lifecycle**: Serialize identity transitions and event
handoff, deduplicate matching `identify()` operations, and snapshot
resolved UID/email together. After auth delivery, restore the configured
or resolved session identity—or reset to anonymous—including for
SDK-late buffered events and auth callbacks that arrive after logout.
Ordinary event delivery does not hold the identity queue open while its
network request completes.
- **Payload privacy**: Use email as a Customer.io person trait while
excluding raw `email` and `share_id` from Customer.io event properties.
- **Dependencies**: None.

## Incident evidence

- Campaign 1 triggers on `app:user_auth_completed` with
`is_new_user=true` and requires the Valid Email Address segment.
- In a recent 15-minute sample, all 30 new-user events had an email
property, while all 30 corresponding Customer.io person profiles had
`identifiers.email=null`.
- Starts fell from 556/day through June 30 to 23.5/day after July 1,
excluding a one-day spike.
- Event properties do not update Customer.io person identifiers, and the
previous fire-and-forget identify allowed the auth event to race the
profile update.

## Review Focus

The auth result's `user_id` is the authoritative Firebase UID for the
identify that gates `app:user_auth_completed`. Once the SDK snapshots
that event, the provider restores the current configured/resolved
identity or the signed-out anonymous state before handing off later
events. Matching identity operations are deduplicated; repeated auth
completions remain distinct telemetry events. A rejected identify must
not suppress auth telemetry.

This supersedes the partial fixes in #13556 (email trait without
ordering) and #12888 (reset/re-login and redaction without the
email-profile race fix).

## Validation

- `pnpm test:unit
src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.test.ts`
— 35 passed
- `pnpm typecheck`
- File-scoped type-aware Oxlint
- File-scoped ESLint
- File-scoped oxfmt check
- `pnpm knip` via the pre-push hook

A browser E2E test is not practical for this SDK delivery-ordering fix:
the regressions require controlling unresolved Customer.io
`identify`/`track` promises and observing the active identity when each
event is handed to the SDK. The focused unit suite controls those
boundaries directly and covers SDK-late queueing, configured overrides,
identity restoration, logout races, missing email, rejection fallback,
payload redaction, and non-blocking event delivery.

## Rollout / backfill

- Deploy or backport this change to the active cloud release line.
- Verify new-user Customer.io profiles receive a non-null email
identifier before the auth event is processed, then monitor Campaign 1
starts against the pre-July baseline.
- Backfill or re-trigger already affected new users separately; this
frontend change repairs future auth deliveries and does not replay
historical events.
- No live Customer.io campaign changes are included.
2026-07-16 00:44:39 +00:00
AustinMroz
13fd07201c Merge branch 'main' into pysssss/product-coachmarks 2026-07-13 12:34:39 -07:00
pythongosssss
62806d5ebd Merge branch 'main' into pysssss/product-coachmarks 2026-07-09 12:32:53 +01:00
pythongosssss
dff76dbc9d make button grow, match design 2026-07-08 03:38:48 -07:00
pythongosssss
66b9f329ce update wording 2026-07-08 03:22:56 -07:00
pythongosssss
cb1be63fb6 decrease padding 2026-07-08 03:17:09 -07:00
pythongosssss
3f5b71a472 refactor: use reka FocusScope for coachmark focus trapping
- Replace the custom two-subtree focus trap with reka FocusScope; the
  spotlight overlay is fully modal so a single-subtree trap suffices
- Focus the primary action via a template ref once it's actionable, keyed
  off waitingForTarget, and keep a global Escape-to-skip
- Add an e2e test asserting focus lands on the primary and Tab stays trapped
2026-07-07 07:24:49 -07:00
pythongosssss
7df585e606 flush -> nextTick 2026-07-06 12:47:08 -07:00
pythongosssss
f1f7c53fbe feat: rework coachmark assets step and add step-back navigation
- Auto-open the assets sidebar tab on the assets step instead of
  requiring a click on the Assets button (drops advanceOnTargetClick,
  skipIfMounted, the click-through blocker, and the idle pulse)
- Make the tour overlay fully modal and add a Back button to steps
- Cache the top-bar inset instead of reading it every animation frame
- Reset waitingForTarget when a deferred step aborts
- Dedupe the assets-panel coachmark binding in LinearView
2026-07-06 11:33:43 -07:00
pythongosssss
25ce1ebbe4 Merge remote-tracking branch 'origin/main' into pysssss/product-coachmarks
# Conflicts:
#	src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts
#	src/platform/telemetry/types.ts
2026-07-06 08:15:27 -07:00
pythongosssss
97308500e9 fix: keep the coachmark tour alive when outputs clear
- Split triggers into holds/autoOpen: only leaving app mode or the
  desktop layout dismisses a running tour, not losing outputs
- Trap focus in the spotlight target only on click-to-advance steps,
  matching the pointer blocker
- Skip non-laid-out elements in the focus cycle
- Drop unused CoachId[] support
- Recompress the landing image
2026-07-06 06:48:28 -07:00
pythongosssss
ccb3cd428b fix: restore the coachmark landing's md max-width
Removing md:max-w-[800px] let the DialogContent variant's sm:max-w-xl
(576px) cap the 800px landing card; tailwind-merge does not dedupe
max-w across breakpoint modifiers.
2026-07-06 05:37:37 -07:00
pythongosssss
9a71b54d05 refactor: move the coachmark tour state machine into a Pinia store
- Convert useCoachmarkTour to onboardingTourStore; replace the
  coachmarkController event hook with a direct store.replayTour() action
- End an active tour (without the seen-flag) when its trigger stops
  holding, e.g. leaving app mode mid-tour
- Toast an error and report skip_reason telemetry (user / target_timeout /
  trigger_lost) when a tour is skipped
- Disable primary buttons while waiting on a deferred target; rename
  suspendFocusGuard to waitingForTarget
- Rename useFocusTrap to useCoachmarkFocusTrap; while suspended, leave
  Tab to the mounting UI but keep the Escape bail-out
- Only auto-open tours on desktop-width (md+) layouts
- Park the registry's rAF poll while no candidate element is registered,
  resuming reactively on registration
- Extract TOUR_SEEN_SETTING and use COACH_IDS constants over string
  literals; move scrim/ring/pulse styles to design-system tokens
- Port tour tests to the store, cover Done in the e2e spec
2026-07-06 03:58:58 -07:00
pythongosssss
61756e977b fix missing modal backdrop 2026-07-03 12:50:35 -07:00
pythongosssss
7630cc9314 fix: use the generic Skip label on the coachmark tour landing
- drop the landing's "Skip for now" override so it falls back to "Skip"
- landing skip already ends the tour and marks it seen (no behavior change)
- unit tests build i18n from the real en locale instead of inline messages
- e2e covers the real auto-open path via a pre-seeded empty seen setting
- trim redundant test comments
2026-07-03 10:52:02 -07:00
pythongosssss
bb39a51d46 fix: narrow the coachmark i18n exemption to derived step keys
- only onboardingCoachmarks.<tour>.<step>.* is dynamically built;
  top-level keys (stepLabel, next, done, skip) stay checked
2026-07-02 06:15:25 -07:00
pythongosssss
76abe8eb3f Merge branch 'main' into pysssss/product-coachmarks 2026-07-02 13:59:01 +01:00
pythongosssss
9bcfda88f6 refactor: reconcile v-coachmark from the element's own data-coach-id
- a single sync() replaces the per-hook register/unregister logic;
  mounted/updated/unmounted all reconcile desired vs current id
- drops the oldValue bookkeeping and its explanatory comment
2026-07-02 05:54:52 -07:00
pythongosssss
e7cdfc8c35 chore: ignore derived coachmark keys in the unused-i18n check
- tour step keys are built as onboardingCoachmarks.<tour>.<name>.*, so
  the literal-key scan can't see them
2026-07-02 05:47:10 -07:00
pythongosssss
e97746fd16 refactor: address coachmark tour PR feedback
- derive step translation keys from a step `name`
  (onboardingCoachmarks.<tour>.<name>.*), with te()-checked
  primary/skip label overrides falling back to Next/Done/Skip
- move all tour i18n into the engine; TourOverlay and TourSpotlight
  now receive translated title/body strings
- replace the landing's required open model with a skip emit and
  drop the landingOpen writable computed
- simplify TourOverlay.test.ts mocks with fromPartial
- replace SCRIM_COLOR with Tailwind classes (bg-black/60,
  spotlight spread shadow)
- narrow the app-run-button anchor so the spotlight excludes the
  run error warning
- key the e2e tour fixture's replay button by tour name
- drop the tour spec's template loading; the test server's default
  workflow already populates the graph (locally: pnpm dev:test)
- prune comments that restated code
2026-07-02 05:39:08 -07:00
pythongosssss
549200a76c fix: address coachmark tour stacking, telemetry, and fixture review notes
- clear the spotlight's previous ZIndex entry before re-raising, so step
  changes stop leaking entries into the shared modal stacking sequence
- report tour telemetry as the user-visible numbering: 1-based step_number
  within counted spotlight steps, omitted for the landing
- seed the e2e seen-tours setting from TOURS so future tours can't
  auto-open under unrelated suites
2026-07-02 01:31:08 -07:00
pythongosssss
96c2ae1182 feat: add landing image to the app-mode tour
- Show public/assets/images/app-mode-landing.png on the welcome landing's
  left panel
2026-07-01 10:19:44 -07:00
pythongosssss
2312b213ce refactor: address coachmark tour review feedback
- Drop the ?coach= query param: remove the forced-entry/replay-any override
  and delayed force-start; tours now start only via auto-open or an explicit
  request. E2E replays via the in-app help button after entering app mode
- Flatten coachmarkController to plain requestTour/onTourRequested exports
  instead of a useCoachmarkController composable
- Derive the top-bar safe inset from the --comfy-topbar-height token plus
  CARD_GAP instead of hardcoding 56
- Remove the landing Start button's fixed width
- Document the real cause of the landing Escape workaround (global keybinding
  preventDefaults Escape before Reka's DismissableLayer dismisses)
- Trim verbose comments in coachmarkRegistry and TourOverlay tests
2026-07-01 10:07:51 -07:00
pythongosssss
ce8b107322 fix: correct coachmark tour skip telemetry and one-shot forcing
- Report the timed-out deferred step in skip telemetry by advancing the
  step index before ending the tour, instead of logging the prior step
- Make the ?coach= override one-shot: named paths force-start only via the
  delayed timer (respecting START_DELAY_MS) and re-entry honors the
  seen-flag again, rather than bypassing it all session
- Resolve waitForTarget to false immediately when the signal is already
  aborted, so it can't hang until timeout
- Reuse the cached dialog locator in Tour.cardForStep
2026-07-01 09:14:15 -07:00
pythongosssss
831813a9db fix: stop coachmark spotlight polling once its target settles
- Drive Floating UI autoUpdate manually so animationFrame polling runs
  only while a deferred target is still moving, then fall back to
  scroll/resize listeners instead of polling every frame for the whole step
- Set aria-modal to false on interaction steps where the user must click
  outside the card
- Ignore unrecognized ?coach= values; keep `any` as the replay keyword
- Align the spotlight scrim with the landing backdrop (0.62 -> 0.6) to
  avoid a dim shift on landing -> spotlight
- Export COACH_IDS and import it in the drift guard instead of a hardcoded
  in-sync list
- Clarify why the landing needs an explicit Escape listener (Reka's
  DismissableLayer doesn't fire update:open here)
- Test that the started telemetry event omits step_index/coach_id while
  per-step events include them

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 07:43:19 -07:00
pythongosssss
64d10da9d7 refactor: place coachmark cards with Floating UI
- Replace the hand-rolled card-placement math (resolvePlacement /
  cardCorner / clampCardPosition) and the manual target tracking (scroll
  listener + useElementBounding + rAF settle loop) with @floating-ui/vue
  (offset/flip/shift + autoUpdate); promote it from a transitive to a
  direct dependency.
- Keep vertically-centred (leftCenter) cards on-screen with
  shift({ crossAxis: true }), and centre the card when its target hasn't
  laid out, so a step never renders off-screen or invisible.
- Make targetMounted/waitForTarget layout-aware (poll per frame) so a
  deferred target that registers before it sizes resolves only once
  measurable, matching what the spotlight actually displays.
- Shrink the spotlight glow (pad 8->4) so it no longer spills onto an
  adjacent control the user might click.
- Label the spotlight dialog with aria-labelledby pointing at its heading
  instead of a duplicated aria-label.
- Drop the unused isActive option from useFocusTrap.
- Add an e2e guard that walks every app-mode spotlight step and asserts
  each card sits fully within the viewport; add unit coverage for
  last-step Skip hiding, modal z-index reclaim, and all four onboarding
  telemetry stages.
2026-07-01 06:20:03 -07:00
pythongosssss
3f84d4f5f2 test: cover forced tour start, button labels, and spotlight escape
Raise useCoachmarkTour.ts to full line coverage and TourSpotlight.vue escape handling with behavioral tests.
2026-07-01 03:45:44 -07:00
pythongosssss
5383e23d24 Merge origin/main into pysssss/product-coachmarks
Conflict in LinearControls.vue: main refactored the run-button test id into a
named constant (linearRunButtonTestId, same 'linear-run-button' value), while
this branch added the v-coachmark anchor and a static test id to the same
sections. Kept the coachmark directive and adopted main's :data-testid binding,
dropping the now-redundant static id.
2026-07-01 02:57:44 -07:00
pythongosssss
9b8dd27f3d refactor: gate coachmark tour listeners behind an active-step component
Extract TourSpotlight, rendered only while a spotlight step is shown, so the
geometry/scroll, focus-trap and click-to-advance listeners (plus z-index and the
stall pulse) register for the active step rather than the whole graph-view
session. useCoachmarkTour slims to the state machine; the registry-query helpers
(targetMounted/waitForTarget) move to coachmarkRegistry and useCoachmarkTarget
becomes geometry-only and self-measuring.

Also from review feedback:
- read the ?coach= force in setup, before the immediate auto-open watcher, so an
  already-populated app no longer drops a ?coach=any replay
- harden isEntryPath against prototype keys (Object.hasOwn)
- decouple each tour's auto-open condition into useTourTriggers, keeping the
  engine tour-agnostic
2026-06-30 14:00:43 -07:00
pythongosssss
a818b7eee8 test: open the template browser in the coachmark drift guard
Trimming the graph-mode anchors earlier removed the templates-button
click that loadTemplate relied on to open the browser; open it via the
Comfy.BrowseTemplates command instead.
2026-06-30 12:48:49 -07:00
pythongosssss
87d0a110cd fix: close the coachmark landing on Escape explicitly
Reka's built-in Escape dismissal proved unreliable for this dialog in
e2e (Skip, which sets open=false directly, works); close via the same
model path on Escape so the welcome landing reliably dismisses.
2026-06-30 12:41:24 -07:00
pythongosssss
b65da23915 refactor: slim coachmark target tracking and spotlight chrome
- Track the spotlight target with VueUse useElementBounding instead of a
  hand-rolled measure/RAF/ResizeObserver loop; keep a capture-phase scroll
  listener so it still follows targets inside scrollable panels
- Fold the spotlight ring into the dim element (CSS outline + box-shadow),
  dropping the separate SVG; the idle pulse now animates outline-color
- Remove the unused CoachStep `elevated` field and its plumbing
2026-06-30 12:07:22 -07:00
pythongosssss
07356e3253 tidy 2026-06-30 11:19:58 -07:00
pythongosssss
51182127f3 feat: auto-open app-mode tour for populated apps
- Open the app-mode tour when entering an app with linear controls
  visible (mode === 'app' && hasOutputs), including when the overlay
  mounts into one already (immediate watch)
- Respect the seen-flag, so it won't reopen once completed or skipped;
  skip the empty/welcome state and arrange (builder) mode
- Move test target DOM cleanup into afterEach so appended nodes are
  removed even if a test fails early
2026-06-30 10:49:42 -07:00
pythongosssss
fdfa9882b1 feat: make app-mode tour explicit-only and refine assets step
- Open the tour only via the help button or ?coach= param (no auto-open
  when entering app mode)
- Drop the loadTemplate step tech and the demo card image — the tour runs
  on the user's existing app
- Skip the assets-button step at tour start when the assets panel is
  already open; step indicator counts 4 instead of 5
2026-06-30 08:27:53 -07:00
pythongosssss
88cd848245 fix test 2026-06-30 04:39:29 -07:00
pythongosssss
5dbb560ef0 refactor + tidy 2026-06-30 04:35:24 -07:00
pythongosssss
f973626ebc improve test coverage 2026-06-30 03:21:59 -07:00
pythongosssss
e9729ca272 drop blankCanvas tour 2026-06-30 02:44:00 -07:00
pythongosssss
c51f963ef2 trim comments 2026-06-30 02:20:03 -07:00
pythongosssss
5e23f76642 fix: address coachmark tour review feedback
- onboardingTours: use 'auto' placement for assets panel so the card follows the sidebar side
- TourOverlay: clamp no-target card left to the viewport margin so it never goes off-screen
- useCoachmarkTour: catch onPrimary action failures, surface a toast, and only advance on success
- useCoachmarkTour: claim the single-instance guard synchronously so a duplicate mount stays inert
- useFocusTrap: send Shift+Tab from outside the trap to the last item instead of skipping it
- telemetry: drop dead run-button imports left over from the rebase (superseded by getRunButtonTelemetryProperties)
2026-06-30 02:02:27 -07:00
pythongosssss
f642384674 change dash stroke to solid 2026-06-30 02:02:27 -07:00
GitHub Action
f80deb9655 [automated] Apply ESLint and Oxfmt fixes 2026-06-29 21:57:11 +00:00
pythongosssss
75af0430fc - change dialog to use standard component
- move tour trigger to linear controls section
2026-06-29 14:48:18 -07:00
pythongosssss
cc74e1dc65 add assets step to app mode tour 2026-06-29 14:48:18 -07:00
pythongosssss
989773995a feat: add product coachmark onboarding tours
- add blank canvas (demo) and app mode (wip) tours
- overlay with target highlight, landing card, step state handling, step card
- v-coachmark directive updating registry for mount/unmount
- frame settling watcher for animated targets (dialogs)
- focus trap for the target plus the coachmark element
- add telemetry for each step
2026-06-29 14:48:18 -07:00
107 changed files with 24060 additions and 136 deletions

3
.gitattributes vendored
View File

@@ -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

View File

@@ -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(/\/$/, '')

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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

View 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)
}
}

View 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))
}
})

View 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)
})
})
})

View File

@@ -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:",

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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
View File

@@ -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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

View File

@@ -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

View File

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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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) {

View 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')
}

View File

@@ -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
)
}
})

View File

@@ -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."
}
}
}
}

View File

@@ -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,

View File

@@ -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
/**

View 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()
})
})

View 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>

View 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)
})
})

View 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>

View 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()
})
})

View 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>

View 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')
})
})

View 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>

View 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)
})
})

View 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
}

View 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)
})
})

View 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()
}

View 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)
})
})

View 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
}
})

View 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)
})
})

View 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'
}
]
}

View 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()
})
})

View 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 }
}

View 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)
}
]
]
}

View 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)
})
})

View 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)
}

View File

@@ -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).

View File

@@ -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',

View File

@@ -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))
}

View 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'
})
})
})

View 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 }
)
}

View File

@@ -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)
)
})
})

View File

@@ -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']))
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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', () => {

View File

@@ -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

View File

@@ -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

View File

@@ -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()
})
})
})

View File

@@ -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 () => {

View 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 }
})

View File

@@ -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' }

View File

@@ -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)

View File

@@ -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 />'
})
}

View 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()
})
})

View 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>

View File

@@ -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
}

View 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()
}
})
})

View 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>

View 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>

View File

@@ -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 }

View File

@@ -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()
})
})

View 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>

View File

@@ -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>

View File

@@ -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
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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)
})
})
})

View 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]}`
}

View 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 tours finished run so the next one can generate', () => {
// The flag drives the Result step's "Generating…"; carried over, a second tour
// would show its result as already done.
resolveTourRoles.mockReturnValue(t2iRoles)
store.prepare(workflow)
store.runFinished = true
store.prepare(workflow)
expect(store.runFinished).toBe(false)
})
it('showNudge() surfaces the post-run nudge', () => {
expect(store.shouldShowNudge).toBe(false)
store.showNudge()
expect(store.shouldShowNudge).toBe(true)
})
it('keeps the nudge visible after the tour ends so it outlives the run', () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.prepare(workflow)
store.showNudge()
store.end()
expect(store.shouldShowNudge).toBe(true)
})
it('keeps the captured media after the tour ends so the nudge still shows it', () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.prepare(workflow)
store.resultMedia = { url: 'blob:first-run', kind: 'image' }
store.showNudge()
store.end()
expect(store.resultMedia).toEqual({ url: 'blob:first-run', kind: 'image' })
})
it('defers the nudge while the upgrade modal is open, then surfaces it on close', () => {
isDialogOpen.mockReturnValue(true)
store.showNudge()
// Held back so it never overlaps the paywall; the arm flag drives the retry.
expect(store.shouldShowNudge).toBe(false)
expect(store.nudgeArmed).toBe(true)
// The end of the tour must not lose the deferred nudge.
store.end()
expect(store.nudgeArmed).toBe(true)
isDialogOpen.mockReturnValue(false)
store.showNudge()
expect(store.shouldShowNudge).toBe(true)
expect(store.nudgeArmed).toBe(false)
})
it('dismissNudge() hides it and blocks any later re-trigger this session', () => {
store.showNudge()
expect(store.shouldShowNudge).toBe(true)
store.dismissNudge()
expect(store.shouldShowNudge).toBe(false)
store.showNudge()
expect(store.shouldShowNudge).toBe(false)
})
it('prepare() resets the nudge lifecycle for a fresh tour', () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.showNudge()
store.dismissNudge()
store.resultMedia = { url: 'blob:prior-run', kind: 'image' }
store.prepare(workflow)
expect(store.shouldShowNudge).toBe(false)
expect(store.nudgeArmed).toBe(false)
// The prior run's media is cleared with the rest of the nudge lifecycle.
expect(store.resultMedia).toBeNull()
// Dismissal from the prior tour no longer blocks the new one.
store.showNudge()
expect(store.shouldShowNudge).toBe(true)
})
})

View File

@@ -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
}
})

View File

@@ -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)
}

View 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
})
})
})

View 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)
}

View 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')
})
})

View 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
}
}

View File

@@ -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()
})
})

View 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)
}

View 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)
})
})

View 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' })
)
}

View 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'
}
]

View File

@@ -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()
})
})

View File

@@ -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
)

View 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)
})
})

View 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