diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 6e2a5e3b4..198909f84 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2251,6 +2251,14 @@ "renderBypassState": "Render Bypass State", "renderErrorState": "Render Error State" }, + "nightlySurvey": { + "title": "Help Us Improve", + "description": "You've been using this feature. Would you take a moment to share your feedback?", + "accept": "Sure, I'll help!", + "notNow": "Not Now", + "dontAskAgain": "Don't Ask Again", + "loadError": "Failed to load survey. Please try again later." + }, "cloudOnboarding": { "skipToCloudApp": "Skip to the cloud app", "survey": { diff --git a/src/platform/surveys/NightlySurveyPopover.test.ts b/src/platform/surveys/NightlySurveyPopover.test.ts new file mode 100644 index 000000000..1027ee005 --- /dev/null +++ b/src/platform/surveys/NightlySurveyPopover.test.ts @@ -0,0 +1,192 @@ +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' + +const FEATURE_USAGE_KEY = 'Comfy.FeatureUsage' + +const mockIsNightly = vi.hoisted(() => ({ value: true })) +const mockIsCloud = vi.hoisted(() => ({ value: false })) +const mockIsDesktop = vi.hoisted(() => ({ value: false })) + +vi.mock('@/platform/distribution/types', () => ({ + get isNightly() { + return mockIsNightly.value + }, + get isCloud() { + return mockIsCloud.value + }, + get isDesktop() { + return mockIsDesktop.value + } +})) + +vi.mock('vue-i18n', () => ({ + useI18n: vi.fn(() => ({ + t: (key: string) => key + })) +})) + +describe('NightlySurveyPopover', () => { + const defaultConfig = { + featureId: 'test-feature', + typeformId: 'abc123', + triggerThreshold: 3, + delayMs: 100, + enabled: true + } + + function setFeatureUsage(featureId: string, useCount: number) { + const existing = JSON.parse(localStorage.getItem(FEATURE_USAGE_KEY) ?? '{}') + existing[featureId] = { + useCount, + firstUsed: Date.now() - 1000, + lastUsed: Date.now() + } + localStorage.setItem(FEATURE_USAGE_KEY, JSON.stringify(existing)) + } + + beforeEach(() => { + localStorage.clear() + vi.resetModules() + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-06-15T12:00:00Z')) + + mockIsNightly.value = true + mockIsCloud.value = false + mockIsDesktop.value = false + }) + + afterEach(() => { + localStorage.clear() + vi.useRealTimers() + document.body.innerHTML = '' + }) + + async function mountComponent(config = defaultConfig) { + const { default: NightlySurveyPopover } = + await import('./NightlySurveyPopover.vue') + return mount(NightlySurveyPopover, { + props: { config }, + global: { + stubs: { + Teleport: true + } + }, + attachTo: document.body + }) + } + + describe('visibility', () => { + it('shows popover after delay when eligible', async () => { + setFeatureUsage('test-feature', 5) + + const wrapper = await mountComponent() + await nextTick() + + expect(wrapper.find('[class*="fixed"]').exists()).toBe(false) + + await vi.advanceTimersByTimeAsync(100) + await nextTick() + + expect(wrapper.find('[class*="fixed"]').exists()).toBe(true) + }) + + it('does not show when not eligible', async () => { + setFeatureUsage('test-feature', 1) + + const wrapper = await mountComponent() + await nextTick() + await vi.advanceTimersByTimeAsync(1000) + await nextTick() + + expect(wrapper.find('[class*="fixed"]').exists()).toBe(false) + }) + + it('does not show on cloud', async () => { + mockIsCloud.value = true + setFeatureUsage('test-feature', 5) + + const wrapper = await mountComponent() + await nextTick() + await vi.advanceTimersByTimeAsync(1000) + await nextTick() + + expect(wrapper.find('[class*="fixed"]').exists()).toBe(false) + }) + }) + + describe('user actions', () => { + it('emits shown event when displayed', async () => { + setFeatureUsage('test-feature', 5) + + const wrapper = await mountComponent() + await vi.advanceTimersByTimeAsync(100) + await nextTick() + + expect(wrapper.emitted('shown')).toHaveLength(1) + }) + + it('emits dismissed when close button clicked', async () => { + setFeatureUsage('test-feature', 5) + + const wrapper = await mountComponent() + await vi.advanceTimersByTimeAsync(100) + await nextTick() + + const closeButton = wrapper.find('[aria-label="g.close"]') + await closeButton.trigger('click') + + expect(wrapper.emitted('dismissed')).toHaveLength(1) + }) + + it('emits optedOut when opt out button clicked', async () => { + setFeatureUsage('test-feature', 5) + + const wrapper = await mountComponent() + await vi.advanceTimersByTimeAsync(100) + await nextTick() + + const buttons = wrapper.findAll('button') + const optOutButton = buttons.find((b) => + b.text().includes('nightlySurvey.dontAskAgain') + ) + await optOutButton?.trigger('click') + + expect(wrapper.emitted('optedOut')).toHaveLength(1) + }) + }) + + describe('config', () => { + it('uses custom delay from config', async () => { + setFeatureUsage('test-feature', 5) + + const wrapper = await mountComponent({ + ...defaultConfig, + delayMs: 500 + }) + await nextTick() + + await vi.advanceTimersByTimeAsync(400) + await nextTick() + expect(wrapper.find('[class*="fixed"]').exists()).toBe(false) + + await vi.advanceTimersByTimeAsync(100) + await nextTick() + expect(wrapper.find('[class*="fixed"]').exists()).toBe(true) + }) + + it('does not show when config is disabled', async () => { + setFeatureUsage('test-feature', 5) + + const wrapper = await mountComponent({ + ...defaultConfig, + enabled: false + }) + await nextTick() + await vi.advanceTimersByTimeAsync(1000) + await nextTick() + + expect(wrapper.find('[class*="fixed"]').exists()).toBe(false) + }) + }) +}) diff --git a/src/platform/surveys/NightlySurveyPopover.vue b/src/platform/surveys/NightlySurveyPopover.vue new file mode 100644 index 000000000..742ac3d2b --- /dev/null +++ b/src/platform/surveys/NightlySurveyPopover.vue @@ -0,0 +1,164 @@ + + + + + + + + + {{ t('nightlySurvey.title') }} + + + + + + + + {{ t('nightlySurvey.description') }} + + + + {{ t('nightlySurvey.loadError') }} + + + + + + + {{ t('nightlySurvey.accept') }} + + + + {{ t('nightlySurvey.notNow') }} + + + {{ t('nightlySurvey.dontAskAgain') }} + + + + + + + + +
+ {{ t('nightlySurvey.description') }} +