diff --git a/src/examples/headerProviderExample.ts b/src/examples/headerProviderExample.ts new file mode 100644 index 0000000000..85968f4b9c --- /dev/null +++ b/src/examples/headerProviderExample.ts @@ -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 { + 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 { + // 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 { + // Simulate async session creation + return 'session-' + Date.now() + } +} diff --git a/src/services/headerRegistry.ts b/src/services/headerRegistry.ts new file mode 100644 index 0000000000..4db8379e99 --- /dev/null +++ b/src/services/headerRegistry.ts @@ -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 { + 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 { + 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() diff --git a/src/services/networkClientAdapter.ts b/src/services/networkClientAdapter.ts new file mode 100644 index 0000000000..2989f0ed0d --- /dev/null +++ b/src/services/networkClientAdapter.ts @@ -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 { + // 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 + }) +} diff --git a/src/types/headerTypes.ts b/src/types/headerTypes.ts new file mode 100644 index 0000000000..f96312ee51 --- /dev/null +++ b/src/types/headerTypes.ts @@ -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) + +/** + * 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 +} + +/** + * 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 + +/** + * 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 +} diff --git a/tests-ui/tests/services/headerRegistry.test.ts b/tests-ui/tests/services/headerRegistry.test.ts new file mode 100644 index 0000000000..d9acc0cae9 --- /dev/null +++ b/tests-ui/tests/services/headerRegistry.test.ts @@ -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) + }) + }) +}) diff --git a/tests-ui/tests/services/networkClientAdapter.test.ts b/tests-ui/tests/services/networkClientAdapter.test.ts new file mode 100644 index 0000000000..85c6bb7a99 --- /dev/null +++ b/tests-ui/tests/services/networkClientAdapter.test.ts @@ -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') + }) + }) +})