Compare commits

..

1 Commits

Author SHA1 Message Date
Glary-Bot
d2ce6eff55 feat(website): snapshot SHOW_FREE_TIER from /features at build time
Follow-on to #12165 which introduced apps/website/src/config/features.ts with a hard-coded SHOW_FREE_TIER=false. Wire that constant to a build-time snapshot of the public /features endpoint so toggling new_free_tier_subscriptions in cloud/PostHog propagates to comfy.org on the next build without a code change.

features.ts now imports the snapshot synchronously and re-exports SHOW_FREE_TIER as snapshot.flags.cloudFreeTier, so existing consumers (PriceSection.vue, cloud PricingSection.vue, pricing.spec.ts) need no changes.

New files mirror the Ashby ISR-like pattern already in apps/website: src/utils/featureFlags.ts (fetcher with inflight cache, retry, timeout, zod validation, snapshot fallback), src/utils/featureFlags.schema.ts (passthrough so unknown server fields are ignored), src/utils/featureFlags.ci.ts (GitHub Actions annotations and step summary), src/data/feature-flags.ts (snapshot type), src/data/feature-flags.snapshot.json (bundled fallback), scripts/refresh-feature-flags-snapshot.ts. Tests cover fetcher and CI reporter.

Snapshot defaults to cloudFreeTier=false. The backend does not yet return new_free_tier_subscriptions, so the live fetch resolves to the same default; once the flag is added there, the next refresh-snapshot run flips SHOW_FREE_TIER.

New script: pnpm --filter @comfyorg/website feature-flags:refresh-snapshot.
2026-05-13 20:31:04 +00:00
11 changed files with 661 additions and 580 deletions

View File

@@ -17,6 +17,7 @@
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
"feature-flags:refresh-snapshot": "tsx ./scripts/refresh-feature-flags-snapshot.ts",
"generate:models": "tsx ./scripts/generate-models.ts"
},
"dependencies": {

View File

@@ -0,0 +1,29 @@
import { renameSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { fetchFeatureFlagsForBuild } from '../src/utils/featureFlags'
const snapshotPath = fileURLToPath(
new URL('../src/data/feature-flags.snapshot.json', import.meta.url)
)
const tempPath = `${snapshotPath}.tmp`
const outcome = await fetchFeatureFlagsForBuild()
if (outcome.status !== 'fresh') {
const reason = 'reason' in outcome ? outcome.reason : '(none)'
console.error(
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
)
process.exit(1)
}
writeFileSync(
tempPath,
JSON.stringify(outcome.snapshot, null, 2) + '\n',
'utf8'
)
renameSync(tempPath, snapshotPath)
process.stdout.write(
`Wrote feature flags snapshot to ${snapshotPath}: cloudFreeTier=${outcome.snapshot.flags.cloudFreeTier}\n`
)

View File

@@ -1 +1,3 @@
export const SHOW_FREE_TIER = false
import snapshot from '../data/feature-flags.snapshot.json' with { type: 'json' }
export const SHOW_FREE_TIER = snapshot.flags.cloudFreeTier

View File

@@ -0,0 +1,6 @@
{
"fetchedAt": "2026-05-13T20:30:41.221Z",
"flags": {
"cloudFreeTier": false
}
}

View File

@@ -0,0 +1,6 @@
export interface FeatureFlagsSnapshot {
fetchedAt: string
flags: {
cloudFreeTier: boolean
}
}

View File

@@ -0,0 +1,112 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FetchOutcome } from './featureFlags'
import type { FeatureFlagsSnapshot } from '../data/feature-flags'
import {
reportFeatureFlagsOutcome,
resetFeatureFlagsReporterForTests
} from './featureFlags.ci'
function baseSnapshot(cloudFreeTier = false): FeatureFlagsSnapshot {
return {
fetchedAt: new Date().toISOString(),
flags: { cloudFreeTier }
}
}
function freshOutcome(cloudFreeTier = false): FetchOutcome {
return { status: 'fresh', snapshot: baseSnapshot(cloudFreeTier) }
}
describe('reportFeatureFlagsOutcome', () => {
let writeSpy: ReturnType<typeof vi.spyOn>
let summaryDir: string
let summaryPath: string
const originalSummary = process.env.GITHUB_STEP_SUMMARY
beforeEach(() => {
resetFeatureFlagsReporterForTests()
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
summaryDir = mkdtempSync(join(tmpdir(), 'feature-flags-summary-'))
summaryPath = join(summaryDir, 'summary.md')
writeFileSync(summaryPath, '')
process.env.GITHUB_STEP_SUMMARY = summaryPath
})
afterEach(() => {
writeSpy.mockRestore()
rmSync(summaryDir, { recursive: true, force: true })
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
else process.env.GITHUB_STEP_SUMMARY = originalSummary
})
it('emits nothing on a fresh outcome', () => {
reportFeatureFlagsOutcome(freshOutcome())
expect(writeSpy).not.toHaveBeenCalled()
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
})
it('records cloudFreeTier value in the step summary', () => {
reportFeatureFlagsOutcome(freshOutcome(true))
expect(readFileSync(summaryPath, 'utf8')).toContain(
'| **cloudFreeTier** | true |'
)
})
it('emits exactly one annotation across repeated calls', () => {
reportFeatureFlagsOutcome({
status: 'stale',
reason: 'HTTP 500 Server Error',
snapshot: baseSnapshot()
})
reportFeatureFlagsOutcome({
status: 'stale',
reason: 'HTTP 500 Server Error',
snapshot: baseSnapshot()
})
expect(writeSpy).toHaveBeenCalledTimes(1)
})
it('emits ::error for schema-mismatch stale outcomes', () => {
reportFeatureFlagsOutcome({
status: 'stale',
reason:
'schema validation failed: new_free_tier_subscriptions: Expected boolean',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=Feature flags schema mismatch')
})
it('emits ::warning for transient API unavailability', () => {
reportFeatureFlagsOutcome({
status: 'stale',
reason: 'HTTP 503 Service Unavailable',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain(
'::warning title=Feature flags API unavailable'
)
})
it('emits ::error for a failed outcome', () => {
reportFeatureFlagsOutcome({
status: 'failed',
reason: 'HTTP 500 Server Error'
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=Feature flags fetch failed')
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
})
it('does not throw when GITHUB_STEP_SUMMARY is not set', () => {
delete process.env.GITHUB_STEP_SUMMARY
expect(() => reportFeatureFlagsOutcome(freshOutcome())).not.toThrow()
})
})

View File

@@ -0,0 +1,92 @@
import { appendFileSync } from 'node:fs'
import type { FetchOutcome } from './featureFlags'
let hasReported = false
export function resetFeatureFlagsReporterForTests(): void {
hasReported = false
}
export function reportFeatureFlagsOutcome(outcome: FetchOutcome): void {
if (hasReported) return
hasReported = true
const lines = buildAnnotations(outcome)
for (const line of lines) {
process.stdout.write(`${line}\n`)
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (summaryPath) {
try {
appendFileSync(summaryPath, buildStepSummary(outcome))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(
`feature-flags reporter: failed to write GITHUB_STEP_SUMMARY: ${message}\n`
)
}
}
}
function buildAnnotations(outcome: FetchOutcome): string[] {
if (outcome.status === 'fresh') return []
if (outcome.status === 'stale') {
return [staleAnnotation(outcome.reason)]
}
return [
`::error title=Feature flags fetch failed and no snapshot is available::Cannot build site without feature flags.%0A%0AReason: ${escapeAnnotation(outcome.reason)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website feature-flags:refresh-snapshot\` locally.%0A 2. Commit apps/website/src/data/feature-flags.snapshot.json.%0A 3. Push and re-run CI.`
]
}
function staleAnnotation(reason: string): string {
const escaped = escapeAnnotation(reason)
if (reason.startsWith('schema')) {
return `::error title=Feature flags schema mismatch::${escaped}. The /features API contract has likely changed. Build continues with the snapshot, but future updates will fail until the schema is fixed.%0A%0AAction items:%0A 1. Inspect the response at https://api.comfy.org/features.%0A 2. Update apps/website/src/utils/featureFlags.schema.ts to match the new shape.`
}
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
return `::error title=Feature flags authentication failed::${escaped}. The /features endpoint should be public; check the backend. Build continues with the last-known-good snapshot.`
}
return `::warning title=Feature flags API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check the status of https://api.comfy.org/features.%0A 2. Re-run this workflow once the API is healthy.`
}
function escapeAnnotation(value: string): string {
return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
}
function buildStepSummary(outcome: FetchOutcome): string {
const header = '## 🚩 Feature Flags (/features)\n'
const rows: Array<[string, string]> = []
if (outcome.status === 'fresh') {
rows.push(['Status', '✅ Fresh (fetched from /features)'])
rows.push(['cloudFreeTier', String(outcome.snapshot.flags.cloudFreeTier)])
} else if (outcome.status === 'stale') {
rows.push(['Status', '⚠️ Stale (using snapshot — /features fetch failed)'])
rows.push(['cloudFreeTier', String(outcome.snapshot.flags.cloudFreeTier)])
rows.push(['Reason', outcome.reason])
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
} else {
rows.push(['Status', '❌ Failed (no snapshot available)'])
rows.push(['Reason', outcome.reason])
}
const table =
'| | |\n|---|---|\n' +
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
'\n'
return `${header}${table}\n`
}
function describeSnapshotAge(fetchedAt: string): string {
const fetched = new Date(fetchedAt).getTime()
if (Number.isNaN(fetched)) return 'unknown'
const days = Math.floor((Date.now() - fetched) / 86_400_000)
if (days <= 0) return 'today'
if (days === 1) return '1 day'
return `${days} days`
}

View File

@@ -0,0 +1,11 @@
import { z } from 'zod'
export const FeaturesResponseSchema = z
.object({
new_free_tier_subscriptions: z.boolean().optional(),
free_tier_credits: z.number().optional(),
partner_node_conversion_rate: z.number().optional()
})
.passthrough()
export type FeaturesResponse = z.infer<typeof FeaturesResponseSchema>

View File

@@ -0,0 +1,190 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FeatureFlagsSnapshot } from '../data/feature-flags'
import {
fetchFeatureFlagsForBuild,
resetFeatureFlagsFetcherForTests
} from './featureFlags'
const BASE_URL = 'https://api.test'
const tempSnapshotDirs: string[] = []
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
const base: ResponseInit = {
status: 200,
headers: { 'content-type': 'application/json' }
}
return new Response(JSON.stringify(body), { ...base, ...init })
}
function makeSnapshot(cloudFreeTier: boolean): FeatureFlagsSnapshot {
return {
fetchedAt: '2026-04-01T00:00:00.000Z',
flags: { cloudFreeTier }
}
}
function withSnapshotDir(snapshot: FeatureFlagsSnapshot | null): URL {
const dir = mkdtempSync(join(tmpdir(), 'feature-flags-test-'))
tempSnapshotDirs.push(dir)
const file = join(dir, 'feature-flags.snapshot.json')
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
return pathToFileURL(file)
}
describe('fetchFeatureFlagsForBuild', () => {
beforeEach(() => {
resetFeatureFlagsFetcherForTests()
})
afterEach(() => {
for (const dir of tempSnapshotDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
vi.restoreAllMocks()
})
it('returns fresh with cloudFreeTier=true when /features sets the flag', async () => {
const fetchImpl = vi.fn(async () =>
response({ new_free_tier_subscriptions: true })
)
const outcome = await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.flags.cloudFreeTier).toBe(true)
expect(fetchImpl).toHaveBeenCalledWith(
`${BASE_URL}/features`,
expect.objectContaining({ method: 'GET' })
)
})
it('defaults cloudFreeTier to false when the flag is absent from /features', async () => {
const fetchImpl = vi.fn(async () =>
response({ partner_node_conversion_rate: 0.05 })
)
const outcome = await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.flags.cloudFreeTier).toBe(false)
})
it('returns fresh with cloudFreeTier=false when explicitly disabled', async () => {
const fetchImpl = vi.fn(async () =>
response({ new_free_tier_subscriptions: false })
)
const outcome = await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.flags.cloudFreeTier).toBe(false)
})
it('returns stale with snapshot when the API returns 401', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot(true))
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
const outcome = await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^HTTP 401/)
expect(outcome.snapshot.flags.cloudFreeTier).toBe(true)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('retries 5xx up to the configured limit then falls back to snapshot', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot(false))
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
const sleep = vi.fn(async () => undefined)
const outcome = await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
snapshotUrl,
retryDelaysMs: [1, 1, 1],
sleep,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
expect(fetchImpl).toHaveBeenCalledTimes(4)
expect(sleep).toHaveBeenCalledTimes(3)
})
it('falls back to snapshot on schema validation failure', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot(false))
const fetchImpl = vi.fn(async () =>
response({ new_free_tier_subscriptions: 'yes' })
)
const outcome = await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^schema validation/)
})
it('falls back to the bundled snapshot when fetch fails and the override is missing', async () => {
const snapshotUrl = withSnapshotDir(null)
const fetchImpl = vi.fn(async () => response({}, { status: 500 }))
const sleep = vi.fn(async () => undefined)
const outcome = await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
snapshotUrl,
retryDelaysMs: [1, 1, 1],
sleep,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.snapshot.flags.cloudFreeTier).toBe(false)
})
it('memoizes within a single process', async () => {
const fetchImpl = vi.fn(async () =>
response({ new_free_tier_subscriptions: true })
)
const opts = {
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
}
const [a, b] = await Promise.all([
fetchFeatureFlagsForBuild(opts),
fetchFeatureFlagsForBuild(opts)
])
expect(a).toBe(b)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('never writes to the snapshot file on success', async () => {
const snapshot = makeSnapshot(true)
const snapshotUrl = withSnapshotDir(snapshot)
const fs = await import('node:fs')
const initial = fs.readFileSync(snapshotUrl).toString()
const fetchImpl = vi.fn(async () =>
response({ new_free_tier_subscriptions: false })
)
await fetchFeatureFlagsForBuild({
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
const after = fs.readFileSync(snapshotUrl).toString()
expect(after).toBe(initial)
})
})

View File

@@ -0,0 +1,189 @@
import { readFile } from 'node:fs/promises'
import type { FeaturesResponse } from './featureFlags.schema'
import type { FeatureFlagsSnapshot } from '../data/feature-flags'
import { FeaturesResponseSchema } from './featureFlags.schema'
import bundledSnapshot from '../data/feature-flags.snapshot.json' with { type: 'json' }
const DEFAULT_BASE_URL = 'https://api.comfy.org'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export type FetchOutcome =
| { status: 'fresh'; snapshot: FeatureFlagsSnapshot }
| { status: 'stale'; snapshot: FeatureFlagsSnapshot; reason: string }
| { status: 'failed'; reason: string }
interface FetchFeatureFlagsOptions {
baseUrl?: string
timeoutMs?: number
retryDelaysMs?: readonly number[]
fetchImpl?: typeof fetch
snapshotUrl?: URL
sleep?: (ms: number) => Promise<void>
}
let inflight: Promise<FetchOutcome> | undefined
export function resetFeatureFlagsFetcherForTests(): void {
inflight = undefined
}
export function fetchFeatureFlagsForBuild(
options: FetchFeatureFlagsOptions = {}
): Promise<FetchOutcome> {
inflight ??= doFetchFeatureFlagsForBuild(options)
return inflight
}
async function doFetchFeatureFlagsForBuild(
options: FetchFeatureFlagsOptions
): Promise<FetchOutcome> {
const result = await tryFetchAndParse(options)
if (result.kind === 'ok') {
return {
status: 'fresh',
snapshot: {
fetchedAt: new Date().toISOString(),
flags: deriveFlags(result.features)
}
}
}
return fallback(result.reason, options.snapshotUrl)
}
async function fallback(
reason: string,
snapshotUrl: URL | undefined
): Promise<FetchOutcome> {
const snapshot = await readSnapshot(snapshotUrl)
if (snapshot) return { status: 'stale', snapshot, reason }
return { status: 'failed', reason }
}
interface FetchOk {
kind: 'ok'
features: FeaturesResponse
}
interface FetchErr {
kind: 'err'
reason: string
}
async function tryFetchAndParse(
options: FetchFeatureFlagsOptions
): Promise<FetchOk | FetchErr> {
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS
const fetchImpl = options.fetchImpl ?? fetch
const sleep = options.sleep ?? defaultSleep
const url = `${baseUrl.replace(/\/+$/, '')}/features`
let lastReason = 'unknown error'
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
if (attempt > 0) await sleep(retryDelaysMs[attempt - 1])
const response = await callOnce(fetchImpl, url, timeoutMs)
if (response.kind === 'err') {
lastReason = response.reason
if (!response.retryable) return response
continue
}
const parsed = FeaturesResponseSchema.safeParse(response.body)
if (!parsed.success) {
return {
kind: 'err',
reason: `schema validation failed: ${parsed.error.issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')}`
}
}
return { kind: 'ok', features: parsed.data }
}
return { kind: 'err', reason: lastReason }
}
type CallResponse =
| { kind: 'ok'; body: unknown }
| { kind: 'err'; reason: string; retryable: boolean }
async function callOnce(
fetchImpl: typeof fetch,
url: string,
timeoutMs: number
): Promise<CallResponse> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetchImpl(url, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal
})
if (res.ok) {
return { kind: 'ok', body: await res.json() }
}
const retryable =
res.status === 429 || (res.status >= 500 && res.status < 600)
return {
kind: 'err',
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
retryable
}
} catch (error) {
const reason =
error instanceof Error
? `network error: ${error.message}`
: 'network error'
return { kind: 'err', reason, retryable: true }
} finally {
clearTimeout(timer)
}
}
function deriveFlags(
features: FeaturesResponse
): FeatureFlagsSnapshot['flags'] {
return {
cloudFreeTier: features.new_free_tier_subscriptions ?? false
}
}
async function readSnapshot(
snapshotUrl: URL | undefined
): Promise<FeatureFlagsSnapshot | null> {
if (snapshotUrl) {
try {
const text = await readFile(snapshotUrl, 'utf8')
const parsed: unknown = JSON.parse(text)
if (isFeatureFlagsSnapshot(parsed)) return parsed
} catch {
// Fall through to the bundled snapshot if the override is unreadable.
}
}
return isFeatureFlagsSnapshot(bundledSnapshot) ? bundledSnapshot : null
}
function isFeatureFlagsSnapshot(value: unknown): value is FeatureFlagsSnapshot {
if (value === null || typeof value !== 'object') return false
const candidate = value as { fetchedAt?: unknown; flags?: unknown }
if (typeof candidate.fetchedAt !== 'string') return false
if (candidate.flags === null || typeof candidate.flags !== 'object') {
return false
}
const flags = candidate.flags as { cloudFreeTier?: unknown }
return typeof flags.cloudFreeTier === 'boolean'
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -1,600 +1,43 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ContextMenuDivElement } from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { getExtraOptionsForWidget } from '@/services/litegraphService'
async function invokeMenuCallback(option: IContextMenuValue): Promise<void> {
// Production callbacks under test do not reference `this`; ContextMenuDivElement
// is a DOM element decorated with extra fields, not realistic to construct in tests.
await option.callback?.call({} as ContextMenuDivElement)
}
const mockPrompt = vi.fn()
const mockCanvas = vi.hoisted(() => ({
setDirty: vi.fn(),
graph_mouse: [100, 200],
ds: {
scale: 1,
offset: [0, 0] as [number, number],
visible_area: [0, 0, 800, 600] as
| [number, number, number, number]
| undefined,
fitToBounds: vi.fn()
},
graph: {
nodes: [] as unknown[],
getNodeById: vi.fn(),
add: vi.fn(),
setDirtyCanvas: vi.fn(),
isRootGraph: true
},
animateToBounds: vi.fn(),
_deserializeItems: vi.fn()
}))
const mockApp = vi.hoisted(() => ({
canvas: undefined as unknown,
graph: undefined as unknown,
dragOverNode: null,
lastExecutionError: null,
rootGraph: {}
}))
const mockFavoritedWidgetsStore = vi.hoisted(() => ({
isFavorited: vi.fn().mockReturnValue(false),
toggleFavorite: vi.fn()
}))
vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
useFavoritedWidgetsStore: () => mockFavoritedWidgetsStore
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
prompt: mockPrompt
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: mockCanvas,
getCanvas: () => mockCanvas
})
}))
vi.mock('@/core/graph/subgraph/promotionUtils', () => ({
addWidgetPromotionOptions: vi.fn(),
isPreviewPseudoWidget: vi.fn()
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key,
st: (_key: string, fallback: string) => fallback
}))
vi.mock('@/utils/formatUtil', () => ({
normalizeI18nKey: (key: string) => key
}))
vi.mock('@/scripts/app', () => ({
app: mockApp,
ComfyApp: {
clipspace: null,
clipspace_return_node: null,
copyToClipspace: vi.fn(),
pasteFromClipspace: vi.fn()
}
app: { canvas: undefined },
ComfyApp: class {}
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert: vi.fn() })
}))
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => ({ widgets: new Map() })
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
nodeLocationProgressStates: {}
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeSubgraph: null,
nodeIdToNodeLocatorId: (id: string) => id
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn().mockReturnValue(false)
})
}))
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
useSelectedLiteGraphItems: () => ({
toggleSelectedNodesMode: vi.fn()
})
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({
invokeExtensionsAsync: vi.fn()
})
}))
vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: () => ({
typePrefix: 'Subgraph::',
getBlueprint: vi.fn()
})
}))
const mockNodeOutputStore = vi.hoisted(() => ({
getNodeOutputs: vi.fn(),
getNodePreviews: vi.fn()
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => mockNodeOutputStore
}))
vi.mock('@/composables/node/useNodeAnimatedImage', () => ({
useNodeAnimatedImage: () => ({
showAnimatedPreview: vi.fn(),
removeAnimatedPreview: vi.fn()
})
}))
vi.mock('@/composables/node/useNodeCanvasImagePreview', () => ({
useNodeCanvasImagePreview: () => ({
showCanvasImagePreview: vi.fn(),
removeCanvasImagePreview: vi.fn()
})
}))
vi.mock('@/composables/node/useNodeImage', () => ({
useNodeImage: () => ({ showPreview: vi.fn() }),
useNodeVideo: () => ({ showPreview: vi.fn() })
}))
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
useSubgraphOperations: () => ({ unpackSubgraph: vi.fn() })
}))
vi.mock('@/composables/maskeditor/useMaskEditor', () => ({
useMaskEditor: () => ({ openMaskEditor: vi.fn() })
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({
widgetStates: new Map(),
registerWidget: vi.fn(),
unregisterWidget: vi.fn()
})
}))
vi.mock('@/stores/promotionStore', () => ({
usePromotionStore: () => ({
getPromotionsRef: vi.fn().mockReturnValue([])
})
}))
vi.mock('@/services/subgraphPseudoWidgetCache', () => ({
resolveSubgraphPseudoWidgetCache: vi.fn().mockReturnValue({
cache: { promotions: [], entries: [], nodes: [] },
nodes: []
})
}))
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({ openPanel: vi.fn() })
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn(),
openFileInNewTab: vi.fn()
}))
vi.mock('@/scripts/domWidget', () => ({
isComponentWidget: vi.fn().mockReturnValue(false),
isDOMWidget: vi.fn().mockReturnValue(false)
}))
const mockCreateBounds = vi.hoisted(() => vi.fn())
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
createBounds: mockCreateBounds
}
})
vi.mock('@/scripts/ui', () => ({
$el: vi.fn()
}))
vi.mock('@/utils/litegraphUtil', () => ({
isAnimatedOutput: vi.fn().mockReturnValue(false),
isImageNode: vi.fn().mockReturnValue(false),
isVideoNode: vi.fn().mockReturnValue(false),
isVideoOutput: vi.fn().mockReturnValue(false),
migrateWidgetsValues: vi.fn().mockReturnValue([])
}))
vi.mock('@/core/graph/widgets/dynamicWidgets', () => ({
applyDynamicInputs: vi.fn().mockReturnValue(false)
}))
vi.mock('@/schemas/nodeDef/migration', () => ({
transformInputSpecV2ToV1: vi.fn().mockReturnValue([])
}))
vi.mock('@/workbench/utils/nodeDefOrderingUtil', () => ({
getOrderedInputSpecs: vi.fn().mockReturnValue([])
}))
vi.mock('@/stores/nodeDefStore', () => ({
ComfyNodeDefImpl: vi.fn().mockImplementation((def: unknown) => def)
}))
function createMockNode(overrides: Record<string, unknown> = {}): LGraphNode {
const node = new LGraphNode('TestNode')
Object.assign(node, {
id: 1,
inputs: [],
graph: null,
getWidgetOnPos: vi.fn()
})
Object.assign(node, overrides)
// Set static nodeData for tests that check constructor.nodeData
;(node.constructor as { nodeData?: { name: string } }).nodeData = {
name: 'TestNode'
}
return node
}
function createMockWidget(
overrides: Record<string, unknown> = {}
): IBaseWidget {
return {
name: 'test_widget',
label: undefined,
value: 42,
callback: vi.fn(),
options: {},
...overrides
} as unknown as IBaseWidget
}
describe('litegraphService', () => {
describe('useLitegraphService().getCanvasCenter', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(false)
mockPrompt.mockReset()
mockCreateBounds.mockReset()
mockCanvas.graph.getNodeById.mockReset()
mockCanvas.ds.scale = 1
mockCanvas.ds.offset = [0, 0]
mockCanvas.ds.visible_area = [0, 0, 800, 600]
mockCanvas.graph.nodes = []
mockApp.canvas = mockCanvas
mockApp.graph = mockCanvas.graph
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('getExtraOptionsForWidget', () => {
it('adds favorite option when widget is not favorited', () => {
const node = createMockNode()
const widget = createMockWidget()
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(false)
it('returns origin when canvas is not yet initialised', () => {
Reflect.set(app, 'canvas', undefined)
const options = getExtraOptionsForWidget(node, widget)
const center = useLitegraphService().getCanvasCenter()
expect(options).toHaveLength(1)
expect(options[0].content).toContain('contextMenu.FavoriteWidget')
expect(options[0].content).toContain('test_widget')
})
it('adds unfavorite option when widget is already favorited', () => {
const node = createMockNode()
const widget = createMockWidget()
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(true)
const options = getExtraOptionsForWidget(node, widget)
expect(options[0].content).toContain('contextMenu.UnfavoriteWidget')
})
it('uses widget label when available', () => {
const node = createMockNode()
const widget = createMockWidget({ label: 'My Label' })
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(false)
const options = getExtraOptionsForWidget(node, widget)
expect(options[0].content).toContain('My Label')
})
it('calls toggleFavorite when favorite option callback is invoked', () => {
const node = createMockNode()
const widget = createMockWidget()
const options = getExtraOptionsForWidget(node, widget)
void invokeMenuCallback(options[0])
expect(mockFavoritedWidgetsStore.toggleFavorite).toHaveBeenCalledWith(
node,
'test_widget'
)
})
it('adds rename option when input matches widget', () => {
const widget = createMockWidget({ name: 'seed' })
const node = createMockNode({
inputs: [{ widget: { name: 'seed' } }]
})
const options = getExtraOptionsForWidget(node, widget)
// rename is unshifted first, then favorite is unshifted (ends up first)
expect(options).toHaveLength(2)
const renameOption = options.find((o: IContextMenuValue) =>
o.content?.includes('contextMenu.RenameWidget')
)
expect(renameOption).toBeDefined()
expect(renameOption!.content).toContain('seed')
})
it('rename callback updates widget and input labels', async () => {
const widget = createMockWidget({ name: 'seed' })
const input = { widget: { name: 'seed' }, label: undefined as unknown }
const node = createMockNode({ inputs: [input] })
mockPrompt.mockResolvedValue('New Name')
const options = getExtraOptionsForWidget(node, widget)
const renameOption = options.find((o: IContextMenuValue) =>
o.content?.includes('contextMenu.RenameWidget')
)
await invokeMenuCallback(renameOption!)
expect(widget.label).toBe('New Name')
expect(input.label).toBe('New Name')
expect(widget.callback).toHaveBeenCalledWith(42)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true)
})
it('rename callback clears label when empty string is returned', async () => {
const widget = createMockWidget({ name: 'seed', label: 'Old' })
const input = {
widget: { name: 'seed' },
label: 'Old' as string | undefined
}
const node = createMockNode({ inputs: [input] })
mockPrompt.mockResolvedValue('')
const options = getExtraOptionsForWidget(node, widget)
const renameOption = options.find((o: IContextMenuValue) =>
o.content?.includes('contextMenu.RenameWidget')
)
await invokeMenuCallback(renameOption!)
expect(widget.label).toBeUndefined()
expect(input.label).toBeUndefined()
})
it('rename callback does nothing when prompt is cancelled', async () => {
const widget = createMockWidget({ name: 'seed', label: 'Original' })
const input = { widget: { name: 'seed' }, label: 'Original' }
const node = createMockNode({ inputs: [input] })
mockPrompt.mockResolvedValue(null)
const options = getExtraOptionsForWidget(node, widget)
const renameOption = options.find((o: IContextMenuValue) =>
o.content?.includes('contextMenu.RenameWidget')
)
await invokeMenuCallback(renameOption!)
expect(widget.label).toBe('Original')
expect(input.label).toBe('Original')
})
it('adds promotion options when node is in a subgraph', async () => {
const { addWidgetPromotionOptions } = vi.mocked(
await import('@/core/graph/subgraph/promotionUtils')
)
const node = createMockNode({
graph: { isRootGraph: false }
})
const widget = createMockWidget()
getExtraOptionsForWidget(node, widget)
expect(addWidgetPromotionOptions).toHaveBeenCalled()
})
it('does not add promotion options on root graph', async () => {
const { addWidgetPromotionOptions } = vi.mocked(
await import('@/core/graph/subgraph/promotionUtils')
)
const node = createMockNode({ graph: null })
const widget = createMockWidget()
getExtraOptionsForWidget(node, widget)
expect(addWidgetPromotionOptions).not.toHaveBeenCalled()
})
expect(center).toEqual([0, 0])
})
describe('useLitegraphService', () => {
// Lazily import to ensure mocks are in place
async function getService() {
const { useLitegraphService } =
await import('@/services/litegraphService')
return useLitegraphService()
}
it('returns origin when canvas exists but ds.visible_area is missing', () => {
Reflect.set(app, 'canvas', { ds: {} })
describe('getCanvasCenter', () => {
it('returns center of visible area', async () => {
const service = await getService()
// visible_area = [0, 0, 800, 600], dpi = 1
const center = service.getCanvasCenter()
expect(center).toEqual([400, 300])
})
const center = useLitegraphService().getCanvasCenter()
it('accounts for visible area offset', async () => {
const saved = mockCanvas.ds.visible_area
mockCanvas.ds.visible_area = [10, 20, 200, 100]
expect(center).toEqual([0, 0])
})
const service = await getService()
const center = service.getCanvasCenter()
expect(center).toEqual([110, 70])
mockCanvas.ds.visible_area = saved
})
it('returns [0, 0] when no visible area', async () => {
const savedVisibleArea = mockCanvas.ds.visible_area
mockCanvas.ds.visible_area = undefined
const service = await getService()
const center = service.getCanvasCenter()
expect(center).toEqual([0, 0])
mockCanvas.ds.visible_area = savedVisibleArea
})
it('returns [0, 0] without throwing when app.canvas is undefined', async () => {
mockApp.canvas = undefined
const service = await getService()
expect(() => service.getCanvasCenter()).not.toThrow()
expect(service.getCanvasCenter()).toEqual([0, 0])
})
it('returns the visible-area centre once the canvas is ready', () => {
Reflect.set(app, 'canvas', {
ds: { visible_area: [10, 20, 200, 100] }
})
describe('resetView', () => {
it('resets canvas scale and offset', async () => {
mockCanvas.ds.scale = 2.5
mockCanvas.ds.offset = [100, 200]
const service = await getService()
const center = useLitegraphService().getCanvasCenter()
service.resetView()
expect(mockCanvas.ds.scale).toBe(1)
expect(mockCanvas.ds.offset).toEqual([0, 0])
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
})
describe('goToNode', () => {
it('animates to node bounds when node exists', async () => {
const bounds = [10, 20, 100, 50]
const graphNode = { boundingRect: bounds }
mockCanvas.graph.getNodeById.mockReturnValue(graphNode)
const service = await getService()
service.goToNode(42)
expect(mockCanvas.animateToBounds).toHaveBeenCalledWith(bounds)
})
it('does nothing when node does not exist', async () => {
mockCanvas.graph.getNodeById.mockReturnValue(null)
const service = await getService()
service.goToNode(999)
expect(mockCanvas.animateToBounds).not.toHaveBeenCalled()
})
})
describe('fitView', () => {
it('calls fitToBounds and setDirty', async () => {
const mockBounds = [0, 0, 500, 400]
mockCreateBounds.mockReturnValue(mockBounds)
const nodeObj = {
boundingRect: [0, 0, 100, 50],
updateArea: vi.fn()
}
mockCanvas.graph.nodes = [nodeObj]
const service = await getService()
service.fitView()
expect(mockCanvas.ds.fitToBounds).toHaveBeenCalledWith(mockBounds)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('calls updateArea for nodes with zero bounds', async () => {
mockCreateBounds.mockReturnValue([0, 0, 100, 100])
const nodeObj = {
boundingRect: [0, 0, 0, 0],
updateArea: vi.fn()
}
mockCanvas.graph.nodes = [nodeObj]
const service = await getService()
service.fitView()
expect(nodeObj.updateArea).toHaveBeenCalled()
})
it('does nothing when createBounds returns null', async () => {
mockCreateBounds.mockReturnValue(null)
mockCanvas.graph.nodes = []
const service = await getService()
service.fitView()
expect(mockCanvas.ds.fitToBounds).not.toHaveBeenCalled()
})
})
describe('updatePreviews', () => {
it('catches errors and logs them', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
mockNodeOutputStore.getNodeOutputs.mockImplementation(() => {
throw new Error('test error')
})
const service = await getService()
const badNode = createMockNode({ flags: { collapsed: false } })
expect(() => service.updatePreviews(badNode)).not.toThrow()
expect(consoleSpy).toHaveBeenCalledWith(
'Error drawing node background',
expect.any(Error)
)
consoleSpy.mockRestore()
})
it('skips collapsed nodes', async () => {
const service = await getService()
const node = createMockNode({
flags: { collapsed: true },
imgs: undefined,
images: undefined,
preview: undefined
})
service.updatePreviews(node)
expect(mockNodeOutputStore.getNodeOutputs).not.toHaveBeenCalled()
})
})
expect(center).toEqual([110, 70])
})
})