Compare commits
15 Commits
cloud/v1.4
...
core/1.45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d9b1fed64 | ||
|
|
deb4045f18 | ||
|
|
0b3927d8d5 | ||
|
|
955472dab5 | ||
|
|
4ad242181b | ||
|
|
16dfc33df3 | ||
|
|
1a8bf498ef | ||
|
|
7b8ad1c11b | ||
|
|
364bcb3831 | ||
|
|
a6699f6922 | ||
|
|
962e70d7a5 | ||
|
|
6193b76157 | ||
|
|
c5c916f80e | ||
|
|
badc97b982 | ||
|
|
67affd2075 |
@@ -1,55 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockInit: vi.fn(),
|
||||
mockCapture: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
init: hoisted.mockInit,
|
||||
capture: hoisted.mockCapture
|
||||
}
|
||||
}))
|
||||
|
||||
describe('initPostHog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('passes a before_send hook to posthog.init that strips PII end-to-end', async () => {
|
||||
const { initPostHog } = await import('./posthog')
|
||||
initPostHog()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledOnce()
|
||||
const initOptions = hoisted.mockInit.mock.calls[0][1]
|
||||
expect(initOptions.person_profiles).toBe('identified_only')
|
||||
expect(typeof initOptions.before_send).toBe('function')
|
||||
|
||||
const event = {
|
||||
properties: {
|
||||
email: 'a@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'b@example.com',
|
||||
$email: 'c@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: { email: 'd@example.com', name: 'keep me' },
|
||||
$set_once: { $email: 'e@example.com', plan: 'free' }
|
||||
}
|
||||
|
||||
const result = initOptions.before_send(event)
|
||||
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
expect(result.$set_once).not.toHaveProperty('$email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,5 @@
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
const POSTHOG_KEY =
|
||||
import.meta.env.PUBLIC_POSTHOG_KEY ??
|
||||
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
|
||||
@@ -20,9 +18,7 @@ export function initPostHog() {
|
||||
ui_host: POSTHOG_UI_HOST,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: true,
|
||||
person_profiles: 'identified_only',
|
||||
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
|
||||
before_send: createPostHogBeforeSend()
|
||||
person_profiles: 'identified_only'
|
||||
})
|
||||
initialized = true
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
function createModelAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
return {
|
||||
id: 'test-model-001',
|
||||
name: 'model.safetensors',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
size: 2_147_483_648,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
@@ -17,13 +16,12 @@ function createModelAsset(
|
||||
}
|
||||
}
|
||||
|
||||
function createInputAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
return {
|
||||
id: 'test-input-001',
|
||||
name: 'input.png',
|
||||
hash: 'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
asset_hash:
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
size: 2_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -34,13 +32,12 @@ function createInputAsset(
|
||||
}
|
||||
}
|
||||
|
||||
function createOutputAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
return {
|
||||
id: 'test-output-001',
|
||||
name: 'output_00001.png',
|
||||
hash: 'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
asset_hash:
|
||||
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
|
||||
@@ -43,10 +43,10 @@ const sharedWorkflowAsset: AssetInfo = {
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const defaultInputAsset: Asset & { hash?: string } = {
|
||||
const defaultInputAsset: Asset = {
|
||||
id: 'default-input-asset',
|
||||
name: defaultInputFileName,
|
||||
hash: defaultInputFileName,
|
||||
asset_hash: defaultInputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -55,10 +55,10 @@ const defaultInputAsset: Asset & { hash?: string } = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const importedInputAsset: Asset & { hash?: string } = {
|
||||
const importedInputAsset: Asset = {
|
||||
id: 'imported-input-asset',
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
hash: sharedWorkflowImportScenario.inputFileName,
|
||||
asset_hash: sharedWorkflowImportScenario.inputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
|
||||
@@ -12,10 +12,11 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
|
||||
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
|
||||
const LOTUS_DIFFUSION_MODEL: Asset = {
|
||||
id: 'test-lotus-depth-d-v1-1',
|
||||
name: LOTUS_MODEL_NAME,
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
size: 1_024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'diffusion_models'],
|
||||
|
||||
@@ -24,10 +24,10 @@ const graphDropPosition = { x: 500, y: 300 }
|
||||
const missingMediaUploadObservationMs = 1_000
|
||||
const missingMediaUploadPollMs = 100
|
||||
|
||||
const cloudOutputAsset: Asset & { hash?: string } = {
|
||||
const cloudOutputAsset: Asset = {
|
||||
id: 'test-output-hash-001',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
hash: outputHash,
|
||||
asset_hash: outputHash,
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
@@ -36,10 +36,10 @@ const cloudOutputAsset: Asset & { hash?: string } = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
|
||||
const cloudUploadedVideoAsset: Asset = {
|
||||
id: 'test-uploaded-video-001',
|
||||
name: plainVideoFileName,
|
||||
hash: plainVideoFileName,
|
||||
asset_hash: plainVideoFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['input'],
|
||||
@@ -50,10 +50,10 @@ const cloudUploadedVideoAsset: Asset & { hash?: string } = {
|
||||
|
||||
// The Cloud test app starts with a default LoadImage node. Keep that baseline
|
||||
// input resolvable so this spec only observes the media it creates.
|
||||
const cloudDefaultGraphInputAsset: Asset & { hash?: string } = {
|
||||
const cloudDefaultGraphInputAsset: Asset = {
|
||||
id: 'test-default-input-001',
|
||||
name: '00000000000000000000000Aexample.png',
|
||||
hash: '00000000000000000000000Aexample.png',
|
||||
asset_hash: '00000000000000000000000Aexample.png',
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
|
||||
14
global.d.ts
vendored
@@ -11,18 +11,6 @@ interface ImpactQueueFunction {
|
||||
a?: unknown[][]
|
||||
}
|
||||
|
||||
interface RewardfulGlobal {
|
||||
referral?: string
|
||||
affiliate?: { id?: string; token?: string; name?: string }
|
||||
campaign?: { id?: string; name?: string }
|
||||
}
|
||||
|
||||
interface RewardfulQueueFunction {
|
||||
(method: 'ready', callback: () => void): void
|
||||
(...args: unknown[]): void
|
||||
q?: unknown[][]
|
||||
}
|
||||
|
||||
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
|
||||
|
||||
interface GtagGetFieldValueMap {
|
||||
@@ -75,8 +63,6 @@ interface Window {
|
||||
gtag?: GtagFunction
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
rewardful?: RewardfulQueueFunction
|
||||
Rewardful?: RewardfulGlobal
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,bytedance-mono,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,gemini-mono,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
|
||||
@@ -25,7 +25,6 @@
|
||||
|
||||
@theme {
|
||||
--shadow-interface: var(--interface-panel-box-shadow);
|
||||
--shadow-inset-highlight: inset 0 1px 0 0 rgb(from white r g b / 0.1);
|
||||
|
||||
--text-2xs: 0.625rem;
|
||||
--text-2xs--line-height: calc(1 / 0.625);
|
||||
@@ -66,9 +65,6 @@
|
||||
--color-ocean-600: #2f687a;
|
||||
--color-ocean-900: #253236;
|
||||
|
||||
--color-primary-comfy-ink: #211927;
|
||||
--color-primary-comfy-canvas: #c2bfb9;
|
||||
|
||||
--color-danger-100: #c02323;
|
||||
--color-danger-200: #d62952;
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M324.094 389.858L284.667 379.567V191.5L326.871 180.816C350.01 174.941 369.446 170.154 370.371 170.339C371.112 170.339 371.667 222.027 371.667 285.326V400.334L367.594 400.15C365.189 400.15 345.566 395.361 324.094 389.835V389.857V389.858Z"/>
|
||||
<path d="M138.667 343.325C138.667 279.602 139.229 227.339 140.166 227.339C140.914 227.154 160.573 231.998 184.164 237.913L226.667 248.65L226.292 342.975L225.73 437.278L187.535 447.107C166.565 452.463 146.906 457.47 144.097 458.029L138.667 459.334V343.325Z"/>
|
||||
<path d="M423.667 248.299C423.667 38.7081 423.853 27.4506 427.037 28.3797C428.722 28.9368 445.386 33.1843 463.921 37.8029C482.458 42.6075 500.807 47.2031 504.739 48.1312L511.667 49.9884L511.293 248.67L510.731 447.539L472.722 457.148C451.939 462.486 432.279 467.291 429.284 468.057L423.667 469.334V248.299Z"/>
|
||||
<path d="M-0.333038 248.845C-0.333038 140.208 0.222275 51.334 1.14852 51.334C1.88822 51.334 21.3242 56.1412 44.4631 61.8769L86.667 72.583V248.66C86.667 345.267 86.296 424.55 85.9262 424.55C85.3709 424.55 65.7494 429.544 42.4262 435.466L-0.333038 446.334V248.823V248.844V248.845Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.854 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB |
@@ -7,8 +7,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./formatUtil": "./src/formatUtil.ts",
|
||||
"./networkUtil": "./src/networkUtil.ts",
|
||||
"./piiUtil": "./src/piiUtil.ts"
|
||||
"./networkUtil": "./src/networkUtil.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createPostHogBeforeSend } from './piiUtil'
|
||||
|
||||
describe('createPostHogBeforeSend', () => {
|
||||
const beforeSend = createPostHogBeforeSend()
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(beforeSend(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('strips all PII keys from properties, $set, and $set_once', () => {
|
||||
const event = {
|
||||
properties: {
|
||||
email: 'a@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'b@example.com',
|
||||
$email: 'c@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: {
|
||||
email: 'd@example.com',
|
||||
user_email: 'e@example.com',
|
||||
$email: 'f@example.com',
|
||||
name: 'keep me'
|
||||
},
|
||||
$set_once: {
|
||||
email: 'g@example.com',
|
||||
plan: 'free'
|
||||
}
|
||||
}
|
||||
|
||||
const result = beforeSend(event)!
|
||||
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).not.toHaveProperty('user_email')
|
||||
expect(result.$set).not.toHaveProperty('$email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
|
||||
expect(result.$set_once).not.toHaveProperty('email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
|
||||
it('handles missing property bags gracefully', () => {
|
||||
const event = { properties: { email: 'a@example.com', safe: true } }
|
||||
const result = beforeSend(event)!
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).toHaveProperty('safe', true)
|
||||
expect(result.$set).toBeUndefined()
|
||||
expect(result.$set_once).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
const PII_KEYS = ['email', 'prompt', 'user_email', '$email'] as const
|
||||
|
||||
function stripPiiKeys(obj?: Record<string, unknown>): void {
|
||||
if (!obj) return
|
||||
for (const key of PII_KEYS) {
|
||||
delete obj[key]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PostHog before_send hook that strips PII from all three property bags
|
||||
* an event can carry: properties, $set, and $set_once.
|
||||
*
|
||||
* posthog.identify(id, { email }) lands in $set, not properties, so all
|
||||
* three bags must be sanitized.
|
||||
*
|
||||
* Ref: posthog.com/tutorials/web-redact-properties
|
||||
*/
|
||||
interface PostHogEventLike {
|
||||
properties?: Record<string, unknown>
|
||||
$set?: Record<string, unknown>
|
||||
$set_once?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function createPostHogBeforeSend() {
|
||||
return function beforeSend<E extends PostHogEventLike>(
|
||||
event: E | null
|
||||
): E | null {
|
||||
if (!event) return null
|
||||
stripPiiKeys(event.properties)
|
||||
stripPiiKeys(event.$set)
|
||||
stripPiiKeys(event.$set_once)
|
||||
return event
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,6 @@ import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -131,12 +130,10 @@ const canvasStore = useCanvasStore()
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
const node = withNodeAddSource('search_modal', () =>
|
||||
litegraphService.addNodeOnGraph(
|
||||
nodeDef,
|
||||
{ pos: getNewNodeLocation() },
|
||||
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
|
||||
)
|
||||
const node = litegraphService.addNodeOnGraph(
|
||||
nodeDef,
|
||||
{ pos: getNewNodeLocation() },
|
||||
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
|
||||
)
|
||||
if (!node) return
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
|
||||
@@ -156,8 +155,8 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
if (this.leaf && model) {
|
||||
const provider = modelToNodeStore.getNodeProvider(model.directory)
|
||||
if (provider) {
|
||||
const graphNode = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(provider.nodeDef)
|
||||
const graphNode = useLitegraphService().addNodeOnGraph(
|
||||
provider.nodeDef
|
||||
)
|
||||
const widget = graphNode?.widgets?.find(
|
||||
(widget) => widget.name === provider.key
|
||||
|
||||
@@ -189,7 +189,6 @@ import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder
|
||||
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import {
|
||||
DEFAULT_GROUPING_ID,
|
||||
@@ -322,11 +321,8 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
}
|
||||
},
|
||||
handleClick(e: MouseEvent) {
|
||||
const nodeDef = this.data
|
||||
if (this.leaf && nodeDef) {
|
||||
withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef)
|
||||
)
|
||||
if (this.leaf && this.data) {
|
||||
useLitegraphService().addNodeOnGraph(this.data)
|
||||
} else {
|
||||
toggleNodeOnEvent(e, this)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue'
|
||||
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -184,11 +183,8 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
|
||||
await nodeBookmarkStore.addBookmark(nodePath)
|
||||
},
|
||||
handleClick(e: MouseEvent) {
|
||||
const nodeDef = this.data
|
||||
if (this.leaf && nodeDef) {
|
||||
withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef)
|
||||
)
|
||||
if (this.leaf && this.data) {
|
||||
useLitegraphService().addNodeOnGraph(this.data)
|
||||
} else {
|
||||
toggleNodeOnEvent(e, node)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { FirebaseError } from 'firebase/app'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -27,20 +26,9 @@ const mockDialogService = vi.hoisted(() => ({
|
||||
confirm: vi.fn()
|
||||
}))
|
||||
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
|
||||
const knownAuthErrorCodes = new Set([
|
||||
'auth/invalid-credential',
|
||||
'auth/email-already-in-use'
|
||||
])
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, values?: { workflow?: string }) =>
|
||||
values?.workflow ? `${key}:${values.workflow}` : key,
|
||||
st: (key: string, fallback: string) => {
|
||||
const code = key.replace('auth.errors.', '')
|
||||
return knownAuthErrorCodes.has(code) ? key : fallback
|
||||
}
|
||||
values?.workflow ? `${key}:${values.workflow}` : key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
@@ -84,7 +72,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
|
||||
action: (...args: TArgs) => Promise<TReturn> | TReturn
|
||||
) => action,
|
||||
toastErrorHandler: mockToastErrorHandler
|
||||
toastErrorHandler: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -205,46 +193,3 @@ describe('useAuthActions.logout', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAuthActions.reportError', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows the friendly message for a known Firebase auth code', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
reportError(new FirebaseError('auth/invalid-credential', 'raw firebase'))
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.auth/invalid-credential'
|
||||
})
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the generic fallback for an unknown Firebase auth code', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
reportError(new FirebaseError('auth/some-new-code', 'raw firebase'))
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.generic'
|
||||
})
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delegates non-Firebase errors to toastErrorHandler', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
const networkError = new TypeError('Failed to fetch')
|
||||
|
||||
reportError(networkError)
|
||||
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
|
||||
expect(mockToastStore.add).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ref } from 'vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
|
||||
import { st, t } from '@/i18n'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -47,12 +47,6 @@ export const useAuthActions = () => {
|
||||
email: 'support@comfy.org'
|
||||
})
|
||||
})
|
||||
} else if (error instanceof FirebaseError) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: st(`auth.errors.${error.code}`, t('auth.errors.generic'))
|
||||
})
|
||||
} else {
|
||||
toastErrorHandler(error)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -38,8 +37,7 @@ function isOverCanvas(clientX: number, clientY: number): boolean {
|
||||
}
|
||||
|
||||
function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
const nodeDef = draggedNode.value
|
||||
if (!nodeDef) return false
|
||||
if (!draggedNode.value) return false
|
||||
const canvas = useCanvasStore().canvas
|
||||
if (!canvas) return false
|
||||
if (!isOverCanvas(clientX, clientY)) return false
|
||||
@@ -48,9 +46,7 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
clientX,
|
||||
clientY
|
||||
} as PointerEvent)
|
||||
const node = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
|
||||
)
|
||||
const node = useLitegraphService().addNodeOnGraph(draggedNode.value, { pos })
|
||||
if (node) canvas.selectItems([node])
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/as
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
@@ -147,11 +146,9 @@ export function useJobMenu(
|
||||
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
|
||||
if (!nodeDef) return
|
||||
const node = withNodeAddSource('programmatic', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
)
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
|
||||
if (!node) return
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv
|
||||
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
|
||||
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
@@ -38,9 +37,7 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
|
||||
// Add an offset on y to make sure after adding the node, the cursor
|
||||
// is on the node (top left corner)
|
||||
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
|
||||
withNodeAddSource('sidebar_drag', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, { pos })
|
||||
)
|
||||
litegraphService.addNodeOnGraph(nodeDef, { pos })
|
||||
} else if (node.data instanceof ComfyModelDef) {
|
||||
const model = node.data
|
||||
const pos = basePos
|
||||
@@ -61,8 +58,11 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
|
||||
if (!targetGraphNode) {
|
||||
const provider = modelToNodeStore.getNodeProvider(model.directory)
|
||||
if (provider) {
|
||||
targetGraphNode = withNodeAddSource('sidebar_drag', () =>
|
||||
litegraphService.addNodeOnGraph(provider.nodeDef, { pos })
|
||||
targetGraphNode = litegraphService.addNodeOnGraph(
|
||||
provider.nodeDef,
|
||||
{
|
||||
pos
|
||||
}
|
||||
)
|
||||
targetProvider = provider
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
@@ -108,8 +107,7 @@ export function useFeatureFlags() {
|
||||
if (override !== undefined) return override
|
||||
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value)
|
||||
return cachedTeamWorkspacesEnabled.value ?? false
|
||||
if (!isAuthenticatedConfigLoaded.value) return false
|
||||
|
||||
return (
|
||||
remoteConfig.value.team_workspaces_enabled ??
|
||||
|
||||
@@ -2278,8 +2278,7 @@
|
||||
"auth/invalid-credential": "Invalid login credentials. Please check your email and password.",
|
||||
"auth/network-request-failed": "Network error. Please check your connection and try again.",
|
||||
"auth/popup-closed-by-user": "Sign-in was cancelled. Please try again.",
|
||||
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again.",
|
||||
"generic": "Something went wrong while signing you in. Please try again."
|
||||
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again."
|
||||
},
|
||||
"deleteAccount": {
|
||||
"contactSupport": "To delete your account, please contact {email}"
|
||||
@@ -2950,29 +2949,6 @@
|
||||
"cloudStart_learnAboutButton": "Learn about Cloud",
|
||||
"cloudStart_wantToRun": "Want to run ComfyUI locally instead?",
|
||||
"cloudStart_download": "Download ComfyUI",
|
||||
"cloudHero": {
|
||||
"previousSlide": "Previous slide",
|
||||
"nextSlide": "Next slide",
|
||||
"slidePagerLabel": "Go to slide {index}",
|
||||
"slides": {
|
||||
"cloud": {
|
||||
"title": "Cloud",
|
||||
"description": "Best for most users who want to work from anywhere with models verified for commercial license."
|
||||
},
|
||||
"workflows": {
|
||||
"title": "Workflows",
|
||||
"description": "From idea to output in minutes. Generate multiple variations side by side."
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"description": "Onboard your team today. Share workflows and assets across your organization."
|
||||
},
|
||||
"models": {
|
||||
"title": "Models",
|
||||
"description": "Curated, commercially licensed models ready to run with zero setup."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cloudWaitlist_questionsText": "Questions? Contact us",
|
||||
"cloudWaitlist_contactLink": "here",
|
||||
"cloudSorryContactSupport_title": "Sorry, contact support",
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('AssetBrowserModal', () => {
|
||||
): AssetItem => ({
|
||||
id,
|
||||
name,
|
||||
hash: `blake3:${id.padEnd(64, '0')}`,
|
||||
asset_hash: `blake3:${id.padEnd(64, '0')}`,
|
||||
size: 1024000,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', category, 'test'],
|
||||
|
||||
@@ -49,10 +49,10 @@ const ORIGINAL_FILENAME = 'sunset_photo.png'
|
||||
function createDisplayAsset(
|
||||
overrides: Partial<AssetDisplayItem> = {}
|
||||
): AssetDisplayItem {
|
||||
const base = {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
name: HASH,
|
||||
hash: HASH,
|
||||
asset_hash: HASH,
|
||||
tags: ['input'],
|
||||
preview_url: '/preview.png',
|
||||
secondaryText: '',
|
||||
@@ -62,7 +62,6 @@ function createDisplayAsset(
|
||||
metadata: { filename: ORIGINAL_FILENAME },
|
||||
...overrides
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function renderCard(asset: AssetDisplayItem) {
|
||||
@@ -98,7 +97,7 @@ describe('AssetCard', () => {
|
||||
})
|
||||
|
||||
describe('FE-228: filename rendering', () => {
|
||||
it('renders the human-readable filename instead of hash when asset.name equals hash', () => {
|
||||
it('renders the human-readable filename instead of asset_hash when asset.name equals asset_hash', () => {
|
||||
const asset = createDisplayAsset()
|
||||
|
||||
renderCard(asset)
|
||||
@@ -131,7 +130,7 @@ describe('AssetCard', () => {
|
||||
const asset = createDisplayAsset({
|
||||
id: 'model-1',
|
||||
name: MODEL_FILENAME,
|
||||
hash: undefined,
|
||||
asset_hash: undefined,
|
||||
tags: ['models', 'loras'],
|
||||
user_metadata: { name: CURATED_NAME },
|
||||
metadata: { filename: MODEL_FILENAME }
|
||||
@@ -147,7 +146,7 @@ describe('AssetCard', () => {
|
||||
it('ignores user_metadata.name that duplicates the hash and falls back to metadata.filename', () => {
|
||||
const asset = createDisplayAsset({
|
||||
name: HASH,
|
||||
hash: HASH,
|
||||
asset_hash: HASH,
|
||||
user_metadata: { name: HASH },
|
||||
metadata: { filename: ORIGINAL_FILENAME }
|
||||
})
|
||||
|
||||
@@ -32,7 +32,7 @@ function makeAsset(overrides: Partial<AssetMeta> = {}): AssetMeta {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
name: 'mesh.glb',
|
||||
hash: null,
|
||||
asset_hash: null,
|
||||
mime_type: 'model/gltf-binary',
|
||||
tags: [],
|
||||
kind: '3D',
|
||||
|
||||
@@ -13,7 +13,7 @@ function createVideoAsset(
|
||||
return {
|
||||
id: 'video-1',
|
||||
name: 'clip.mp4',
|
||||
hash: null,
|
||||
asset_hash: null,
|
||||
mime_type: mimeType,
|
||||
tags: [],
|
||||
kind: 'video',
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('ModelInfoPanel', () => {
|
||||
): AssetDisplayItem => ({
|
||||
id: 'test-id',
|
||||
name: 'test-model.safetensors',
|
||||
hash: 'hash123',
|
||||
asset_hash: 'hash123',
|
||||
size: 1024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
|
||||
@@ -26,7 +26,7 @@ function makeAsset(index: number): AssetItem {
|
||||
return {
|
||||
id: `asset-${index}`,
|
||||
name: `asset-${index}.safetensors`,
|
||||
hash: `blake3:${index}`,
|
||||
asset_hash: `blake3:${index}`,
|
||||
size: 1024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', category],
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('useAssetBrowser', () => {
|
||||
const createApiAsset = (overrides: Partial<AssetItem> = {}): AssetItem => ({
|
||||
id: 'test-id',
|
||||
name: 'test-asset.safetensors',
|
||||
hash: 'blake3:abc123',
|
||||
asset_hash: 'blake3:abc123',
|
||||
size: 1024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
|
||||
@@ -296,7 +296,7 @@ describe('useMediaAssetActions', () => {
|
||||
|
||||
const asset = createMockAsset({
|
||||
name: 'my-image.jpeg',
|
||||
hash: 'hash123.jpeg'
|
||||
asset_hash: 'hash123.jpeg'
|
||||
})
|
||||
|
||||
await actions.addWorkflow(asset)
|
||||
@@ -310,12 +310,12 @@ describe('useMediaAssetActions', () => {
|
||||
mockIsCloud.value = true
|
||||
})
|
||||
|
||||
it('should use hash as filename when available', async () => {
|
||||
it('should use asset_hash as filename when available', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const asset = createMockAsset({
|
||||
name: 'original.jpeg',
|
||||
hash: 'abc123hash.jpeg'
|
||||
asset_hash: 'abc123hash.jpeg'
|
||||
})
|
||||
|
||||
await actions.addWorkflow(asset)
|
||||
@@ -323,12 +323,12 @@ describe('useMediaAssetActions', () => {
|
||||
expect(capturedFilenames.values).toContain('abc123hash.jpeg')
|
||||
})
|
||||
|
||||
it('should fall back to asset.name when hash is not available', async () => {
|
||||
it('should fall back to asset.name when asset_hash is not available', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const asset = createMockAsset({
|
||||
name: 'fallback-name.jpeg',
|
||||
hash: undefined
|
||||
asset_hash: undefined
|
||||
})
|
||||
|
||||
await actions.addWorkflow(asset)
|
||||
@@ -336,12 +336,12 @@ describe('useMediaAssetActions', () => {
|
||||
expect(capturedFilenames.values).toContain('fallback-name.jpeg')
|
||||
})
|
||||
|
||||
it('should fall back to asset.name when hash is null', async () => {
|
||||
it('should fall back to asset.name when asset_hash is null', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const asset = createMockAsset({
|
||||
name: 'fallback-null.jpeg',
|
||||
hash: null
|
||||
asset_hash: null
|
||||
})
|
||||
|
||||
await actions.addWorkflow(asset)
|
||||
@@ -357,19 +357,19 @@ describe('useMediaAssetActions', () => {
|
||||
mockIsCloud.value = true
|
||||
})
|
||||
|
||||
it('should use hash for each asset', async () => {
|
||||
it('should use asset_hash for each asset', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const assets = [
|
||||
createMockAsset({
|
||||
id: '1',
|
||||
name: 'file1.jpeg',
|
||||
hash: 'hash1.jpeg'
|
||||
asset_hash: 'hash1.jpeg'
|
||||
}),
|
||||
createMockAsset({
|
||||
id: '2',
|
||||
name: 'file2.jpeg',
|
||||
hash: 'hash2.jpeg'
|
||||
asset_hash: 'hash2.jpeg'
|
||||
})
|
||||
]
|
||||
|
||||
@@ -973,7 +973,7 @@ describe('useMediaAssetActions', () => {
|
||||
const asset = createMockAsset({
|
||||
id: 'asset-match',
|
||||
name: 'foo.png',
|
||||
hash: 'abc123.png',
|
||||
asset_hash: 'abc123.png',
|
||||
tags: ['input']
|
||||
})
|
||||
|
||||
@@ -1051,7 +1051,7 @@ describe('useMediaAssetActions', () => {
|
||||
const asset = createMockAsset({
|
||||
id: 'asset-failed',
|
||||
name: 'failed.png',
|
||||
hash: 'failhash.png'
|
||||
asset_hash: 'failhash.png'
|
||||
})
|
||||
|
||||
await actions.deleteAssets(asset)
|
||||
|
||||
@@ -6,7 +6,6 @@ import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationD
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
@@ -43,8 +42,8 @@ const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
|
||||
*
|
||||
* Output assets emit `<name> [output]` (and the subfolder-prefixed form when
|
||||
* present in metadata). Input/temp assets emit the bare name plus the explicit
|
||||
* annotation. The content `hash` is included whenever present, since
|
||||
* cloud-stored assets can be referenced by hash.
|
||||
* annotation. `asset_hash` is included whenever present, since cloud-stored
|
||||
* assets can be referenced by hash.
|
||||
*/
|
||||
function widgetValueVariantsForAsset(asset: AssetItem): string[] {
|
||||
const variants: string[] = []
|
||||
@@ -62,8 +61,7 @@ function widgetValueVariantsForAsset(asset: AssetItem): string[] {
|
||||
variants.push(`${name} [input]`)
|
||||
}
|
||||
}
|
||||
const hash = asset.hash
|
||||
if (hash) variants.push(hash)
|
||||
if (asset.asset_hash) variants.push(asset.asset_hash)
|
||||
return variants
|
||||
}
|
||||
|
||||
@@ -281,11 +279,9 @@ export function useMediaAssetActions() {
|
||||
return
|
||||
}
|
||||
|
||||
const node = withNodeAddSource('programmatic', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
)
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
|
||||
if (!node) {
|
||||
toast.add({
|
||||
@@ -300,10 +296,12 @@ export function useMediaAssetActions() {
|
||||
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
|
||||
const assetType = getAssetType(targetAsset, 'input')
|
||||
|
||||
// In Cloud mode, use the content hash (the actual stored filename).
|
||||
// In OSS mode, use the original name.
|
||||
const cloudHash = targetAsset.hash
|
||||
const filename = isCloud && cloudHash ? cloudHash : targetAsset.name
|
||||
// In Cloud mode, use asset_hash (the actual stored filename)
|
||||
// In OSS mode, use the original name
|
||||
const filename =
|
||||
isCloud && targetAsset.asset_hash
|
||||
? targetAsset.asset_hash
|
||||
: targetAsset.name
|
||||
|
||||
// Create annotated path for the asset
|
||||
const annotated = createAnnotatedPath(
|
||||
@@ -427,14 +425,12 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
|
||||
const center = litegraphService.getCanvasCenter()
|
||||
const node = withNodeAddSource('programmatic', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: [
|
||||
center[0] + nodeIndex * NODE_OFFSET,
|
||||
center[1] + nodeIndex * NODE_OFFSET
|
||||
]
|
||||
})
|
||||
)
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: [
|
||||
center[0] + nodeIndex * NODE_OFFSET,
|
||||
center[1] + nodeIndex * NODE_OFFSET
|
||||
]
|
||||
})
|
||||
|
||||
if (!node) {
|
||||
failed++
|
||||
@@ -444,10 +440,10 @@ export function useMediaAssetActions() {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
const assetType = getAssetType(asset, 'input')
|
||||
|
||||
// In Cloud mode, use the content hash (the actual stored filename).
|
||||
// In OSS mode, use the original name.
|
||||
const cloudHash = asset.hash
|
||||
const filename = isCloud && cloudHash ? cloudHash : asset.name
|
||||
// In Cloud mode, use asset_hash (the actual stored filename)
|
||||
// In OSS mode, use the original name
|
||||
const filename =
|
||||
isCloud && asset.asset_hash ? asset.asset_hash : asset.name
|
||||
|
||||
const annotated = createAnnotatedPath(
|
||||
{
|
||||
|
||||
@@ -97,12 +97,11 @@ export function createMockAssets(count: number = 20): AssetItem[] {
|
||||
const lastAccessTime = getRandomISODate()
|
||||
|
||||
const fakeFileName = `${fakeFunnyModelNames[index]}${extension}`
|
||||
const fakeAssetHash = generateFakeAssetHash()
|
||||
|
||||
return {
|
||||
id: `mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake`,
|
||||
name: fakeFileName,
|
||||
hash: fakeAssetHash,
|
||||
asset_hash: generateFakeAssetHash(),
|
||||
size: sizeInBytes,
|
||||
mime_type: mimeType,
|
||||
tags: [
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from 'zod'
|
||||
const zAsset = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
hash: z.string().nullish(),
|
||||
asset_hash: z.string().nullish(),
|
||||
size: z.number().optional(), // TBD: Will be provided by history API in the future
|
||||
mime_type: z.string().nullish(),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('assetMetadataUtils', () => {
|
||||
const mockAsset: AssetItem = {
|
||||
id: 'test-id',
|
||||
name: 'test-model',
|
||||
hash: 'hash123',
|
||||
asset_hash: 'hash123',
|
||||
size: 1024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
|
||||
@@ -201,10 +201,10 @@ export function getAssetCardTitle(asset: AssetItem): string {
|
||||
|
||||
/**
|
||||
* Returns the filename component the cloud `/api/view` endpoint resolves
|
||||
* for this asset — `hash` when present (cloud assets are hash-keyed
|
||||
* for this asset — `asset_hash` when present (cloud assets are hash-keyed
|
||||
* in storage), otherwise `asset.name`. Use this when constructing widget
|
||||
* values or media URLs that must round-trip through the view endpoint.
|
||||
*/
|
||||
export function getAssetUrlFilename(asset: AssetItem): string {
|
||||
return asset.hash ?? asset.name
|
||||
return asset.asset_hash || asset.name
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ function mockFetchError() {
|
||||
const cloudAsset = {
|
||||
id: '72d169cc-7f9a-40d2-9382-35eadcba0a6a',
|
||||
name: 'mesh/ComfyUI_00003_.glb',
|
||||
hash: 'c6cadcee57dd.glb',
|
||||
asset_hash: 'c6cadcee57dd.glb',
|
||||
preview_id: null,
|
||||
preview_url: undefined
|
||||
}
|
||||
@@ -110,7 +110,9 @@ describe('findOutputAsset', () => {
|
||||
const result = await findOutputAsset('c6cadcee57dd.glb')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledOnce()
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain('hash=c6cadcee57dd.glb')
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain(
|
||||
'asset_hash=c6cadcee57dd.glb'
|
||||
)
|
||||
expect(result).toEqual(cloudAsset)
|
||||
})
|
||||
|
||||
@@ -121,7 +123,7 @@ describe('findOutputAsset', () => {
|
||||
const result = await findOutputAsset('ComfyUI_00081_.glb')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain('hash=')
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain('asset_hash=')
|
||||
expect(mockFetchApi.mock.calls[1][0]).toContain('name_contains=')
|
||||
expect(result).toEqual(localAsset)
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAssetsStore } from '@/stores/assetsStore'
|
||||
interface AssetRecord {
|
||||
id: string
|
||||
name: string
|
||||
hash?: string
|
||||
asset_hash?: string
|
||||
preview_url?: string
|
||||
preview_id?: string | null
|
||||
}
|
||||
@@ -35,14 +35,14 @@ function resolvePreviewUrl(asset: AssetRecord): string {
|
||||
|
||||
/**
|
||||
* Find an output asset record by content hash, falling back to name.
|
||||
* On cloud, output filenames are content-hashed; use hash to match.
|
||||
* On cloud, output filenames are content-hashed; use asset_hash to match.
|
||||
* On local, filenames are not hashed; use name_contains to match.
|
||||
*/
|
||||
export async function findOutputAsset(
|
||||
name: string
|
||||
): Promise<AssetRecord | undefined> {
|
||||
const byHash = await fetchAssets({ hash: name })
|
||||
const hashMatch = byHash.find((a) => a.hash === name)
|
||||
const byHash = await fetchAssets({ asset_hash: name })
|
||||
const hashMatch = byHash.find((a) => a.asset_hash === name)
|
||||
if (hashMatch) return hashMatch
|
||||
|
||||
const byName = await fetchAssets({ name_contains: name })
|
||||
|
||||
@@ -15,7 +15,7 @@ import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
*
|
||||
* Comparison is full-string against the widget value as stored — callers must
|
||||
* provide the canonical widget-value variants for each deleted asset (e.g.
|
||||
* `foo.png`, `foo.png [output]`, `sub/foo.png [output]`, `<hash>`). This
|
||||
* `foo.png`, `foo.png [output]`, `sub/foo.png [output]`, `<asset_hash>`). This
|
||||
* avoids false matches when two distinct assets share a basename across
|
||||
* input/output sources.
|
||||
*
|
||||
|
||||
@@ -124,11 +124,5 @@ const handleSubmit = async () => {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: #2d2e32 !important;
|
||||
color: var(--color-primary-comfy-canvas) !important;
|
||||
caret-color: var(--color-primary-comfy-canvas);
|
||||
}
|
||||
|
||||
:deep(.p-inputtext::placeholder) {
|
||||
color: rgb(from var(--color-primary-comfy-canvas) r g b / 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,83 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-full items-center justify-center px-4 py-8 sm:px-6 md:px-8 lg:py-12"
|
||||
>
|
||||
<div class="flex w-full max-w-md flex-col items-start">
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<h1
|
||||
class="my-0 font-inter text-xl/8 font-extrabold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
|
||||
>
|
||||
<div class="flex h-full items-center justify-center p-8">
|
||||
<div class="max-w-screen p-2 lg:w-96">
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex flex-col gap-4">
|
||||
<h1 class="my-0 text-xl/normal font-medium">
|
||||
{{ t('auth.login.title') }}
|
||||
</h1>
|
||||
<p
|
||||
class="my-0 text-base/6 tracking-[-0.02em] text-primary-comfy-canvas"
|
||||
<i18n-t
|
||||
v-if="isFreeTierEnabled && !googleSsoBlockedReason"
|
||||
keypath="auth.login.signUpFreeTierPromo"
|
||||
tag="p"
|
||||
class="my-0 text-base text-muted"
|
||||
:plural="freeTierCredits ?? undefined"
|
||||
>
|
||||
<template #signUp>
|
||||
<span
|
||||
class="cursor-pointer text-blue-500"
|
||||
@click="navigateToSignup"
|
||||
>{{ t('auth.login.signUp') }}</span
|
||||
>
|
||||
</template>
|
||||
<template #credits>{{ freeTierCredits }}</template>
|
||||
</i18n-t>
|
||||
<p v-else class="my-0 text-base text-muted">
|
||||
{{ t('auth.login.newUser') }}
|
||||
<span
|
||||
class="cursor-pointer text-azure-600"
|
||||
class="cursor-pointer text-blue-500"
|
||||
@click="navigateToSignup"
|
||||
>{{ t('auth.login.signUp') }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mt-4 w-full">
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
|
||||
{{ t('auth.login.insecureContextWarning') }}
|
||||
</Message>
|
||||
|
||||
<div class="flex w-full flex-col gap-4 pt-5 pb-2">
|
||||
<template v-if="!showEmailForm">
|
||||
<template v-if="!showEmailForm">
|
||||
<!-- OAuth Buttons (primary) -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<Button
|
||||
v-if="!googleSsoBlockedReason"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
|
||||
class="h-10 w-full"
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<i class="pi pi-google text-base" />
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
{{ t('auth.login.loginWithGoogle') }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10 bg-charcoal-500"
|
||||
variant="secondary"
|
||||
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 font-inter text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
|
||||
@click="signInWithGithub"
|
||||
>
|
||||
<i class="pi pi-github text-base" />
|
||||
<i class="pi pi-github mr-2"></i>
|
||||
{{ t('auth.login.loginWithGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
class="text-sm/4 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
|
||||
variant="muted-textonly"
|
||||
class="text-sm underline"
|
||||
@click="switchToEmailForm"
|
||||
>
|
||||
{{ t('auth.login.useEmailInstead') }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
|
||||
<template v-else>
|
||||
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-1 h-10 w-full rounded-md border-none bg-smoke-800/5 text-sm/5 font-normal tracking-[-0.011em] text-primary-comfy-canvas/55 hover:bg-primary-comfy-canvas/10"
|
||||
variant="muted-textonly"
|
||||
class="text-sm underline"
|
||||
@click="switchToSocialLogin"
|
||||
>
|
||||
{{ t('auth.login.backToSocialLogin') }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p
|
||||
class="mx-auto my-0 flex w-full max-w-10/12 flex-wrap items-center justify-center gap-x-1 py-4 text-center text-sm/5 tracking-[-0.011em] text-primary-comfy-canvas"
|
||||
>
|
||||
<!-- Terms & Contact -->
|
||||
<p class="mt-5 text-sm text-gray-600">
|
||||
{{ t('auth.login.termsText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
>
|
||||
{{ t('auth.login.termsLink') }}
|
||||
</a>
|
||||
@@ -85,7 +99,7 @@
|
||||
<a
|
||||
href="https://www.comfy.org/privacy-policy"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
>
|
||||
{{ t('auth.login.privacyLink') }} </a
|
||||
>.
|
||||
@@ -103,6 +117,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
|
||||
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
|
||||
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
|
||||
import type { SignInData } from '@/schemas/signInSchema'
|
||||
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
|
||||
@@ -114,6 +129,7 @@ const authActions = useAuthActions()
|
||||
const isSecureContext = globalThis.isSecureContext
|
||||
const authError = ref('')
|
||||
const showEmailForm = ref(false)
|
||||
const { isFreeTierEnabled, freeTierCredits } = useFreeTierOnboarding()
|
||||
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
|
||||
const { onAuthSuccess } = usePostAuthRedirect({
|
||||
authError,
|
||||
|
||||
@@ -1,81 +1,92 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-full items-center justify-center px-4 py-8 sm:px-6 md:px-8 lg:py-12"
|
||||
>
|
||||
<div class="flex w-full max-w-md flex-col items-start">
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<h1
|
||||
class="my-0 font-inter text-xl/8 font-extrabold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
|
||||
>
|
||||
<div class="flex h-full items-center justify-center p-8">
|
||||
<div class="max-w-screen p-2 lg:w-96">
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex flex-col gap-4">
|
||||
<h1 class="my-0 text-xl/normal font-medium">
|
||||
{{ t('auth.signup.title') }}
|
||||
</h1>
|
||||
<p
|
||||
class="my-0 text-base/6 tracking-[-0.02em] text-primary-comfy-canvas"
|
||||
>
|
||||
<span class="text-primary-comfy-canvas/70">{{
|
||||
<p class="my-0 text-base">
|
||||
<span class="text-muted">{{
|
||||
t('auth.signup.alreadyHaveAccount')
|
||||
}}</span>
|
||||
<span
|
||||
class="ml-1 cursor-pointer text-azure-600"
|
||||
class="ml-1 cursor-pointer text-blue-500"
|
||||
@click="navigateToLogin"
|
||||
>{{ t('auth.signup.signIn') }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mt-4 w-full">
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
|
||||
{{ t('auth.login.insecureContextWarning') }}
|
||||
</Message>
|
||||
|
||||
<div class="flex w-full flex-col gap-4 pt-5 pb-2">
|
||||
<template v-if="!showEmailForm">
|
||||
<Button
|
||||
v-if="!googleSsoBlockedReason"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<i class="pi pi-google text-base" />
|
||||
{{ t('auth.signup.signUpWithGoogle') }}
|
||||
</Button>
|
||||
<template v-if="!showEmailForm">
|
||||
<p
|
||||
v-if="isFreeTierEnabled && !googleSsoBlockedReason"
|
||||
class="mb-4 text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
freeTierCredits
|
||||
? t('auth.login.freeTierDescription', {
|
||||
credits: freeTierCredits
|
||||
})
|
||||
: t('auth.login.freeTierDescriptionGeneric')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<!-- OAuth Buttons (primary) -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="!googleSsoBlockedReason" class="relative">
|
||||
<Button type="button" class="h-10 w-full" @click="signInWithGoogle">
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
{{ t('auth.signup.signUpWithGoogle') }}
|
||||
</Button>
|
||||
<span
|
||||
v-if="isFreeTierEnabled"
|
||||
class="absolute -top-2.5 -right-2.5 rounded-full bg-yellow-400 px-2 py-0.5 text-2xs font-bold whitespace-nowrap text-gray-900"
|
||||
>
|
||||
{{ t('auth.login.freeTierBadge') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10 bg-charcoal-500"
|
||||
variant="secondary"
|
||||
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 font-inter text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
|
||||
@click="signInWithGithub"
|
||||
>
|
||||
<i class="pi pi-github text-base" />
|
||||
<i class="pi pi-github mr-2"></i>
|
||||
{{ t('auth.signup.signUpWithGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
class="text-sm/4 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
|
||||
variant="muted-textonly"
|
||||
class="text-sm underline"
|
||||
@click="switchToEmailForm"
|
||||
>
|
||||
{{ t('auth.login.useEmailInstead') }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Message v-if="isFreeTierEnabled" severity="warn" class="w-full">
|
||||
{{ t('auth.signup.emailNotEligibleForFreeTier') }}
|
||||
</Message>
|
||||
<template v-else>
|
||||
<Message v-if="isFreeTierEnabled" severity="warn" class="mb-4">
|
||||
{{ t('auth.signup.emailNotEligibleForFreeTier') }}
|
||||
</Message>
|
||||
|
||||
<Message v-if="userIsInChina" severity="warn" class="w-full">
|
||||
{{ t('auth.signup.regionRestrictionChina') }}
|
||||
</Message>
|
||||
<SignUpForm
|
||||
v-else
|
||||
:auth-error="authError"
|
||||
@submit="signUpWithEmail"
|
||||
/>
|
||||
<Message v-if="userIsInChina" severity="warn" class="mb-4">
|
||||
{{ t('auth.signup.regionRestrictionChina') }}
|
||||
</Message>
|
||||
<SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-1 h-10 w-full rounded-md border-none bg-smoke-800/5 text-sm/5 font-normal tracking-[-0.011em] text-primary-comfy-canvas/55 hover:bg-sand-300/10"
|
||||
variant="muted-textonly"
|
||||
class="text-sm underline"
|
||||
@click="switchToSocialLogin"
|
||||
>
|
||||
{{
|
||||
@@ -84,17 +95,16 @@
|
||||
: t('auth.login.backToSocialLogin')
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p
|
||||
class="mx-auto my-0 flex w-full max-w-10/12 flex-wrap items-center justify-center gap-x-1 py-4 text-center text-sm/5 tracking-[-0.011em] text-primary-comfy-canvas"
|
||||
>
|
||||
<!-- Terms & Contact -->
|
||||
<p class="mt-5 text-sm text-gray-600">
|
||||
{{ t('auth.login.termsText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
>
|
||||
{{ t('auth.login.termsLink') }}
|
||||
</a>
|
||||
@@ -102,18 +112,16 @@
|
||||
<a
|
||||
href="https://www.comfy.org/privacy-policy"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
>
|
||||
{{ t('auth.login.privacyLink') }} </a
|
||||
>.
|
||||
</p>
|
||||
<p
|
||||
class="mx-auto mt-2 mb-0 flex w-full max-w-10/12 flex-wrap items-center justify-center gap-x-1 text-center text-sm/5 tracking-[-0.011em] text-primary-comfy-canvas"
|
||||
>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
{{ t('cloudWaitlist_questionsText') }}
|
||||
<a
|
||||
href="https://support.comfy.org"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -151,6 +159,7 @@ const userIsInChina = ref(false)
|
||||
const telemetry = useTelemetry()
|
||||
const {
|
||||
showEmailForm,
|
||||
freeTierCredits,
|
||||
isFreeTierEnabled,
|
||||
switchToEmailForm,
|
||||
switchToSocialLogin
|
||||
@@ -188,6 +197,7 @@ const signUpWithEmail = async (values: SignUpData) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Track signup screen opened
|
||||
if (isCloud) {
|
||||
telemetry?.trackSignupOpened()
|
||||
}
|
||||
@@ -200,22 +210,12 @@ onMounted(async () => {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: #2d2e32 !important;
|
||||
color: var(--color-primary-comfy-canvas) !important;
|
||||
caret-color: var(--color-primary-comfy-canvas);
|
||||
}
|
||||
|
||||
:deep(.p-inputtext::placeholder) {
|
||||
color: rgb(from var(--color-primary-comfy-canvas) r g b / 0.5);
|
||||
}
|
||||
|
||||
:deep(.p-password input) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:deep(.p-password-toggle-mask-icon) {
|
||||
cursor: pointer;
|
||||
}
|
||||
:deep(.p-checkbox-checked .p-checkbox-box) {
|
||||
background-color: #f0ff41 !important;
|
||||
border-color: #f0ff41 !important;
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 121 KiB |
@@ -1,106 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex size-full flex-col items-center overflow-hidden rounded-lg bg-primary-comfy-canvas/4 pt-10 pb-4"
|
||||
>
|
||||
<div
|
||||
class="flex w-full flex-1 flex-col items-center justify-center gap-6 md:gap-8 lg:gap-10"
|
||||
>
|
||||
<div
|
||||
class="relative aspect-5/4 w-full max-w-xs md:max-w-sm lg:max-w-md xl:max-w-lg"
|
||||
>
|
||||
<div class="absolute inset-0">
|
||||
<div
|
||||
class="absolute top-1/2 left-1/2 aspect-3/2 w-3/4 -translate-1/2"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-3xl border border-white/20 bg-white/10 p-3.5 shadow-2xl"
|
||||
>
|
||||
<div
|
||||
class="size-full overflow-hidden rounded-xl bg-cover bg-center bg-no-repeat"
|
||||
:style="{ backgroundImage: `url(${centerImage})` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute -top-1/20 -right-1/50 flex aspect-square w-7 items-center justify-center rounded-lg border border-primary-comfy-canvas/30 bg-white/20 text-primary-comfy-canvas shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<i class="icon-[comfy--gemini-mono] size-3.5" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute -bottom-2/25 left-1/2 flex aspect-square w-10 -translate-x-1/2 items-center justify-center rounded-xl border border-primary-comfy-canvas/30 bg-white/20 text-primary-comfy-canvas shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<i class="icon-[comfy--grok] size-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-3/20 left-1/20 aspect-5/3 w-7/20">
|
||||
<div
|
||||
class="absolute inset-0 overflow-hidden rounded-2xl border border-primary-comfy-canvas/50 bg-cover bg-center bg-no-repeat shadow-2xl"
|
||||
:style="{ backgroundImage: `url(${topLeft})` }"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute -top-1/10 right-1/10 flex aspect-square w-7 items-center justify-center rounded-lg border border-primary-comfy-canvas/30 bg-white/20 text-primary-comfy-canvas shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<i class="icon-[comfy--bytedance-mono] size-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-3/20 left-1/10 aspect-4/3 w-1/4 overflow-hidden rounded-lg bg-cover bg-center bg-no-repeat shadow-2xl"
|
||||
:style="{ backgroundImage: `url(${bottomLeft})` }"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute right-2/25 bottom-1/10 aspect-4/3 w-3/10 overflow-hidden rounded-lg border border-primary-comfy-canvas/50 bg-cover bg-center bg-no-repeat shadow-2xl"
|
||||
:style="{ backgroundImage: `url(${bottomRight})` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex w-full max-w-md flex-col items-center gap-1 text-center"
|
||||
>
|
||||
<p
|
||||
class="m-0 font-inter text-base font-semibold text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('cloudHero.slides.cloud.title') }}
|
||||
</p>
|
||||
<p class="m-0 font-inter text-sm text-primary-comfy-canvas/70">
|
||||
{{ t('cloudHero.slides.cloud.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCloud"
|
||||
class="flex w-full items-center justify-center gap-3 pt-6"
|
||||
>
|
||||
<p class="m-0 hidden text-sm text-primary-comfy-canvas/90 md:block">
|
||||
{{ t('cloudStart_wantToRun') }}
|
||||
</p>
|
||||
<a
|
||||
href="https://www.comfy.org/download"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex h-8 items-center gap-2 rounded-lg border border-primary-comfy-canvas/20 bg-charcoal-500 p-2 text-xs font-medium text-primary-comfy-canvas no-underline hover:bg-charcoal-500/80"
|
||||
>
|
||||
<i class="pi pi-download text-xs text-primary-comfy-canvas/90" />
|
||||
{{ t('cloudStart_download') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import bottomLeft from '@/platform/cloud/onboarding/assets/hero/bottom-left.jpg'
|
||||
import bottomRight from '@/platform/cloud/onboarding/assets/hero/bottom-right.jpg'
|
||||
import centerImage from '@/platform/cloud/onboarding/assets/hero/center-image.jpg'
|
||||
import topLeft from '@/platform/cloud/onboarding/assets/hero/top-left.jpg'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
9
src/platform/cloud/onboarding/components/CloudLogo.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="mx-auto flex h-[7%] max-h-[70px] w-5/6 items-end">
|
||||
<img
|
||||
src="/assets/images/comfy-cloud-logo.svg"
|
||||
:alt="$t('subscription.comfyCloudLogo')"
|
||||
class="h-3/4 max-h-10 w-auto"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -67,8 +67,7 @@
|
||||
<Button
|
||||
v-else
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
class="relative mt-4 h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
|
||||
class="mt-4 h-10 font-medium text-white"
|
||||
:disabled="!$form.valid"
|
||||
>
|
||||
{{ t('auth.login.loginButton') }}
|
||||
@@ -118,22 +117,12 @@ const onSubmit = (event: FormSubmitEvent) => {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: #2d2e32 !important;
|
||||
color: var(--color-primary-comfy-canvas) !important;
|
||||
caret-color: var(--color-primary-comfy-canvas);
|
||||
}
|
||||
|
||||
:deep(.p-inputtext::placeholder) {
|
||||
color: rgb(from var(--color-primary-comfy-canvas) r g b / 0.5);
|
||||
}
|
||||
|
||||
:deep(.p-password input) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:deep(.p-password-toggle-mask-icon) {
|
||||
cursor: pointer;
|
||||
}
|
||||
:deep(.p-checkbox-checked .p-checkbox-box) {
|
||||
background-color: #f0ff41 !important;
|
||||
border-color: #f0ff41 !important;
|
||||
|
||||
@@ -1,27 +1,81 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-svh w-screen bg-primary-comfy-ink font-sans text-primary-comfy-canvas"
|
||||
>
|
||||
<div class="relative flex flex-1 flex-col">
|
||||
<div class="flex h-16 shrink-0 items-center px-6">
|
||||
<i
|
||||
class="icon-[comfy--comfy-logo] h-5 w-22 text-brand-yellow md:h-6 md:w-26"
|
||||
/>
|
||||
<div class="flex">
|
||||
<BaseViewTemplate dark class="flex-1">
|
||||
<template #header>
|
||||
<CloudLogo />
|
||||
</template>
|
||||
<slot />
|
||||
<template #footer>
|
||||
<CloudTemplateFooter />
|
||||
</template>
|
||||
</BaseViewTemplate>
|
||||
<div class="relative hidden flex-1 overflow-hidden bg-black lg:block">
|
||||
<!-- Video Background -->
|
||||
<video
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
:poster="videoPoster"
|
||||
>
|
||||
<source :src="videoSrc" type="video/mp4" />
|
||||
</video>
|
||||
|
||||
<div class="absolute inset-0 size-full bg-black/30"></div>
|
||||
|
||||
<!-- Optional Overlay for better visual -->
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center text-center text-white"
|
||||
>
|
||||
<div>
|
||||
<h1 class="font-abcrom hero-title font-black uppercase italic">
|
||||
{{ t('cloudStart_title') }}
|
||||
</h1>
|
||||
<p class="m-2 text-center text-xl text-white">
|
||||
{{ t('cloudStart_desc') }}
|
||||
</p>
|
||||
<p class="m-0 text-center text-xl text-white">
|
||||
{{ t('cloudStart_explain') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center justify-center overflow-auto">
|
||||
<slot />
|
||||
<div class="absolute inset-0 flex flex-col justify-end px-14 pb-[64px]">
|
||||
<div class="flex items-center justify-end">
|
||||
<div class="flex items-center gap-3">
|
||||
<p class="text-md text-white">
|
||||
{{ t('cloudStart_wantToRun') }}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10 bg-black font-bold text-white"
|
||||
variant="secondary"
|
||||
@click="handleDownloadClick"
|
||||
>
|
||||
{{ t('cloudStart_download') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CloudTemplateFooter />
|
||||
</div>
|
||||
<div class="relative hidden flex-1 overflow-hidden py-2 pr-2 lg:block">
|
||||
<CloudHeroCarousel />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CloudHeroCarousel from '@/platform/cloud/onboarding/components/CloudHeroCarousel.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import videoPoster from '@/platform/cloud/onboarding/assets/videos/thumbnail.png'
|
||||
import videoSrc from '@/platform/cloud/onboarding/assets/videos/video.mp4'
|
||||
import CloudLogo from '@/platform/cloud/onboarding/components/CloudLogo.vue'
|
||||
import CloudTemplateFooter from '@/platform/cloud/onboarding/components/CloudTemplateFooter.vue'
|
||||
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const handleDownloadClick = () => {
|
||||
window.open('https://www.comfy.org/download', '_blank')
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
@import '../assets/css/fonts.css';
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<footer
|
||||
class="mx-auto flex h-[5%] max-h-[60px] w-5/6 items-start justify-center gap-2.5"
|
||||
>
|
||||
<footer class="mx-auto flex h-[5%] max-h-[60px] w-5/6 items-start gap-2.5">
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { oauthConsentRedirect } from '@/platform/cloud/onboarding/onboardingCloudRoutes'
|
||||
import {
|
||||
captureOAuthRequestId,
|
||||
clearOAuthRequestId
|
||||
} from '@/platform/cloud/oauth/oauthState'
|
||||
|
||||
const VALID_REQUEST_ID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
|
||||
const createSessionOrThrow = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock('@/platform/auth/session/useSessionCookie', () => ({
|
||||
useSessionCookie: () => ({ createSessionOrThrow })
|
||||
}))
|
||||
|
||||
describe('oauthConsentRedirect', () => {
|
||||
beforeEach(() => {
|
||||
clearOAuthRequestId()
|
||||
createSessionOrThrow.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('routes to user-check and mints no session when no OAuth flow is pending', async () => {
|
||||
const target = await oauthConsentRedirect()
|
||||
|
||||
expect(target).toEqual({ name: 'cloud-user-check' })
|
||||
expect(createSessionOrThrow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mints the Cloud session cookie before redirecting to consent when resuming OAuth', async () => {
|
||||
// Regression: an already-signed-in user (Firebase) carries no Cloud session
|
||||
// cookie, so the consent challenge fetch fails unless the cookie is minted
|
||||
// here, mirroring the post-login resume path.
|
||||
captureOAuthRequestId({ oauth_request_id: VALID_REQUEST_ID })
|
||||
|
||||
const target = await oauthConsentRedirect()
|
||||
|
||||
expect(createSessionOrThrow).toHaveBeenCalledOnce()
|
||||
expect(target).toEqual({
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: VALID_REQUEST_ID }
|
||||
})
|
||||
})
|
||||
|
||||
it('still lands on consent when session minting fails so the view can surface the error', async () => {
|
||||
captureOAuthRequestId({ oauth_request_id: VALID_REQUEST_ID })
|
||||
createSessionOrThrow.mockRejectedValue(new Error('Unauthorized'))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const target = await oauthConsentRedirect()
|
||||
|
||||
expect(target).toEqual({
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: VALID_REQUEST_ID }
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Failed to establish Cloud session cookie before OAuth consent:',
|
||||
expect.any(Error)
|
||||
)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -5,39 +5,14 @@ import { getOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
|
||||
// `oauth_request_id` capture lives in the global router.beforeEach guard
|
||||
// (src/router.ts), which runs before any per-route beforeEnter. Per-route
|
||||
// guards read it back via getOAuthRequestId().
|
||||
//
|
||||
// When an already-signed-in user is bounced to login/signup mid-OAuth, we skip
|
||||
// the sign-in step and jump straight to consent. The consent challenge fetch
|
||||
// (GET /oauth/authorize) is authenticated by the Cloud *session cookie*, which
|
||||
// is a separate credential from the Firebase client login that `isLoggedIn`
|
||||
// reflects. The post-login resume path mints that cookie via
|
||||
// `createSessionOrThrow` (see useOAuthPostLoginRedirect); the already-signed-in
|
||||
// path must do the same. Without it the consent fetch is unauthenticated, the
|
||||
// backend 302s it to login, and the consent view fails with
|
||||
// "OAuth request failed. Please restart from the client app."
|
||||
export async function oauthConsentRedirect() {
|
||||
function oauthConsentRedirect() {
|
||||
const oauthRequestId = getOAuthRequestId()
|
||||
if (!oauthRequestId) return { name: 'cloud-user-check' }
|
||||
|
||||
try {
|
||||
const { useSessionCookie } =
|
||||
await import('@/platform/auth/session/useSessionCookie')
|
||||
await useSessionCookie().createSessionOrThrow()
|
||||
} catch (error) {
|
||||
// Best effort: if the cookie can't be minted (e.g. an expired Firebase
|
||||
// token), still land on the consent view so it can surface the failure and
|
||||
// prompt the user to restart from the client app, rather than silently
|
||||
// dropping the OAuth flow.
|
||||
console.warn(
|
||||
'Failed to establish Cloud session cookie before OAuth consent:',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: oauthRequestId }
|
||||
}
|
||||
return oauthRequestId
|
||||
? {
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: oauthRequestId }
|
||||
}
|
||||
: { name: 'cloud-user-check' }
|
||||
}
|
||||
|
||||
export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
@@ -59,7 +34,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
return next(await oauthConsentRedirect())
|
||||
return next(oauthConsentRedirect())
|
||||
}
|
||||
}
|
||||
next()
|
||||
@@ -77,7 +52,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
return next(await oauthConsentRedirect())
|
||||
return next(oauthConsentRedirect())
|
||||
}
|
||||
}
|
||||
next()
|
||||
|
||||
@@ -50,7 +50,7 @@ function makeAsset(name: string, assetHash: string | null = null): AssetItem {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
hash: assetHash,
|
||||
asset_hash: assetHash,
|
||||
mime_type: null,
|
||||
tags: ['input']
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function getAssetDetectionNames(
|
||||
): string[] {
|
||||
const names = new Set<string>()
|
||||
// Treat names and hashes as opaque match keys because Cloud may use either in widget values.
|
||||
addPathDetectionNames(names, asset.hash, options)
|
||||
addPathDetectionNames(names, asset.asset_hash, options)
|
||||
addPathDetectionNames(names, asset.name, options)
|
||||
|
||||
const subfolder = asset.user_metadata?.subfolder
|
||||
|
||||
@@ -115,7 +115,7 @@ function makeAsset(name: string, assetHash: string | null = null): AssetItem {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
hash: assetHash,
|
||||
asset_hash: assetHash,
|
||||
mime_type: null,
|
||||
tags: ['input']
|
||||
}
|
||||
@@ -532,7 +532,7 @@ describe('verifyMediaCandidates', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('matches asset names when hash is null', async () => {
|
||||
it('matches asset names when asset_hash is null', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'legacy-photo.png', { isMissing: undefined }),
|
||||
makeCandidate('2', 'missing-photo.png', { isMissing: undefined })
|
||||
|
||||
@@ -140,8 +140,8 @@ interface MediaVerificationOptions {
|
||||
* Verify media candidates against assets available to the current runtime.
|
||||
*
|
||||
* A candidate's `name` may be either a filename or an opaque asset hash.
|
||||
* Cloud-side `hash` is not guaranteed to follow a single shape, so we
|
||||
* match against the union of `asset.name` and `asset.hash`. Output
|
||||
* Cloud-side `asset_hash` is not guaranteed to follow a single shape, so we
|
||||
* match against the union of `asset.name` and `asset.asset_hash`. Output
|
||||
* candidates are matched against Cloud output assets or Core generated-history
|
||||
* assets because Core resolves those annotations against output folders, not
|
||||
* input files.
|
||||
|
||||
@@ -1445,13 +1445,13 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
hash: null,
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'my_model.safetensors' }
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'other_model.safetensors',
|
||||
hash: null,
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'other_model.safetensors' }
|
||||
}
|
||||
])
|
||||
@@ -1465,7 +1465,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should resolve isMissing=false when asset with matching hash exists', async () => {
|
||||
it('should resolve isMissing=false when asset with matching asset_hash exists', async () => {
|
||||
const candidates = [
|
||||
makeAssetCandidate('model.safetensors', {
|
||||
hash: 'abc123',
|
||||
@@ -1473,7 +1473,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
})
|
||||
]
|
||||
mockGetAssets.mockReturnValue([
|
||||
{ id: '1', name: 'model.safetensors', hash: 'sha256:abc123' }
|
||||
{ id: '1', name: 'model.safetensors', asset_hash: 'sha256:abc123' }
|
||||
])
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
@@ -1487,7 +1487,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
hash: null,
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'my_model.safetensors' }
|
||||
}
|
||||
])
|
||||
@@ -1578,7 +1578,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'checkpoint.safetensors',
|
||||
hash: null,
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'checkpoint.safetensors' }
|
||||
}
|
||||
]
|
||||
@@ -1601,7 +1601,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'model.safetensors',
|
||||
hash: null,
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'model.safetensors' }
|
||||
}
|
||||
])
|
||||
@@ -1617,7 +1617,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
hash: null,
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'subfolder/my_model.safetensors' }
|
||||
}
|
||||
])
|
||||
|
||||
@@ -501,7 +501,7 @@ function isAssetInstalled(
|
||||
): boolean {
|
||||
if (candidate.hash && candidate.hashType) {
|
||||
const candidateHash = `${candidate.hashType}:${candidate.hash}`
|
||||
if (assets.some((a) => a.hash === candidateHash)) return true
|
||||
if (assets.some((a) => a.asset_hash === candidateHash)) return true
|
||||
}
|
||||
|
||||
const normalizedName = normalizePath(candidate.name)
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import {
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
} from './remoteConfig'
|
||||
import { remoteConfig, remoteConfigState } from './remoteConfig'
|
||||
|
||||
interface RefreshRemoteConfigOptions {
|
||||
/**
|
||||
@@ -38,10 +34,6 @@ export async function refreshRemoteConfig(
|
||||
window.__CONFIG__ = config
|
||||
remoteConfig.value = config
|
||||
remoteConfigState.value = useAuth ? 'authenticated' : 'anonymous'
|
||||
if (useAuth)
|
||||
cachedTeamWorkspacesEnabled.value = Boolean(
|
||||
config.team_workspaces_enabled
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
import type { ServerFeatureFlag } from '@/composables/useFeatureFlags'
|
||||
|
||||
/**
|
||||
* Remote configuration service
|
||||
*
|
||||
@@ -54,8 +50,3 @@ export function configValueOrDefault<K extends keyof RemoteConfig>(
|
||||
const configValue = remoteConfig[key]
|
||||
return configValue || defaultValue
|
||||
}
|
||||
|
||||
export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
|
||||
'team_workspaces_enabled' satisfies `${ServerFeatureFlag.TEAM_WORKSPACES_ENABLED}`,
|
||||
undefined
|
||||
)
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
PageViewMetadata,
|
||||
@@ -199,10 +198,6 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
)
|
||||
}
|
||||
|
||||
trackNodeAdded(metadata: NodeAddedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackNodeAdded?.(metadata))
|
||||
}
|
||||
|
||||
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
|
||||
this.dispatch((provider) => provider.trackTemplateFilterChanged?.(metadata))
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
|
||||
import { installNodeAddedTelemetry } from './installNodeAddedTelemetry'
|
||||
import { withNodeAddSource } from './nodeAddSource'
|
||||
|
||||
const trackNodeAdded = vi.fn()
|
||||
|
||||
vi.mock('..', () => ({
|
||||
useTelemetry: () => ({ trackNodeAdded })
|
||||
}))
|
||||
|
||||
function fakeGraph(): LGraph {
|
||||
return { onNodeAdded: undefined } as unknown as LGraph
|
||||
}
|
||||
|
||||
function fakeNode(type: string): LGraphNode {
|
||||
return { type } as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('installNodeAddedTelemetry', () => {
|
||||
beforeEach(() => {
|
||||
trackNodeAdded.mockClear()
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
})
|
||||
|
||||
it('fires trackNodeAdded with the current source on add', () => {
|
||||
const graph = fakeGraph()
|
||||
installNodeAddedTelemetry(graph)
|
||||
|
||||
withNodeAddSource('sidebar_drag', () => {
|
||||
graph.onNodeAdded?.(fakeNode('KSampler'))
|
||||
})
|
||||
|
||||
expect(trackNodeAdded).toHaveBeenCalledExactlyOnceWith({
|
||||
node_type: 'KSampler',
|
||||
source: 'sidebar_drag'
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults source to "unknown" outside withNodeAddSource', () => {
|
||||
const graph = fakeGraph()
|
||||
installNodeAddedTelemetry(graph)
|
||||
|
||||
graph.onNodeAdded?.(fakeNode('CheckpointLoader'))
|
||||
|
||||
expect(trackNodeAdded).toHaveBeenCalledWith({
|
||||
node_type: 'CheckpointLoader',
|
||||
source: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('skips telemetry during workflow load', () => {
|
||||
const graph = fakeGraph()
|
||||
installNodeAddedTelemetry(graph)
|
||||
ChangeTracker.isLoadingGraph = true
|
||||
|
||||
graph.onNodeAdded?.(fakeNode('VAEDecode'))
|
||||
|
||||
expect(trackNodeAdded).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves an existing onNodeAdded subscriber', () => {
|
||||
const graph = fakeGraph()
|
||||
const previous = vi.fn()
|
||||
graph.onNodeAdded = previous
|
||||
installNodeAddedTelemetry(graph)
|
||||
|
||||
const node = fakeNode('LoadImage')
|
||||
graph.onNodeAdded?.(node)
|
||||
|
||||
expect(previous).toHaveBeenCalledExactlyOnceWith(node)
|
||||
expect(trackNodeAdded).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
|
||||
import { useTelemetry } from '..'
|
||||
import { getCurrentNodeAddSource } from './nodeAddSource'
|
||||
|
||||
/**
|
||||
* Wire `app:node_added_to_workflow` telemetry into a graph. Wraps any existing
|
||||
* `onNodeAdded` callback so we don't displace other subscribers. Bulk
|
||||
* additions during workflow load are skipped — `workflow_imported`
|
||||
* already covers those.
|
||||
*/
|
||||
export function installNodeAddedTelemetry(graph: LGraph): void {
|
||||
const previous = graph.onNodeAdded
|
||||
graph.onNodeAdded = function (node) {
|
||||
previous?.call(this, node)
|
||||
if (ChangeTracker.isLoadingGraph) return
|
||||
useTelemetry()?.trackNodeAdded({
|
||||
node_type: node.type ?? 'unknown',
|
||||
source: getCurrentNodeAddSource()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { NodeAddSource } from '../types'
|
||||
|
||||
let currentSource: NodeAddSource = 'unknown'
|
||||
|
||||
export function getCurrentNodeAddSource(): NodeAddSource {
|
||||
return currentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the node-add source for the duration of `fn`. Synchronous only —
|
||||
* the source is read by the synchronous LGraph.onNodeAdded callback that
|
||||
* fires inside `graph.add()`. Nesting restores the previous value on exit.
|
||||
*/
|
||||
export function withNodeAddSource<T>(source: NodeAddSource, fn: () => T): T {
|
||||
const previous = currentSource
|
||||
currentSource = source
|
||||
try {
|
||||
return fn()
|
||||
} finally {
|
||||
currentSource = previous
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { TelemetryEvents } from '../../types'
|
||||
|
||||
@@ -7,30 +7,20 @@ const hoisted = vi.hoisted(() => {
|
||||
const mockInit = vi.fn()
|
||||
const mockIdentify = vi.fn()
|
||||
const mockPeopleSet = vi.fn()
|
||||
const mockPeopleSetOnce = vi.fn()
|
||||
const mockRegister = vi.fn()
|
||||
const mockReset = vi.fn()
|
||||
const mockOnUserResolved = vi.fn()
|
||||
const mockOnUserLogout = vi.fn()
|
||||
|
||||
return {
|
||||
mockCapture,
|
||||
mockInit,
|
||||
mockIdentify,
|
||||
mockPeopleSet,
|
||||
mockPeopleSetOnce,
|
||||
mockRegister,
|
||||
mockReset,
|
||||
mockOnUserResolved,
|
||||
mockOnUserLogout,
|
||||
mockPosthog: {
|
||||
default: {
|
||||
init: mockInit,
|
||||
capture: mockCapture,
|
||||
identify: mockIdentify,
|
||||
register: mockRegister,
|
||||
people: { set: mockPeopleSet, set_once: mockPeopleSetOnce },
|
||||
reset: mockReset
|
||||
people: { set: mockPeopleSet }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,8 +36,7 @@ vi.mock('vue', async () => {
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
onUserResolved: hoisted.mockOnUserResolved,
|
||||
onUserLogout: hoisted.mockOnUserLogout
|
||||
onUserResolved: hoisted.mockOnUserResolved
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -141,7 +130,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
expect(hoisted.mockOnUserResolved).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('identifies user without setting first_auth_at when onUserResolved fires', async () => {
|
||||
it('identifies user when onUserResolved fires', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -152,99 +141,6 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('desktop entry capture', () => {
|
||||
function setLocation(search: string): void {
|
||||
Object.defineProperty(window.location, 'search', {
|
||||
configurable: true,
|
||||
value: search,
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setLocation('')
|
||||
})
|
||||
|
||||
it('does not register desktop props when utm_source is absent', async () => {
|
||||
setLocation('')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not register desktop props when utm_source is not comfy.desktop', async () => {
|
||||
setLocation('?utm_source=google&desktop_device_id=should-be-ignored')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers source_app and desktop_device_id when arriving from desktop', async () => {
|
||||
setLocation(
|
||||
'?utm_source=comfy.desktop&utm_medium=app_feature&desktop_device_id=device-abc'
|
||||
)
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).toHaveBeenCalledWith({
|
||||
source_app: 'desktop',
|
||||
desktop_device_id: 'device-abc'
|
||||
})
|
||||
})
|
||||
|
||||
it('registers source_app alone when desktop_device_id is missing', async () => {
|
||||
setLocation('?utm_source=comfy.desktop')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).toHaveBeenCalledWith({
|
||||
source_app: 'desktop'
|
||||
})
|
||||
})
|
||||
|
||||
it('persists desktop props to the person on identify so backend events inherit them', async () => {
|
||||
setLocation('?utm_source=comfy.desktop&desktop_device_id=device-xyz')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const callback = hoisted.mockOnUserResolved.mock.calls[0][0]
|
||||
callback({ id: 'user-456' })
|
||||
|
||||
const setCall = hoisted.mockPeopleSet.mock.calls.find(
|
||||
([props]) => props && 'desktop_device_id' in props
|
||||
)
|
||||
expect(setCall?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
source_app: 'desktop',
|
||||
desktop_device_id: 'device-xyz',
|
||||
last_seen_via_desktop: expect.any(String)
|
||||
})
|
||||
)
|
||||
expect(hoisted.mockPeopleSetOnce).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ first_seen_via_desktop: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not touch the person profile on identify for non-desktop visitors', async () => {
|
||||
setLocation('')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const callback = hoisted.mockOnUserResolved.mock.calls[0][0]
|
||||
callback({ id: 'user-789' })
|
||||
|
||||
const desktopSetCall = hoisted.mockPeopleSet.mock.calls.find(
|
||||
([props]) =>
|
||||
props &&
|
||||
('desktop_device_id' in props || 'last_seen_via_desktop' in props)
|
||||
)
|
||||
expect(desktopSetCall).toBeUndefined()
|
||||
expect(hoisted.mockPeopleSetOnce).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('event tracking', () => {
|
||||
it('captures events after initialization', async () => {
|
||||
const provider = createProvider()
|
||||
@@ -270,88 +166,6 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('sets first_auth_at on new-user auth', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'user-123'
|
||||
})
|
||||
|
||||
expect(hoisted.mockIdentify).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
first_auth_at: expect.any(String)
|
||||
})
|
||||
)
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
{
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'user-123'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('does not set first_auth_at on returning-user auth', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'google',
|
||||
is_new_user: false,
|
||||
user_id: 'user-123'
|
||||
})
|
||||
|
||||
expect(hoisted.mockIdentify).not.toHaveBeenCalled()
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
{
|
||||
method: 'google',
|
||||
is_new_user: false,
|
||||
user_id: 'user-123'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('flushes queued first_auth_at before queued auth event', async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'user-123'
|
||||
})
|
||||
|
||||
expect(hoisted.mockIdentify).not.toHaveBeenCalled()
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockIdentify).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
first_auth_at: expect.any(String)
|
||||
})
|
||||
)
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
{
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'user-123'
|
||||
}
|
||||
)
|
||||
expect(hoisted.mockIdentify.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.mockCapture.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('queues events before initialization and flushes after', async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
@@ -422,32 +236,6 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('registers onUserLogout watcher after init', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockOnUserLogout).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls posthog.reset(true) when the watcher fires', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const callback = hoisted.mockOnUserLogout.mock.calls[0][0]
|
||||
callback()
|
||||
|
||||
expect(hoisted.mockReset).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('does not register the watcher before init resolves', () => {
|
||||
createProvider()
|
||||
|
||||
expect(hoisted.mockOnUserLogout).not.toHaveBeenCalled()
|
||||
expect(hoisted.mockReset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('page view', () => {
|
||||
it('captures page view with page_name property', async () => {
|
||||
const provider = createProvider()
|
||||
@@ -475,72 +263,4 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('before_send', () => {
|
||||
it('strips PII keys from event properties, $set, and $set_once', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const { before_send } = hoisted.mockInit.mock.calls[0][1]
|
||||
|
||||
const event = {
|
||||
event: 'test',
|
||||
properties: {
|
||||
email: 'props@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'props_user@example.com',
|
||||
$email: 'props_posthog@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: {
|
||||
email: 'set@example.com',
|
||||
user_email: 'set_user@example.com',
|
||||
$email: 'set_posthog@example.com',
|
||||
name: 'keep me'
|
||||
},
|
||||
$set_once: {
|
||||
email: 'set_once@example.com',
|
||||
plan: 'free'
|
||||
}
|
||||
}
|
||||
|
||||
const result = before_send(event)
|
||||
|
||||
// event.properties — all four PII keys stripped, non-PII preserved
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
|
||||
// event.$set — PII stripped, non-PII preserved
|
||||
// posthog.identify(id, { email }) lands here, not in properties
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).not.toHaveProperty('user_email')
|
||||
expect(result.$set).not.toHaveProperty('$email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
|
||||
// event.$set_once — PII stripped, non-PII preserved
|
||||
expect(result.$set_once).not.toHaveProperty('email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
|
||||
it('remoteConfig.posthog_config cannot override before_send or person_profiles', async () => {
|
||||
const remoteBefore_send = vi.fn()
|
||||
mockRemoteConfig.value = {
|
||||
posthog_config: {
|
||||
before_send: remoteBefore_send,
|
||||
person_profiles: 'always'
|
||||
}
|
||||
}
|
||||
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const initConfig = hoisted.mockInit.mock.calls[0][1]
|
||||
|
||||
expect(initConfig.before_send).not.toBe(remoteBefore_send)
|
||||
expect(initConfig.person_profiles).toBe('identified_only')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
@@ -21,7 +19,6 @@ import type {
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
PageViewMetadata,
|
||||
@@ -72,20 +69,6 @@ interface QueuedEvent {
|
||||
properties?: TelemetryEventProperties
|
||||
}
|
||||
|
||||
interface DesktopEntryProps {
|
||||
source_app: 'desktop'
|
||||
desktop_device_id?: string
|
||||
}
|
||||
|
||||
function readDesktopEntryProps(): DesktopEntryProps | null {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('utm_source') !== 'comfy.desktop') return null
|
||||
const props: DesktopEntryProps = { source_app: 'desktop' }
|
||||
const deviceId = params.get('desktop_device_id')
|
||||
if (deviceId) props.desktop_device_id = deviceId
|
||||
return props
|
||||
}
|
||||
|
||||
/**
|
||||
* PostHog Telemetry Provider - Cloud Build Implementation
|
||||
*
|
||||
@@ -99,11 +82,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
private isEnabled = true
|
||||
private posthog: PostHog | null = null
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private pendingFirstAuthAt = new Map<string, string>()
|
||||
private isInitialized = false
|
||||
private lastTriggerSource: ExecutionTriggerSource | undefined
|
||||
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
|
||||
private desktopEntryProps: DesktopEntryProps | null = null
|
||||
|
||||
constructor() {
|
||||
this.configureDisabledEvents(
|
||||
@@ -133,38 +114,17 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
|
||||
...serverConfig,
|
||||
person_profiles: 'identified_only',
|
||||
// cookie_domain omitted: posthog-js sets a first-party cross-subdomain cookie
|
||||
// automatically when persistence includes 'cookie' (the default).
|
||||
// Explicit override interacts badly with posthog-js#3578 where reset() fails
|
||||
// to clear localStorage on other subdomains, causing identity bleed on logout.
|
||||
before_send: createPostHogBeforeSend()
|
||||
...serverConfig
|
||||
})
|
||||
this.isInitialized = true
|
||||
this.flushEventQueue()
|
||||
this.registerDesktopEntryProps()
|
||||
|
||||
const currentUser = useCurrentUser()
|
||||
currentUser.onUserResolved((user) => {
|
||||
useCurrentUser().onUserResolved((user) => {
|
||||
if (this.posthog && user.id) {
|
||||
this.posthog.identify(user.id)
|
||||
this.setDesktopEntryPersonProperties()
|
||||
this.setSubscriptionProperties()
|
||||
}
|
||||
})
|
||||
// Anchored to session state rather than the logout button so it
|
||||
// also covers token revocation, account deletion, and cross-tab
|
||||
// sign-out (browserLocalPersistence). A logout that lands during
|
||||
// the posthog-js dynamic-import window will not be observed here:
|
||||
// events buffered pre-init are intentionally NOT queue-cleared on
|
||||
// logout, which leaves a narrow race where a logout + different
|
||||
// login both inside the import window would flush pre-init events
|
||||
// under the new identity. Accepted as a known edge — re-adding
|
||||
// pre-init logout handling would defeat the simplification.
|
||||
currentUser.onUserLogout(() => {
|
||||
this.posthog?.reset(true)
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load PostHog:', error)
|
||||
@@ -183,8 +143,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
private flushEventQueue(): void {
|
||||
if (!this.isInitialized || !this.posthog) return
|
||||
|
||||
this.flushPendingFirstAuthAt()
|
||||
|
||||
while (this.eventQueue.length > 0) {
|
||||
const event = this.eventQueue.shift()!
|
||||
try {
|
||||
@@ -195,33 +153,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private flushPendingFirstAuthAt(): void {
|
||||
for (const [userId, firstAuthAt] of this.pendingFirstAuthAt) {
|
||||
this.setFirstAuthAt(userId, firstAuthAt)
|
||||
}
|
||||
this.pendingFirstAuthAt.clear()
|
||||
}
|
||||
|
||||
private setFirstAuthAt(
|
||||
userId: string,
|
||||
firstAuthAt = new Date().toISOString()
|
||||
): void {
|
||||
if (!this.isEnabled) return
|
||||
|
||||
if (this.isInitialized && this.posthog) {
|
||||
try {
|
||||
this.posthog.identify(userId, undefined, { first_auth_at: firstAuthAt })
|
||||
} catch (error) {
|
||||
console.error('Failed to set PostHog first auth timestamp:', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.pendingFirstAuthAt.has(userId)) {
|
||||
this.pendingFirstAuthAt.set(userId, firstAuthAt)
|
||||
}
|
||||
}
|
||||
|
||||
private trackEvent(
|
||||
eventName: TelemetryEventName,
|
||||
properties?: TelemetryEventProperties
|
||||
@@ -284,34 +215,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
)
|
||||
}
|
||||
|
||||
private registerDesktopEntryProps(): void {
|
||||
if (!this.posthog) return
|
||||
const props = readDesktopEntryProps()
|
||||
if (!props) return
|
||||
this.desktopEntryProps = props
|
||||
try {
|
||||
this.posthog.register(props)
|
||||
} catch (error) {
|
||||
console.error('Failed to register desktop entry props:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Persisted onto the person so backend-fired billing events inherit
|
||||
// desktop_device_id via person-on-events at ingest.
|
||||
private setDesktopEntryPersonProperties(): void {
|
||||
if (!this.posthog || !this.desktopEntryProps) return
|
||||
const now = new Date().toISOString()
|
||||
try {
|
||||
this.posthog.people.set({
|
||||
...this.desktopEntryProps,
|
||||
last_seen_via_desktop: now
|
||||
})
|
||||
this.posthog.people.set_once({ first_seen_via_desktop: now })
|
||||
} catch (error) {
|
||||
console.error('Failed to set desktop entry person properties:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private setSubscriptionProperties(): void {
|
||||
const { subscriptionTier } = useSubscription()
|
||||
watch(
|
||||
@@ -330,9 +233,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
if (metadata.is_new_user && metadata.user_id) {
|
||||
this.setFirstAuthAt(metadata.user_id)
|
||||
}
|
||||
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
|
||||
}
|
||||
|
||||
@@ -503,10 +403,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
|
||||
}
|
||||
|
||||
trackNodeAdded(metadata: NodeAddedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.NODE_ADDED, metadata)
|
||||
}
|
||||
|
||||
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -139,23 +139,9 @@ export interface CreditTopupMetadata {
|
||||
/**
|
||||
* Workflow import metadata
|
||||
*/
|
||||
export interface MissingNodePack {
|
||||
/**
|
||||
* Custom node pack identifier (cnrId / aux_id from node properties).
|
||||
* `'unknown'` when the workflow JSON has no pack hint for the node.
|
||||
*/
|
||||
pack_id: string
|
||||
node_types: string[]
|
||||
}
|
||||
|
||||
export interface WorkflowImportMetadata {
|
||||
missing_node_count: number
|
||||
missing_node_types: string[]
|
||||
/**
|
||||
* Missing nodes grouped by their custom node pack. Populated from the
|
||||
* `cnr_id` / `aux_id` baked into node properties — no network lookups.
|
||||
*/
|
||||
missing_node_packs?: MissingNodePack[]
|
||||
/**
|
||||
* The source of the workflow open/import action
|
||||
*/
|
||||
@@ -246,23 +232,6 @@ export interface NodeSearchMetadata {
|
||||
query: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Node added metadata. `source` indicates how the user initiated the add.
|
||||
* Bulk additions during workflow load are excluded — workflow_imported
|
||||
* already covers that.
|
||||
*/
|
||||
export type NodeAddSource =
|
||||
| 'sidebar_drag'
|
||||
| 'search_modal'
|
||||
| 'paste'
|
||||
| 'programmatic'
|
||||
| 'unknown'
|
||||
|
||||
export interface NodeAddedMetadata {
|
||||
node_type: string
|
||||
source: NodeAddSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Node search result selection metadata
|
||||
*/
|
||||
@@ -356,7 +325,6 @@ export interface CheckoutAttributionMetadata {
|
||||
ga_session_id?: string
|
||||
ga_session_number?: string
|
||||
im_ref?: string
|
||||
rewardful_referral?: string
|
||||
utm_source?: string
|
||||
utm_medium?: string
|
||||
utm_campaign?: string
|
||||
@@ -469,9 +437,6 @@ export interface TelemetryProvider {
|
||||
trackNodeSearch?(metadata: NodeSearchMetadata): void
|
||||
trackNodeSearchResultSelected?(metadata: NodeSearchResultMetadata): void
|
||||
|
||||
// Node-added-to-canvas analytics
|
||||
trackNodeAdded?(metadata: NodeAddedMetadata): void
|
||||
|
||||
// Template filter tracking events
|
||||
trackTemplateFilterChanged?(metadata: TemplateFilterMetadata): void
|
||||
|
||||
@@ -558,7 +523,6 @@ export const TelemetryEvents = {
|
||||
// Node Search Analytics
|
||||
NODE_SEARCH: 'app:node_search',
|
||||
NODE_SEARCH_RESULT_SELECTED: 'app:node_search_result_selected',
|
||||
NODE_ADDED: 'app:node_added_to_workflow',
|
||||
|
||||
// Template Filter Analytics
|
||||
TEMPLATE_FILTER_CHANGED: 'app:template_filter_changed',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
captureCheckoutAttributionFromSearch,
|
||||
@@ -15,15 +15,9 @@ describe('getCheckoutAttribution', () => {
|
||||
}
|
||||
window.gtag = undefined
|
||||
window.ire = undefined
|
||||
window.rewardful = undefined
|
||||
window.Rewardful = undefined
|
||||
window.history.pushState({}, '', '/')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reads GA identity and URL attribution, and prefers generated click id', async () => {
|
||||
window.__CONFIG__ = {
|
||||
...window.__CONFIG__,
|
||||
@@ -234,80 +228,4 @@ describe('getCheckoutAttribution', () => {
|
||||
|
||||
expect(attribution.im_ref).toBeUndefined()
|
||||
})
|
||||
|
||||
it('captures Rewardful referral from window.Rewardful', async () => {
|
||||
window.Rewardful = {
|
||||
referral: 'rwd-abc-123'
|
||||
}
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution.rewardful_referral).toBe('rwd-abc-123')
|
||||
})
|
||||
|
||||
it('returns undefined Rewardful referral when window.Rewardful is absent', async () => {
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution.rewardful_referral).toBeUndefined()
|
||||
})
|
||||
|
||||
it('waits for Rewardful ready before reading the referral', async () => {
|
||||
let readyCallback: (() => void) | undefined
|
||||
window.rewardful = vi.fn((_method: 'ready', callback: () => void) => {
|
||||
readyCallback = callback
|
||||
}) as Window['rewardful']
|
||||
|
||||
const attributionPromise = getCheckoutAttribution()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(window.rewardful).toHaveBeenCalledWith('ready', expect.any(Function))
|
||||
|
||||
window.Rewardful = {
|
||||
referral: 'rwd-ready-123'
|
||||
}
|
||||
readyCallback?.()
|
||||
|
||||
const attribution = await attributionPromise
|
||||
|
||||
expect(attribution.rewardful_referral).toBe('rwd-ready-123')
|
||||
})
|
||||
|
||||
it('continues checkout attribution when Rewardful ready never runs', async () => {
|
||||
vi.useFakeTimers()
|
||||
window.rewardful = vi.fn() as Window['rewardful']
|
||||
|
||||
const attributionPromise = getCheckoutAttribution()
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
const attribution = await attributionPromise
|
||||
|
||||
expect(window.rewardful).toHaveBeenCalledWith('ready', expect.any(Function))
|
||||
expect(attribution.rewardful_referral).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined Rewardful referral when window.Rewardful.referral is empty', async () => {
|
||||
window.Rewardful = { referral: '' }
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution.rewardful_referral).toBeUndefined()
|
||||
})
|
||||
|
||||
it('captures Rewardful referral alongside Impact attribution', async () => {
|
||||
window.history.pushState(
|
||||
{},
|
||||
'',
|
||||
'/?im_ref=impact-url-id&utm_source=affiliate'
|
||||
)
|
||||
window.Rewardful = {
|
||||
referral: 'rwd-xyz-789'
|
||||
}
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution).toMatchObject({
|
||||
im_ref: 'impact-url-id',
|
||||
utm_source: 'affiliate',
|
||||
rewardful_referral: 'rwd-xyz-789'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,7 +31,6 @@ type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number]
|
||||
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
|
||||
const GENERATE_CLICK_ID_TIMEOUT_MS = 300
|
||||
const GET_GA_IDENTITY_TIMEOUT_MS = 300
|
||||
const GET_REWARDFUL_REFERRAL_TIMEOUT_MS = 300
|
||||
|
||||
function readStoredAttribution(): Partial<Record<AttributionQueryKey, string>> {
|
||||
if (typeof window === 'undefined') return {}
|
||||
@@ -181,30 +180,6 @@ async function getGeneratedClickId(): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getRewardfulReferral(): Promise<string | undefined> {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
|
||||
const referral = asNonEmptyString(window.Rewardful?.referral)
|
||||
if (referral) return referral
|
||||
|
||||
const rewardful = window.rewardful
|
||||
if (typeof rewardful !== 'function') return undefined
|
||||
|
||||
return withTimeout(
|
||||
() =>
|
||||
new Promise<string | undefined>((resolve, reject) => {
|
||||
try {
|
||||
rewardful('ready', () => {
|
||||
resolve(asNonEmptyString(window.Rewardful?.referral))
|
||||
})
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}),
|
||||
GET_REWARDFUL_REFERRAL_TIMEOUT_MS
|
||||
).catch(() => undefined)
|
||||
}
|
||||
|
||||
export function captureCheckoutAttributionFromSearch(search: string): void {
|
||||
const fromUrl = readAttributionFromUrl(search)
|
||||
const storedAttribution = readStoredAttribution()
|
||||
@@ -223,7 +198,6 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
|
||||
|
||||
const storedAttribution = readStoredAttribution()
|
||||
const fromUrl = readAttributionFromUrl(window.location.search)
|
||||
const rewardfulReferralPromise = getRewardfulReferral()
|
||||
const generatedClickId = await getGeneratedClickId()
|
||||
const attribution: Partial<Record<AttributionQueryKey, string>> = {
|
||||
...storedAttribution,
|
||||
@@ -238,16 +212,12 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
|
||||
persistAttribution(attribution)
|
||||
}
|
||||
|
||||
const [gaIdentity, rewardfulReferral] = await Promise.all([
|
||||
getGaIdentity(),
|
||||
rewardfulReferralPromise
|
||||
])
|
||||
const gaIdentity = await getGaIdentity()
|
||||
|
||||
return {
|
||||
...attribution,
|
||||
ga_client_id: gaIdentity?.client_id,
|
||||
ga_session_id: gaIdentity?.session_id,
|
||||
ga_session_number: gaIdentity?.session_number,
|
||||
rewardful_referral: rewardfulReferral
|
||||
ga_session_number: gaIdentity?.session_number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
import { groupMissingNodesByPack } from './groupMissingNodesByPack'
|
||||
|
||||
describe('groupMissingNodesByPack', () => {
|
||||
it('returns an empty array when no missing nodes', () => {
|
||||
expect(groupMissingNodesByPack([])).toEqual([])
|
||||
})
|
||||
|
||||
it('groups multiple node types under the same pack', () => {
|
||||
const missing: MissingNodeType[] = [
|
||||
{ type: 'ImpactSampler', cnrId: 'impact-pack' },
|
||||
{ type: 'ImpactDetailer', cnrId: 'impact-pack' },
|
||||
{ type: 'WASImageBlend', cnrId: 'was-node-suite' }
|
||||
]
|
||||
|
||||
const result = groupMissingNodesByPack(missing)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
pack_id: 'impact-pack',
|
||||
node_types: ['ImpactSampler', 'ImpactDetailer']
|
||||
},
|
||||
{ pack_id: 'was-node-suite', node_types: ['WASImageBlend'] }
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('deduplicates the same node type within a pack', () => {
|
||||
const missing: MissingNodeType[] = [
|
||||
{ type: 'ImpactSampler', cnrId: 'impact-pack' },
|
||||
{ type: 'ImpactSampler', cnrId: 'impact-pack' }
|
||||
]
|
||||
|
||||
expect(groupMissingNodesByPack(missing)).toEqual([
|
||||
{ pack_id: 'impact-pack', node_types: ['ImpactSampler'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to "unknown" for nodes without a cnrId', () => {
|
||||
const missing: MissingNodeType[] = [
|
||||
{ type: 'MysteryNode' },
|
||||
'LegacyStringNode'
|
||||
]
|
||||
|
||||
expect(groupMissingNodesByPack(missing)).toEqual([
|
||||
{ pack_id: 'unknown', node_types: ['MysteryNode', 'LegacyStringNode'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps "unknown" separate from identified packs', () => {
|
||||
const missing: MissingNodeType[] = [
|
||||
{ type: 'ImpactSampler', cnrId: 'impact-pack' },
|
||||
{ type: 'MysteryNode' }
|
||||
]
|
||||
|
||||
const result = groupMissingNodesByPack(missing)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ pack_id: 'impact-pack', node_types: ['ImpactSampler'] },
|
||||
{ pack_id: 'unknown', node_types: ['MysteryNode'] }
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
import type { MissingNodePack } from '../types'
|
||||
|
||||
const UNKNOWN_PACK_ID = 'unknown'
|
||||
|
||||
export function groupMissingNodesByPack(
|
||||
missingNodes: readonly MissingNodeType[]
|
||||
): MissingNodePack[] {
|
||||
const byPack = new Map<string, Set<string>>()
|
||||
|
||||
for (const node of missingNodes) {
|
||||
const type = typeof node === 'string' ? node : node.type
|
||||
const packId =
|
||||
typeof node === 'string' ? UNKNOWN_PACK_ID : node.cnrId || UNKNOWN_PACK_ID
|
||||
|
||||
const existing = byPack.get(packId)
|
||||
if (existing) {
|
||||
existing.add(type)
|
||||
} else {
|
||||
byPack.set(packId, new Set([type]))
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byPack, ([pack_id, types]) => ({
|
||||
pack_id,
|
||||
node_types: Array.from(types)
|
||||
}))
|
||||
}
|
||||
@@ -13,7 +13,7 @@ function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'test-asset-id',
|
||||
name: 'test-image.png',
|
||||
hash: 'hash123',
|
||||
asset_hash: 'hash123',
|
||||
size: 1024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -539,7 +539,7 @@ describe('useComboWidget', () => {
|
||||
createMockAssetItem({
|
||||
id: 'asset-123',
|
||||
name: 'image1.png',
|
||||
hash: HASH_FILENAME
|
||||
asset_hash: HASH_FILENAME
|
||||
})
|
||||
]
|
||||
mockAssetsStoreState.inputLoading = false
|
||||
|
||||
@@ -183,7 +183,7 @@ const createInputMappingWidget = (
|
||||
getMediaTypeFromFilename(asset.name) ===
|
||||
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
|
||||
)
|
||||
.map((asset) => asset.hash)
|
||||
.map((asset) => asset.asset_hash)
|
||||
.filter((hash): hash is string => !!hash)
|
||||
)
|
||||
|
||||
|
||||
@@ -684,14 +684,15 @@ describe('useWidgetSelectItems', () => {
|
||||
|
||||
it('does not expand a hash-keyed asset even if its metadata reports outputCount > 1', async () => {
|
||||
// Defense against future cloud-schema changes: if a flat output row
|
||||
// ever ships with both hash AND multi-output user_metadata, the
|
||||
// ever ships with both asset_hash AND multi-output user_metadata, the
|
||||
// watcher must NOT replace it with synthesized AssetItems lacking the
|
||||
// hash, or select+load reverts to the FE-227 broken state.
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'asset-flat-1',
|
||||
name: 'z-image-turbo_00093_.png',
|
||||
hash: '039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
asset_hash:
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId: 'job-future',
|
||||
@@ -728,12 +729,13 @@ describe('useWidgetSelectItems', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses hash (not human filename) as the dropdown value when present, so cloud /view can resolve by hash', async () => {
|
||||
it('uses asset_hash (not human filename) as the dropdown value when present, so cloud /view can resolve by hash', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'asset-out-1',
|
||||
name: 'z-image-turbo_00093_.png',
|
||||
hash: '039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
asset_hash:
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
preview_url: '/api/view?filename=039b...0b13.png',
|
||||
tags: ['output']
|
||||
}
|
||||
@@ -751,7 +753,7 @@ describe('useWidgetSelectItems', () => {
|
||||
expect(dropdownItems.value).toHaveLength(1)
|
||||
// The value (item.name) — what becomes modelValue on click — must be the
|
||||
// hash-keyed path so /api/view resolves it. Cloud's hash is in
|
||||
// asset.hash, not asset.name (which is the human filename).
|
||||
// asset_hash, not asset.name (which is the human filename).
|
||||
expect(dropdownItems.value[0].name).toBe(
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png [output]'
|
||||
)
|
||||
@@ -759,7 +761,7 @@ describe('useWidgetSelectItems', () => {
|
||||
expect(dropdownItems.value[0].label).toContain('z-image-turbo_00093_.png')
|
||||
})
|
||||
|
||||
it('falls back to asset.name when hash is absent (local/history path)', async () => {
|
||||
it('falls back to asset.name when asset_hash is absent (local/history path)', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'local-1',
|
||||
@@ -971,7 +973,8 @@ describe('useWidgetSelectItems', () => {
|
||||
{
|
||||
id: 'asset-hash-1',
|
||||
name: 'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5.png',
|
||||
hash: 'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5',
|
||||
asset_hash:
|
||||
'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5',
|
||||
preview_url: '/preview.png',
|
||||
tags: ['output'],
|
||||
metadata: {
|
||||
|
||||
@@ -131,8 +131,8 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
// Hash-keyed assets are leaf rows from the cloud `/assets` API and
|
||||
// already carry their own URL-resolvable filename. Expanding them via
|
||||
// resolveOutputAssetItems would synthesize sibling AssetItems without
|
||||
// a hash and reintroduce the FE-227 hash→name fallback bug.
|
||||
if (asset.hash) continue
|
||||
// an asset_hash and reintroduce the FE-227 hash→name fallback bug.
|
||||
if (asset.asset_hash) continue
|
||||
|
||||
const meta = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!meta) continue
|
||||
|
||||
@@ -25,8 +25,6 @@ import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { installNodeAddedTelemetry } from '@/platform/telemetry/nodeAdded/installNodeAddedTelemetry'
|
||||
import { groupMissingNodesByPack } from '@/platform/telemetry/utils/groupMissingNodesByPack'
|
||||
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { updatePendingWarnings } from '@/platform/workflow/core/utils/pendingWarnings'
|
||||
@@ -890,7 +888,6 @@ export class ComfyApp {
|
||||
this.addAfterConfigureHandler(graph)
|
||||
|
||||
this.rootGraphInternal = graph
|
||||
installNodeAddedTelemetry(graph)
|
||||
this.canvas = new LGraphCanvas(canvasEl, graph)
|
||||
// Make canvas states reactive so we can observe changes on them.
|
||||
this.canvas.state = reactive(this.canvas.state)
|
||||
@@ -1428,7 +1425,6 @@ export class ComfyApp {
|
||||
missing_node_types: missingNodeTypes.map((node) =>
|
||||
typeof node === 'string' ? node : node.type
|
||||
),
|
||||
missing_node_packs: groupMissingNodesByPack(missingNodeTypes),
|
||||
open_source: openSource ?? 'unknown'
|
||||
}
|
||||
useTelemetry()?.trackWorkflowOpened(telemetryPayload)
|
||||
|
||||
@@ -1463,7 +1463,7 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
|
||||
{
|
||||
id: 'input-1',
|
||||
name: 'cute-puppy.png',
|
||||
hash: 'abc123def.png',
|
||||
asset_hash: 'abc123def.png',
|
||||
tags: ['input']
|
||||
}
|
||||
])
|
||||
@@ -1509,10 +1509,14 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
|
||||
describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
const FLAT_OUTPUT_PAGE_SIZE = 200
|
||||
|
||||
const makeAsset = (id: string, name: string, hash?: string): AssetItem => ({
|
||||
const makeAsset = (
|
||||
id: string,
|
||||
name: string,
|
||||
asset_hash?: string
|
||||
): AssetItem => ({
|
||||
id,
|
||||
name,
|
||||
hash,
|
||||
asset_hash,
|
||||
size: 0,
|
||||
tags: ['output']
|
||||
})
|
||||
|
||||
@@ -347,14 +347,13 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
|
||||
/**
|
||||
* Map of asset hash filename to asset item for O(1) lookup
|
||||
* Cloud assets use hash for the hash-based filename
|
||||
* Cloud assets use asset_hash for the hash-based filename
|
||||
*/
|
||||
const inputAssetsByFilename = computed(() => {
|
||||
const map = new Map<string, AssetItem>()
|
||||
for (const asset of inputAssets.value) {
|
||||
const hash = asset.hash
|
||||
if (hash) {
|
||||
map.set(hash, asset)
|
||||
if (asset.asset_hash) {
|
||||
map.set(asset.asset_hash, asset)
|
||||
}
|
||||
}
|
||||
return map
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
|
||||
/**
|
||||
* Serialises an array of nodes using a modified version of the old Litegraph copy (& paste) function
|
||||
@@ -107,7 +106,7 @@ export function deserialiseAndCreate(data: string, canvas: LGraphCanvas): void {
|
||||
node.pos[1] += graph_mouse[1] - topLeft[1]
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
withNodeAddSource('paste', () => graph.add(node, true))
|
||||
graph.add(node, true)
|
||||
nodes.push(node)
|
||||
}
|
||||
|
||||
|
||||