mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 22:25:05 +00:00
Compare commits
1 Commits
test/cov-l
...
glary/hide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2ce6eff55 |
@@ -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": {
|
||||
|
||||
29
apps/website/scripts/refresh-feature-flags-snapshot.ts
Normal file
29
apps/website/scripts/refresh-feature-flags-snapshot.ts
Normal 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`
|
||||
)
|
||||
@@ -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
|
||||
|
||||
6
apps/website/src/data/feature-flags.snapshot.json
Normal file
6
apps/website/src/data/feature-flags.snapshot.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-13T20:30:41.221Z",
|
||||
"flags": {
|
||||
"cloudFreeTier": false
|
||||
}
|
||||
}
|
||||
6
apps/website/src/data/feature-flags.ts
Normal file
6
apps/website/src/data/feature-flags.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface FeatureFlagsSnapshot {
|
||||
fetchedAt: string
|
||||
flags: {
|
||||
cloudFreeTier: boolean
|
||||
}
|
||||
}
|
||||
112
apps/website/src/utils/featureFlags.ci.test.ts
Normal file
112
apps/website/src/utils/featureFlags.ci.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
92
apps/website/src/utils/featureFlags.ci.ts
Normal file
92
apps/website/src/utils/featureFlags.ci.ts
Normal 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`
|
||||
}
|
||||
11
apps/website/src/utils/featureFlags.schema.ts
Normal file
11
apps/website/src/utils/featureFlags.schema.ts
Normal 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>
|
||||
190
apps/website/src/utils/featureFlags.test.ts
Normal file
190
apps/website/src/utils/featureFlags.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
189
apps/website/src/utils/featureFlags.ts
Normal file
189
apps/website/src/utils/featureFlags.ts
Normal 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))
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user