From e7e1ae25a61176bb5bbe2ff26cfd3a9264d9e482 Mon Sep 17 00:00:00 2001 From: Kelly Yang <124ykl@gmail.com> Date: Fri, 1 May 2026 15:49:31 -0700 Subject: [PATCH 01/17] fix(load3d): suppress error toast on 404 when loading output model file (#11807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `silentOnNotFound` option to `LoadModelOptions` interface, threaded through `Load3d.loadModel` → `LoaderManager.loadModel` - 404 errors (detected via message text or `response.status`) are silently swallowed when `silentOnNotFound: true`; all other errors still surface a toast - Sets `silentOnNotFound: true` for output-folder loads in `load3d.ts` and `saveMesh.ts` — covers shared workflows opened on a machine that never ran them ## Test plan - [x] `LoaderManager.test.ts` — 40 unit tests covering 404 suppression, non-404 still toasts, stale load handling - [x] `Load3DConfiguration.test.ts` — 4 unit tests verifying `silentOnNotFound` propagates correctly through `configureForSaveMesh` and `configure` - [x] `load3d.spec.ts` — 2 E2E tests: 404 → no toast, 500 → toast appears --- > [!NOTE] > **Medium Risk** > Changes error-handling behavior in the 3D model loading pipeline and extends method signatures/options; risk is mainly missed call sites or incorrectly classifying non-404 errors as 404 and hiding real failures. > > **Overview** > Prevents noisy user-facing toasts when an *output* 3D model referenced by `Preview3D`/`SaveGLB` is missing locally by adding a `silentOnNotFound` flag and suppressing the "Error loading model" toast specifically for HTTP 404 failures. > > Threads the new `LoadModelOptions` through `Load3d.loadModel` → `LoaderManager.loadModel` and updates `Load3DConfiguration`/callers to opt in for output-folder loads, with new unit + Playwright coverage (404 stays silent, non-404 still toasts). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 049f75ef60afef4ad3314e0b39f17e4725fd4e67. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). ┆Issue is synchronized with this [Notion page](https://app.notion.com/p/PR-11807-fix-load3d-suppress-error-toast-on-404-when-loading-output-model-file-3536d73d36508129ac0de1d5b081dcf0) by [Unito](https://www.unito.io) --- .../assets/3d/load3d_missing_model.json | 27 ++++++ browser_tests/tests/load3d/load3d.spec.ts | 51 +++++++++++ src/extensions/core/load3d.ts | 6 +- .../core/load3d/Load3DConfiguration.test.ts | 89 ++++++++++++++++++- .../core/load3d/Load3DConfiguration.ts | 39 ++++++-- src/extensions/core/load3d/Load3d.ts | 18 +++- .../core/load3d/LoaderManager.test.ts | 49 ++++++++++ src/extensions/core/load3d/LoaderManager.ts | 24 ++++- src/extensions/core/load3d/interfaces.ts | 17 +++- src/extensions/core/saveMesh.ts | 4 +- 10 files changed, 304 insertions(+), 20 deletions(-) create mode 100644 browser_tests/assets/3d/load3d_missing_model.json diff --git a/browser_tests/assets/3d/load3d_missing_model.json b/browser_tests/assets/3d/load3d_missing_model.json new file mode 100644 index 0000000000..bf0b2704f2 --- /dev/null +++ b/browser_tests/assets/3d/load3d_missing_model.json @@ -0,0 +1,27 @@ +{ + "last_node_id": 1, + "last_link_id": 0, + "nodes": [ + { + "id": 1, + "type": "Preview3D", + "pos": [50, 50], + "size": [450, 600], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "Preview3D", + "Last Time Model File": "nonexistent_model.glb" + }, + "widgets_values": ["nonexistent_model.glb"] + } + ], + "links": [], + "groups": [], + "config": {}, + "extra": { "ds": { "offset": [0, 0], "scale": 1 } }, + "version": 0.4 +} diff --git a/browser_tests/tests/load3d/load3d.spec.ts b/browser_tests/tests/load3d/load3d.spec.ts index 7197845049..f3ec05cbc1 100644 --- a/browser_tests/tests/load3d/load3d.spec.ts +++ b/browser_tests/tests/load3d/load3d.spec.ts @@ -282,6 +282,57 @@ test.describe('Load3D', () => { }) }) +test.describe('Load3D silent 404 on missing output model', () => { + test('Does not show an error toast when the output model file is missing (404)', async ({ + comfyPage + }) => { + // Intercept model fetch and return 404 to simulate a missing output file + // (e.g. shared workflow opened on a machine that never ran it) + await comfyPage.page.route('**/view?**', (route) => + route.fulfill({ status: 404, body: 'Not Found' }) + ) + + // This workflow has a Preview3D node with Last Time Model File set, + // triggering the loadFolder: 'output' + silentOnNotFound: true path. + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + + // Wait for the 404 response before asserting — gives the load attempt time + // to complete without using waitForTimeout + const responsePromise = comfyPage.page.waitForResponse('**/view?**') + await comfyPage.workflow.loadWorkflow('3d/load3d_missing_model') + await responsePromise + + await expect( + comfyPage.toast.visibleToasts.filter({ hasText: 'Error loading model' }) + ).toHaveCount(0) + }) + + test('Shows an error toast when a non-404 error occurs loading the output model', async ({ + comfyPage + }) => { + // Intercept with a 500 to simulate a real server error (not 404) — toast must appear + await comfyPage.page.route('**/view?**', (route) => + route.fulfill({ status: 500, body: 'Internal Server Error' }) + ) + + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + + const responsePromise = comfyPage.page.waitForResponse('**/view?**') + await comfyPage.workflow.loadWorkflow('3d/load3d_missing_model') + await responsePromise + + await expect + .poll( + () => + comfyPage.toast.visibleToasts + .filter({ hasText: 'Error loading model' }) + .count(), + { timeout: 10000 } + ) + .toBeGreaterThan(0) + }) +}) + test.describe('Load3D initialization failure', () => { test('Surfaces a toast when the THREE.WebGLRenderer cannot be created', async ({ comfyPage diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts index 6462465594..0a0fe143a2 100644 --- a/src/extensions/core/load3d.ts +++ b/src/extensions/core/load3d.ts @@ -497,7 +497,8 @@ useExtensionService().registerExtension({ const settings = { loadFolder: 'output', modelWidget: modelWidget, - cameraState: cameraState + cameraState: cameraState, + silentOnNotFound: true } config.configure(settings) @@ -528,7 +529,8 @@ useExtensionService().registerExtension({ loadFolder: 'output', modelWidget: modelWidget, cameraState: cameraState, - bgImagePath: bgImagePath + bgImagePath: bgImagePath, + silentOnNotFound: true } config.configure(settings) diff --git a/src/extensions/core/load3d/Load3DConfiguration.test.ts b/src/extensions/core/load3d/Load3DConfiguration.test.ts index b26881ec3f..0aa445673f 100644 --- a/src/extensions/core/load3d/Load3DConfiguration.test.ts +++ b/src/extensions/core/load3d/Load3DConfiguration.test.ts @@ -1,11 +1,13 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type Load3d from '@/extensions/core/load3d/Load3d' import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration' +import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' import type { GizmoConfig, ModelConfig } from '@/extensions/core/load3d/interfaces' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type { Dictionary } from '@/lib/litegraph/src/interfaces' import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode' @@ -162,3 +164,88 @@ describe('Load3DConfiguration.loadModelConfig', () => { expect(result.gizmo).toEqual(fullGizmo) }) }) + +describe('Load3DConfiguration.silentOnNotFound propagation', () => { + let loadModelSpy: ReturnType + + function makeLoad3dMock(): Load3d { + loadModelSpy = vi.fn().mockResolvedValue(undefined) + return { + loadModel: loadModelSpy, + setUpDirection: vi.fn(), + setMaterialMode: vi.fn(), + setTargetSize: vi.fn(), + setCameraState: vi.fn(), + toggleGrid: vi.fn(), + setBackgroundColor: vi.fn(), + setBackgroundImage: vi.fn().mockResolvedValue(undefined), + setBackgroundRenderMode: vi.fn(), + toggleCamera: vi.fn(), + setFOV: vi.fn(), + setLightIntensity: vi.fn(), + setHDRIIntensity: vi.fn(), + setHDRIAsBackground: vi.fn(), + setHDRIEnabled: vi.fn() + } as unknown as Load3d + } + + async function flush() { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + beforeEach(() => { + vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb']) + vi.mocked(Load3dUtils.getResourceURL).mockReturnValue( + '/view?filename=model.glb' + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('configureForSaveMesh forwards silentOnNotFound: true to loadModel', async () => { + const config = new Load3DConfiguration(makeLoad3dMock()) + config.configureForSaveMesh('output', 'model.glb', { + silentOnNotFound: true + }) + await flush() + expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', { + silentOnNotFound: true + }) + }) + + it('configureForSaveMesh uses silentOnNotFound: false when option is omitted', async () => { + const config = new Load3DConfiguration(makeLoad3dMock()) + config.configureForSaveMesh('output', 'model.glb') + await flush() + expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', { + silentOnNotFound: false + }) + }) + + it('configure forwards silentOnNotFound: true from settings to loadModel', async () => { + const config = new Load3DConfiguration(makeLoad3dMock()) + config.configure({ + modelWidget: { value: 'model.glb' } as unknown as IBaseWidget, + loadFolder: 'output', + silentOnNotFound: true + }) + await flush() + expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', { + silentOnNotFound: true + }) + }) + + it('configure uses silentOnNotFound: false when setting is omitted', async () => { + const config = new Load3DConfiguration(makeLoad3dMock()) + config.configure({ + modelWidget: { value: 'model.glb' } as unknown as IBaseWidget, + loadFolder: 'output' + }) + await flush() + expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', { + silentOnNotFound: false + }) + }) +}) diff --git a/src/extensions/core/load3d/Load3DConfiguration.ts b/src/extensions/core/load3d/Load3DConfiguration.ts index 4906213abf..2486e8472a 100644 --- a/src/extensions/core/load3d/Load3DConfiguration.ts +++ b/src/extensions/core/load3d/Load3DConfiguration.ts @@ -21,6 +21,7 @@ type Load3DConfigurationSettings = { width?: IBaseWidget height?: IBaseWidget bgImagePath?: string + silentOnNotFound?: boolean } class Load3DConfiguration { @@ -29,8 +30,16 @@ class Load3DConfiguration { private properties?: Dictionary ) {} - configureForSaveMesh(loadFolder: 'input' | 'output', filePath: string) { - this.setupModelHandlingForSaveMesh(filePath, loadFolder) + configureForSaveMesh( + loadFolder: 'input' | 'output', + filePath: string, + options?: { silentOnNotFound?: boolean } + ) { + this.setupModelHandlingForSaveMesh( + filePath, + loadFolder, + options?.silentOnNotFound ?? false + ) this.setupDefaultProperties() } @@ -38,7 +47,8 @@ class Load3DConfiguration { this.setupModelHandling( setting.modelWidget, setting.loadFolder, - setting.cameraState + setting.cameraState, + setting.silentOnNotFound ?? false ) this.setupTargetSize(setting.width, setting.height) this.setupDefaultProperties(setting.bgImagePath) @@ -58,8 +68,16 @@ class Load3DConfiguration { } } - private setupModelHandlingForSaveMesh(filePath: string, loadFolder: string) { - const onModelWidgetUpdate = this.createModelUpdateHandler(loadFolder) + private setupModelHandlingForSaveMesh( + filePath: string, + loadFolder: string, + silentOnNotFound: boolean + ) { + const onModelWidgetUpdate = this.createModelUpdateHandler( + loadFolder, + undefined, + silentOnNotFound + ) if (filePath) { onModelWidgetUpdate(filePath) @@ -69,11 +87,13 @@ class Load3DConfiguration { private setupModelHandling( modelWidget: IBaseWidget, loadFolder: string, - cameraState?: CameraState + cameraState?: CameraState, + silentOnNotFound: boolean = false ) { const onModelWidgetUpdate = this.createModelUpdateHandler( loadFolder, - cameraState + cameraState, + silentOnNotFound ) if (modelWidget.value) { onModelWidgetUpdate(modelWidget.value) @@ -241,7 +261,8 @@ class Load3DConfiguration { private createModelUpdateHandler( loadFolder: string, - cameraState?: CameraState + cameraState?: CameraState, + silentOnNotFound: boolean = false ) { let isFirstLoad = true return async (value: string | number | boolean | object) => { @@ -258,7 +279,7 @@ class Load3DConfiguration { ) ) - await this.load3d.loadModel(modelUrl, filename) + await this.load3d.loadModel(modelUrl, filename, { silentOnNotFound }) const modelConfig = this.loadModelConfig() this.applyModelConfig(modelConfig) diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 5e25e541f4..28579297ed 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -22,6 +22,7 @@ import type { EventCallback, GizmoMode, Load3DOptions, + LoadModelOptions, MaterialMode, UpDirection } from './interfaces' @@ -500,7 +501,11 @@ class Load3d { return this._loadGeneration } - async loadModel(url: string, originalFileName?: string): Promise { + async loadModel( + url: string, + originalFileName?: string, + options?: LoadModelOptions + ): Promise { this._loadGeneration += 1 if (this.loadingPromise) { @@ -509,7 +514,11 @@ class Load3d { } catch (e) {} } - this.loadingPromise = this._loadModelInternal(url, originalFileName) + this.loadingPromise = this._loadModelInternal( + url, + originalFileName, + options + ) return this.loadingPromise } @@ -525,7 +534,8 @@ class Load3d { private async _loadModelInternal( url: string, - originalFileName?: string + originalFileName?: string, + options?: LoadModelOptions ): Promise { this.cameraManager.reset() this.controlsManager.reset() @@ -533,7 +543,7 @@ class Load3d { this.modelManager.clearModel() this.animationManager.dispose() - await this.loaderManager.loadModel(url, originalFileName) + await this.loaderManager.loadModel(url, originalFileName, options) // Auto-detect and setup animations if present if (this.modelManager.currentModel) { diff --git a/src/extensions/core/load3d/LoaderManager.test.ts b/src/extensions/core/load3d/LoaderManager.test.ts index 87b9efb975..ca4b636f9d 100644 --- a/src/extensions/core/load3d/LoaderManager.test.ts +++ b/src/extensions/core/load3d/LoaderManager.test.ts @@ -436,6 +436,55 @@ describe('LoaderManager', () => { expect(consoleError).toHaveBeenCalled() }) + it('suppresses the alert on a 404 when silentOnNotFound is set', async () => { + const { lm } = makeLoaderManager() + const notFound = new Error( + 'fetch for "..." responded with 404: Not Found' + ) + meshLoad.mockRejectedValueOnce(notFound) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + await lm.loadModel('api/view?filename=cube.glb', undefined, { + silentOnNotFound: true + }) + + expect(consoleError).toHaveBeenCalled() + expect(addAlert).not.toHaveBeenCalledWith( + 'toastMessages.errorLoadingModel' + ) + }) + + it('detects a 404 from the response status field on three.js HttpError', async () => { + const { lm } = makeLoaderManager() + const httpError = Object.assign(new Error('not found'), { + response: { status: 404 } + }) + meshLoad.mockRejectedValueOnce(httpError) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await lm.loadModel('api/view?filename=cube.glb', undefined, { + silentOnNotFound: true + }) + + expect(addAlert).not.toHaveBeenCalledWith( + 'toastMessages.errorLoadingModel' + ) + }) + + it('still alerts on non-404 errors when silentOnNotFound is set', async () => { + const { lm } = makeLoaderManager() + meshLoad.mockRejectedValueOnce(new Error('parse failure: bad header')) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await lm.loadModel('api/view?filename=cube.glb', undefined, { + silentOnNotFound: true + }) + + expect(addAlert).toHaveBeenCalledWith('toastMessages.errorLoadingModel') + }) + it('discards the result of a stale load when a newer one has started', async () => { const { lm, modelManager, eventManager } = makeLoaderManager() diff --git a/src/extensions/core/load3d/LoaderManager.ts b/src/extensions/core/load3d/LoaderManager.ts index e39e4bc2a2..4879e9df31 100644 --- a/src/extensions/core/load3d/LoaderManager.ts +++ b/src/extensions/core/load3d/LoaderManager.ts @@ -10,10 +10,24 @@ import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter' import { SplatModelAdapter } from './SplatModelAdapter' import type { EventManagerInterface, + LoadModelOptions, LoaderManagerInterface, ModelManagerInterface } from './interfaces' +/** + * three.js's HttpError attaches the failed `Response` to the thrown Error. + * fetchModelData throws a plain Error whose message embeds the status code. + * Detect both forms so we can keep the toast for parse / network failures + * but stay silent on 404 when the caller opted in. + */ +function isNotFoundError(error: unknown): boolean { + if (!(error instanceof Error)) return false + const withResponse = error as Error & { response?: { status?: number } } + if (withResponse.response?.status === 404) return true + return /\b404\b/.test(error.message) +} + /** * Default adapter set: mesh + pointCloud + splat. Each adapter declares the * file extensions it owns; LoaderManager picks one by extension. @@ -53,7 +67,11 @@ export class LoaderManager implements LoaderManagerInterface { dispose(): void {} - async loadModel(url: string, originalFileName?: string): Promise { + async loadModel( + url: string, + originalFileName?: string, + options?: LoadModelOptions + ): Promise { const loadId = ++this.currentLoadId try { @@ -105,7 +123,9 @@ export class LoaderManager implements LoaderManagerInterface { if (loadId === this.currentLoadId) { this.eventManager.emitEvent('modelLoadingEnd', null) console.error('Error loading model:', error) - useToastStore().addAlert(t('toastMessages.errorLoadingModel')) + if (!(options?.silentOnNotFound && isNotFoundError(error))) { + useToastStore().addAlert(t('toastMessages.errorLoadingModel')) + } } } } diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts index 750273a023..a5dc7c3b08 100644 --- a/src/extensions/core/load3d/interfaces.ts +++ b/src/extensions/core/load3d/interfaces.ts @@ -198,8 +198,23 @@ export interface ModelManagerInterface { setupModelMaterials(model: THREE.Object3D): void } +export interface LoadModelOptions { + /** + * When true, suppress the user-facing toast for file-not-found + * (HTTP 404) errors. Other errors (parse failures, network drops) + * still surface a toast. Use for "preview" surfaces whose model + * file is server-produced and may legitimately be absent locally + * (e.g. shared workflows on a fresh machine). + */ + silentOnNotFound?: boolean +} + export interface LoaderManagerInterface { init(): void dispose(): void - loadModel(url: string, originalFileName?: string): Promise + loadModel( + url: string, + originalFileName?: string, + options?: LoadModelOptions + ): Promise } diff --git a/src/extensions/core/saveMesh.ts b/src/extensions/core/saveMesh.ts index 6af81231b6..09b1e9a80b 100644 --- a/src/extensions/core/saveMesh.ts +++ b/src/extensions/core/saveMesh.ts @@ -103,7 +103,9 @@ useExtensionService().registerExtension({ const loadFolder = fileInfo.type as 'input' | 'output' - config.configureForSaveMesh(loadFolder, filePath) + config.configureForSaveMesh(loadFolder, filePath, { + silentOnNotFound: true + }) if (isAssetPreviewSupported()) { const filename = fileInfo.filename ?? '' From 96575fcec93dd939bcb782d506da0b502c2ab4d0 Mon Sep 17 00:00:00 2001 From: Robin Huang Date: Fri, 1 May 2026 16:25:17 -0700 Subject: [PATCH 02/17] feat: redesign cloud onboarding survey for ICP and persona signal (#11628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replaces the 4-step Cloud onboarding survey with a 7-step flow that captures both ICP attributes and user persona dimensions. The survey questions are now populated dynamically from remoteConfig. ## Changes - **What**: New survey questions — Usage, Familiarity, Role, Team size, Industry, Making, Source. Role / Team size / Industry are gated to "Work" usage; Education users see a Student / Educator short list for Role. Most option lists are randomized per visit (familiarity and team size stay ordered as ordinals). \`SurveyResponses\` extended with optional \`usage\`, \`role\`, \`teamSize\`, \`source\` fields. - **Breaking**: None — \`useCase\` and \`workflowRelationship\` remain optional in the type and existing telemetry normalization keeps working unchanged. ## Review Focus - The \`role\` step has a function-form \`options\` so the list can swap based on \`usage\`. \`steps\` is a computed that filters by \`showWhen()\` and resolves the option function — verify reactivity when \`usage\` changes. - Changing \`usage\` clears the previously-picked \`role\` via a watcher to prevent a stale value from carrying over between Work / Education modes. - Per-visit shuffle is stable: option lists are passed through \`randomize()\` once at module load, not on every render. ## Screenshots https://github.com/user-attachments/assets/3602a388-50dc-401e-ada9-ea9016c5052d ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11628-feat-redesign-cloud-onboarding-survey-for-ICP-and-persona-signal-34d6d73d365081f4a792cfe76a987ffb) by [Unito](https://www.unito.io) --------- Co-authored-by: Dante --- package.json | 2 + pnpm-lock.yaml | 38 +- pnpm-workspace.yaml | 2 + src/locales/en/main.json | 84 ++-- .../cloud/onboarding/CloudSurveyView.vue | 390 ++---------------- .../onboarding/survey/DynamicSurveyField.vue | 161 ++++++++ .../survey/DynamicSurveyForm.test.ts | 320 ++++++++++++++ .../onboarding/survey/DynamicSurveyForm.vue | 212 ++++++++++ .../onboarding/survey/defaultSurveySchema.ts | 76 ++++ .../onboarding/survey/surveySchema.test.ts | 248 +++++++++++ .../cloud/onboarding/survey/surveySchema.ts | 137 ++++++ src/platform/remoteConfig/types.ts | 49 +++ src/platform/telemetry/types.ts | 5 + 13 files changed, 1320 insertions(+), 404 deletions(-) create mode 100644 src/platform/cloud/onboarding/survey/DynamicSurveyField.vue create mode 100644 src/platform/cloud/onboarding/survey/DynamicSurveyForm.test.ts create mode 100644 src/platform/cloud/onboarding/survey/DynamicSurveyForm.vue create mode 100644 src/platform/cloud/onboarding/survey/defaultSurveySchema.ts create mode 100644 src/platform/cloud/onboarding/survey/surveySchema.test.ts create mode 100644 src/platform/cloud/onboarding/survey/surveySchema.ts diff --git a/package.json b/package.json index 5d0acfc510..2748b0cb63 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@tiptap/extension-table-row": "catalog:", "@tiptap/pm": "catalog:", "@tiptap/starter-kit": "catalog:", + "@vee-validate/zod": "catalog:", "@vueuse/core": "catalog:", "@vueuse/integrations": "catalog:", "@vueuse/router": "^14.2.0", @@ -113,6 +114,7 @@ "three": "^0.170.0", "tiptap-markdown": "^0.8.10", "typegpu": "catalog:", + "vee-validate": "catalog:", "vue": "catalog:", "vue-i18n": "catalog:", "vue-router": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bc5d8593b..3bf369cc14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,6 +162,9 @@ catalogs: '@types/three': specifier: ^0.169.0 version: 0.169.0 + '@vee-validate/zod': + specifier: ^4.15.1 + version: 4.15.1 '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1 @@ -360,6 +363,9 @@ catalogs: unplugin-vue-components: specifier: ^30.0.0 version: 30.0.0 + vee-validate: + specifier: ^4.15.1 + version: 4.15.1 vite-plugin-dts: specifier: ^4.5.4 version: 4.5.4 @@ -497,6 +503,9 @@ importers: '@tiptap/starter-kit': specifier: 'catalog:' version: 2.27.2 + '@vee-validate/zod': + specifier: 'catalog:' + version: 4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76) '@vueuse/core': specifier: 'catalog:' version: 14.2.0(vue@3.5.13(typescript@5.9.3)) @@ -587,6 +596,9 @@ importers: typegpu: specifier: 'catalog:' version: 0.8.2 + vee-validate: + specifier: 'catalog:' + version: 4.15.1(vue@3.5.13(typescript@5.9.3)) vue: specifier: 'catalog:' version: 3.5.13(typescript@5.9.3) @@ -4724,6 +4736,11 @@ packages: peerDependencies: valibot: ^1.2.0 + '@vee-validate/zod@4.15.1': + resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==} + peerDependencies: + zod: ^3.24.0 + '@vercel/analytics@2.0.1': resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==} peerDependencies: @@ -9596,6 +9613,11 @@ packages: typescript: optional: true + vee-validate@4.15.1: + resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==} + peerDependencies: + vue: ^3.4.26 + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -14041,6 +14063,14 @@ snapshots: dependencies: valibot: 1.2.0(typescript@5.9.3) + '@vee-validate/zod@4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)': + dependencies: + type-fest: 4.41.0 + vee-validate: 4.15.1(vue@3.5.13(typescript@5.9.3)) + zod: 3.25.76 + transitivePeerDependencies: + - vue + '@vercel/analytics@2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))': optionalDependencies: react: 19.2.4 @@ -14159,7 +14189,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) '@vitest/utils@3.2.4': dependencies: @@ -20054,6 +20084,12 @@ snapshots: optionalDependencies: typescript: 5.9.3 + vee-validate@4.15.1(vue@3.5.13(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 7.7.9 + type-fest: 4.41.0 + vue: 3.5.13(typescript@5.9.3) + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 29b3a82afa..2059d90ab8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -55,6 +55,7 @@ catalog: '@types/node': ^24.1.0 '@types/semver': ^7.7.0 '@types/three': ^0.169.0 + '@vee-validate/zod': ^4.15.1 '@vercel/analytics': ^2.0.1 '@vitejs/plugin-vue': ^6.0.0 '@vitest/coverage-v8': ^4.0.16 @@ -121,6 +122,7 @@ catalog: unplugin-icons: ^22.5.0 unplugin-typegpu: 0.8.0 unplugin-vue-components: ^30.0.0 + vee-validate: ^4.15.1 vite: ^8.0.0 vite-plugin-dts: ^4.5.4 vite-plugin-html: ^3.2.2 diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 187b0825cc..882b61245c 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2795,51 +2795,55 @@ "survey": { "title": "Cloud Survey", "placeholder": "Survey questions placeholder", - "steps": { - "familiarity": "How familiar are you with ComfyUI?", - "purpose": "What will you primarily use ComfyUI for?", - "industry": "What's your primary industry?", - "making": "What do you plan on making?" + "intro": "Help us tailor your ComfyUI experience.", + "errors": { + "chooseAnOption": "Please choose an option.", + "selectAtLeastOne": "Please select at least one option.", + "describeAnswer": "Please describe your answer." }, - "questions": { + "steps": { + "usage": "How do you plan to use ComfyUI?", "familiarity": "How familiar are you with ComfyUI?", - "purpose": "What will you primarily use ComfyUI for?", - "industry": "What's your primary industry?", - "making": "What do you plan on making?" + "intent": "What do you want to create with ComfyUI?", + "source": "Where did you hear about ComfyUI?" }, "options": { + "usage": { + "personal": "Personal use", + "work": "Work", + "education": "Education (student or educator)" + }, "familiarity": { - "new": "New to ComfyUI (never used it before)", - "starting": "Just getting started (following tutorials)", - "basics": "Comfortable with basics", - "advanced": "Advanced user (custom workflows)", - "expert": "Expert (help others)" + "new": "New — never used it", + "starting": "Beginner — following tutorials", + "basics": "Intermediate — comfortable with basics", + "advanced": "Advanced — build and edit workflows", + "expert": "Expert — I help others" }, - "purpose": { - "personal": "Personal projects / hobby", - "community": "Community contributions (nodes, workflows, etc.)", - "client": "Client work (freelance)", - "inhouse": "My own workplace (in-house)", - "research": "Academic research" - }, - "industry": { - "film_tv_animation": "Film, TV, & animation", - "gaming": "Gaming", - "marketing": "Marketing & advertising", - "architecture": "Architecture", - "product_design": "Product & graphic design", - "fine_art": "Fine art & illustration", - "software": "Software & technology", - "education": "Education", - "other": "Other", - "otherPlaceholder": "Please specify" - }, - "making": { + "intent": { + "workflows": "Custom workflows or pipelines", + "custom_nodes": "Custom nodes", + "videos": "Videos", "images": "Images", - "video": "Video & animation", - "3d": "3D assets", + "3d_game": "3D assets / game assets", "audio": "Audio / music", - "custom_nodes": "Custom nodes & workflows" + "apps": "Simplified Apps from workflows", + "api": "API endpoints to run workflows", + "not_sure": "Not sure" + }, + "source": { + "youtube": "YouTube", + "reddit": "Reddit", + "twitter": "Twitter / X", + "instagram": "Instagram", + "linkedin": "LinkedIn", + "friend": "Friend or colleague", + "search": "Google / search", + "newsletter": "Newsletter or blog", + "conference": "Conference or event", + "discord": "Discord / community", + "github": "GitHub", + "other": "Other" } } }, @@ -2909,10 +2913,10 @@ "cloudForgotPassword_emailRequired": "Email is required", "cloudForgotPassword_passwordResetSent": "Password reset sent", "cloudForgotPassword_passwordResetError": "Failed to send password reset email", + "cloudSurvey_steps_usage": "How do you plan to use ComfyUI?", "cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?", - "cloudSurvey_steps_purpose": "What will you primarily use ComfyUI for?", - "cloudSurvey_steps_industry": "What's your primary industry?", - "cloudSurvey_steps_making": "What do you plan on making?", + "cloudSurvey_steps_intent": "What do you want to create with ComfyUI?", + "cloudSurvey_steps_source": "Where did you hear about ComfyUI?", "assetBrowser": { "allCategory": "All {category}", "allModels": "All Models", diff --git a/src/platform/cloud/onboarding/CloudSurveyView.vue b/src/platform/cloud/onboarding/CloudSurveyView.vue index 2ec132c1c0..56b50f33d3 100644 --- a/src/platform/cloud/onboarding/CloudSurveyView.vue +++ b/src/platform/cloud/onboarding/CloudSurveyView.vue @@ -1,251 +1,40 @@ - - diff --git a/src/platform/cloud/onboarding/survey/DynamicSurveyField.vue b/src/platform/cloud/onboarding/survey/DynamicSurveyField.vue new file mode 100644 index 0000000000..8148d00089 --- /dev/null +++ b/src/platform/cloud/onboarding/survey/DynamicSurveyField.vue @@ -0,0 +1,161 @@ + + + diff --git a/src/platform/cloud/onboarding/survey/DynamicSurveyForm.test.ts b/src/platform/cloud/onboarding/survey/DynamicSurveyForm.test.ts new file mode 100644 index 0000000000..72d91d24d0 --- /dev/null +++ b/src/platform/cloud/onboarding/survey/DynamicSurveyForm.test.ts @@ -0,0 +1,320 @@ +import userEvent from '@testing-library/user-event' +import { render, screen } from '@testing-library/vue' +import PrimeVue from 'primevue/config' +import { describe, expect, it } from 'vitest' +import { createI18n } from 'vue-i18n' + +import type { OnboardingSurvey } from '@/platform/remoteConfig/types' + +import DynamicSurveyForm from './DynamicSurveyForm.vue' + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { back: 'Back', next: 'Next', submit: 'Submit' }, + cloudOnboarding: { + survey: { + intro: 'Help us tailor your ComfyUI experience.', + errors: { + chooseAnOption: 'Please choose an option.', + selectAtLeastOne: 'Please select at least one option.', + describeAnswer: 'Please describe your answer.' + } + } + } + } + } +}) + +const renderForm = (survey: OnboardingSurvey) => + render(DynamicSurveyForm, { + global: { plugins: [PrimeVue, i18n] }, + props: { survey } + }) + +const twoStepSurvey: OnboardingSurvey = { + version: 1, + introKey: 'cloudOnboarding.survey.intro', + fields: [ + { + id: 'usage', + type: 'single', + label: 'How do you plan to use ComfyUI?', + required: true, + options: [ + { value: 'personal', label: 'Personal use' }, + { value: 'work', label: 'Work' } + ] + }, + { + id: 'intent', + type: 'multi', + label: 'What do you want to create with ComfyUI?', + required: true, + options: [ + { value: 'images', label: 'Images' }, + { value: 'videos', label: 'Videos' } + ] + } + ] +} + +describe('DynamicSurveyForm', () => { + it('renders the intro text and the first field options', () => { + renderForm(twoStepSurvey) + + expect( + screen.getByText('Help us tailor your ComfyUI experience.') + ).toBeInTheDocument() + expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible() + expect(screen.getByLabelText('Personal use')).toBeInTheDocument() + expect(screen.getByLabelText('Work')).toBeInTheDocument() + }) + + it('disables Next until the user selects an option, then advances', async () => { + const user = userEvent.setup() + renderForm(twoStepSurvey) + + const next = screen.getByRole('button', { name: 'Next' }) + expect(next).toBeDisabled() + + await user.click(screen.getByLabelText('Personal use')) + expect(next).toBeEnabled() + + await user.click(next) + await flushPromises() + + expect( + screen.getByText('What do you want to create with ComfyUI?') + ).toBeVisible() + expect(screen.getByLabelText('Images')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument() + }) + + it('navigates back to the previous step', async () => { + const user = userEvent.setup() + renderForm(twoStepSurvey) + + await user.click(screen.getByLabelText('Personal use')) + await user.click(screen.getByRole('button', { name: 'Next' })) + await flushPromises() + expect( + screen.getByText('What do you want to create with ComfyUI?') + ).toBeVisible() + + await user.click(screen.getByRole('button', { name: 'Back' })) + await flushPromises() + expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible() + }) + + it('resolves option and field labels via labelKey when provided', () => { + const localizedI18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { back: 'Back', next: 'Next', submit: 'Submit' }, + cloudOnboarding: { + survey: { + intro: 'Help us tailor your ComfyUI experience.', + errors: { + chooseAnOption: '', + selectAtLeastOne: '', + describeAnswer: '' + } + } + }, + survey_label: 'Localized question?', + survey_a: 'Localized A', + survey_b: 'Localized B' + } + } + }) + + render(DynamicSurveyForm, { + global: { plugins: [PrimeVue, localizedI18n] }, + props: { + survey: { + version: 1, + fields: [ + { + id: 'q', + type: 'single', + labelKey: 'survey_label', + required: true, + options: [ + { value: 'a', labelKey: 'survey_a' }, + { value: 'b', labelKey: 'survey_b' } + ] + } + ] + } + } + }) + + expect(screen.getByText('Localized question?')).toBeVisible() + expect(screen.getByLabelText('Localized A')).toBeInTheDocument() + expect(screen.getByLabelText('Localized B')).toBeInTheDocument() + }) + + it('renders server-supplied translations from a label locale map', () => { + const koreanI18n = createI18n({ + legacy: false, + locale: 'ko', + fallbackLocale: 'en', + messages: { + en: { + g: { back: 'Back', next: 'Next', submit: 'Submit' }, + cloudOnboarding: { + survey: { + intro: '', + errors: { + chooseAnOption: '', + selectAtLeastOne: '', + describeAnswer: '' + } + } + } + }, + ko: { g: { back: '뒤로', next: '다음', submit: '제출' } } + } + }) + + render(DynamicSurveyForm, { + global: { plugins: [PrimeVue, koreanI18n] }, + props: { + survey: { + version: 1, + fields: [ + { + id: 'usage', + type: 'single', + label: { + en: 'How will you use it?', + ko: '어떻게 사용하시겠어요?' + }, + required: true, + options: [ + { + value: 'personal', + label: { en: 'Personal use', ko: '개인 용도' } + }, + { value: 'work', label: { en: 'Work', ko: '업무' } } + ] + } + ] + } + } + }) + + expect(screen.getByText('어떻게 사용하시겠어요?')).toBeVisible() + expect(screen.getByLabelText('개인 용도')).toBeInTheDocument() + expect(screen.getByLabelText('업무')).toBeInTheDocument() + }) + + it('falls back to English when current locale missing from label map', () => { + const fallbackI18n = createI18n({ + legacy: false, + locale: 'fr', + fallbackLocale: 'en', + messages: { + en: { + g: { back: 'Back', next: 'Next', submit: 'Submit' }, + cloudOnboarding: { + survey: { + intro: '', + errors: { + chooseAnOption: '', + selectAtLeastOne: '', + describeAnswer: '' + } + } + } + }, + fr: {} + } + }) + + render(DynamicSurveyForm, { + global: { plugins: [PrimeVue, fallbackI18n] }, + props: { + survey: { + version: 1, + fields: [ + { + id: 'q', + type: 'single', + label: { en: 'English question', ko: '한국어' }, + required: true, + options: [ + { value: 'a', label: { en: 'English A', ko: '한국어 A' } } + ] + } + ] + } + } + }) + + // fr is not in the map → falls back to en + expect(screen.getByText('English question')).toBeVisible() + expect(screen.getByLabelText('English A')).toBeInTheDocument() + }) + + it('allows advancing past an optional field while still empty', async () => { + const user = userEvent.setup() + render(DynamicSurveyForm, { + global: { plugins: [PrimeVue, i18n] }, + props: { + survey: { + version: 1, + fields: [ + { + id: 'q1', + type: 'single', + label: 'Optional question?', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' } + ] + // no required: true — should be skippable + }, + { + id: 'q2', + type: 'single', + label: 'Required question?', + required: true, + options: [{ value: 'c', label: 'C' }] + } + ] + } + } + }) + + const next = screen.getByRole('button', { name: 'Next' }) + expect(next).toBeEnabled() + + await user.click(next) + await flushPromises() + expect(screen.getByText('Required question?')).toBeVisible() + }) + + it('enables Submit only after the multi-select field has at least one choice', async () => { + const user = userEvent.setup() + renderForm(twoStepSurvey) + + await user.click(screen.getByLabelText('Work')) + await user.click(screen.getByRole('button', { name: 'Next' })) + await flushPromises() + + const submitBtn = screen.getByRole('button', { name: 'Submit' }) + expect(submitBtn).toBeDisabled() + + await user.click(screen.getByRole('checkbox', { name: /Images/i })) + await flushPromises() + expect(submitBtn).toBeEnabled() + }) +}) diff --git a/src/platform/cloud/onboarding/survey/DynamicSurveyForm.vue b/src/platform/cloud/onboarding/survey/DynamicSurveyForm.vue new file mode 100644 index 0000000000..9ed1148a72 --- /dev/null +++ b/src/platform/cloud/onboarding/survey/DynamicSurveyForm.vue @@ -0,0 +1,212 @@ + + + diff --git a/src/platform/cloud/onboarding/survey/defaultSurveySchema.ts b/src/platform/cloud/onboarding/survey/defaultSurveySchema.ts new file mode 100644 index 0000000000..69c05ce5e7 --- /dev/null +++ b/src/platform/cloud/onboarding/survey/defaultSurveySchema.ts @@ -0,0 +1,76 @@ +import type { OnboardingSurvey } from '@/platform/remoteConfig/types' + +const optionsFor = ( + fieldId: string, + values: string[] +): { value: string; labelKey: string }[] => + values.map((value) => ({ + value, + labelKey: `cloudOnboarding.survey.options.${fieldId}.${value}` + })) + +export const defaultOnboardingSurvey: OnboardingSurvey = { + version: 2, + introKey: 'cloudOnboarding.survey.intro', + fields: [ + { + id: 'usage', + type: 'single', + labelKey: 'cloudSurvey_steps_usage', + required: true, + options: optionsFor('usage', ['personal', 'work', 'education']) + }, + { + id: 'familiarity', + type: 'single', + labelKey: 'cloudSurvey_steps_familiarity', + required: true, + options: optionsFor('familiarity', [ + 'new', + 'starting', + 'basics', + 'advanced', + 'expert' + ]) + }, + { + id: 'intent', + type: 'multi', + labelKey: 'cloudSurvey_steps_intent', + required: true, + randomize: true, + options: optionsFor('intent', [ + 'workflows', + 'custom_nodes', + 'videos', + 'images', + '3d_game', + 'audio', + 'apps', + 'api', + 'not_sure' + ]) + }, + { + id: 'source', + type: 'single', + labelKey: 'cloudSurvey_steps_source', + required: true, + randomize: true, + options: optionsFor('source', [ + 'youtube', + 'reddit', + 'twitter', + 'instagram', + 'linkedin', + 'friend', + 'search', + 'newsletter', + 'conference', + 'discord', + 'github', + 'other' + ]) + } + ] +} diff --git a/src/platform/cloud/onboarding/survey/surveySchema.test.ts b/src/platform/cloud/onboarding/survey/surveySchema.test.ts new file mode 100644 index 0000000000..1695c85132 --- /dev/null +++ b/src/platform/cloud/onboarding/survey/surveySchema.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest' + +import type { OnboardingSurvey } from '@/platform/remoteConfig/types' + +import { + buildInitialValues, + buildSubmissionPayload, + buildZodSchema, + prepareSurvey, + visibleFields +} from './surveySchema' + +const baseSurvey: OnboardingSurvey = { + version: 1, + fields: [ + { + id: 'usage', + type: 'single', + required: true, + options: [ + { value: 'work', label: 'Work' }, + { value: 'personal', label: 'Personal' } + ] + }, + { + id: 'role', + type: 'single', + required: true, + showWhen: { field: 'usage', equals: 'work' }, + options: [{ value: 'engineer', label: 'Engineer' }] + }, + { + id: 'industry', + type: 'single', + required: true, + allowOther: true, + otherFieldId: 'industryOther', + showWhen: { field: 'usage', equals: 'work' }, + options: [ + { value: 'tech', label: 'Tech' }, + { value: 'other', label: 'Other' } + ] + }, + { + id: 'making', + type: 'multi', + required: true, + options: [ + { value: 'video', label: 'Video' }, + { value: 'images', label: 'Images' } + ] + } + ] +} + +describe('visibleFields', () => { + it('hides fields when showWhen does not match', () => { + const visible = visibleFields(baseSurvey, { usage: 'personal' }) + expect(visible.map((f) => f.id)).toEqual(['usage', 'making']) + }) + + it('shows gated fields when showWhen matches', () => { + const visible = visibleFields(baseSurvey, { usage: 'work' }) + expect(visible.map((f) => f.id)).toEqual([ + 'usage', + 'role', + 'industry', + 'making' + ]) + }) + + it('treats array equals as membership', () => { + const survey: OnboardingSurvey = { + version: 1, + fields: [ + { + id: 'role', + type: 'single', + showWhen: { field: 'usage', equals: ['work', 'education'] } + } + ] + } + expect(visibleFields(survey, { usage: 'education' })).toHaveLength(1) + expect(visibleFields(survey, { usage: 'personal' })).toHaveLength(0) + }) + + it('intersects multi-select source values with expected set', () => { + const survey: OnboardingSurvey = { + version: 1, + fields: [ + { + id: 'follow_up', + type: 'single', + showWhen: { field: 'making', equals: ['video', '3d'] } + } + ] + } + expect(visibleFields(survey, { making: [] })).toHaveLength(0) + expect(visibleFields(survey, { making: ['images'] })).toHaveLength(0) + expect(visibleFields(survey, { making: ['images', 'video'] })).toHaveLength( + 1 + ) + }) +}) + +describe('buildInitialValues', () => { + it('initializes single fields to empty string and multi to empty array', () => { + expect(buildInitialValues(baseSurvey)).toMatchObject({ + usage: '', + role: '', + industry: '', + industryOther: '', + making: [] + }) + }) +}) + +describe('buildZodSchema', () => { + it('omits hidden fields from validation', () => { + const schema = buildZodSchema(baseSurvey, { usage: 'personal' }) + const result = schema.safeParse({ usage: 'personal', making: ['video'] }) + expect(result.success).toBe(true) + }) + + it('requires gated fields once visible', () => { + const schema = buildZodSchema(baseSurvey, { usage: 'work' }) + const result = schema.safeParse({ usage: 'work', making: ['video'] }) + expect(result.success).toBe(false) + }) + + it('requires "other" detail when option is selected', () => { + const schema = buildZodSchema(baseSurvey, { + usage: 'work', + role: 'engineer', + industry: 'other', + making: ['video'] + }) + expect( + schema.safeParse({ + usage: 'work', + role: 'engineer', + industry: 'other', + industryOther: '', + making: ['video'] + }).success + ).toBe(false) + expect( + schema.safeParse({ + usage: 'work', + role: 'engineer', + industry: 'other', + industryOther: 'Aerospace', + making: ['video'] + }).success + ).toBe(true) + }) +}) + +describe('buildSubmissionPayload', () => { + it('clears hidden fields and prefers free-text "other" detail', () => { + const payload = buildSubmissionPayload(baseSurvey, { + usage: 'work', + role: 'engineer', + industry: 'other', + industryOther: ' Aerospace ', + making: ['video'] + }) + expect(payload).toEqual({ + usage: 'work', + role: 'engineer', + industry: 'Aerospace', + making: ['video'] + }) + }) + + it('falls back to "other" when free-text is empty', () => { + const payload = buildSubmissionPayload(baseSurvey, { + usage: 'work', + role: 'engineer', + industry: 'other', + industryOther: '', + making: ['video'] + }) + expect(payload.industry).toBe('other') + }) + + it('zeroes out fields hidden by showWhen', () => { + const payload = buildSubmissionPayload(baseSurvey, { + usage: 'personal', + role: 'engineer', + making: ['video'] + }) + expect(payload).toMatchObject({ + usage: 'personal', + role: '', + industry: '', + making: ['video'] + }) + }) +}) + +describe('prepareSurvey', () => { + it('preserves option contents but may reorder when randomize=true', () => { + const survey: OnboardingSurvey = { + version: 1, + fields: [ + { + id: 'making', + type: 'multi', + randomize: true, + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + { value: 'other', label: 'Other' } + ] + } + ] + } + const prepared = prepareSurvey(survey) + const values = prepared.fields[0]!.options!.map((o) => o.value) + expect(values).toContain('a') + expect(values).toContain('b') + expect(values[values.length - 1]).toBe('other') + }) + + it('pins both "other" and "not_sure" at the end while randomizing the rest', () => { + const survey: OnboardingSurvey = { + version: 1, + fields: [ + { + id: 'intent', + type: 'multi', + randomize: true, + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + { value: 'other', label: 'Other' }, + { value: 'not_sure', label: 'Not sure' } + ] + } + ] + } + const prepared = prepareSurvey(survey) + const values = prepared.fields[0]!.options!.map((o) => o.value) + expect(values.slice(-2).sort()).toEqual(['not_sure', 'other']) + expect(values.slice(0, -2).sort()).toEqual(['a', 'b']) + }) +}) diff --git a/src/platform/cloud/onboarding/survey/surveySchema.ts b/src/platform/cloud/onboarding/survey/surveySchema.ts new file mode 100644 index 0000000000..d039d89139 --- /dev/null +++ b/src/platform/cloud/onboarding/survey/surveySchema.ts @@ -0,0 +1,137 @@ +import { shuffle } from 'es-toolkit' +import { z } from 'zod' + +import type { + OnboardingSurvey, + OnboardingSurveyField, + OnboardingSurveyFieldCondition +} from '@/platform/remoteConfig/types' + +export type SurveyValues = Record + +const hasNonEmptyValue = (current: string | string[] | undefined): boolean => { + if (current === undefined || current === '') return false + if (Array.isArray(current)) return current.length > 0 + return true +} + +const conditionMatches = ( + condition: OnboardingSurveyFieldCondition | undefined, + values: SurveyValues +): boolean => { + if (!condition) return true + const current = values[condition.field] + if (!hasNonEmptyValue(current)) return false + const expected = condition.equals + if (expected === undefined) return true + const expectedSet = Array.isArray(expected) ? expected : [expected] + if (Array.isArray(current)) { + return current.some((v) => expectedSet.includes(v)) + } + return typeof current === 'string' && expectedSet.includes(current) +} + +export const visibleFields = ( + survey: OnboardingSurvey, + values: SurveyValues +): OnboardingSurveyField[] => + survey.fields.filter((field) => conditionMatches(field.showWhen, values)) + +const PIN_LAST_VALUES = new Set(['other', 'not_sure']) + +const randomizeOptions = (field: OnboardingSurveyField) => { + if (!field.randomize || !field.options) return field + const pinned = field.options.filter((opt) => PIN_LAST_VALUES.has(opt.value)) + const rest = field.options.filter((opt) => !PIN_LAST_VALUES.has(opt.value)) + return { + ...field, + options: [...shuffle(rest), ...pinned] + } +} + +export const prepareSurvey = (survey: OnboardingSurvey): OnboardingSurvey => ({ + ...survey, + fields: survey.fields.map(randomizeOptions) +}) + +type Translator = (key: string) => string + +const identityTranslator: Translator = (key) => key + +const fieldSchema = (field: OnboardingSurveyField, t: Translator) => { + if (field.type === 'multi') { + const arr = z.array(z.string()) + return field.required + ? arr.min(1, { + message: t('cloudOnboarding.survey.errors.selectAtLeastOne') + }) + : arr.optional() + } + if (field.required) { + return z.string().min(1, { + message: t('cloudOnboarding.survey.errors.chooseAnOption') + }) + } + return z.string().optional() +} + +export const buildZodSchema = ( + survey: OnboardingSurvey, + values: SurveyValues, + t: Translator = identityTranslator +) => { + const shape: Record = {} + for (const field of survey.fields) { + if (!conditionMatches(field.showWhen, values)) continue + shape[field.id] = fieldSchema(field, t) + if ( + field.allowOther && + field.otherFieldId && + values[field.id] === 'other' + ) { + shape[field.otherFieldId] = z.string().min(1, { + message: t('cloudOnboarding.survey.errors.describeAnswer') + }) + } else if (field.otherFieldId) { + shape[field.otherFieldId] = z.string().optional() + } + } + return z.object(shape) +} + +export const buildInitialValues = (survey: OnboardingSurvey): SurveyValues => { + const initial: SurveyValues = {} + for (const field of survey.fields) { + initial[field.id] = field.type === 'multi' ? [] : '' + if (field.otherFieldId) initial[field.otherFieldId] = '' + } + return initial +} + +export const buildSubmissionPayload = ( + survey: OnboardingSurvey, + values: SurveyValues +): Record => { + const payload: Record = {} + for (const field of survey.fields) { + const visible = conditionMatches(field.showWhen, values) + if (!visible) { + payload[field.id] = field.type === 'multi' ? [] : '' + continue + } + const value = values[field.id] + const otherRaw = field.otherFieldId ? values[field.otherFieldId] : undefined + if ( + field.allowOther && + field.otherFieldId && + value === 'other' && + typeof otherRaw === 'string' + ) { + const other = otherRaw.trim() + payload[field.id] = other || 'other' + } else { + payload[field.id] = field.type === 'multi' ? (value ?? []) : (value ?? '') + } + } + return payload +} diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts index fbae538fdf..f2134aa513 100644 --- a/src/platform/remoteConfig/types.ts +++ b/src/platform/remoteConfig/types.ts @@ -23,6 +23,54 @@ type FirebaseRuntimeConfig = { measurementId?: string } +/** + * Server-driven onboarding survey schema. + * + * The backend ships the entire form definition so onboarding questions can + * be tweaked without a frontend release. Field types map 1:1 to a component + * in our internal UI library — see `DynamicSurveyField.vue`. + */ +export type OnboardingSurveyFieldType = 'single' | 'multi' | 'text' + +/** + * A translatable string. Either: + * - a single literal (treated as the fallback in any locale), or + * - a locale → text map, e.g. `{ en: 'Personal use', ko: '개인 용도' }`, + * so the backend can ship translations without a frontend release. + */ +export type LocalizedString = string | Record + +export type OnboardingSurveyOption = { + value: string + label?: LocalizedString + labelKey?: string +} + +export type OnboardingSurveyFieldCondition = { + field: string + equals?: string | string[] +} + +export type OnboardingSurveyField = { + id: string + type: OnboardingSurveyFieldType + labelKey?: string + label?: LocalizedString + options?: OnboardingSurveyOption[] + required?: boolean + randomize?: boolean + allowOther?: boolean + otherFieldId?: string + placeholder?: string + showWhen?: OnboardingSurveyFieldCondition +} + +export type OnboardingSurvey = { + version: number + introKey?: string + fields: OnboardingSurveyField[] +} + /** * Remote configuration type * Configuration fetched from the server at runtime @@ -45,6 +93,7 @@ export type RemoteConfig = { asset_rename_enabled?: boolean private_models_enabled?: boolean onboarding_survey_enabled?: boolean + onboarding_survey?: OnboardingSurvey linear_toggle_enabled?: boolean team_workspaces_enabled?: boolean user_secrets_enabled?: boolean diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index 2bdfbf0955..e606379eb5 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -40,6 +40,11 @@ export interface SurveyResponses { industry?: string useCase?: string making?: string[] + role?: string + teamSize?: string + source?: string + usage?: string + intent?: string[] } export interface SurveyResponsesNormalized extends SurveyResponses { From e831daae59a6de1ba7aab787b3bd9f14315f9ff8 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Fri, 1 May 2026 21:04:45 -0700 Subject: [PATCH 03/17] feat(website): point robots.txt at /sitemap-index.xml + AI crawler rules (#11823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Once [comfy-router#22](https://github.com/Comfy-Org/comfy-router/pull/22) ships, `comfy.org/sitemap-index.xml` will return a unified index aggregating both the website (38 URLs) and workflow-templates sitemaps. This PR: 1. Reverts `Sitemap:` back to `/sitemap-index.xml` (was `/sitemap-0.xml` in #11802 as a workaround for the 404). 2. Adds explicit allow records for 21 search and AI/LLM crawlers (GPTBot, ChatGPT-User, OAI-SearchBot, Google-Extended, ClaudeBot, Claude-Web, anthropic-ai, PerplexityBot, Perplexity-User, Applebot-Extended, Bytespider, Amazonbot, CCBot, Meta-ExternalAgent, Meta-ExternalFetcher, Diffbot, etc.). 3. Adds `Disallow:` for `/_astro/`, `/_website/`, `/_vercel/` — Vercel build artifacts that aren't useful to crawl. ## Why granular UAs Stacked `User-agent:` records (per [RFC 9309 §2.2](https://datatracker.ietf.org/doc/html/rfc9309#section-2.2)) share one rule block. Listing each bot explicitly: - Signals intent to AI bots that look for their UA in robots.txt before crawling more aggressively. - Surfaces our crawl policy clearly to anyone inspecting the file. - Lets us add per-bot Disallows in future without restructuring. ## Merge order ⚠️ **Do NOT merge until comfy-router#22 is deployed to production.** Until then, `/sitemap-index.xml` returns 404 and this PR would re-break the issue PR #11802 patched. Verification: ```bash curl -sI https://comfy.org/sitemap-index.xml # expect: HTTP/2 200, x-served-by: worker-sitemap-index ``` Once that returns 200, this is safe to merge. ## Verification (after merge + deploy) ```bash # robots.txt is served and points at the unified index curl -s https://comfy.org/robots.txt | grep '^Sitemap:' # → Sitemap: https://comfy.org/sitemap-index.xml # Each AI crawler can fetch it for ua in 'GPTBot/1.0' 'ClaudeBot/1.0' 'PerplexityBot/1.0' 'Google-Extended' 'Applebot-Extended'; do curl -s -o /dev/null -w "$ua → %{http_code}\n" -A "$ua" https://comfy.org/robots.txt done # Sitemap is reachable from robots.txt SITEMAP=$(curl -s https://comfy.org/robots.txt | awk -F': ' '/^Sitemap:/ {print $2}') curl -s "$SITEMAP" | xmllint --noout - && echo "valid XML" ``` ## Linear / closes - Closes FE-437 (AI crawler rules) - Updates FE-432 — the robots.txt change in #11802 was a workaround that's no longer needed once #22 ships ┆Issue is synchronized with this [Notion page](https://app.notion.com/p/PR-11823-feat-website-point-robots-txt-at-sitemap-index-xml-AI-crawler-rules-3546d73d3650811dbceedd06c00db444) by [Unito](https://www.unito.io) --- apps/website/public/robots.txt | 35 +++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/website/public/robots.txt b/apps/website/public/robots.txt index 1a250fa8e2..5e6114b55e 100644 --- a/apps/website/public/robots.txt +++ b/apps/website/public/robots.txt @@ -1,4 +1,33 @@ -User-agent: * -Allow: / +# robots.txt for comfy.org +# Open to all crawlers — including AI/LLM bots — for maximum visibility +# in AI-powered search, chat-based answer engines, and traditional search. +# Granular UAs are listed explicitly to signal intent; rules are shared +# via stacked user-agent records (RFC 9309 §2.2). -Sitemap: https://comfy.org/sitemap-0.xml +User-agent: * +User-agent: Googlebot +User-agent: Bingbot +User-agent: DuckDuckBot +User-agent: GPTBot +User-agent: ChatGPT-User +User-agent: OAI-SearchBot +User-agent: Google-Extended +User-agent: ClaudeBot +User-agent: Claude-Web +User-agent: anthropic-ai +User-agent: PerplexityBot +User-agent: Perplexity-User +User-agent: Applebot +User-agent: Applebot-Extended +User-agent: Bytespider +User-agent: Amazonbot +User-agent: CCBot +User-agent: Meta-ExternalAgent +User-agent: Meta-ExternalFetcher +User-agent: Diffbot +Allow: / +Disallow: /_astro/ +Disallow: /_website/ +Disallow: /_vercel/ + +Sitemap: https://comfy.org/sitemap-index.xml From e356addeb6e676cbcf39b4cae5bf7e48348c022f Mon Sep 17 00:00:00 2001 From: "Daxiong (Lin)" Date: Sat, 2 May 2026 12:24:08 +0800 Subject: [PATCH 04/17] feat: add model links for default workflow (#11308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We now support detecting the missing models when loading the workflow. But the default workflow didn't include an embedded model link, so users don't know where to download the model or which one to use. Users will see an error when loading the default workflow every time, so I updated it to include the model link. Before image After image ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11308-feat-add-model-links-for-default-workflow-3446d73d365081188978e1d313c38ffe) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action --- browser_tests/assets/default.json | 10 +++++++++- src/scripts/defaultGraph.ts | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/browser_tests/assets/default.json b/browser_tests/assets/default.json index 9582c04f78..d8711bdcb2 100644 --- a/browser_tests/assets/default.json +++ b/browser_tests/assets/default.json @@ -119,7 +119,15 @@ { "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 }, { "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 } ], - "properties": {}, + "properties": { + "models": [ + { + "name": "v1-5-pruned-emaonly-fp16.safetensors", + "url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors", + "directory": "checkpoints" + } + ] + }, "widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"] } ], diff --git a/src/scripts/defaultGraph.ts b/src/scripts/defaultGraph.ts index bfec5f8c7f..67629f92e3 100644 --- a/src/scripts/defaultGraph.ts +++ b/src/scripts/defaultGraph.ts @@ -115,7 +115,15 @@ export const defaultGraph: ComfyWorkflowJSON = { { name: 'CLIP', type: 'CLIP', links: [3, 5], slot_index: 1 }, { name: 'VAE', type: 'VAE', links: [8], slot_index: 2 } ], - properties: {}, + properties: { + models: [ + { + name: 'v1-5-pruned-emaonly-fp16.safetensors', + url: 'https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors', + directory: 'checkpoints' + } + ] + }, widgets_values: ['v1-5-pruned-emaonly-fp16.safetensors'] } ], From 5cad2c952b07fc70b991ef44435aef312b00a908 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Fri, 1 May 2026 21:50:44 -0700 Subject: [PATCH 05/17] refactor+test: extract useSubscriptionCheckout composable, rewrite tests (#11396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds 20 component tests for `SubscriptionRequiredDialogContentWorkspace.vue` covering: - **Initial rendering**: pricing table display, close/back button visibility, out_of_credits reason message - **Close button**: calls onClose callback - **Subscribe click flow**: pricing→preview transitions (new subscription & upgrade), error toasts for disallowed/missing/failed previews, monthly billing cycle - **Back button**: returns from preview to pricing step - **Add credit card**: handles subscribed status (success toast + close), needs_payment_method (opens Stripe URL), error state - **Confirm transition**: success path with close emit, error toast on failure - **Resubscribe**: success path with toast + close, error toast on failure ## Testing ```bash pnpm test:unit -- src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts ``` All 20 tests pass. Quality gates (typecheck, lint, format, knip) pass. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11396-test-add-component-tests-for-SubscriptionRequiredDialogContentWorkspace-3476d73d36508156a218dcb67a2a334e) by [Unito](https://www.unito.io) --- ...tionRequiredDialogContentWorkspace.test.ts | 198 ++++++++++ ...criptionRequiredDialogContentWorkspace.vue | 255 +----------- .../useSubscriptionCheckout.test.ts | 369 ++++++++++++++++++ .../composables/useSubscriptionCheckout.ts | 210 ++++++++++ 4 files changed, 795 insertions(+), 237 deletions(-) create mode 100644 src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts create mode 100644 src/platform/workspace/composables/useSubscriptionCheckout.test.ts create mode 100644 src/platform/workspace/composables/useSubscriptionCheckout.ts diff --git a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts new file mode 100644 index 0000000000..680af8da60 --- /dev/null +++ b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts @@ -0,0 +1,198 @@ +import { createTestingPinia } from '@pinia/testing' +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' + +import SubscriptionRequiredDialogContentWorkspace from './SubscriptionRequiredDialogContentWorkspace.vue' + +const mockHandleSubscribeClick = vi.fn() +const mockHandleBackToPricing = vi.fn() +const mockHandleAddCreditCard = vi.fn() +const mockHandleConfirmTransition = vi.fn() +const mockHandleResubscribe = vi.fn() +const mockCheckoutStep = ref<'pricing' | 'preview'>('pricing') +const mockPreviewData = ref<{ transition_type: string } | null>(null) + +vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({ + useSubscriptionCheckout: () => ({ + checkoutStep: mockCheckoutStep, + isLoadingPreview: ref(false), + loadingTier: ref(null), + isSubscribing: ref(false), + isResubscribing: ref(false), + previewData: mockPreviewData, + selectedTierKey: ref('standard'), + selectedBillingCycle: ref('yearly'), + isPolling: ref(false), + handleSubscribeClick: mockHandleSubscribeClick, + handleBackToPricing: mockHandleBackToPricing, + handleAddCreditCard: mockHandleAddCreditCard, + handleConfirmTransition: mockHandleConfirmTransition, + handleResubscribe: mockHandleResubscribe + }) +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { back: 'Back', close: 'Close' }, + subscription: { + plansForWorkspace: 'Plans for {workspace}', + teamWorkspace: 'Team' + }, + credits: { + topUp: { + insufficientTitle: 'Insufficient Credits', + insufficientMessage: 'You have run out of credits.' + } + } + } + } +}) + +const PricingTableStub = { + name: 'PricingTableWorkspace', + template: `
+ + +
` +} + +const AddPaymentPreviewStub = { + name: 'SubscriptionAddPaymentPreviewWorkspace', + template: `
+ +
` +} + +const TransitionPreviewStub = { + name: 'SubscriptionTransitionPreviewWorkspace', + template: `
+ +
` +} + +function renderComponent( + props: { onClose?: () => void; reason?: SubscriptionDialogReason } = {} +) { + return render(SubscriptionRequiredDialogContentWorkspace, { + props: { + onClose: props.onClose ?? vi.fn(), + ...(props.reason ? { reason: props.reason } : {}) + }, + global: { + plugins: [ + createTestingPinia({ createSpy: vi.fn, stubActions: false }), + i18n + ], + stubs: { + PricingTableWorkspace: PricingTableStub, + SubscriptionAddPaymentPreviewWorkspace: AddPaymentPreviewStub, + SubscriptionTransitionPreviewWorkspace: TransitionPreviewStub + } + } + }) +} + +describe('SubscriptionRequiredDialogContentWorkspace', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckoutStep.value = 'pricing' + mockPreviewData.value = null + }) + + it('shows pricing table on pricing step', () => { + renderComponent() + expect(screen.getByTestId('pricing-table')).toBeInTheDocument() + expect(screen.queryByTestId('add-payment-preview')).not.toBeInTheDocument() + expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument() + }) + + it('shows close button and hides back button on pricing step', () => { + renderComponent() + expect(screen.getByLabelText('Close')).toBeInTheDocument() + expect(screen.queryByLabelText('Back')).not.toBeInTheDocument() + }) + + it('calls onClose when close button is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + renderComponent({ onClose }) + + await user.click(screen.getByLabelText('Close')) + + expect(onClose).toHaveBeenCalledOnce() + }) + + it('shows back button on preview step', () => { + mockCheckoutStep.value = 'preview' + mockPreviewData.value = { transition_type: 'new_subscription' } + renderComponent() + expect(screen.getByLabelText('Back')).toBeInTheDocument() + }) + + it('shows insufficient credits message when reason is out_of_credits', () => { + renderComponent({ reason: 'out_of_credits' }) + expect(screen.getByText('Insufficient Credits')).toBeInTheDocument() + expect(screen.getByText('You have run out of credits.')).toBeInTheDocument() + }) + + it('does not show insufficient credits message without reason', () => { + renderComponent() + expect(screen.queryByText('Insufficient Credits')).not.toBeInTheDocument() + }) + + it('shows new subscription preview when transition_type is new_subscription', () => { + mockCheckoutStep.value = 'preview' + mockPreviewData.value = { transition_type: 'new_subscription' } + renderComponent() + expect(screen.getByTestId('add-payment-preview')).toBeInTheDocument() + expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument() + }) + + it('shows transition preview when transition_type is upgrade', () => { + mockCheckoutStep.value = 'preview' + mockPreviewData.value = { transition_type: 'upgrade' } + renderComponent() + expect(screen.getByTestId('transition-preview')).toBeInTheDocument() + expect(screen.queryByTestId('add-payment-preview')).not.toBeInTheDocument() + }) + + it('wires subscribe event to handleSubscribeClick', async () => { + const user = userEvent.setup() + renderComponent() + + await user.click(screen.getByTestId('subscribe-btn')) + + expect(mockHandleSubscribeClick).toHaveBeenCalledWith({ + tierKey: 'standard', + billingCycle: 'yearly' + }) + }) + + it('wires resubscribe event to handleResubscribe', async () => { + const user = userEvent.setup() + renderComponent() + + await user.click(screen.getByTestId('resubscribe-btn')) + + expect(mockHandleResubscribe).toHaveBeenCalled() + }) + + it('wires back button to handleBackToPricing', async () => { + const user = userEvent.setup() + mockCheckoutStep.value = 'preview' + mockPreviewData.value = { transition_type: 'new_subscription' } + renderComponent() + + await user.click(screen.getByLabelText('Back')) + + expect(mockHandleBackToPricing).toHaveBeenCalled() + }) +}) diff --git a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue index bbceecd5b4..f8a815ac37 100644 --- a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue +++ b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue @@ -18,7 +18,7 @@ variant="muted-textonly" class="absolute top-2.5 right-2.5 shrink-0 rounded-full text-text-secondary hover:bg-white/10" :aria-label="$t('g.close')" - @click="handleClose" + @click="onClose" > @@ -94,28 +94,14 @@