Compare commits

...

8 Commits

Author SHA1 Message Date
Benjamin Lu
cbc93f38f3 Add Desktop2 survey identity provider 2026-06-19 12:40:40 -07:00
pythongosssss
27b1487b17 feat: add stable survey identity
- add IdentityProvider, defaulting to anonymous uuid upgraded to provider based on cloud
- persist anon uuid in localStorage (in memory fallback if no localStorage)
- feed through to typeform surveys, removing email
2026-06-19 06:20:45 -07:00
pythongosssss
46b80e6f06 test: drop type cast and dedupe WorkflowTabs feedback button tests 2026-06-18 13:39:54 -07:00
pythongosssss
967c8b5341 test: use real i18n instead of mocking vue-i18n in survey tests 2026-06-18 13:38:10 -07:00
pythongosssss
b7113223f3 test: cover TypeformPopoverButton desktop/mobile/inactive branches 2026-06-18 13:31:07 -07:00
pythongosssss
a2bafc8717 test: update WorkflowTabs feedback button test for in-app dialog 2026-06-18 13:15:27 -07:00
pythongosssss
ad7961293c feat: include logged-in user email as a feedback form hidden field 2026-06-18 12:53:55 -07:00
pythongosssss
2ec2886332 feat: update typeform feedback to be an in app dialog
- update feedback to show via in app dialog
- extract reusable TypeformEmbed from existing surveys
- track feedback opens
2026-06-18 12:32:26 -07:00
33 changed files with 991 additions and 130 deletions

View File

@@ -41,6 +41,10 @@ vi.mock('@/platform/settings/settingStore', () => ({
})
}))
vi.mock('@/platform/surveys/surveyIdentity', () => ({
getSurveyIdentityTags: () => ({ anon_id: 'anon-1' })
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackHelpResourceClicked: vi.fn(),
@@ -132,7 +136,7 @@ describe('HelpCenterMenuContent feedback item', () => {
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=help-center',
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=help-center&anon_id=anon-1',
'_blank',
'noopener,noreferrer'
)
@@ -146,7 +150,7 @@ describe('HelpCenterMenuContent feedback item', () => {
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=help-center',
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=help-center&anon_id=anon-1',
'_blank',
'noopener,noreferrer'
)

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, reactive } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -36,7 +36,15 @@ vi.mock('@/platform/settings/settingStore', () => ({
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: { value: false } })
useCurrentUser: () => ({
isLoggedIn: { value: false },
userEmail: { value: undefined }
})
}))
const openFeedbackDialog = vi.hoisted(() => vi.fn())
vi.mock('@/platform/support/feedbackDialog', () => ({
openFeedbackDialog
}))
vi.mock('@/composables/useFeatureFlags', () => ({
@@ -128,44 +136,28 @@ function renderComponent() {
}
describe('WorkflowTabs feedback button', () => {
let openSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
distribution.isCloud = false
distribution.isDesktop = false
distribution.isNightly = false
tabBarLayout.value = 'Default'
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
openFeedbackDialog.mockReset()
})
afterEach(() => {
openSpy.mockRestore()
})
it('opens the Typeform survey tagged with topbar source on Cloud', async () => {
it('opens the feedback dialog tagged with topbar source when clicked', async () => {
distribution.isCloud = true
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar',
'_blank',
'noopener,noreferrer'
)
expect(openFeedbackDialog).toHaveBeenCalledWith('topbar')
})
it('opens the Typeform survey tagged with topbar source on Nightly', async () => {
it('renders the feedback button on Nightly', () => {
distribution.isNightly = true
const { user } = renderComponent()
renderComponent()
await user.click(screen.getByRole('button', { name: 'Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=topbar',
'_blank',
'noopener,noreferrer'
)
expect(screen.getByRole('button', { name: 'Feedback' })).toBeInTheDocument()
})
it('does not render the feedback button on non-Cloud/non-Nightly builds', () => {

View File

@@ -119,7 +119,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { openFeedbackDialog } from '@/platform/support/feedbackDialog'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -153,11 +153,7 @@ const isIntegratedTabBar = computed(
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
function openFeedback() {
window.open(
buildFeedbackTypeformUrl('topbar'),
'_blank',
'noopener,noreferrer'
)
openFeedbackDialog('topbar')
}
const containerRef = ref<HTMLElement | null>(null)

View File

@@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueUse from '@vueuse/core'
import { computed, defineComponent, h } from 'vue'
import TypeformPopoverButton from './TypeformPopoverButton.vue'
const isMobile = vi.hoisted(() => ({ value: false }))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal<typeof VueUse>()
return {
...actual,
useBreakpoints: () => ({ smaller: () => computed(() => isMobile.value) })
}
})
vi.mock('@/platform/surveys/surveyIdentity', () => ({
getSurveyIdentityTags: () => ({ anon_id: 'anon-1' }),
getSurveyIdentityTagsAsync: () => Promise.resolve({ anon_id: 'anon-1' })
}))
const PopoverStub = defineComponent({
name: 'Popover',
setup(_, { slots }) {
return () => h('div', [slots.button?.(), slots.default?.()])
}
})
const TypeformEmbedStub = defineComponent({
name: 'TypeformEmbed',
props: { typeformId: { type: String, required: true } },
setup(props) {
return () =>
h('div', { 'data-testid': 'embed', 'data-typeform-id': props.typeformId })
}
})
function renderButton(props: { dataTfWidget: string; active?: boolean }) {
return render(TypeformPopoverButton, {
props,
global: {
stubs: { Popover: PopoverStub, TypeformEmbed: TypeformEmbedStub }
}
})
}
describe('TypeformPopoverButton', () => {
beforeEach(() => {
isMobile.value = false
})
it('embeds the active survey in a popover on desktop', () => {
renderButton({ dataTfWidget: 'abc123' })
expect(screen.getByTestId('embed')).toHaveAttribute(
'data-typeform-id',
'abc123'
)
})
it('omits the embed when inactive', () => {
renderButton({ dataTfWidget: 'abc123', active: false })
expect(screen.queryByTestId('embed')).not.toBeInTheDocument()
})
it('links directly to the form on mobile instead of embedding', () => {
isMobile.value = true
renderButton({ dataTfWidget: 'abc123' })
expect(screen.queryByTestId('embed')).not.toBeInTheDocument()
expect(screen.getByRole('link')).toHaveAttribute(
'href',
'https://form.typeform.com/to/abc123#anon_id=anon-1'
)
})
})

View File

@@ -1,29 +1,46 @@
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { ref, watchEffect } from 'vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import TypeformEmbed from '@/platform/surveys/TypeformEmbed.vue'
import {
getSurveyIdentityTags,
getSurveyIdentityTagsAsync
} from '@/platform/surveys/surveyIdentity'
const { active = true } = defineProps<{
const { dataTfWidget, active = true } = defineProps<{
dataTfWidget: string
active?: boolean
}>()
const feedbackRef = useTemplateRef('feedbackRef')
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
whenever(feedbackRef, () => {
const scriptEl = document.createElement('script')
scriptEl.src = '//embed.typeform.com/next/embed.js'
feedbackRef.value?.appendChild(scriptEl)
// Mobile opens the form externally, so identity rides in the URL fragment.
function buildFormUrl(widgetId: string, tags: Record<string, string>): string {
const params = new URLSearchParams(tags)
return `https://form.typeform.com/to/${widgetId}#${params.toString()}`
}
const formUrl = ref(buildFormUrl(dataTfWidget, getSurveyIdentityTags()))
watchEffect((onCleanup) => {
const widgetId = dataTfWidget
let cancelled = false
void getSurveyIdentityTagsAsync().then((tags) => {
if (!cancelled) formUrl.value = buildFormUrl(widgetId, tags)
})
onCleanup(() => {
cancelled = true
})
})
</script>
<template>
<Button
v-if="isMobile"
as="a"
:href="`https://form.typeform.com/to/${dataTfWidget}`"
:href="formUrl"
target="_blank"
variant="inverted"
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
@@ -41,6 +58,6 @@ whenever(feedbackRef, () => {
<i class="icon-[lucide--circle-help] size-4" />
</Button>
</template>
<div v-if="active" ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
<TypeformEmbed v-if="active" :typeform-id="dataTfWidget" auto-resize />
</Popover>
</template>

View File

@@ -1,11 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ActionBarButton } from '@/types/comfy'
const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
const registerExtension = vi.hoisted(() => vi.fn())
const openFeedbackDialog = vi.hoisted(() => vi.fn())
vi.mock('@/i18n', () => ({
t: (key: string) => key
@@ -24,28 +23,15 @@ vi.mock('@/services/extensionService', () => ({
})
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
},
get isNightly() {
return distribution.isNightly
}
vi.mock('@/platform/support/feedbackDialog', () => ({
openFeedbackDialog
}))
describe('cloudFeedbackTopbarButton', () => {
let openSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.resetModules()
registerExtension.mockReset()
distribution.isCloud = false
distribution.isNightly = false
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
})
afterEach(() => {
openSpy.mockRestore()
openFeedbackDialog.mockReset()
})
function getRegisteredButtons(): ActionBarButton[] {
@@ -56,22 +42,15 @@ describe('cloudFeedbackTopbarButton', () => {
return extension.actionBarButtons
}
it('opens the Typeform survey tagged with action-bar source on Cloud', async () => {
it('opens the feedback survey tagged with the action-bar source', async () => {
tabBarLayout.value = 'Legacy'
distribution.isCloud = true
await import('./cloudFeedbackTopbarButton')
const buttons = getRegisteredButtons()
expect(buttons).toHaveLength(1)
buttons[0].onClick?.()
expect(openSpy).toHaveBeenCalledTimes(1)
const [url, target, features] = openSpy.mock.calls[0]
expect(url).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=action-bar'
)
expect(target).toBe('_blank')
expect(features).toBe('noopener,noreferrer')
expect(openFeedbackDialog).toHaveBeenCalledWith('action-bar')
})
it('only registers the action bar button when the tab bar is Legacy', async () => {

View File

@@ -1,6 +1,6 @@
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { openFeedbackDialog } from '@/platform/support/feedbackDialog'
import { useExtensionService } from '@/services/extensionService'
import type { ActionBarButton } from '@/types/comfy'
@@ -9,13 +9,7 @@ const buttons: ActionBarButton[] = [
icon: 'icon-[lucide--message-square-text]',
label: t('actionbar.feedback'),
tooltip: t('actionbar.feedbackTooltip'),
onClick: () => {
window.open(
buildFeedbackTypeformUrl('action-bar'),
'_blank',
'noopener,noreferrer'
)
}
onClick: () => openFeedbackDialog('action-bar')
}
]

View File

@@ -2900,13 +2900,18 @@
"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."
"dontAskAgain": "Don't Ask Again"
},
"errorPanelSurvey": {
"ctaText": "How's the new error panel?",
"ctaButton": "Give feedback"
},
"feedback": {
"title": "Share Feedback"
},
"typeform": {
"loadError": "Failed to load the form. Please try again later."
},
"cloudOnboarding": {
"skipToCloudApp": "Skip to the cloud app",
"survey": {

View File

@@ -44,6 +44,20 @@ if (isCloud) {
const { initTelemetry } = await import('@/platform/telemetry/initTelemetry')
await initTelemetry()
const { setCurrentIdentityProvider } =
await import('@/platform/surveys/surveyIdentity')
const { cloudIdentityProvider } =
await import('@/platform/surveys/cloudSurveyIdentity')
setCurrentIdentityProvider(cloudIdentityProvider)
}
if (isDesktop) {
const { setCurrentIdentityProvider } =
await import('@/platform/surveys/surveyIdentity')
const { desktopSurveyIdentityProvider } =
await import('@/platform/surveys/desktopSurveyIdentity')
setCurrentIdentityProvider(desktopSurveyIdentityProvider)
}
const ComfyUIPreset = definePreset(Aura, {

View File

@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as SurveyIdentityModule from '@/platform/surveys/surveyIdentity'
const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
vi.mock('@/platform/distribution/types', () => ({
@@ -11,6 +13,11 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
vi.mock('@/platform/surveys/surveyIdentity', async (importOriginal) => ({
...(await importOriginal<typeof SurveyIdentityModule>()),
getSurveyIdentityTags: () => ({ anon_id: 'anon-1' })
}))
describe('buildFeedbackTypeformUrl', () => {
beforeEach(() => {
distribution.isCloud = false
@@ -26,27 +33,51 @@ describe('buildFeedbackTypeformUrl', () => {
it('tags Cloud builds with distribution=ccloud', async () => {
distribution.isCloud = true
expect(await build('topbar')).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar'
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar&anon_id=anon-1'
)
})
it('tags Nightly builds with distribution=oss-nightly', async () => {
distribution.isNightly = true
expect(await build('action-bar')).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=action-bar'
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=action-bar&anon_id=anon-1'
)
})
it('tags OSS builds with distribution=oss', async () => {
expect(await build('help-center')).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=oss&source=help-center'
)
it('includes the survey identity for analytics joins', async () => {
const url = new URL(await build('help-center'))
expect(url.hash).toContain('anon_id=anon-1')
})
it('uses a URL fragment so distribution and source are not sent to the server', async () => {
it('uses a URL fragment so the tags are not sent to the server', async () => {
distribution.isCloud = true
const url = new URL(await build('topbar'))
expect(url.search).toBe('')
expect(url.hash).toBe('#distribution=ccloud&source=topbar')
expect(url.hash).toBe('#distribution=ccloud&source=topbar&anon_id=anon-1')
})
})
describe('buildFeedbackHiddenFields', () => {
beforeEach(() => {
distribution.isCloud = false
distribution.isNightly = false
})
async function build(source: 'topbar' | 'action-bar' | 'help-center') {
vi.resetModules()
const { buildFeedbackHiddenFields } = await import('./config')
return buildFeedbackHiddenFields(source)
}
it('formats segmentation tags comma-separated for data-tf-hidden', async () => {
distribution.isCloud = true
expect(await build('topbar')).toBe('distribution=ccloud,source=topbar')
})
it('reflects the build distribution', async () => {
distribution.isNightly = true
expect(await build('action-bar')).toBe(
'distribution=oss-nightly,source=action-bar'
)
})
})

View File

@@ -1,4 +1,8 @@
import { isCloud, isNightly } from '@/platform/distribution/types'
import {
formatTypeformHiddenFields,
getSurveyIdentityTags
} from '@/platform/surveys/surveyIdentity'
/**
* Zendesk ticket form field IDs.
@@ -25,7 +29,17 @@ function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
}
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
export type FeedbackSource = 'topbar' | 'action-bar' | 'help-center'
export const FEEDBACK_TYPEFORM_ID = 'q7azbWPi'
const FEEDBACK_TYPEFORM_BASE_URL = `https://form.typeform.com/to/${FEEDBACK_TYPEFORM_ID}`
/** Shared by the URL and embed builders so their segmentation tags can't drift. */
function getFeedbackTags(source: FeedbackSource): Record<string, string> {
return { distribution: getDistribution(), source }
}
/**
* Builds the feedback Typeform URL tagged with the current build distribution
@@ -33,16 +47,18 @@ const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
* (Typeform's hidden-field convention) so survey responses can be segmented
* by distribution (cloud / oss-nightly / oss) and entry point.
*/
export function buildFeedbackTypeformUrl(
source: 'topbar' | 'action-bar' | 'help-center'
): string {
export function buildFeedbackTypeformUrl(source: FeedbackSource): string {
const params = new URLSearchParams({
distribution: getDistribution(),
source
...getFeedbackTags(source),
...getSurveyIdentityTags()
})
return `${FEEDBACK_TYPEFORM_BASE_URL}#${params.toString()}`
}
export function buildFeedbackHiddenFields(source: FeedbackSource): string {
return formatTypeformHiddenFields(getFeedbackTags(source))
}
/**
* Builds the support URL with optional user information for pre-filling.
* Users without login information will still get a valid support URL without pre-fill.

View File

@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { openTypeformDialog } from '@/platform/surveys/openTypeformDialog'
import { useTelemetry } from '@/platform/telemetry'
import { FEEDBACK_TYPEFORM_ID } from './config'
import { openFeedbackDialog } from './feedbackDialog'
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/surveys/openTypeformDialog', () => ({
openTypeformDialog: vi.fn()
}))
const trackUiButtonClicked = vi.fn()
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({ trackUiButtonClicked }))
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: true,
isNightly: false
}))
describe('openFeedbackDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('opens the feedback form tagged with distribution and source', () => {
openFeedbackDialog('action-bar')
expect(openTypeformDialog).toHaveBeenCalledWith({
key: 'global-feedback',
typeformId: FEEDBACK_TYPEFORM_ID,
title: 'feedback.title',
hiddenFields: 'distribution=ccloud,source=action-bar'
})
})
it('tracks the button click tagged with the opening source', () => {
openFeedbackDialog('topbar')
expect(trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'feedback_button_clicked',
element_group: 'topbar'
})
})
it('does not throw when telemetry is unavailable', () => {
vi.mocked(useTelemetry).mockReturnValueOnce(null)
expect(() => openFeedbackDialog('action-bar')).not.toThrow()
expect(openTypeformDialog).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,19 @@
import { t } from '@/i18n'
import { openTypeformDialog } from '@/platform/surveys/openTypeformDialog'
import { useTelemetry } from '@/platform/telemetry'
import { FEEDBACK_TYPEFORM_ID, buildFeedbackHiddenFields } from './config'
import type { FeedbackSource } from './config'
export function openFeedbackDialog(source: FeedbackSource) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'feedback_button_clicked',
element_group: source
})
openTypeformDialog({
key: 'global-feedback',
typeformId: FEEDBACK_TYPEFORM_ID,
title: t('feedback.title'),
hiddenFields: buildFeedbackHiddenFields(source)
})
}

View File

@@ -2,9 +2,12 @@ import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
const FEATURE_USAGE_KEY = 'Comfy.FeatureUsage'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
const mockIsNightly = vi.hoisted(() => ({ value: true }))
const mockIsCloud = vi.hoisted(() => ({ value: false }))
const mockIsDesktop = vi.hoisted(() => ({ value: false }))
@@ -21,12 +24,6 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string) => key
}))
}))
describe('NightlySurveyPopover', () => {
const defaultConfig = {
featureId: 'test-feature',
@@ -74,6 +71,7 @@ describe('NightlySurveyPopover', () => {
...eventHandlers
},
global: {
plugins: [i18n],
stubs: {
Teleport: true
}
@@ -111,6 +109,19 @@ describe('NightlySurveyPopover', () => {
).not.toBeInTheDocument()
})
it('does not show when typeform id is invalid', async () => {
setFeatureUsage('test-feature', 5)
await renderComponent({ ...defaultConfig, typeformId: 'invalid id!' })
await nextTick()
await vi.advanceTimersByTimeAsync(1000)
await nextTick()
expect(
screen.queryByTestId('nightly-survey-popover')
).not.toBeInTheDocument()
})
it('does not show on cloud', async () => {
mockIsCloud.value = true
setFeatureUsage('test-feature', 5)
@@ -182,6 +193,7 @@ describe('NightlySurveyPopover', () => {
open
},
global: {
plugins: [i18n],
stubs: {
Teleport: true
}

View File

@@ -25,18 +25,10 @@
</Button>
</div>
<div v-if="typeformError" class="text-danger text-sm">
{{ t('nightlySurvey.loadError') }}
<div class="min-h-[300px]">
<TypeformEmbed :typeform-id="config.typeformId" auto-resize />
</div>
<div
v-show="!typeformError && isValidTypeformId"
ref="typeformRef"
data-tf-auto-resize
:data-tf-widget="typeformId"
class="min-h-[300px]"
/>
<div
class="mt-3 flex items-center gap-2"
:class="mode === 'eligible' ? 'justify-center' : 'justify-end'"
@@ -59,14 +51,15 @@
</template>
<script setup lang="ts">
import { onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import TypeformEmbed from './TypeformEmbed.vue'
import type { FeatureSurveyConfig } from './useSurveyEligibility'
import { useSurveyEligibility } from './useSurveyEligibility'
import { useTypeformEmbed } from './useTypeformEmbed'
import { isTypeformIdValid } from './useTypeformEmbed'
const { config, mode = 'eligible' } = defineProps<{
config: FeatureSurveyConfig
@@ -95,12 +88,6 @@ const { isEligible, delayMs, markSurveyShown, optOut } = useSurveyEligibility(
)
const hasOpenedOnce = ref(openModel.value)
const typeformRef = useTemplateRef<HTMLDivElement>('typeformRef')
const { typeformError, isValidTypeformId, typeformId } = useTypeformEmbed(
typeformRef,
() => config.typeformId
)
// Teleport stays mounted after the first open so the Typeform iframe
// persists across consumer-side lifecycle changes.
@@ -126,7 +113,7 @@ watch(
showTimeout = setTimeout(() => {
showTimeout = null
if (!isValidTypeformId.value) return
if (!isTypeformIdValid(config.typeformId)) return
if (openModel.value) return
openModel.value = true
markSurveyShown()

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import TypeformEmbed from './TypeformEmbed.vue'
const { typeformId, hiddenFields } = defineProps<{
typeformId: string
hiddenFields?: string
}>()
</script>
<template>
<div class="h-[70vh] min-h-96">
<TypeformEmbed :typeform-id :hidden-fields redirect-target="_self" />
</div>
</template>

View File

@@ -0,0 +1,93 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import TypeformEmbed from './TypeformEmbed.vue'
import type * as SurveyIdentityModule from './surveyIdentity'
const embedState = vi.hoisted(() => ({
typeformError: false,
isValidTypeformId: true
}))
vi.mock('./useTypeformEmbed', () => ({
useTypeformEmbed: vi.fn(() => ({
typeformError: ref(embedState.typeformError),
isValidTypeformId: ref(embedState.isValidTypeformId)
}))
}))
vi.mock('./surveyIdentity', async (importOriginal) => ({
...(await importOriginal<typeof SurveyIdentityModule>()),
getSurveyIdentityTagsAsync: () => Promise.resolve({ anon_id: 'anon-1' })
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
function renderEmbed(props: ComponentProps<typeof TypeformEmbed>) {
return render(TypeformEmbed, { props, global: { plugins: [i18n] } })
}
describe('TypeformEmbed', () => {
beforeEach(() => {
embedState.typeformError = false
embedState.isValidTypeformId = true
})
it('appends the survey identity to the caller hidden fields', async () => {
renderEmbed({ typeformId: 'abc123', hiddenFields: 'source=topbar' })
const embed = await screen.findByTestId('typeform-embed')
expect(embed).toHaveAttribute('data-tf-widget', 'abc123')
expect(embed).toHaveAttribute(
'data-tf-hidden',
'source=topbar,anon_id=anon-1'
)
expect(embed).not.toHaveAttribute('data-tf-redirect-target')
expect(embed).not.toHaveAttribute('data-tf-auto-resize')
})
it('sends the survey identity even without caller hidden fields', async () => {
renderEmbed({ typeformId: 'abc123' })
expect(await screen.findByTestId('typeform-embed')).toHaveAttribute(
'data-tf-hidden',
'anon_id=anon-1'
)
})
it('keeps redirect-on-completion inside the iframe when requested', async () => {
renderEmbed({ typeformId: 'abc123', redirectTarget: '_self' })
expect(await screen.findByTestId('typeform-embed')).toHaveAttribute(
'data-tf-redirect-target',
'_self'
)
})
it('enables auto-resize when requested', async () => {
renderEmbed({ typeformId: 'abc123', autoResize: true })
expect(await screen.findByTestId('typeform-embed')).toHaveAttribute(
'data-tf-auto-resize'
)
})
it('shows the load-error message instead of the embed when the script fails', () => {
embedState.typeformError = true
renderEmbed({ typeformId: 'abc123' })
expect(screen.getByText('typeform.loadError')).toBeInTheDocument()
expect(screen.queryByTestId('typeform-embed')).toBeNull()
})
it('shows the load-error message instead of the embed for an invalid form id', () => {
embedState.isValidTypeformId = false
renderEmbed({ typeformId: 'bad id!' })
expect(screen.getByText('typeform.loadError')).toBeInTheDocument()
expect(screen.queryByTestId('typeform-embed')).toBeNull()
})
})

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
formatTypeformHiddenFields,
getSurveyIdentityTagsAsync
} from './surveyIdentity'
import { useTypeformEmbed } from './useTypeformEmbed'
const {
typeformId,
hiddenFields,
autoResize = false,
redirectTarget
} = defineProps<{
typeformId: string
/** Comma-separated `key=value` tags passed to Typeform via `data-tf-hidden`. */
hiddenFields?: string
autoResize?: boolean
/** `_self` keeps a form's completion redirect inside the iframe, never navigating the host app. */
redirectTarget?: '_self' | '_blank'
}>()
const { t } = useI18n()
const typeformRef = useTemplateRef<HTMLDivElement>('typeformRef')
const { typeformError, isValidTypeformId } = useTypeformEmbed(
typeformRef,
() => typeformId
)
const dataTfHidden = ref<string>()
watch(
() => hiddenFields,
async (_, __, onCleanup) => {
let cancelled = false
onCleanup(() => {
cancelled = true
})
const identity = formatTypeformHiddenFields(
await getSurveyIdentityTagsAsync()
)
if (cancelled) return
dataTfHidden.value = [hiddenFields, identity].filter(Boolean).join(',')
},
{ immediate: true }
)
</script>
<template>
<div
v-if="typeformError || !isValidTypeformId"
class="text-danger flex h-full items-center text-sm"
>
{{ t('typeform.loadError') }}
</div>
<!-- `data-tf-auto-resize` is read by presence, so it must be absent (not "false") when disabled -->
<div
v-else-if="dataTfHidden !== undefined"
ref="typeformRef"
data-testid="typeform-embed"
:data-tf-widget="typeformId"
:data-tf-hidden="dataTfHidden"
:data-tf-redirect-target="redirectTarget"
:data-tf-auto-resize="autoResize || undefined"
:class="autoResize ? 'w-full' : 'size-full'"
/>
</template>

View File

@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cloudIdentityProvider } from './cloudSurveyIdentity'
const currentUser = vi.hoisted(
(): { resolvedUserInfo: { value: { id: string } | null } } => ({
resolvedUserInfo: { value: null }
})
)
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => currentUser
}))
const distinctId = vi.hoisted((): { value: string | null } => ({ value: null }))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ getDistinctId: () => distinctId.value })
}))
describe('cloudIdentityProvider', () => {
beforeEach(() => {
localStorage.clear()
currentUser.resolvedUserInfo.value = null
distinctId.value = null
})
it('uses the stable local id as anon_id regardless of PostHog', () => {
distinctId.value = 'ph-123'
const identity = cloudIdentityProvider.getIdentity()
expect(identity.anon_id).toBe(localStorage.getItem('Comfy.SurveyAnonId'))
expect(identity.anon_id).not.toBe('ph-123')
})
it('sends the PostHog distinct id as a separate field for stitching', () => {
distinctId.value = 'ph-123'
expect(cloudIdentityProvider.getIdentity().distinct_id).toBe('ph-123')
})
it('omits distinct_id when PostHog has none', () => {
expect(cloudIdentityProvider.getIdentity().distinct_id).toBeUndefined()
})
it('includes the authenticated id when signed in', () => {
currentUser.resolvedUserInfo.value = { id: 'uid-1' }
expect(cloudIdentityProvider.getIdentity().comfy_id).toBe('uid-1')
})
it('omits the authenticated id for an anonymous visitor', () => {
expect(cloudIdentityProvider.getIdentity().comfy_id).toBeUndefined()
})
})

View File

@@ -0,0 +1,16 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useTelemetry } from '@/platform/telemetry'
import { getOrCreateAnonId } from './surveyIdentity'
import type { IdentityProvider } from './surveyIdentity'
export const cloudIdentityProvider = {
getIdentity() {
const { resolvedUserInfo } = useCurrentUser()
return {
anon_id: getOrCreateAnonId(),
distinct_id: useTelemetry()?.getDistinctId() ?? undefined,
comfy_id: resolvedUserInfo.value?.id
}
}
} satisfies IdentityProvider

View File

@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { desktopSurveyIdentityProvider } from './desktopSurveyIdentity'
function setDesktopBridge(
bridge:
| {
Surveys?: {
getIdentity?: () => Promise<Record<string, string> | null>
}
}
| undefined
) {
;(window as unknown as { __comfyDesktop2?: typeof bridge }).__comfyDesktop2 =
bridge
}
describe('desktopSurveyIdentityProvider', () => {
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
setDesktopBridge(undefined)
vi.restoreAllMocks()
})
it('uses the Desktop-owned survey identity when available', async () => {
setDesktopBridge({
Surveys: {
getIdentity: vi.fn().mockResolvedValue({
anon_id: 'install-1',
distinct_id: 'user-1',
comfy_id: 'user-1'
})
}
})
await expect(desktopSurveyIdentityProvider.getIdentity()).resolves.toEqual({
anon_id: 'install-1',
distinct_id: 'user-1',
comfy_id: 'user-1'
})
})
it('falls back to a local anonymous id when the bridge returns null', async () => {
setDesktopBridge({
Surveys: {
getIdentity: vi.fn().mockResolvedValue(null)
}
})
const identity = await desktopSurveyIdentityProvider.getIdentity()
expect(identity?.anon_id).toBeTruthy()
expect(identity?.distinct_id).toBeUndefined()
expect(identity?.comfy_id).toBeUndefined()
})
it('falls back to a local anonymous id when the bridge is unavailable', async () => {
setDesktopBridge(undefined)
const identity = await desktopSurveyIdentityProvider.getIdentity()
expect(identity?.anon_id).toBe(localStorage.getItem('Comfy.SurveyAnonId'))
})
})

View File

@@ -0,0 +1,27 @@
import { getOrCreateAnonId } from './surveyIdentity'
import type { IdentityProvider, SurveyIdentity } from './surveyIdentity'
interface DesktopSurveyIdentityBridge {
Surveys?: {
getIdentity?: () => Promise<Partial<SurveyIdentity> | null>
}
}
function getDesktopBridge(): DesktopSurveyIdentityBridge | undefined {
return (window as Window & { __comfyDesktop2?: DesktopSurveyIdentityBridge })
.__comfyDesktop2
}
export const desktopSurveyIdentityProvider: IdentityProvider = {
async getIdentity() {
try {
const identity = await getDesktopBridge()?.Surveys?.getIdentity?.()
return {
...identity,
anon_id: identity?.anon_id ?? getOrCreateAnonId()
}
} catch {
return { anon_id: getOrCreateAnonId() }
}
}
}

View File

@@ -0,0 +1,43 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDialogStore } from '@/stores/dialogStore'
import TypeformDialogContent from './TypeformDialogContent.vue'
import { openTypeformDialog } from './openTypeformDialog'
describe('openTypeformDialog', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('opens the form embed in a Reka dialog with the given id and hidden fields', () => {
const showDialog = vi.spyOn(useDialogStore(), 'showDialog')
openTypeformDialog({
typeformId: 'abc123',
title: 'A Form',
hiddenFields: 'foo=bar'
})
expect(showDialog).toHaveBeenCalledWith(
expect.objectContaining({
key: 'typeform-abc123',
title: 'A Form',
component: TypeformDialogContent,
props: { typeformId: 'abc123', hiddenFields: 'foo=bar' },
dialogComponentProps: expect.objectContaining({ renderer: 'reka' })
})
)
})
it('honors an explicit dialog key', () => {
const showDialog = vi.spyOn(useDialogStore(), 'showDialog')
openTypeformDialog({ typeformId: 'abc123', title: 'A Form', key: 'custom' })
expect(showDialog).toHaveBeenCalledWith(
expect.objectContaining({ key: 'custom' })
)
})
})

View File

@@ -0,0 +1,29 @@
import { useDialogStore } from '@/stores/dialogStore'
import TypeformDialogContent from './TypeformDialogContent.vue'
export interface TypeformDialogOptions {
typeformId: string
title: string
/** Comma-separated `key=value` tags passed to Typeform via `data-tf-hidden`. */
hiddenFields?: string
key?: string
}
export function openTypeformDialog({
typeformId,
title,
hiddenFields,
key
}: TypeformDialogOptions) {
useDialogStore().showDialog({
key: key ?? `typeform-${typeformId}`,
title,
component: TypeformDialogContent,
props: { typeformId, hiddenFields },
dialogComponentProps: {
renderer: 'reka',
size: 'lg'
}
})
}

View File

@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
anonymousIdentityProvider,
getSurveyIdentityTags,
getSurveyIdentityTagsAsync,
setCurrentIdentityProvider
} from './surveyIdentity'
describe('survey identity', () => {
beforeEach(() => {
localStorage.clear()
setCurrentIdentityProvider(anonymousIdentityProvider)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('is anonymous by default', () => {
const tags = getSurveyIdentityTags()
expect(tags.anon_id).toBeTruthy()
expect(tags.comfy_id).toBeUndefined()
})
it('mints a stable anonymous id and reuses it across calls', () => {
const anonId = getSurveyIdentityTags().anon_id
expect(localStorage.getItem('Comfy.SurveyAnonId')).toBe(anonId)
expect(getSurveyIdentityTags().anon_id).toBe(anonId)
})
it('falls back to a stable in-memory id when storage is unavailable', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage disabled')
})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('storage disabled')
})
const anonId = getSurveyIdentityTags().anon_id
expect(anonId).toBeTruthy()
expect(getSurveyIdentityTags().anon_id).toBe(anonId)
})
it('resolves tags through the registered provider, dropping absent fields', () => {
setCurrentIdentityProvider({ getIdentity: () => ({ anon_id: 'fixed' }) })
expect(getSurveyIdentityTags()).toEqual({ anon_id: 'fixed' })
})
it('includes distinct_id and comfy_id when the provider supplies them', () => {
setCurrentIdentityProvider({
getIdentity: () => ({
anon_id: 'fixed',
distinct_id: 'ph-1',
comfy_id: 'uid-9'
})
})
expect(getSurveyIdentityTags()).toEqual({
anon_id: 'fixed',
distinct_id: 'ph-1',
comfy_id: 'uid-9'
})
})
it('resolves async providers for bridge-backed identities', async () => {
setCurrentIdentityProvider({
getIdentity: () =>
Promise.resolve({
anon_id: 'desktop-install',
distinct_id: 'desktop-user',
comfy_id: 'desktop-user'
})
})
await expect(getSurveyIdentityTagsAsync()).resolves.toEqual({
anon_id: 'desktop-install',
distinct_id: 'desktop-user',
comfy_id: 'desktop-user'
})
})
it('keeps the sync accessor on an anonymous fallback for async providers', () => {
setCurrentIdentityProvider({
getIdentity: () => Promise.resolve({ anon_id: 'desktop-install' })
})
expect(getSurveyIdentityTags().anon_id).toBeTruthy()
expect(getSurveyIdentityTags().anon_id).not.toBe('desktop-install')
})
})

View File

@@ -0,0 +1,92 @@
import { createUuidv4 } from '@/utils/uuid'
const ANON_ID_KEY = 'Comfy.SurveyAnonId'
export interface SurveyIdentity {
/** Stable local id, present for the user's whole journey across opt-in/login. */
anon_id: string
/** PostHog distinct id, bridging responses to product analytics when present. */
distinct_id?: string
/** Set only for an authenticated (consented) user. */
comfy_id?: string
}
type MaybePromise<T> = T | Promise<T>
type SurveyIdentityResult = Partial<SurveyIdentity> | null | undefined
export interface IdentityProvider {
getIdentity(): MaybePromise<SurveyIdentityResult>
}
let memoryAnonId: string | undefined
export function getOrCreateAnonId(): string {
try {
const existing = localStorage.getItem(ANON_ID_KEY)
if (existing) return existing
const anonId = createUuidv4()
localStorage.setItem(ANON_ID_KEY, anonId)
return anonId
} catch {
// Storage disabled: degrade to an in-memory id so a feedback click never throws.
return (memoryAnonId ??= createUuidv4())
}
}
export const anonymousIdentityProvider: IdentityProvider = {
getIdentity: () => ({ anon_id: getOrCreateAnonId() })
}
let currentProvider: IdentityProvider = anonymousIdentityProvider
export function setCurrentIdentityProvider(provider: IdentityProvider): void {
currentProvider = provider
}
function isPromiseLike<T>(value: MaybePromise<T>): value is Promise<T> {
return typeof (value as Promise<T>)?.then === 'function'
}
function normalizeSurveyIdentity(
identity: SurveyIdentityResult
): SurveyIdentity {
return {
...(identity ?? {}),
anon_id: identity?.anon_id ?? getOrCreateAnonId()
}
}
function tagsFromIdentity(
identity: SurveyIdentityResult
): Record<string, string> {
const { anon_id, distinct_id, comfy_id } = normalizeSurveyIdentity(identity)
return {
anon_id,
...(distinct_id ? { distinct_id } : {}),
...(comfy_id ? { comfy_id } : {})
}
}
/** Identity as Typeform hidden-field tags, dropping any absent field. */
export function getSurveyIdentityTags(): Record<string, string> {
const identity = currentProvider.getIdentity()
return isPromiseLike(identity)
? tagsFromIdentity(null)
: tagsFromIdentity(identity)
}
export async function getSurveyIdentityTagsAsync(): Promise<
Record<string, string>
> {
return tagsFromIdentity(await currentProvider.getIdentity())
}
/** Formats tags as Typeform's comma-separated `key=value` hidden-field string. */
export function formatTypeformHiddenFields(
tags: Record<string, string>
): string {
return Object.entries(tags)
.map(([key, value]) => `${key}=${value}`)
.join(',')
}

View File

@@ -24,7 +24,6 @@ describe('useTypeformEmbed', () => {
const result = runInScope(() => useTypeformEmbed(containerRef, 'goZLqjKL'))
expect(result.isValidTypeformId.value).toBe(true)
expect(result.typeformId.value).toBe('goZLqjKL')
})
it('marks ids with non-alphanumeric characters as invalid', () => {
@@ -34,7 +33,6 @@ describe('useTypeformEmbed', () => {
)
expect(result.isValidTypeformId.value).toBe(false)
expect(result.typeformId.value).toBe('')
})
it('marks undefined id as invalid', () => {
@@ -42,7 +40,6 @@ describe('useTypeformEmbed', () => {
const result = runInScope(() => useTypeformEmbed(containerRef, undefined))
expect(result.isValidTypeformId.value).toBe(false)
expect(result.typeformId.value).toBe('')
})
it('marks empty string id as invalid', () => {
@@ -50,7 +47,6 @@ describe('useTypeformEmbed', () => {
const result = runInScope(() => useTypeformEmbed(containerRef, ''))
expect(result.isValidTypeformId.value).toBe(false)
expect(result.typeformId.value).toBe('')
})
it('exposes a reactive typeformError ref initialized to false', () => {

View File

@@ -99,10 +99,6 @@ export function useTypeformEmbed(
isTypeformIdValid(toValue(typeformIdInput))
)
const typeformId = computed(() =>
isValidTypeformId.value ? (toValue(typeformIdInput) ?? '') : ''
)
whenever(typeformRef, async () => {
try {
await ensureScriptLoaded()
@@ -119,7 +115,6 @@ export function useTypeformEmbed(
return {
typeformError,
isValidTypeformId,
typeformId
isValidTypeformId
}
}

View File

@@ -45,4 +45,34 @@ describe('TelemetryRegistry', () => {
})
).not.toThrow()
})
it('getDistinctId returns the first provider id, skipping those without one', () => {
const registry = new TelemetryRegistry()
registry.registerProvider({})
registry.registerProvider({ getDistinctId: () => 'ph-1' })
registry.registerProvider({ getDistinctId: () => 'ph-2' })
expect(registry.getDistinctId()).toBe('ph-1')
})
it('getDistinctId returns null when no provider has one', () => {
const registry = new TelemetryRegistry()
registry.registerProvider({})
registry.registerProvider({ getDistinctId: () => null })
expect(registry.getDistinctId()).toBeNull()
})
it('getDistinctId skips a provider that throws and returns the next id', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const registry = new TelemetryRegistry()
registry.registerProvider({
getDistinctId: () => {
throw new Error('boom')
}
})
registry.registerProvider({ getDistinctId: () => 'ph-2' })
expect(registry.getDistinctId()).toBe('ph-2')
})
})

View File

@@ -75,6 +75,18 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackUserLoggedIn?.())
}
getDistinctId(): string | null {
for (const provider of this.providers) {
try {
const distinctId = provider.getDistinctId?.()
if (distinctId) return distinctId
} catch (error) {
console.error('[Telemetry] getDistinctId failed', error)
}
}
return null
}
trackSubscription(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata

View File

@@ -10,6 +10,7 @@ const hoisted = vi.hoisted(() => {
const mockPeopleSetOnce = vi.fn()
const mockRegister = vi.fn()
const mockReset = vi.fn()
const mockGetDistinctId = vi.fn(() => 'distinct-xyz')
const mockOnUserResolved = vi.fn()
const mockOnUserLogout = vi.fn()
@@ -21,6 +22,7 @@ const hoisted = vi.hoisted(() => {
mockPeopleSetOnce,
mockRegister,
mockReset,
mockGetDistinctId,
mockOnUserResolved,
mockOnUserLogout,
mockPosthog: {
@@ -30,7 +32,8 @@ const hoisted = vi.hoisted(() => {
identify: mockIdentify,
register: mockRegister,
people: { set: mockPeopleSet, set_once: mockPeopleSetOnce },
reset: mockReset
reset: mockReset,
get_distinct_id: mockGetDistinctId
}
}
}
@@ -88,6 +91,21 @@ describe('PostHogTelemetryProvider', () => {
} as typeof window.__CONFIG__
})
describe('getDistinctId', () => {
it('returns the PostHog distinct id once initialized', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
expect(provider.getDistinctId()).toBe('distinct-xyz')
})
it('returns null before initialization', () => {
const provider = createProvider()
expect(provider.getDistinctId()).toBeNull()
})
})
describe('initialization', () => {
it('disables itself when posthog_project_token is not provided', async () => {
const provider = createProvider({ posthog_project_token: undefined })

View File

@@ -338,6 +338,11 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
}
getDistinctId(): string | null {
if (!this.isEnabled || !this.isInitialized || !this.posthog) return null
return this.posthog.get_distinct_id()
}
trackSubscription(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata

View File

@@ -473,6 +473,9 @@ export interface TelemetryProvider {
trackAuth?(metadata: AuthMetadata): void
trackUserLoggedIn?(): void
/** Anonymous/identified id used to stitch activity; null when unavailable. */
getDistinctId?(): string | null
// Subscription flow events
trackSubscription?(
event: 'modal_opened' | 'subscribe_clicked',