mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
15 Commits
pr5-list-v
...
feat/wire-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f33f6011ac | ||
|
|
d930e055f3 | ||
|
|
18cc800bd3 | ||
|
|
85783a9932 | ||
|
|
501a7e49e5 | ||
|
|
4ed516c3a8 | ||
|
|
efa440a381 | ||
|
|
d05153a0dc | ||
|
|
d6695ea66e | ||
|
|
0d6e5a75a9 | ||
|
|
2e80a5789c | ||
|
|
81ddfc2f58 | ||
|
|
e19f0b2da9 | ||
|
|
afecff6c94 | ||
|
|
5afeee258f |
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
180
src/examples/authHeaderAutoInjection.ts
Normal file
180
src/examples/authHeaderAutoInjection.ts
Normal 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
|
||||
}
|
||||
133
src/examples/headerProviderExample.ts
Normal file
133
src/examples/headerProviderExample.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
167
src/examples/headerRegistrationExtension.ts
Normal file
167
src/examples/headerRegistrationExtension.ts
Normal 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
|
||||
29
src/extensions/core/authHeaders.ts
Normal file
29
src/extensions/core/authHeaders.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import './authHeaders'
|
||||
import './clipspace'
|
||||
import './contextMenuFilter'
|
||||
import './dynamicPrompts'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
64
src/providers/authHeaderProvider.ts
Normal file
64
src/providers/authHeaderProvider.ts
Normal 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 {}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
129
src/services/headerRegistry.ts
Normal file
129
src/services/headerRegistry.ts
Normal 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()
|
||||
87
src/services/networkClientAdapter.ts
Normal file
87
src/services/networkClientAdapter.ts
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
61
src/types/headerTypes.ts
Normal 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
|
||||
}
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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()
|
||||
|
||||
83
tests-ui/tests/extension/authHeadersExtension.test.ts
Normal file
83
tests-ui/tests/extension/authHeadersExtension.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
328
tests-ui/tests/extension/preInitHook.test.ts
Normal file
328
tests-ui/tests/extension/preInitHook.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
234
tests-ui/tests/integration/authHeaderIntegration.test.ts
Normal file
234
tests-ui/tests/integration/authHeaderIntegration.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
144
tests-ui/tests/providers/authHeaderProvider.test.ts
Normal file
144
tests-ui/tests/providers/authHeaderProvider.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
253
tests-ui/tests/services/headerRegistry.test.ts
Normal file
253
tests-ui/tests/services/headerRegistry.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
289
tests-ui/tests/services/networkClientAdapter.test.ts
Normal file
289
tests-ui/tests/services/networkClientAdapter.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => ''
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user