Compare commits

...

15 Commits

Author SHA1 Message Date
bymyself
f33f6011ac chore: Remove unrelated files from wire auth headers branch 2025-08-17 14:34:08 -07:00
bymyself
d930e055f3 feat: Wire authentication header system with auth stores
- Create AuthHeaderProvider that integrates with Firebase and API key stores
- Add core extension to register auth provider during preInit
- Implement automatic auth header injection for all HTTP requests
- Add comprehensive unit and integration tests
- Include examples showing migration from manual to automatic auth

This completes the header registration system by connecting it to the
actual authentication mechanisms in ComfyUI.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-17 14:30:48 -07:00
bymyself
18cc800bd3 [feat] Add header registry infrastructure
Create extensible header registration system for HTTP requests:
- Add HeaderRegistry service with VSCode-style registration patterns
- Create network client adapters for axios and fetch with automatic header injection
- Add comprehensive TypeScript types for header providers
- Include example implementations for extension developers

This provides the foundation for extensions to register custom headers that will be automatically added to all network requests.

Part of #5017
2025-08-17 14:30:48 -07:00
bymyself
85783a9932 chore: Remove unrelated files from pre-init lifecycle hook branch 2025-08-17 14:26:51 -07:00
bymyself
501a7e49e5 feat: Add preInit lifecycle hook for early extension initialization
- Add preInit hook to ComfyExtension interface that runs before canvas creation
- Implement preInit invocation in app.ts after extension loading
- Create example extension demonstrating header provider registration
- Add comprehensive tests for hook execution order and error handling

This enables extensions to register services and cross-cutting concerns
before any other initialization occurs.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-17 14:25:21 -07:00
bymyself
4ed516c3a8 [feat] Add header registry infrastructure
Create extensible header registration system for HTTP requests:
- Add HeaderRegistry service with VSCode-style registration patterns
- Create network client adapters for axios and fetch with automatic header injection
- Add comprehensive TypeScript types for header providers
- Include example implementations for extension developers

This provides the foundation for extensions to register custom headers that will be automatically added to all network requests.

Part of #5017
2025-08-17 14:25:21 -07:00
bymyself
efa440a381 chore: Remove unrelated files from integrate header registry branch 2025-08-17 14:16:53 -07:00
bymyself
d05153a0dc [feat] Integrate header registry with all HTTP clients
- Replace direct fetch() with fetchWithHeaders across entire codebase
- Replace axios.create() with createAxiosWithHeaders in all services
- Update tests to properly mock network client adapters
- Fix hoisting issues in test mocks for axios instances
- Ensure all network calls now support header injection

This completes Step 2: integrating the header registry infrastructure with all existing HTTP clients in the codebase.
2025-08-17 14:00:48 -07:00
bymyself
d6695ea66e [feat] Add header registry infrastructure
Create extensible header registration system for HTTP requests:
- Add HeaderRegistry service with VSCode-style registration patterns
- Create network client adapters for axios and fetch with automatic header injection
- Add comprehensive TypeScript types for header providers
- Include example implementations for extension developers

This provides the foundation for extensions to register custom headers that will be automatically added to all network requests.

Part of #5017
2025-08-17 14:00:48 -07:00
bymyself
0d6e5a75a9 feat: Add header registry infrastructure
- Create TypeScript interfaces for header providers
- Implement header registry with priority-based ordering
- Add network client adapters that integrate with the registry
- Add comprehensive unit tests

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-17 13:43:58 -07:00
bymyself
2e80a5789c [test] Fix axios mocking in firebaseAuthStore tests
Use vi.mock() with factory function to ensure axios mock is set up before module imports. This fixes test failures caused by the store creating axios client at module level.

Fixes test execution in CI
2025-08-15 15:37:06 -07:00
bymyself
81ddfc2f58 [fix] Update firebaseAuthStore tests to mock axios client
- Move axios mock setup before store import to ensure client is mocked
- Set up default successful responses for customer API endpoints
- Fix TypeScript issues with isAxiosError mock type
2025-08-15 14:40:29 -07:00
bymyself
e19f0b2da9 [fix] Add missing await for response.json() calls in template workflows 2025-08-15 14:14:03 -07:00
bymyself
afecff6c94 [feat] Complete network call consolidation to use consistent clients
Template Workflows:
- Replace direct fetch with api.fetchApi() for API endpoints
- Keep direct fetch for static file URLs (already using api.fileURL())

Model Exporter:
- Add logic to distinguish ComfyUI URLs from external URLs
- Use api.apiURL() for ComfyUI URLs, direct fetch for external URLs
- Maintain existing download functionality

Other files already following correct patterns:
- Upload Audio: Already using api.fetchApi()
- 3D Loading Utils: Already using api.fetchApi() (fetch call is for blob conversion)
- Download Utility: Uses direct fetch for external URLs (correct)

All network calls now use consistent client patterns where appropriate.
2025-08-15 13:34:37 -07:00
bymyself
5afeee258f [feat] Consolidate firebaseAuthStore network calls to use axios client
- Replace all direct fetch() calls with axios client instance
- Maintain exact same error handling behavior and logic flow
- Use consistent pattern with other services (customerEventsService)
- All customer API endpoints now use centralized client
- Prepares for header registration system implementation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-15 12:28:52 -07:00
35 changed files with 2508 additions and 150 deletions

View File

@@ -2,6 +2,7 @@ import { whenever } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { useCivitaiModel } from '@/composables/useCivitaiModel'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
export function useDownload(url: string, fileName?: string) {
@@ -14,7 +15,7 @@ export function useDownload(url: string, fileName?: string) {
const fetchFileSize = async () => {
try {
const response = await fetch(url, { method: 'HEAD' })
const response = await fetchWithHeaders(url, { method: 'HEAD' })
if (!response.ok) throw new Error('Failed to fetch file size')
const size = response.headers.get('content-length')

View File

@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { useDialogStore } from '@/stores/dialogStore'
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
import type {
@@ -160,12 +161,17 @@ export function useTemplateWorkflows() {
*/
const fetchTemplateJson = async (id: string, sourceModule: string) => {
if (sourceModule === 'default') {
// Default templates provided by frontend are served on this separate endpoint
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
// Default templates provided by frontend are served as static files
const response = await fetchWithHeaders(
api.fileURL(`/templates/${id}.json`)
)
return await response.json()
} else {
return fetch(
api.apiURL(`/workflow_templates/${sourceModule}/${id}.json`)
).then((r) => r.json())
// Custom node templates served via API
const response = await api.fetchApi(
`/workflow_templates/${sourceModule}/${id}.json`
)
return await response.json()
}
}

View File

@@ -1,14 +1,16 @@
import axios from 'axios'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { IWidget } from '@/lib/litegraph/src/litegraph'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { createAxiosWithHeaders } from '@/services/networkClientAdapter'
const MAX_RETRIES = 5
const TIMEOUT = 4096
// Create axios client with header injection
const axiosClient = createAxiosWithHeaders()
export interface CacheEntry<T> {
data: T
timestamp?: number
@@ -58,7 +60,7 @@ const fetchData = async (
controller: AbortController
) => {
const { route, response_key, query_params, timeout = TIMEOUT } = config
const res = await axios.get(route, {
const res = await axiosClient.get(route, {
params: query_params,
signal: controller.signal,
timeout

View File

@@ -0,0 +1,180 @@
/**
* Example showing how authentication headers are automatically injected
* with the new header registration system.
*
* Before: Services had to manually retrieve and add auth headers
* After: Headers are automatically injected via the network adapters
*/
import {
createAxiosWithHeaders,
fetchWithHeaders
} from '@/services/networkClientAdapter'
// ============================================
// BEFORE: Manual header management
// ============================================
// This is how services used to handle auth headers:
/*
import axios from 'axios'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
export async function oldWayToMakeRequest() {
// Had to manually get auth headers
const authHeaders = await useFirebaseAuthStore().getAuthHeader()
if (!authHeaders) {
throw new Error('Not authenticated')
}
// Had to manually add headers to each request
const response = await axios.get('/api/data', {
headers: {
...authHeaders,
'Content-Type': 'application/json'
}
})
return response.data
}
*/
// ============================================
// AFTER: Automatic header injection
// ============================================
// With the new system, auth headers are automatically injected:
/**
* Example 1: Using fetchWithHeaders
* Headers are automatically injected - no manual auth handling needed
*/
export async function modernFetchExample() {
// Just make the request - auth headers are added automatically!
const response = await fetchWithHeaders('/api/data', {
headers: {
'Content-Type': 'application/json'
// Auth headers are automatically added by the AuthHeaderProvider
}
})
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
return response.json()
}
/**
* Example 2: Using createAxiosWithHeaders
* Create an axios client that automatically injects headers
*/
export function createModernApiClient() {
// Create a client with automatic header injection
const client = createAxiosWithHeaders({
baseURL: '/api',
timeout: 30000
})
return {
async getData() {
// No need to manually add auth headers!
const response = await client.get('/data')
return response.data
},
async postData(data: any) {
// Auth headers are automatically included
const response = await client.post('/data', data)
return response.data
},
async updateData(id: string, data: any) {
// Works with all HTTP methods
const response = await client.put(`/data/${id}`, data)
return response.data
}
}
}
/**
* Example 3: Real-world service refactoring
* Shows how to update an existing service to use automatic headers
*/
// Before: CustomerEventsService with manual auth
/*
class OldCustomerEventsService {
private async makeRequest(url: string) {
const authHeaders = await useFirebaseAuthStore().getAuthHeader()
if (!authHeaders) {
throw new Error('Authentication required')
}
return axios.get(url, { headers: authHeaders })
}
async getEvents() {
return this.makeRequest('/customers/events')
}
}
*/
// After: CustomerEventsService with automatic auth
class ModernCustomerEventsService {
private client = createAxiosWithHeaders({
baseURL: '/api'
})
async getEvents() {
// Auth headers are automatically included!
const response = await this.client.get('/customers/events')
return response.data
}
async getEventDetails(eventId: string) {
// No manual auth handling needed
const response = await this.client.get(`/customers/events/${eventId}`)
return response.data
}
}
// ============================================
// Benefits of the new system:
// ============================================
/**
* 1. Cleaner code - no auth header boilerplate
* 2. Consistent auth handling across all services
* 3. Automatic token refresh (handled by Firebase SDK)
* 4. Fallback to API key when Firebase auth unavailable
* 5. Easy to add new header providers (debug headers, etc.)
* 6. Headers can be conditionally applied based on URL/method
* 7. Priority system allows overriding headers when needed
*/
// ============================================
// How it works behind the scenes:
// ============================================
/**
* 1. During app initialization (preInit hook), the AuthHeadersExtension
* registers the AuthHeaderProvider with the header registry
*
* 2. When you use fetchWithHeaders or createAxiosWithHeaders, they
* automatically query the header registry for all registered providers
*
* 3. The AuthHeaderProvider checks for Firebase token first, then
* falls back to API key if needed
*
* 4. Headers are merged and added to the request automatically
*
* 5. If authentication fails, the request proceeds without auth headers
* (the backend will handle the 401/403 response)
*/
export const examples = {
modernFetchExample,
createModernApiClient,
ModernCustomerEventsService
}

View File

@@ -0,0 +1,133 @@
/**
* Example of how extensions can register header providers
* This file demonstrates the header registration API for extension developers
*/
import { headerRegistry } from '@/services/headerRegistry'
import type {
HeaderMap,
HeaderProviderContext,
IHeaderProvider
} from '@/types/headerTypes'
/**
* Example 1: Simple static header provider
*/
class StaticHeaderProvider implements IHeaderProvider {
provideHeaders(_context: HeaderProviderContext): HeaderMap {
return {
'X-Extension-Name': 'my-extension',
'X-Extension-Version': '1.0.0'
}
}
}
/**
* Example 2: Dynamic header provider that adds headers based on the request
*/
class DynamicHeaderProvider implements IHeaderProvider {
async provideHeaders(context: HeaderProviderContext): Promise<HeaderMap> {
const headers: HeaderMap = {}
// Add different headers based on the URL
if (context.url.includes('/api/')) {
headers['X-API-Version'] = 'v2'
}
// Add headers based on request method
if (context.method === 'POST' || context.method === 'PUT') {
headers['X-Request-ID'] = () => crypto.randomUUID()
}
// Add timestamp header
headers['X-Timestamp'] = () => new Date().toISOString()
return headers
}
}
/**
* Example 3: Auth token provider
*/
class AuthTokenProvider implements IHeaderProvider {
private getToken(): string | null {
// This could retrieve a token from storage, state, etc.
return localStorage.getItem('auth-token')
}
provideHeaders(_context: HeaderProviderContext): HeaderMap {
const token = this.getToken()
if (token) {
return {
Authorization: `Bearer ${token}`
}
}
return {}
}
}
/**
* Example of how to register providers in an extension
*/
export function setupHeaderProviders() {
// Register a simple static provider
const staticRegistration = headerRegistry.registerHeaderProvider(
new StaticHeaderProvider()
)
// Register a dynamic provider with higher priority
const dynamicRegistration = headerRegistry.registerHeaderProvider(
new DynamicHeaderProvider(),
{ priority: 10 }
)
// Register an auth provider that only applies to API endpoints
const authRegistration = headerRegistry.registerHeaderProvider(
new AuthTokenProvider(),
{
priority: 20, // Higher priority to override other auth headers
filter: (context) => context.url.includes('/api/')
}
)
// Return cleanup function for when extension is unloaded
return () => {
staticRegistration.dispose()
dynamicRegistration.dispose()
authRegistration.dispose()
}
}
/**
* Example of a provider that integrates with a cloud service
*/
export class CloudServiceHeaderProvider implements IHeaderProvider {
constructor(
private apiKey: string,
private workspaceId: string
) {}
async provideHeaders(context: HeaderProviderContext): Promise<HeaderMap> {
// Only add headers for requests to the cloud service
if (!context.url.includes('cloud.comfyui.com')) {
return {}
}
return {
'X-API-Key': this.apiKey,
'X-Workspace-ID': this.workspaceId,
'X-Client-Version': '1.0.0',
'X-Session-ID': async () => {
// Could fetch or generate session ID asynchronously
const sessionId = await this.getOrCreateSessionId()
return sessionId
}
}
}
private async getOrCreateSessionId(): Promise<string> {
// Simulate async session creation
return 'session-' + Date.now()
}
}

View File

@@ -0,0 +1,167 @@
import { headerRegistry } from '@/services/headerRegistry'
import type { ComfyExtension } from '@/types/comfy'
import type {
HeaderMap,
HeaderProviderContext,
IHeaderProvider
} from '@/types/headerTypes'
/**
* Example extension showing how to register header providers
* during the pre-init lifecycle hook.
*
* The pre-init hook is the earliest extension lifecycle hook,
* called before the canvas is created. This makes it perfect
* for registering cross-cutting concerns like header providers.
*/
// Example: Authentication token provider
class AuthTokenProvider implements IHeaderProvider {
async provideHeaders(_context: HeaderProviderContext): Promise<HeaderMap> {
// This could fetch tokens from a secure store, refresh them, etc.
const token = await this.getAuthToken()
if (token) {
return {
Authorization: `Bearer ${token}`
}
}
return {}
}
private async getAuthToken(): Promise<string | null> {
// Example: Get token from localStorage or a secure store
// In a real implementation, this might refresh tokens, handle expiration, etc.
return localStorage.getItem('auth_token')
}
}
// Example: API key provider for specific domains
class ApiKeyProvider implements IHeaderProvider {
private apiKeys: Record<string, string> = {
'api.example.com': 'example-api-key',
'api.another.com': 'another-api-key'
}
provideHeaders(context: HeaderProviderContext): HeaderMap {
const url = new URL(context.url)
const apiKey = this.apiKeys[url.hostname]
if (apiKey) {
return {
'X-API-Key': apiKey
}
}
return {}
}
}
// Example: Custom header provider for debugging
class DebugHeaderProvider implements IHeaderProvider {
provideHeaders(_context: HeaderProviderContext): HeaderMap {
if (process.env.NODE_ENV === 'development') {
return {
'X-Debug-Mode': 'true',
'X-Request-ID': crypto.randomUUID()
}
}
return {}
}
}
export const headerRegistrationExtension: ComfyExtension = {
name: 'HeaderRegistration',
/**
* Pre-init hook - called before canvas creation.
* This is the perfect place to register header providers.
*/
async preInit(_app) {
console.log(
'[HeaderRegistration] Registering header providers in pre-init hook'
)
// Register auth token provider with high priority
const authRegistration = headerRegistry.registerHeaderProvider(
new AuthTokenProvider(),
{
priority: 100
}
)
// Register API key provider
const apiKeyRegistration = headerRegistry.registerHeaderProvider(
new ApiKeyProvider(),
{
priority: 90
}
)
// Register debug header provider with lower priority
const debugRegistration = headerRegistry.registerHeaderProvider(
new DebugHeaderProvider(),
{
priority: 10
}
)
// Store registrations for potential cleanup later
// Extensions can store their data on the app instance
const extensionData = {
headerRegistrations: [
authRegistration,
apiKeyRegistration,
debugRegistration
]
}
// Store a reference on the extension itself for potential cleanup
;(headerRegistrationExtension as any).registrations =
extensionData.headerRegistrations
},
/**
* Standard init hook - called after canvas creation.
* At this point, header providers are already active.
*/
async init(_app) {
console.log(
'[HeaderRegistration] Headers are now being injected into all HTTP requests'
)
},
/**
* Setup hook - called after app is fully initialized.
* We could add UI elements here to manage headers.
*/
async setup(_app) {
// Example: Add a command to test header injection
const { useCommandStore } = await import('@/stores/commandStore')
useCommandStore().registerCommand({
id: 'header-registration.test',
icon: 'pi pi-globe',
label: 'Test Header Injection',
function: async () => {
try {
// Make a test request to see headers in action
const response = await fetch('/api/test')
console.log('[HeaderRegistration] Test request completed', {
status: response.status,
headers: response.headers
})
} catch (error) {
console.error('[HeaderRegistration] Test request failed', error)
}
}
})
}
}
// Extension usage:
// 1. Import this extension in your extension index
// 2. Register it with app.registerExtension(headerRegistrationExtension)
// 3. Header providers will be automatically registered before any network activity

View File

@@ -0,0 +1,29 @@
import { AuthHeaderProvider } from '@/providers/authHeaderProvider'
import { app } from '@/scripts/app'
import { headerRegistry } from '@/services/headerRegistry'
/**
* Core extension that registers authentication header providers.
* This ensures all HTTP requests automatically include authentication headers.
*/
app.registerExtension({
name: 'Comfy.AuthHeaders',
/**
* Register authentication header provider in the pre-init phase.
* This ensures headers are available before any network activity.
*/
async preInit(_app) {
console.log('[AuthHeaders] Registering authentication header provider')
// Register the auth header provider with high priority
// This ensures auth headers are added to all requests
headerRegistry.registerHeaderProvider(new AuthHeaderProvider(), {
priority: 1000 // High priority to ensure auth headers are applied
})
console.log(
'[AuthHeaders] Authentication headers will be automatically injected'
)
}
})

View File

@@ -1,3 +1,4 @@
import './authHeaders'
import './clipspace'
import './contextMenuFilter'
import './dynamicPrompts'

View File

@@ -1,6 +1,7 @@
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { useToastStore } from '@/stores/toastStore'
class Load3dUtils {
@@ -9,7 +10,7 @@ class Load3dUtils {
prefix: string,
fileType: string = 'png'
) {
const blob = await fetch(imageData).then((r) => r.blob())
const blob = await fetchWithHeaders(imageData).then((r) => r.blob())
const name = `${prefix}_${Date.now()}.${fileType}`
const file = new File([blob], name, {
type: fileType === 'mp4' ? 'video/mp4' : 'image/png'

View File

@@ -4,6 +4,8 @@ import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter'
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter'
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { useToastStore } from '@/stores/toastStore'
export class ModelExporter {
@@ -36,7 +38,18 @@ export class ModelExporter {
desiredFilename: string
): Promise<void> {
try {
const response = await fetch(url)
// Check if this is a ComfyUI relative URL
const isComfyUrl = url.startsWith('/') || url.includes('/view?')
let response: Response
if (isComfyUrl) {
// Use ComfyUI API client for internal URLs
response = await fetchWithHeaders(api.apiURL(url))
} else {
// Use direct fetch for external URLs
response = await fetchWithHeaders(url)
}
const blob = await response.blob()
const link = document.createElement('a')

View File

@@ -0,0 +1,64 @@
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type {
HeaderMap,
HeaderProviderContext,
IHeaderProvider
} from '@/types/headerTypes'
/**
* Header provider for authentication headers.
* Automatically adds Firebase Bearer tokens or API keys to outgoing requests.
*
* Priority order:
* 1. Firebase Bearer token (if user is authenticated)
* 2. API key (if configured)
* 3. No authentication header
*/
export class AuthHeaderProvider implements IHeaderProvider {
async provideHeaders(_context: HeaderProviderContext): Promise<HeaderMap> {
// Try to get Firebase auth header first (includes fallback to API key)
const authHeader = await useFirebaseAuthStore().getAuthHeader()
if (authHeader) {
return authHeader
}
// No authentication available
return {}
}
}
/**
* Header provider specifically for API key authentication.
* Only provides API key headers, ignoring Firebase auth.
* Useful for specific endpoints that require API key auth.
*/
export class ApiKeyHeaderProvider implements IHeaderProvider {
provideHeaders(_context: HeaderProviderContext): HeaderMap {
const apiKeyHeader = useApiKeyAuthStore().getAuthHeader()
return apiKeyHeader || {}
}
}
/**
* Header provider specifically for Firebase Bearer token authentication.
* Only provides Firebase auth headers, ignoring API keys.
* Useful for specific endpoints that require Firebase auth.
*/
export class FirebaseAuthHeaderProvider implements IHeaderProvider {
async provideHeaders(_context: HeaderProviderContext): Promise<HeaderMap> {
const firebaseStore = useFirebaseAuthStore()
// Only get Firebase token, not the fallback API key
const token = await firebaseStore.getIdToken()
if (token) {
return {
Authorization: `Bearer ${token}`
}
}
return {}
}
}

View File

@@ -1,5 +1,3 @@
import axios from 'axios'
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json'
import type {
DisplayComponentWsMessage,
@@ -35,6 +33,7 @@ import type {
NodeId
} from '@/schemas/comfyWorkflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { WorkflowTemplates } from '@/types/workflowTemplateTypes'
@@ -329,7 +328,7 @@ export class ComfyApi extends EventTarget {
} else {
options.headers['Comfy-User'] = this.user
}
return fetch(this.apiURL(route), options)
return fetchWithHeaders(this.apiURL(route), options)
}
override addEventListener<TEvent extends keyof ApiEvents>(
@@ -599,9 +598,9 @@ export class ComfyApi extends EventTarget {
* Gets the index of core workflow templates.
*/
async getCoreWorkflowTemplates(): Promise<WorkflowTemplates[]> {
const res = await axios.get(this.fileURL('/templates/index.json'))
const contentType = res.headers['content-type']
return contentType?.includes('application/json') ? res.data : []
const res = await fetchWithHeaders(this.fileURL('/templates/index.json'))
const contentType = res.headers.get('content-type')
return contentType?.includes('application/json') ? await res.json() : []
}
/**
@@ -1002,22 +1001,31 @@ export class ComfyApi extends EventTarget {
}
async getLogs(): Promise<string> {
return (await axios.get(this.internalURL('/logs'))).data
const response = await fetchWithHeaders(this.internalURL('/logs'))
return response.text()
}
async getRawLogs(): Promise<LogsRawResponse> {
return (await axios.get(this.internalURL('/logs/raw'))).data
const response = await fetchWithHeaders(this.internalURL('/logs/raw'))
return response.json()
}
async subscribeLogs(enabled: boolean): Promise<void> {
return await axios.patch(this.internalURL('/logs/subscribe'), {
enabled,
clientId: this.clientId
await fetchWithHeaders(this.internalURL('/logs/subscribe'), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
enabled,
clientId: this.clientId
})
})
}
async getFolderPaths(): Promise<Record<string, string[]>> {
return (await axios.get(this.internalURL('/folder_paths'))).data
const response = await fetchWithHeaders(this.internalURL('/folder_paths'))
return response.json()
}
/**
@@ -1026,7 +1034,8 @@ export class ComfyApi extends EventTarget {
* @returns The custom nodes i18n data
*/
async getCustomNodesI18n(): Promise<Record<string, any>> {
return (await axios.get(this.apiURL('/i18n'))).data
const response = await fetchWithHeaders(this.apiURL('/i18n'))
return response.json()
}
/**

View File

@@ -40,6 +40,7 @@ import { getSvgMetadata } from '@/scripts/metadata/svg'
import { useDialogService } from '@/services/dialogService'
import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { useSubgraphService } from '@/services/subgraphService'
import { useWorkflowService } from '@/services/workflowService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
@@ -533,7 +534,7 @@ export class ComfyApp {
if (match) {
const uri = event.dataTransfer.getData(match)?.split('\n')?.[0]
if (uri) {
const blob = await (await fetch(uri)).blob()
const blob = await (await fetchWithHeaders(uri)).blob()
await this.handleFile(new File([blob], uri, { type: blob.type }))
}
}
@@ -798,6 +799,9 @@ export class ComfyApp {
await useWorkspaceStore().workflow.syncWorkflows()
await useExtensionService().loadExtensions()
// Call preInit hook before any other initialization
await useExtensionService().invokeExtensionsAsync('preInit')
this.#addProcessKeyHandler()
this.#addConfigureHandler()
this.#addApiUpdateHandlers()

View File

@@ -2,6 +2,7 @@ import axios, { AxiosError, AxiosResponse } from 'axios'
import { ref } from 'vue'
import { api } from '@/scripts/api'
import { createAxiosWithHeaders } from '@/services/networkClientAdapter'
import {
type InstallPackParams,
type InstalledPacksResponse,
@@ -35,7 +36,7 @@ enum ManagerRoute {
REBOOT = 'manager/reboot'
}
const managerApiClient = axios.create({
const managerApiClient = createAxiosWithHeaders({
baseURL: api.apiURL(''),
headers: {
'Content-Type': 'application/json'

View File

@@ -1,12 +1,13 @@
import axios, { AxiosError, AxiosResponse } from 'axios'
import { ref } from 'vue'
import { createAxiosWithHeaders } from '@/services/networkClientAdapter'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
const API_BASE_URL = 'https://api.comfy.org'
const registryApiClient = axios.create({
const registryApiClient = createAxiosWithHeaders({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'

View File

@@ -3,6 +3,7 @@ import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { createAxiosWithHeaders } from '@/services/networkClientAdapter'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { type components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
@@ -22,7 +23,7 @@ type CustomerEventsResponseQuery =
export type AuditLog = components['schemas']['AuditLog']
const customerApiClient = axios.create({
const customerApiClient = createAxiosWithHeaders({
baseURL: COMFY_API_BASE_URL,
headers: {
'Content-Type': 'application/json'

View File

@@ -0,0 +1,129 @@
import type {
HeaderMap,
HeaderProviderContext,
HeaderProviderOptions,
HeaderValue,
IHeaderProvider,
IHeaderProviderRegistration
} from '@/types/headerTypes'
/**
* Internal registration entry
*/
interface HeaderProviderEntry {
id: string
provider: IHeaderProvider
options: HeaderProviderOptions
}
/**
* Registry for HTTP header providers
* Follows VSCode extension patterns for registration and lifecycle
*/
class HeaderRegistry {
private providers: HeaderProviderEntry[] = []
private nextId = 1
/**
* Registers a header provider
* @param provider - The header provider implementation
* @param options - Registration options
* @returns Registration handle for disposal
*/
registerHeaderProvider(
provider: IHeaderProvider,
options: HeaderProviderOptions = {}
): IHeaderProviderRegistration {
const id = `header-provider-${this.nextId++}`
const entry: HeaderProviderEntry = {
id,
provider,
options: {
priority: options.priority ?? 0,
filter: options.filter
}
}
// Insert provider in priority order (higher priority = later in array)
const insertIndex = this.providers.findIndex(
(p) => (p.options.priority ?? 0) > (entry.options.priority ?? 0)
)
if (insertIndex === -1) {
this.providers.push(entry)
} else {
this.providers.splice(insertIndex, 0, entry)
}
// Return disposable handle
return {
id,
dispose: () => {
const index = this.providers.findIndex((p) => p.id === id)
if (index !== -1) {
this.providers.splice(index, 1)
}
}
}
}
/**
* Gets all headers for a request by combining all registered providers
* @param context - Request context
* @returns Combined headers from all providers
*/
async getHeaders(context: HeaderProviderContext): Promise<HeaderMap> {
const result: HeaderMap = {}
// Process providers in order (lower priority first, so higher priority can override)
for (const entry of this.providers) {
// Check filter if provided
if (entry.options.filter && !entry.options.filter(context)) {
continue
}
try {
const headers = await entry.provider.provideHeaders(context)
// Merge headers, resolving any function values
for (const [key, value] of Object.entries(headers)) {
result[key] = await this.resolveHeaderValue(value)
}
} catch (error) {
console.error(`Error getting headers from provider ${entry.id}:`, error)
// Continue with other providers even if one fails
}
}
return result
}
/**
* Resolves a header value, handling functions
*/
private async resolveHeaderValue(
value: HeaderValue
): Promise<string | number | boolean> {
if (typeof value === 'function') {
return await value()
}
return value
}
/**
* Clears all registered providers
*/
clear(): void {
this.providers = []
}
/**
* Gets the count of registered providers
*/
get providerCount(): number {
return this.providers.length
}
}
// Export singleton instance
export const headerRegistry = new HeaderRegistry()

View File

@@ -0,0 +1,87 @@
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
import axios from 'axios'
import { headerRegistry } from '@/services/headerRegistry'
import type { HeaderProviderContext } from '@/types/headerTypes'
/**
* Creates an axios instance with automatic header injection from the registry
* @param config - Base axios configuration
* @returns Axios instance with header injection
*/
export function createAxiosWithHeaders(
config?: AxiosRequestConfig
): AxiosInstance {
const instance = axios.create(config)
// Add request interceptor to inject headers
instance.interceptors.request.use(
async (requestConfig) => {
// Build context for header providers
const context: HeaderProviderContext = {
url: requestConfig.url || '',
method: requestConfig.method || 'GET',
body: requestConfig.data,
config: requestConfig
}
// Get headers from registry
const registryHeaders = await headerRegistry.getHeaders(context)
// Merge with existing headers (registry headers take precedence)
for (const [key, value] of Object.entries(registryHeaders)) {
requestConfig.headers[key] = value
}
return requestConfig
},
(error) => {
return Promise.reject(error)
}
)
return instance
}
/**
* Wraps the native fetch API with header injection from the registry
* @param input - Request URL or Request object
* @param init - Request initialization options
* @returns Promise resolving to Response
*/
export async function fetchWithHeaders(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
// Extract URL and method for context
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url
const method =
init?.method || (input instanceof Request ? input.method : 'GET')
// Build context for header providers
const context: HeaderProviderContext = {
url,
method,
body: init?.body
}
// Get headers from registry
const registryHeaders = await headerRegistry.getHeaders(context)
// Convert registry headers to Headers object format
const headers = new Headers(init?.headers)
for (const [key, value] of Object.entries(registryHeaders)) {
headers.set(key, String(value))
}
// Perform fetch with merged headers
return fetch(input, {
...init,
headers
})
}

View File

@@ -1,4 +1,5 @@
import { api } from '@/scripts/api'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
import { extractCustomNodeName } from '@/utils/nodeHelpUtil'
@@ -25,12 +26,12 @@ export class NodeHelpService {
// Try locale-specific path first
const localePath = `/extensions/${customNodeName}/docs/${node.name}/${locale}.md`
let res = await fetch(api.fileURL(localePath))
let res = await fetchWithHeaders(api.fileURL(localePath))
if (!res.ok) {
// Fall back to non-locale path
const fallbackPath = `/extensions/${customNodeName}/docs/${node.name}.md`
res = await fetch(api.fileURL(fallbackPath))
res = await fetchWithHeaders(api.fileURL(fallbackPath))
}
if (!res.ok) {
@@ -45,7 +46,7 @@ export class NodeHelpService {
locale: string
): Promise<string> {
const mdUrl = `/docs/${node.name}/${locale}.md`
const res = await fetch(api.fileURL(mdUrl))
const res = await fetchWithHeaders(api.fileURL(mdUrl))
if (!res.ok) {
throw new Error(res.statusText)

View File

@@ -2,10 +2,11 @@ import axios, { AxiosError, AxiosResponse } from 'axios'
import { ref } from 'vue'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { createAxiosWithHeaders } from '@/services/networkClientAdapter'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
const releaseApiClient = axios.create({
const releaseApiClient = createAxiosWithHeaders({
baseURL: COMFY_API_BASE_URL,
headers: {
'Content-Type': 'application/json'

View File

@@ -1,3 +1,4 @@
import axios from 'axios'
import {
type Auth,
GithubAuthProvider,
@@ -20,6 +21,7 @@ import { useFirebaseAuth } from 'vuefire'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { t } from '@/i18n'
import { createAxiosWithHeaders } from '@/services/networkClientAdapter'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { type AuthHeader } from '@/types/authTypes'
import { operations } from '@/types/comfyRegistryTypes'
@@ -44,6 +46,15 @@ export class FirebaseAuthStoreError extends Error {
}
}
// Customer API client - follows the same pattern as other services
// Now with automatic header injection from the registry
const customerApiClient = createAxiosWithHeaders({
baseURL: COMFY_API_BASE_URL,
headers: {
'Content-Type': 'application/json'
}
})
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
// State
const loading = ref(false)
@@ -129,27 +140,27 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
)
}
const response = await fetch(`${COMFY_API_BASE_URL}/customers/balance`, {
headers: {
...authHeader,
'Content-Type': 'application/json'
let balanceData
try {
const response = await customerApiClient.get('/customers/balance', {
headers: authHeader
})
balanceData = response.data
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
if (error.response.status === 404) {
// Customer not found is expected for new users
return null
}
const errorData = error.response.data
throw new FirebaseAuthStoreError(
t('toastMessages.failedToFetchBalance', {
error: errorData.message
})
)
}
})
if (!response.ok) {
if (response.status === 404) {
// Customer not found is expected for new users
return null
}
const errorData = await response.json()
throw new FirebaseAuthStoreError(
t('toastMessages.failedToFetchBalance', {
error: errorData.message
})
)
throw error
}
const balanceData = await response.json()
// Update the last balance update time
lastBalanceUpdateTime.value = new Date()
balance.value = balanceData
@@ -165,23 +176,26 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const createCustomerRes = await fetch(`${COMFY_API_BASE_URL}/customers`, {
method: 'POST',
headers: {
...authHeader,
'Content-Type': 'application/json'
}
})
if (!createCustomerRes.ok) {
throw new FirebaseAuthStoreError(
t('toastMessages.failedToCreateCustomer', {
error: createCustomerRes.statusText
})
let createCustomerResJson: CreateCustomerResponse
try {
const createCustomerRes = await customerApiClient.post(
'/customers',
{},
{
headers: authHeader
}
)
createCustomerResJson = createCustomerRes.data
} catch (error) {
if (axios.isAxiosError(error)) {
throw new FirebaseAuthStoreError(
t('toastMessages.failedToCreateCustomer', {
error: error.response?.statusText || error.message
})
)
}
throw error
}
const createCustomerResJson: CreateCustomerResponse =
await createCustomerRes.json()
if (!createCustomerResJson?.id) {
throw new FirebaseAuthStoreError(
t('toastMessages.failedToCreateCustomer', {
@@ -282,25 +296,26 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
customerCreated.value = true
}
const response = await fetch(`${COMFY_API_BASE_URL}/customers/credit`, {
method: 'POST',
headers: {
...authHeader,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBodyContent)
})
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
t('toastMessages.failedToInitiateCreditPurchase', {
error: errorData.message
})
try {
const response = await customerApiClient.post(
'/customers/credit',
requestBodyContent,
{
headers: authHeader
}
)
return response.data
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
const errorData = error.response.data
throw new FirebaseAuthStoreError(
t('toastMessages.failedToInitiateCreditPurchase', {
error: errorData.message
})
)
}
throw error
}
return response.json()
}
const initiateCreditPurchase = async (
@@ -316,27 +331,26 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(`${COMFY_API_BASE_URL}/customers/billing`, {
method: 'POST',
headers: {
...authHeader,
'Content-Type': 'application/json'
},
...(requestBody && {
body: JSON.stringify(requestBody)
})
})
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
t('toastMessages.failedToAccessBillingPortal', {
error: errorData.message
})
try {
const response = await customerApiClient.post(
'/customers/billing',
requestBody,
{
headers: authHeader
}
)
return response.data
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
const errorData = error.response.data
throw new FirebaseAuthStoreError(
t('toastMessages.failedToAccessBillingPortal', {
error: errorData.message
})
)
}
throw error
}
return response.json()
}
return {

View File

@@ -70,6 +70,13 @@ export interface ComfyExtension {
* Badges to add to the about page
*/
aboutPageBadges?: AboutPageBadge[]
/**
* Allows the extension to run code before the app is initialized. This is the earliest lifecycle hook.
* Called before the canvas is created and before any other extension hooks.
* Useful for registering services, header providers, or other cross-cutting concerns.
* @param app The ComfyUI app instance
*/
preInit?(app: ComfyApp): Promise<void> | void
/**
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
* @param app The ComfyUI app instance

61
src/types/headerTypes.ts Normal file
View File

@@ -0,0 +1,61 @@
import type { AxiosRequestConfig } from 'axios'
/**
* Header value can be a string, number, boolean, or a function that returns one of these
*/
export type HeaderValue =
| string
| number
| boolean
| (() => string | number | boolean | Promise<string | number | boolean>)
/**
* Header provider interface for extensions to implement
*/
export interface IHeaderProvider {
/**
* Provides headers for HTTP requests
* @param context - Request context containing URL and method
* @returns Headers to be added to the request
*/
provideHeaders(context: HeaderProviderContext): HeaderMap | Promise<HeaderMap>
}
/**
* Context passed to header providers
*/
export interface HeaderProviderContext {
/** The URL being requested */
url: string
/** HTTP method */
method: string
/** Optional request body */
body?: any
/** Original request config if available */
config?: AxiosRequestConfig
}
/**
* Map of header names to values
*/
export type HeaderMap = Record<string, HeaderValue>
/**
* Registration handle returned when registering a header provider
*/
export interface IHeaderProviderRegistration {
/** Unique ID for this registration */
id: string
/** Disposes of this registration */
dispose(): void
}
/**
* Options for registering a header provider
*/
export interface HeaderProviderOptions {
/** Priority for this provider (higher = runs later, can override earlier providers) */
priority?: number
/** Optional filter to limit which requests this provider applies to */
filter?: (context: HeaderProviderContext) => boolean
}

View File

@@ -2,6 +2,7 @@ import { flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
// Mock the store
@@ -41,6 +42,11 @@ vi.mock('@/stores/dialogStore', () => ({
// Mock fetch
global.fetch = vi.fn()
// Mock fetchWithHeaders
vi.mock('@/services/networkClientAdapter', () => ({
fetchWithHeaders: vi.fn()
}))
describe('useTemplateWorkflows', () => {
let mockWorkflowTemplatesStore: any
@@ -100,6 +106,11 @@ describe('useTemplateWorkflows', () => {
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ workflow: 'data' })
} as unknown as Response)
// Also mock fetchWithHeaders
vi.mocked(fetchWithHeaders).mockResolvedValue({
json: vi.fn().mockResolvedValue({ workflow: 'data' })
} as unknown as Response)
})
it('should load templates from store', async () => {
@@ -258,7 +269,9 @@ describe('useTemplateWorkflows', () => {
await flushPromises()
expect(result).toBe(true)
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
expect(vi.mocked(fetchWithHeaders)).toHaveBeenCalledWith(
'mock-file-url/templates/template1.json'
)
expect(loadingTemplateId.value).toBe(null) // Should reset after loading
})
@@ -273,7 +286,9 @@ describe('useTemplateWorkflows', () => {
await flushPromises()
expect(result).toBe(true)
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
expect(vi.mocked(fetchWithHeaders)).toHaveBeenCalledWith(
'mock-file-url/templates/template1.json'
)
})
it('should handle errors when loading templates', async () => {
@@ -282,8 +297,10 @@ describe('useTemplateWorkflows', () => {
// Set the store as loaded
mockWorkflowTemplatesStore.isLoaded = true
// Mock fetch to throw an error
vi.mocked(fetch).mockRejectedValueOnce(new Error('Failed to fetch'))
// Mock fetchWithHeaders to throw an error
vi.mocked(fetchWithHeaders).mockRejectedValueOnce(
new Error('Failed to fetch')
)
// Spy on console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

View File

@@ -1,17 +1,35 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRemoteWidget } from '@/composables/widgets/useRemoteWidget'
import { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
// Hoist the mock to avoid hoisting issues
const mockAxiosInstance = vi.hoisted(() => ({
get: vi.fn(),
interceptors: {
request: {
use: vi.fn()
},
response: {
use: vi.fn()
}
}
}))
vi.mock('axios', () => {
return {
default: {
get: vi.fn()
get: vi.fn(),
create: vi.fn(() => mockAxiosInstance)
}
}
})
// Mock networkClientAdapter to return the same axios instance
vi.mock('@/services/networkClientAdapter', () => ({
createAxiosWithHeaders: vi.fn(() => mockAxiosInstance)
}))
vi.mock('@/i18n', () => ({
i18n: {
global: {
@@ -63,12 +81,12 @@ const createMockOptions = (inputOverrides = {}) => ({
})
function mockAxiosResponse(data: unknown, status = 200) {
vi.mocked(axios.get).mockResolvedValueOnce({ data, status })
vi.mocked(mockAxiosInstance.get).mockResolvedValueOnce({ data, status })
}
function mockAxiosError(error: Error | string) {
const err = error instanceof Error ? error : new Error(error)
vi.mocked(axios.get).mockRejectedValueOnce(err)
vi.mocked(mockAxiosInstance.get).mockRejectedValueOnce(err)
}
function createHookWithData(data: unknown, inputOverrides = {}) {
@@ -96,7 +114,7 @@ describe('useRemoteWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset mocks
vi.mocked(axios.get).mockReset()
vi.mocked(mockAxiosInstance.get).mockReset()
// Reset cache between tests
vi.spyOn(Map.prototype, 'get').mockClear()
vi.spyOn(Map.prototype, 'set').mockClear()
@@ -137,7 +155,7 @@ describe('useRemoteWidget', () => {
const mockData = ['optionA', 'optionB']
const { hook, result } = await setupHookWithResponse(mockData)
expect(result).toEqual(mockData)
expect(vi.mocked(axios.get)).toHaveBeenCalledWith(
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledWith(
hook.cacheKey.split(';')[0], // Get the route part from cache key
expect.any(Object)
)
@@ -216,7 +234,7 @@ describe('useRemoteWidget', () => {
await getResolvedValue(hook)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
})
it('permanent widgets should re-fetch if refreshValue is called', async () => {
@@ -237,12 +255,12 @@ describe('useRemoteWidget', () => {
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
const secondData = await getResolvedValue(hook)
expect(secondData).toBe('Loading...')
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(2)
})
it('should treat empty refresh field as permanent', async () => {
@@ -251,7 +269,7 @@ describe('useRemoteWidget', () => {
await getResolvedValue(hook)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
})
})
@@ -267,7 +285,7 @@ describe('useRemoteWidget', () => {
const newData = await getResolvedValue(hook)
expect(newData).toEqual(mockData2)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(2)
})
it('should not refresh when data is not stale', async () => {
@@ -278,7 +296,7 @@ describe('useRemoteWidget', () => {
vi.setSystemTime(Date.now() + 128)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
})
it('should use backoff instead of refresh after error', async () => {
@@ -290,13 +308,13 @@ describe('useRemoteWidget', () => {
mockAxiosError('Network error')
vi.setSystemTime(Date.now() + refresh)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(2)
mockAxiosResponse(['second success'])
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
const thirdData = await getResolvedValue(hook)
expect(thirdData).toEqual(['second success'])
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(3)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(3)
})
it('should use last valid value after error', async () => {
@@ -310,7 +328,7 @@ describe('useRemoteWidget', () => {
const secondData = await getResolvedValue(hook)
expect(secondData).toEqual(['a valid value'])
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(2)
})
})
@@ -332,15 +350,15 @@ describe('useRemoteWidget', () => {
expect(entry1?.error).toBeTruthy()
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
vi.setSystemTime(Date.now() + 500)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1) // Still backing off
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1) // Still backing off
vi.setSystemTime(Date.now() + 3000)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(2)
expect(entry1?.data).toBeDefined()
})
@@ -418,7 +436,9 @@ describe('useRemoteWidget', () => {
it('should prevent duplicate in-flight requests', async () => {
const promise = Promise.resolve({ data: ['non-duplicate'] })
vi.mocked(axios.get).mockImplementationOnce(() => promise as any)
vi.mocked(mockAxiosInstance.get).mockImplementationOnce(
() => promise as any
)
const hook = useRemoteWidget(createMockOptions())
const [result1, result2] = await Promise.all([
@@ -427,7 +447,7 @@ describe('useRemoteWidget', () => {
])
expect(result1).toBe(result2)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
})
})
@@ -446,7 +466,7 @@ describe('useRemoteWidget', () => {
expect(data1).toEqual(['shared data'])
expect(data2).toEqual(['shared data'])
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
})
@@ -467,7 +487,7 @@ describe('useRemoteWidget', () => {
expect(data2).toBe(data1)
expect(data3).toBe(data1)
expect(data4).toBe(data1)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
expect(hook2.getCachedValue()).toBe(hook3.getCachedValue())
expect(hook3.getCachedValue()).toBe(hook4.getCachedValue())
@@ -479,7 +499,9 @@ describe('useRemoteWidget', () => {
resolvePromise = resolve
})
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise as any)
vi.mocked(mockAxiosInstance.get).mockImplementationOnce(
() => delayedPromise as any
)
const hook = useRemoteWidget(createMockOptions())
hook.getValue()
@@ -500,7 +522,9 @@ describe('useRemoteWidget', () => {
resolvePromise = resolve
})
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise as any)
vi.mocked(mockAxiosInstance.get).mockImplementationOnce(
() => delayedPromise as any
)
let hook = useRemoteWidget(createMockOptions())
const fetchPromise = hook.getValue()

View File

@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthHeaderProvider } from '@/providers/authHeaderProvider'
import { headerRegistry } from '@/services/headerRegistry'
// Mock the providers module
vi.mock('@/providers/authHeaderProvider', () => ({
AuthHeaderProvider: vi.fn()
}))
// Mock headerRegistry
vi.mock('@/services/headerRegistry', () => ({
headerRegistry: {
registerHeaderProvider: vi.fn()
}
}))
// Mock app
const mockApp = {
registerExtension: vi.fn()
}
vi.mock('@/scripts/app', () => ({
app: mockApp
}))
describe('authHeaders extension', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset module cache to ensure fresh imports
vi.resetModules()
})
it('should register extension with correct name', async () => {
// Import the extension (this will call app.registerExtension)
await import('@/extensions/core/authHeaders')
expect(mockApp.registerExtension).toHaveBeenCalledOnce()
const extensionConfig = mockApp.registerExtension.mock.calls[0][0]
expect(extensionConfig.name).toBe('Comfy.AuthHeaders')
})
it('should register auth header provider in preInit hook', async () => {
// Import the extension
await import('@/extensions/core/authHeaders')
const extensionConfig = mockApp.registerExtension.mock.calls[0][0]
expect(extensionConfig.preInit).toBeDefined()
// Call the preInit hook
await extensionConfig.preInit({})
// Verify AuthHeaderProvider was instantiated
expect(AuthHeaderProvider).toHaveBeenCalledOnce()
// Verify header provider was registered with high priority
expect(headerRegistry.registerHeaderProvider).toHaveBeenCalledWith(
expect.any(Object), // The AuthHeaderProvider instance
{ priority: 1000 }
)
})
it('should log initialization messages', async () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
// Import the extension
await import('@/extensions/core/authHeaders')
const extensionConfig = mockApp.registerExtension.mock.calls[0][0]
// Call the preInit hook
await extensionConfig.preInit({})
expect(consoleLogSpy).toHaveBeenCalledWith(
'[AuthHeaders] Registering authentication header provider'
)
expect(consoleLogSpy).toHaveBeenCalledWith(
'[AuthHeaders] Authentication headers will be automatically injected'
)
consoleLogSpy.mockRestore()
})
})

View File

@@ -0,0 +1,328 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { ComfyApp } from '@/scripts/app'
import type { ComfyExtension } from '@/types/comfy'
// Create mock extension service
const mockExtensionService = {
loadExtensions: vi.fn().mockResolvedValue(undefined),
registerExtension: vi.fn(),
invokeExtensions: vi.fn(),
invokeExtensionsAsync: vi.fn().mockResolvedValue(undefined),
enabledExtensions: [] as ComfyExtension[]
}
// Mock extension service
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => mockExtensionService
}))
// Mock dependencies
vi.mock('@/stores/toastStore', () => ({
useToastStore: () => ({
add: vi.fn()
})
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({
workflow: {
syncWorkflows: vi.fn().mockResolvedValue(undefined)
}
})
}))
vi.mock('@/services/subgraphService', () => ({
useSubgraphService: () => ({
registerNewSubgraph: vi.fn()
})
}))
// Mock LiteGraph
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
const actual = (await importOriginal()) as any
return {
...actual,
LGraph: vi.fn().mockImplementation(() => ({
events: {
addEventListener: vi.fn()
},
start: vi.fn(),
stop: vi.fn(),
registerNodeType: vi.fn(),
createNode: vi.fn()
})),
LGraphCanvas: vi.fn().mockImplementation((canvasEl) => ({
state: reactive({}),
draw: vi.fn(),
canvas: canvasEl
})),
LiteGraph: {
...actual.LiteGraph,
alt_drag_do_clone_nodes: false,
macGesturesRequireMac: true
}
}
})
// Mock other required methods
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: () => ({
disabledExtensions: new Set()
})
}))
vi.mock('@/utils/app.utils', () => ({
makeUUID: vi.fn(() => 'test-uuid')
}))
// Mock API
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
apiURL: vi.fn((path) => `/api${path}`),
connect: vi.fn(),
init: vi.fn().mockResolvedValue(undefined),
getSystemStats: vi.fn().mockResolvedValue({}),
getNodeDefs: vi.fn().mockResolvedValue({})
}
}))
describe('Extension Pre-Init Hook', () => {
let app: ComfyApp
let mockCanvas: HTMLCanvasElement
let callOrder: string[]
beforeEach(() => {
vi.clearAllMocks()
callOrder = []
// Reset mock extension service
mockExtensionService.enabledExtensions = []
mockExtensionService.invokeExtensionsAsync.mockReset()
mockExtensionService.invokeExtensionsAsync.mockImplementation(
async (method: keyof ComfyExtension) => {
// Call the appropriate hook on all registered extensions
for (const ext of mockExtensionService.enabledExtensions) {
const hookFn = ext[method]
if (typeof hookFn === 'function') {
try {
await hookFn.call(ext, app)
} catch (error) {
console.error(`Error in extension ${ext.name} ${method}`, error)
}
}
}
}
)
// Create mock canvas
mockCanvas = document.createElement('canvas')
mockCanvas.getContext = vi.fn().mockReturnValue({
scale: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
clearRect: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn()
})
// Create mock DOM elements
const createMockElement = (id: string) => {
const el = document.createElement('div')
el.id = id
document.body.appendChild(el)
return el
}
createMockElement('comfyui-body-top')
createMockElement('comfyui-body-left')
createMockElement('comfyui-body-right')
createMockElement('comfyui-body-bottom')
createMockElement('graph-canvas-container')
app = new ComfyApp()
// Mock app methods that are called during setup
app.registerNodes = vi.fn().mockResolvedValue(undefined)
// Mock addEventListener for canvas element
mockCanvas.addEventListener = vi.fn()
mockCanvas.removeEventListener = vi.fn()
// Mock window methods
window.addEventListener = vi.fn()
window.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
disconnect: vi.fn()
}))
// Mock WebSocket
const mockWebSocket = vi.fn().mockImplementation(() => ({
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
send: vi.fn(),
close: vi.fn(),
readyState: 1,
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3
}))
;(mockWebSocket as any).CONNECTING = 0
;(mockWebSocket as any).OPEN = 1
;(mockWebSocket as any).CLOSING = 2
;(mockWebSocket as any).CLOSED = 3
global.WebSocket = mockWebSocket as any
})
afterEach(() => {
// Clean up DOM elements
document.body.innerHTML = ''
})
it('should call preInit hook before init hook', async () => {
const testExtension: ComfyExtension = {
name: 'TestExtension',
preInit: vi.fn(async () => {
callOrder.push('preInit')
}),
init: vi.fn(async () => {
callOrder.push('init')
}),
setup: vi.fn(async () => {
callOrder.push('setup')
})
}
// Register the extension
mockExtensionService.enabledExtensions.push(testExtension)
// Run app setup
await app.setup(mockCanvas)
// Verify all hooks were called
expect(testExtension.preInit).toHaveBeenCalledWith(app)
expect(testExtension.init).toHaveBeenCalledWith(app)
expect(testExtension.setup).toHaveBeenCalledWith(app)
// Verify correct order
expect(callOrder).toEqual(['preInit', 'init', 'setup'])
})
it('should call preInit before canvas creation', async () => {
const events: string[] = []
const testExtension: ComfyExtension = {
name: 'CanvasTestExtension',
preInit: vi.fn(async () => {
events.push('preInit')
// Canvas should not exist yet
expect(app.canvas).toBeUndefined()
}),
init: vi.fn(async () => {
events.push('init')
// Canvas should exist by init
expect(app.canvas).toBeDefined()
})
}
mockExtensionService.enabledExtensions.push(testExtension)
await app.setup(mockCanvas)
expect(events).toEqual(['preInit', 'init'])
})
it('should handle async preInit hooks', async () => {
const preInitComplete = vi.fn()
const testExtension: ComfyExtension = {
name: 'AsyncExtension',
preInit: vi.fn(async () => {
// Simulate async operation
await new Promise((resolve) => setTimeout(resolve, 10))
preInitComplete()
}),
init: vi.fn()
}
mockExtensionService.enabledExtensions.push(testExtension)
await app.setup(mockCanvas)
// Ensure async preInit completed before init
expect(preInitComplete).toHaveBeenCalled()
expect(testExtension.init).toHaveBeenCalled()
// Verify order - preInit should be called before init
const preInitCallOrder = (preInitComplete as any).mock
.invocationCallOrder[0]
const initCallOrder = (testExtension.init as any).mock
.invocationCallOrder[0]
expect(preInitCallOrder).toBeLessThan(initCallOrder)
})
it('should call preInit for multiple extensions in registration order', async () => {
const extension1: ComfyExtension = {
name: 'Extension1',
preInit: vi.fn(() => {
callOrder.push('ext1-preInit')
})
}
const extension2: ComfyExtension = {
name: 'Extension2',
preInit: vi.fn(() => {
callOrder.push('ext2-preInit')
})
}
const extension3: ComfyExtension = {
name: 'Extension3',
preInit: vi.fn(() => {
callOrder.push('ext3-preInit')
})
}
mockExtensionService.enabledExtensions.push(
extension1,
extension2,
extension3
)
await app.setup(mockCanvas)
expect(callOrder).toContain('ext1-preInit')
expect(callOrder).toContain('ext2-preInit')
expect(callOrder).toContain('ext3-preInit')
})
it('should handle errors in preInit gracefully', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const errorExtension: ComfyExtension = {
name: 'ErrorExtension',
preInit: vi.fn(async () => {
throw new Error('PreInit error')
}),
init: vi.fn() // Should still be called
}
mockExtensionService.enabledExtensions.push(errorExtension)
await app.setup(mockCanvas)
// Error should be logged
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('Error in extension ErrorExtension'),
expect.any(Error)
)
// Other hooks should still be called
expect(errorExtension.init).toHaveBeenCalled()
consoleError.mockRestore()
})
})

View File

@@ -0,0 +1,234 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthHeaderProvider } from '@/providers/authHeaderProvider'
import { headerRegistry } from '@/services/headerRegistry'
import {
createAxiosWithHeaders,
fetchWithHeaders
} from '@/services/networkClientAdapter'
// Mock stores
const mockFirebaseAuthStore = {
getAuthHeader: vi.fn(),
getIdToken: vi.fn()
}
const mockApiKeyAuthStore = {
getAuthHeader: vi.fn()
}
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => mockFirebaseAuthStore
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => mockApiKeyAuthStore
}))
// Mock fetch
const mockFetch = vi.fn()
global.fetch = mockFetch
// Mock axios
vi.mock('axios')
const mockedAxios = axios as any
describe('Auth Header Integration', () => {
let authProviderRegistration: any
beforeEach(() => {
vi.clearAllMocks()
// Reset fetch mock
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ success: true })
})
// Reset axios mock
mockedAxios.create.mockReturnValue({
interceptors: {
request: {
use: vi.fn()
},
response: {
use: vi.fn()
}
},
defaults: {
headers: {
common: {},
get: {},
post: {},
put: {},
patch: {},
delete: {}
}
}
})
// Register auth header provider
authProviderRegistration = headerRegistry.registerHeaderProvider(
new AuthHeaderProvider(),
{ priority: 1000 }
)
})
afterEach(() => {
// Unregister the provider
authProviderRegistration.unregister()
vi.restoreAllMocks()
})
describe('fetchWithHeaders integration', () => {
it('should automatically add Firebase auth headers to fetch requests', async () => {
const mockAuthHeader = { Authorization: 'Bearer firebase-token-123' }
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeader)
await fetchWithHeaders('https://api.example.com/data')
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/data',
expect.objectContaining({
headers: expect.any(Headers)
})
)
// Verify the auth header was added
const callArgs = mockFetch.mock.calls[0]
const headers = callArgs[1].headers as Headers
expect(headers.get('Authorization')).toBe('Bearer firebase-token-123')
})
it('should automatically add API key headers when Firebase is not available', async () => {
const mockApiKeyHeader = { 'X-API-KEY': 'test-api-key' }
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockApiKeyHeader)
await fetchWithHeaders('https://api.example.com/data')
const callArgs = mockFetch.mock.calls[0]
const headers = callArgs[1].headers as Headers
expect(headers.get('X-API-KEY')).toBe('test-api-key')
})
it('should merge auth headers with existing headers', async () => {
const mockAuthHeader = { Authorization: 'Bearer firebase-token-123' }
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeader)
await fetchWithHeaders('https://api.example.com/data', {
headers: {
'Content-Type': 'application/json',
'X-Custom-Header': 'custom-value'
}
})
const callArgs = mockFetch.mock.calls[0]
const headers = callArgs[1].headers as Headers
expect(headers.get('Authorization')).toBe('Bearer firebase-token-123')
expect(headers.get('Content-Type')).toBe('application/json')
expect(headers.get('X-Custom-Header')).toBe('custom-value')
})
it('should not add headers when no auth is available', async () => {
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(null)
await fetchWithHeaders('https://api.example.com/data')
const callArgs = mockFetch.mock.calls[0]
const headers = callArgs[1].headers as Headers
expect(headers.get('Authorization')).toBeNull()
expect(headers.get('X-API-KEY')).toBeNull()
})
})
describe('createAxiosWithHeaders integration', () => {
it('should setup interceptor to add auth headers', async () => {
const mockInstance = {
interceptors: {
request: {
use: vi.fn()
},
response: {
use: vi.fn()
}
},
defaults: {
headers: {
common: {},
get: {},
post: {},
put: {},
patch: {},
delete: {}
}
}
}
mockedAxios.create.mockReturnValue(mockInstance)
createAxiosWithHeaders({ baseURL: 'https://api.example.com' })
// Verify interceptor was registered
expect(mockInstance.interceptors.request.use).toHaveBeenCalledOnce()
// Get the interceptor function
const interceptorCall =
mockInstance.interceptors.request.use.mock.calls[0]
const requestInterceptor = interceptorCall[0]
// Test the interceptor
const mockAuthHeader = { Authorization: 'Bearer firebase-token-123' }
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeader)
const config = {
url: '/test',
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
const modifiedConfig = await requestInterceptor(config)
expect(modifiedConfig.headers.Authorization).toBe(
'Bearer firebase-token-123'
)
expect(modifiedConfig.headers['Content-Type']).toBe('application/json')
})
})
describe('Multiple providers with priority', () => {
it('should apply headers in priority order', async () => {
// Register a second provider with higher priority
const customProvider = {
provideHeaders: vi.fn().mockResolvedValue({
'X-Custom': 'high-priority',
Authorization: 'Bearer custom-token' // This should override the auth provider
})
}
const customRegistration = headerRegistry.registerHeaderProvider(
customProvider,
{ priority: 2000 } // Higher priority than auth provider
)
// Auth provider returns different token
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue({
Authorization: 'Bearer firebase-token'
})
await fetchWithHeaders('https://api.example.com/data')
const callArgs = mockFetch.mock.calls[0]
const headers = callArgs[1].headers as Headers
// Higher priority provider should win
expect(headers.get('Authorization')).toBe('Bearer custom-token')
expect(headers.get('X-Custom')).toBe('high-priority')
customRegistration.dispose()
})
})
})

View File

@@ -0,0 +1,144 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
ApiKeyHeaderProvider,
AuthHeaderProvider,
FirebaseAuthHeaderProvider
} from '@/providers/authHeaderProvider'
import type { HeaderProviderContext } from '@/types/headerTypes'
// Mock stores
const mockFirebaseAuthStore = {
getAuthHeader: vi.fn(),
getIdToken: vi.fn()
}
const mockApiKeyAuthStore = {
getAuthHeader: vi.fn()
}
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => mockFirebaseAuthStore
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => mockApiKeyAuthStore
}))
describe('authHeaderProvider', () => {
const mockContext: HeaderProviderContext = {
url: 'https://api.example.com/test',
method: 'GET'
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('AuthHeaderProvider', () => {
it('should provide Firebase auth header when available', async () => {
const provider = new AuthHeaderProvider()
const mockAuthHeader = { Authorization: 'Bearer firebase-token-123' }
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeader)
const headers = await provider.provideHeaders(mockContext)
expect(headers).toEqual(mockAuthHeader)
expect(mockFirebaseAuthStore.getAuthHeader).toHaveBeenCalledOnce()
})
it('should provide API key header when Firebase auth is not available', async () => {
const provider = new AuthHeaderProvider()
const mockApiKeyHeader = { 'X-API-KEY': 'test-api-key' }
// Firebase returns null, but includes API key as fallback
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockApiKeyHeader)
const headers = await provider.provideHeaders(mockContext)
expect(headers).toEqual(mockApiKeyHeader)
})
it('should return empty object when no auth is available', async () => {
const provider = new AuthHeaderProvider()
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(null)
const headers = await provider.provideHeaders(mockContext)
expect(headers).toEqual({})
})
})
describe('ApiKeyHeaderProvider', () => {
it('should provide API key header when available', () => {
const provider = new ApiKeyHeaderProvider()
const mockApiKeyHeader = { 'X-API-KEY': 'test-api-key' }
mockApiKeyAuthStore.getAuthHeader.mockReturnValue(mockApiKeyHeader)
const headers = provider.provideHeaders(mockContext)
expect(headers).toEqual(mockApiKeyHeader)
expect(mockApiKeyAuthStore.getAuthHeader).toHaveBeenCalledOnce()
})
it('should return empty object when no API key is available', () => {
const provider = new ApiKeyHeaderProvider()
mockApiKeyAuthStore.getAuthHeader.mockReturnValue(null)
const headers = provider.provideHeaders(mockContext)
expect(headers).toEqual({})
})
})
describe('FirebaseAuthHeaderProvider', () => {
it('should provide Firebase auth header when available', async () => {
const provider = new FirebaseAuthHeaderProvider()
const mockToken = 'firebase-token-456'
mockFirebaseAuthStore.getIdToken.mockResolvedValue(mockToken)
const headers = await provider.provideHeaders(mockContext)
expect(headers).toEqual({
Authorization: `Bearer ${mockToken}`
})
expect(mockFirebaseAuthStore.getIdToken).toHaveBeenCalledOnce()
})
it('should return empty object when no Firebase token is available', async () => {
const provider = new FirebaseAuthHeaderProvider()
mockFirebaseAuthStore.getIdToken.mockResolvedValue(null)
const headers = await provider.provideHeaders(mockContext)
expect(headers).toEqual({})
})
it('should not fall back to API key', async () => {
const provider = new FirebaseAuthHeaderProvider()
// Firebase has no token
mockFirebaseAuthStore.getIdToken.mockResolvedValue(null)
// API key is available
mockApiKeyAuthStore.getAuthHeader.mockReturnValue({
'X-API-KEY': 'test-key'
})
const headers = await provider.provideHeaders(mockContext)
expect(headers).toEqual({})
// Should not call API key store
expect(mockApiKeyAuthStore.getAuthHeader).not.toHaveBeenCalled()
})
})
})

View File

@@ -27,6 +27,11 @@ vi.mock('axios', () => ({
}
}))
// Mock networkClientAdapter to return the same axios instance
vi.mock('@/services/networkClientAdapter', () => ({
createAxiosWithHeaders: vi.fn(() => mockAxiosInstance)
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => mockFirebaseAuthStore)
}))

View File

@@ -0,0 +1,253 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { headerRegistry } from '@/services/headerRegistry'
import type {
HeaderProviderContext,
IHeaderProvider
} from '@/types/headerTypes'
describe('headerRegistry', () => {
beforeEach(() => {
headerRegistry.clear()
})
describe('registerHeaderProvider', () => {
it('should register a header provider', () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({ 'X-Test': 'value' })
}
const registration = headerRegistry.registerHeaderProvider(provider)
expect(registration).toBeDefined()
expect(registration.id).toMatch(/^header-provider-\d+$/)
expect(headerRegistry.providerCount).toBe(1)
})
it('should return a disposable registration', () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn()
}
const registration = headerRegistry.registerHeaderProvider(provider)
expect(headerRegistry.providerCount).toBe(1)
registration.dispose()
expect(headerRegistry.providerCount).toBe(0)
})
it('should insert providers in priority order', async () => {
const provider1: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({ 'X-Priority': 'low' })
}
const provider2: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({ 'X-Priority': 'high' })
}
const provider3: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({ 'X-Priority': 'medium' })
}
headerRegistry.registerHeaderProvider(provider1, { priority: 1 })
headerRegistry.registerHeaderProvider(provider2, { priority: 10 })
headerRegistry.registerHeaderProvider(provider3, { priority: 5 })
const context: HeaderProviderContext = {
url: 'https://api.example.com',
method: 'GET'
}
const headers = await headerRegistry.getHeaders(context)
// Higher priority provider should override
expect(headers['X-Priority']).toBe('high')
})
})
describe('getHeaders', () => {
it('should combine headers from all providers', async () => {
const provider1: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Header-1': 'value1',
'X-Common': 'provider1'
})
}
const provider2: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Header-2': 'value2',
'X-Common': 'provider2'
})
}
headerRegistry.registerHeaderProvider(provider1, { priority: 1 })
headerRegistry.registerHeaderProvider(provider2, { priority: 2 })
const context: HeaderProviderContext = {
url: 'https://api.example.com',
method: 'GET'
}
const headers = await headerRegistry.getHeaders(context)
expect(headers).toEqual({
'X-Header-1': 'value1',
'X-Header-2': 'value2',
'X-Common': 'provider2' // Higher priority wins
})
})
it('should resolve function header values', async () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Static': 'static',
'X-Dynamic': () => 'dynamic',
'X-Async': async () => 'async-value'
})
}
headerRegistry.registerHeaderProvider(provider)
const context: HeaderProviderContext = {
url: 'https://api.example.com',
method: 'GET'
}
const headers = await headerRegistry.getHeaders(context)
expect(headers).toEqual({
'X-Static': 'static',
'X-Dynamic': 'dynamic',
'X-Async': 'async-value'
})
})
it('should handle async providers', async () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
return { 'X-Async': 'resolved' }
})
}
headerRegistry.registerHeaderProvider(provider)
const context: HeaderProviderContext = {
url: 'https://api.example.com',
method: 'GET'
}
const headers = await headerRegistry.getHeaders(context)
expect(headers).toEqual({ 'X-Async': 'resolved' })
})
it('should apply filters when provided', async () => {
const provider1: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({ 'X-Api': 'api-header' })
}
const provider2: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({ 'X-Other': 'other-header' })
}
// Only apply to API URLs
headerRegistry.registerHeaderProvider(provider1, {
filter: (ctx) => ctx.url.includes('/api/')
})
// Apply to all URLs
headerRegistry.registerHeaderProvider(provider2)
const apiContext: HeaderProviderContext = {
url: 'https://example.com/api/users',
method: 'GET'
}
const otherContext: HeaderProviderContext = {
url: 'https://example.com/assets/image.png',
method: 'GET'
}
const apiHeaders = await headerRegistry.getHeaders(apiContext)
const otherHeaders = await headerRegistry.getHeaders(otherContext)
expect(apiHeaders).toEqual({
'X-Api': 'api-header',
'X-Other': 'other-header'
})
expect(otherHeaders).toEqual({
'X-Other': 'other-header'
})
})
it('should continue with other providers if one fails', async () => {
const provider1: IHeaderProvider = {
provideHeaders: vi.fn().mockRejectedValue(new Error('Provider error'))
}
const provider2: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({ 'X-Header': 'value' })
}
headerRegistry.registerHeaderProvider(provider1)
headerRegistry.registerHeaderProvider(provider2)
const context: HeaderProviderContext = {
url: 'https://api.example.com',
method: 'GET'
}
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const headers = await headerRegistry.getHeaders(context)
expect(headers).toEqual({ 'X-Header': 'value' })
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Error getting headers from provider'),
expect.any(Error)
)
consoleSpy.mockRestore()
})
})
describe('clear', () => {
it('should remove all providers', () => {
const provider1: IHeaderProvider = {
provideHeaders: vi.fn()
}
const provider2: IHeaderProvider = {
provideHeaders: vi.fn()
}
headerRegistry.registerHeaderProvider(provider1)
headerRegistry.registerHeaderProvider(provider2)
expect(headerRegistry.providerCount).toBe(2)
headerRegistry.clear()
expect(headerRegistry.providerCount).toBe(0)
})
})
describe('providerCount', () => {
it('should return the correct count of providers', () => {
expect(headerRegistry.providerCount).toBe(0)
const provider: IHeaderProvider = {
provideHeaders: vi.fn()
}
const reg1 = headerRegistry.registerHeaderProvider(provider)
expect(headerRegistry.providerCount).toBe(1)
const reg2 = headerRegistry.registerHeaderProvider(provider)
expect(headerRegistry.providerCount).toBe(2)
reg1.dispose()
expect(headerRegistry.providerCount).toBe(1)
reg2.dispose()
expect(headerRegistry.providerCount).toBe(0)
})
})
})

View File

@@ -0,0 +1,289 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { headerRegistry } from '@/services/headerRegistry'
import {
createAxiosWithHeaders,
fetchWithHeaders
} from '@/services/networkClientAdapter'
import type { IHeaderProvider } from '@/types/headerTypes'
// Mock axios
vi.mock('axios')
// Mock fetch globally
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
describe('networkClientAdapter', () => {
beforeEach(() => {
vi.clearAllMocks()
headerRegistry.clear()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('createAxiosWithHeaders', () => {
it('should create an axios instance with header injection', async () => {
// Setup mock axios instance
const mockInterceptors = {
request: {
use: vi.fn()
},
response: {
use: vi.fn()
}
}
const mockAxiosInstance = {
interceptors: mockInterceptors,
get: vi.fn(),
post: vi.fn()
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
// Create instance
createAxiosWithHeaders({ baseURL: 'https://api.example.com' })
// Verify axios.create was called with config
expect(axios.create).toHaveBeenCalledWith({
baseURL: 'https://api.example.com'
})
// Verify interceptor was added
expect(mockInterceptors.request.use).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function)
)
})
it('should inject headers from registry on request', async () => {
// Setup header provider
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Custom-Header': 'custom-value'
})
}
headerRegistry.registerHeaderProvider(provider)
// Setup mock axios
const mockInterceptors = {
request: {
use: vi.fn()
},
response: {
use: vi.fn()
}
}
const mockAxiosInstance = {
interceptors: mockInterceptors
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
// Create instance
createAxiosWithHeaders()
// Get the interceptor function
const [interceptorFn] = mockInterceptors.request.use.mock.calls[0]
// Test the interceptor
const config = {
url: '/api/test',
method: 'POST',
data: { foo: 'bar' },
headers: {
'Content-Type': 'application/json'
}
}
const result = await interceptorFn(config)
// Verify provider was called with correct context
expect(provider.provideHeaders).toHaveBeenCalledWith({
url: '/api/test',
method: 'POST',
body: { foo: 'bar' },
config
})
// Verify headers were merged
expect(result.headers).toEqual({
'Content-Type': 'application/json',
'X-Custom-Header': 'custom-value'
})
})
it('should handle interceptor errors', async () => {
// Setup mock axios
const mockInterceptors = {
request: {
use: vi.fn()
},
response: {
use: vi.fn()
}
}
const mockAxiosInstance = {
interceptors: mockInterceptors
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
// Create instance
createAxiosWithHeaders()
// Get the error handler
const [, errorHandler] = mockInterceptors.request.use.mock.calls[0]
// Test error handling
const error = new Error('Request error')
await expect(errorHandler(error)).rejects.toThrow('Request error')
})
})
describe('fetchWithHeaders', () => {
it('should inject headers from registry into fetch requests', async () => {
// Setup header provider
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Api-Key': 'test-key',
'X-Request-ID': '12345'
})
}
headerRegistry.registerHeaderProvider(provider)
// Setup fetch mock
mockFetch.mockResolvedValue(new Response('OK'))
// Make request
await fetchWithHeaders('https://api.example.com/data', {
method: 'GET',
headers: {
Accept: 'application/json'
}
})
// Verify provider was called
expect(provider.provideHeaders).toHaveBeenCalledWith({
url: 'https://api.example.com/data',
method: 'GET',
body: undefined
})
// Verify fetch was called with merged headers
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/data',
expect.objectContaining({
method: 'GET',
headers: expect.any(Headers)
})
)
// Check the headers
const [, init] = mockFetch.mock.calls[0]
const headers = init.headers as Headers
expect(headers.get('Accept')).toBe('application/json')
expect(headers.get('X-Api-Key')).toBe('test-key')
expect(headers.get('X-Request-ID')).toBe('12345')
})
it('should handle URL objects', async () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({})
}
headerRegistry.registerHeaderProvider(provider)
mockFetch.mockResolvedValue(new Response('OK'))
const url = new URL('https://api.example.com/test')
await fetchWithHeaders(url)
expect(provider.provideHeaders).toHaveBeenCalledWith({
url: 'https://api.example.com/test',
method: 'GET',
body: undefined
})
})
it('should handle Request objects', async () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Custom': 'value'
})
}
headerRegistry.registerHeaderProvider(provider)
mockFetch.mockResolvedValue(new Response('OK'))
const request = new Request('https://api.example.com/test', {
method: 'POST',
body: JSON.stringify({ data: 'test' })
})
await fetchWithHeaders(request)
expect(provider.provideHeaders).toHaveBeenCalledWith({
url: 'https://api.example.com/test',
method: 'POST',
body: undefined // init.body is undefined when using Request object
})
// Verify headers were added
const [, init] = mockFetch.mock.calls[0]
const headers = init.headers as Headers
expect(headers.get('X-Custom')).toBe('value')
})
it('should convert header values to strings', async () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Number': 123,
'X-Boolean': true,
'X-String': 'test'
})
}
headerRegistry.registerHeaderProvider(provider)
mockFetch.mockResolvedValue(new Response('OK'))
await fetchWithHeaders('https://api.example.com')
const [, init] = mockFetch.mock.calls[0]
const headers = init.headers as Headers
expect(headers.get('X-Number')).toBe('123')
expect(headers.get('X-Boolean')).toBe('true')
expect(headers.get('X-String')).toBe('test')
})
it('should preserve existing headers and let registry override', async () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Override': 'new-value',
'X-New': 'added'
})
}
headerRegistry.registerHeaderProvider(provider)
mockFetch.mockResolvedValue(new Response('OK'))
await fetchWithHeaders('https://api.example.com', {
headers: {
'X-Override': 'old-value',
'X-Existing': 'keep-me'
}
})
const [, init] = mockFetch.mock.calls[0]
const headers = init.headers as Headers
expect(headers.get('X-Override')).toBe('new-value') // Registry wins
expect(headers.get('X-Existing')).toBe('keep-me')
expect(headers.get('X-New')).toBe('added')
})
})
})

View File

@@ -15,6 +15,11 @@ vi.mock('axios', () => ({
}
}))
// Mock networkClientAdapter to return the same axios instance
vi.mock('@/services/networkClientAdapter', () => ({
createAxiosWithHeaders: vi.fn(() => mockAxiosInstance)
}))
describe('useReleaseService', () => {
let service: ReturnType<typeof useReleaseService>

View File

@@ -1,3 +1,4 @@
import axios from 'axios'
import * as firebaseAuth from 'firebase/auth'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -5,6 +6,42 @@ import * as vuefire from 'vuefire'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
// Hoist the mock to avoid hoisting issues
const mockAxiosInstance = vi.hoisted(() => ({
get: vi.fn().mockResolvedValue({
data: { balance: { credits: 0 } },
status: 200
}),
post: vi.fn().mockResolvedValue({
data: { id: 'test-customer-id' },
status: 201
}),
interceptors: {
request: {
use: vi.fn()
},
response: {
use: vi.fn()
}
}
}))
vi.mock('axios', () => {
return {
default: {
create: vi.fn().mockReturnValue(mockAxiosInstance),
isAxiosError: vi.fn().mockImplementation(() => false)
}
}
})
// Mock networkClientAdapter
vi.mock('@/services/networkClientAdapter', () => ({
createAxiosWithHeaders: vi.fn(() => mockAxiosInstance)
}))
const mockedAxios = vi.mocked(axios)
// Mock fetch
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
@@ -91,7 +128,18 @@ describe('useFirebaseAuthStore', () => {
}
beforeEach(() => {
vi.resetAllMocks()
vi.clearAllMocks()
// Reset axios mock responses to defaults
mockAxiosInstance.get.mockResolvedValue({
data: { balance: { credits: 0 } },
status: 200
})
mockAxiosInstance.post.mockResolvedValue({
data: { id: 'test-customer-id' },
status: 201
})
;(mockedAxios.isAxiosError as any).mockReturnValue(false)
// Mock useFirebaseAuth to return our mock auth object
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(mockAuth as any)

View File

@@ -3,6 +3,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
vi.mock('@/scripts/api', () => ({
@@ -67,6 +68,11 @@ vi.mock('marked', () => ({
}
}))
// Mock fetchWithHeaders
vi.mock('@/services/networkClientAdapter', () => ({
fetchWithHeaders: vi.fn()
}))
describe('nodeHelpStore', () => {
// Define a mock node for testing
const mockCoreNode = {
@@ -91,10 +97,17 @@ describe('nodeHelpStore', () => {
const mockFetch = vi.fn()
global.fetch = mockFetch
// Helper to mock both fetch and fetchWithHeaders
const mockFetchResponse = (response: any) => {
mockFetch.mockResolvedValueOnce(response)
vi.mocked(fetchWithHeaders).mockResolvedValueOnce(response)
}
beforeEach(() => {
// Setup Pinia
setActivePinia(createPinia())
mockFetch.mockReset()
vi.mocked(fetchWithHeaders).mockReset()
})
it('should initialize with empty state', () => {
@@ -144,7 +157,7 @@ describe('nodeHelpStore', () => {
it('should render markdown content correctly', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
mockFetchResponse({
ok: true,
text: async () => '# Test Help\nThis is test help content'
})
@@ -160,7 +173,7 @@ describe('nodeHelpStore', () => {
it('should handle fetch errors and fall back to description', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
mockFetchResponse({
ok: false,
statusText: 'Not Found'
})
@@ -175,7 +188,7 @@ describe('nodeHelpStore', () => {
it('should include alt attribute for images', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
mockFetchResponse({
ok: true,
text: async () => '![image](test.jpg)'
})
@@ -188,7 +201,7 @@ describe('nodeHelpStore', () => {
it('should prefix relative video src in custom nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
mockFetchResponse({
ok: true,
text: async () => '<video src="video.mp4"></video>'
})
@@ -203,7 +216,7 @@ describe('nodeHelpStore', () => {
it('should prefix relative video src for core nodes with node-specific base URL', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
mockFetchResponse({
ok: true,
text: async () => '<video src="video.mp4"></video>'
})
@@ -218,7 +231,7 @@ describe('nodeHelpStore', () => {
it('should prefix relative source src in custom nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
mockFetchResponse({
ok: true,
text: async () =>
'<video><source src="video.mp4" type="video/mp4" /></video>'
@@ -234,7 +247,7 @@ describe('nodeHelpStore', () => {
it('should prefix relative source src for core nodes with node-specific base URL', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
mockFetchResponse({
ok: true,
text: async () =>
'<video><source src="video.webm" type="video/webm" /></video>'
@@ -250,7 +263,9 @@ describe('nodeHelpStore', () => {
it('should handle loading state', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockImplementationOnce(() => new Promise(() => {})) // Never resolves
vi.mocked(fetchWithHeaders).mockImplementationOnce(
() => new Promise(() => {})
) // Never resolves
nodeHelpStore.openHelp(mockCoreNode as any)
await nextTick()
@@ -261,24 +276,24 @@ describe('nodeHelpStore', () => {
it('should try fallback URL for custom nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch
vi.mocked(fetchWithHeaders)
.mockResolvedValueOnce({
ok: false,
statusText: 'Not Found'
})
} as Response)
.mockResolvedValueOnce({
ok: true,
text: async () => '# Fallback content'
})
} as Response)
nodeHelpStore.openHelp(mockCustomNode as any)
await flushPromises()
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenCalledWith(
expect(vi.mocked(fetchWithHeaders)).toHaveBeenCalledTimes(2)
expect(vi.mocked(fetchWithHeaders)).toHaveBeenCalledWith(
'/extensions/test_module/docs/CustomNode/en.md'
)
expect(mockFetch).toHaveBeenCalledWith(
expect(vi.mocked(fetchWithHeaders)).toHaveBeenCalledWith(
'/extensions/test_module/docs/CustomNode.md'
)
})
@@ -286,7 +301,7 @@ describe('nodeHelpStore', () => {
it('should prefix relative img src in raw HTML for custom nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
mockFetchResponse({
ok: true,
text: async () => '# Test\n<img src="image.png" alt="Test image">'
})
@@ -302,7 +317,7 @@ describe('nodeHelpStore', () => {
it('should prefix relative img src in raw HTML for core nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
mockFetchResponse({
ok: true,
text: async () => '# Test\n<img src="image.png" alt="Test image">'
})
@@ -318,7 +333,7 @@ describe('nodeHelpStore', () => {
it('should not prefix absolute img src in raw HTML', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
mockFetchResponse({
ok: true,
text: async () => '<img src="/absolute/image.png" alt="Absolute">'
})
@@ -334,7 +349,7 @@ describe('nodeHelpStore', () => {
it('should not prefix external img src in raw HTML', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
mockFetchResponse({
ok: true,
text: async () =>
'<img src="https://example.com/image.png" alt="External">'
@@ -351,7 +366,7 @@ describe('nodeHelpStore', () => {
it('should handle various quote styles in media src attributes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
mockFetchResponse({
ok: true,
text: async () => `# Media Test