Skip to content

Commit 8ed9be2

Browse files
authored
feat(useRemoteWidget): add cloud firebase auth (#6249)
## Summary Add Firebase authentication for `useRemoteWidget` Cloud API calls. ## Changes - Incorporate changes from c27edb7 - Add tests ## Screenshots <img width="849" height="552" alt="Screenshot 2025-10-23 at 8 41 00 PM" src="https://github.com/user-attachments/assets/23e07aac-22ea-4222-a90c-00335937a011" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6249-feat-useRemoteWidget-add-cloud-firebase-auth-2966d73d36508121935efc9ed07c47d2) by [Unito](https://www.unito.io)
1 parent 393f77e commit 8ed9be2

File tree

2 files changed

+133
-47
lines changed

2 files changed

+133
-47
lines changed

src/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import axios from 'axios'
22

33
import { useChainCallback } from '@/composables/functional/useChainCallback'
44
import type { IWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
5+
import { isCloud } from '@/platform/distribution/types'
56
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
67
import { api } from '@/scripts/api'
8+
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
79

810
const MAX_RETRIES = 5
911
const TIMEOUT = 4096
@@ -19,6 +21,17 @@ interface CacheEntry<T> {
1921
failed?: boolean
2022
}
2123

24+
async function getAuthHeaders() {
25+
if (isCloud) {
26+
const authStore = useFirebaseAuthStore()
27+
const authHeader = await authStore.getAuthHeader()
28+
return {
29+
...(authHeader && { headers: authHeader })
30+
}
31+
}
32+
return {}
33+
}
34+
2235
const dataCache = new Map<string, CacheEntry<any>>()
2336

2437
const createCacheKey = (config: RemoteWidgetConfig): string => {
@@ -57,11 +70,16 @@ const fetchData = async (
5770
controller: AbortController
5871
) => {
5972
const { route, response_key, query_params, timeout = TIMEOUT } = config
73+
74+
const authHeaders = await getAuthHeaders()
75+
6076
const res = await axios.get(route, {
6177
params: query_params,
6278
signal: controller.signal,
63-
timeout
79+
timeout,
80+
...authHeaders
6481
})
82+
6583
return response_key ? res.data[response_key] : res.data
6684
}
6785

tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget.test.ts

Lines changed: 114 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,62 @@
11
import axios from 'axios'
22
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
33

4+
import type { IWidget } from '@/lib/litegraph/src/litegraph'
5+
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
46
import { useRemoteWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget'
57
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
68

7-
vi.mock('axios', () => {
9+
const createMockNode = (overrides: Partial<LGraphNode> = {}): LGraphNode => {
10+
const node = new LGraphNode('TestNode')
11+
Object.assign(node, overrides)
12+
return node
13+
}
14+
15+
const createMockWidget = (overrides = {}): IWidget =>
16+
({ ...overrides }) as unknown as IWidget
17+
18+
const mockCloudAuth = vi.hoisted(() => ({
19+
isCloud: false,
20+
authHeader: null as { Authorization: string } | null
21+
}))
22+
23+
vi.mock('axios', async (importOriginal) => {
24+
const actual = await importOriginal<typeof import('axios')>()
825
return {
926
default: {
27+
...actual.default,
1028
get: vi.fn()
1129
}
1230
}
1331
})
1432

15-
vi.mock('@/i18n', () => ({
16-
i18n: {
17-
global: {
18-
t: vi.fn((key) => key)
19-
}
33+
vi.mock('@/platform/distribution/types', () => ({
34+
get isCloud() {
35+
return mockCloudAuth.isCloud
2036
}
2137
}))
2238

23-
vi.mock('@/platform/settings/settingStore', () => ({
24-
useSettingStore: () => ({
25-
settings: {}
26-
})
27-
}))
28-
29-
vi.mock('@/scripts/api', () => ({
30-
api: {
31-
addEventListener: vi.fn(),
32-
removeEventListener: vi.fn()
39+
vi.mock('@/stores/firebaseAuthStore', async (importOriginal) => {
40+
const actual =
41+
await importOriginal<typeof import('@/stores/firebaseAuthStore')>()
42+
return {
43+
...actual,
44+
useFirebaseAuthStore: vi.fn(() => ({
45+
getAuthHeader: vi.fn(() => Promise.resolve(mockCloudAuth.authHeader))
46+
}))
3347
}
34-
}))
48+
})
3549

36-
vi.mock('@/composables/functional/useChainCallback', () => ({
37-
useChainCallback: vi.fn((original, ...callbacks) => {
38-
return function (this: any, ...args: any[]) {
39-
original?.apply(this, args)
40-
callbacks.forEach((cb: any) => cb.apply(this, args))
41-
}
42-
})
43-
}))
50+
vi.mock('@/platform/settings/settingStore', async (importOriginal) => {
51+
const actual =
52+
await importOriginal<typeof import('@/platform/settings/settingStore')>()
53+
return {
54+
...actual,
55+
useSettingStore: () => ({
56+
settings: {}
57+
})
58+
}
59+
})
4460

4561
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
4662
const DEFAULT_VALUE = 'Loading...'
@@ -56,10 +72,8 @@ function createMockConfig(overrides = {}): RemoteWidgetConfig {
5672
const createMockOptions = (inputOverrides = {}) => ({
5773
remoteConfig: createMockConfig(inputOverrides),
5874
defaultValue: DEFAULT_VALUE,
59-
node: {
60-
addWidget: vi.fn()
61-
} as any,
62-
widget: {} as any
75+
node: createMockNode(),
76+
widget: createMockWidget()
6377
})
6478

6579
function mockAxiosResponse(data: unknown, status = 200) {
@@ -224,12 +238,19 @@ describe('useRemoteWidget', () => {
224238
const { hook } = await setupHookWithResponse(mockData)
225239

226240
await getResolvedValue(hook)
241+
expect(hook.getCachedValue()).toEqual(mockData)
242+
227243
const refreshedData = ['data that user forced to be fetched']
228244
mockAxiosResponse(refreshedData)
229245

230246
hook.refreshValue()
231-
const data = await getResolvedValue(hook)
232-
expect(data).toEqual(refreshedData)
247+
248+
// Wait for cache to update with refreshed data
249+
await vi.waitFor(() => {
250+
expect(hook.getCachedValue()).toEqual(refreshedData)
251+
})
252+
253+
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
233254
})
234255

235256
it('permanent widgets should still retry if request fails', async () => {
@@ -417,16 +438,25 @@ describe('useRemoteWidget', () => {
417438
})
418439

419440
it('should prevent duplicate in-flight requests', async () => {
420-
const promise = Promise.resolve({ data: ['non-duplicate'] })
421-
vi.mocked(axios.get).mockImplementationOnce(() => promise as any)
441+
const mockData = ['non-duplicate']
442+
mockAxiosResponse(mockData)
422443

423444
const hook = useRemoteWidget(createMockOptions())
424-
const [result1, result2] = await Promise.all([
425-
getResolvedValue(hook),
426-
getResolvedValue(hook)
427-
])
428445

429-
expect(result1).toBe(result2)
446+
// Start two concurrent getValue calls
447+
const promise1 = new Promise<void>((resolve) => {
448+
hook.getValue(() => resolve())
449+
})
450+
const promise2 = new Promise<void>((resolve) => {
451+
hook.getValue(() => resolve())
452+
})
453+
454+
// Wait for both e
455+
await Promise.all([promise1, promise2])
456+
457+
// Both should see the same cached data
458+
expect(hook.getCachedValue()).toEqual(mockData)
459+
// Only one axios call should have been made
430460
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
431461
})
432462
})
@@ -518,6 +548,44 @@ describe('useRemoteWidget', () => {
518548
})
519549
})
520550

551+
describe('cloud distribution authentication', () => {
552+
describe('when distribution is cloud', () => {
553+
describe('when authenticated', () => {
554+
it('passes Firebase authentication token in request headers', async () => {
555+
const mockData = ['authenticated data']
556+
mockCloudAuth.authHeader = null
557+
mockCloudAuth.isCloud = true
558+
mockCloudAuth.authHeader = { Authorization: 'Bearer test-token' }
559+
mockAxiosResponse(mockData)
560+
561+
const hook = useRemoteWidget(createMockOptions())
562+
await getResolvedValue(hook)
563+
564+
expect(vi.mocked(axios.get)).toHaveBeenCalledWith(
565+
expect.any(String),
566+
expect.objectContaining({
567+
headers: { Authorization: 'Bearer test-token' }
568+
})
569+
)
570+
})
571+
})
572+
})
573+
574+
describe('when distribution is not cloud', () => {
575+
it('bypasses authentication for non-cloud environments', async () => {
576+
const mockData = ['non-cloud data']
577+
mockCloudAuth.isCloud = false
578+
mockAxiosResponse(mockData)
579+
580+
const hook = useRemoteWidget(createMockOptions())
581+
await getResolvedValue(hook)
582+
583+
const axiosCall = vi.mocked(axios.get).mock.calls[0][1]
584+
expect(axiosCall).not.toHaveProperty('headers')
585+
})
586+
})
587+
})
588+
521589
describe('auto-refresh on task completion', () => {
522590
it('should add auto-refresh toggle widget', () => {
523591
const mockNode = {
@@ -550,6 +618,7 @@ describe('useRemoteWidget', () => {
550618

551619
it('should register event listener when enabled', async () => {
552620
const { api } = await import('@/scripts/api')
621+
const addEventListenerSpy = vi.spyOn(api, 'addEventListener')
553622

554623
const mockNode = {
555624
addWidget: vi.fn(),
@@ -567,7 +636,7 @@ describe('useRemoteWidget', () => {
567636
})
568637

569638
// Event listener should be registered immediately
570-
expect(api.addEventListener).toHaveBeenCalledWith(
639+
expect(addEventListenerSpy).toHaveBeenCalledWith(
571640
'execution_success',
572641
expect.any(Function)
573642
)
@@ -577,8 +646,7 @@ describe('useRemoteWidget', () => {
577646
const { api } = await import('@/scripts/api')
578647
let executionSuccessHandler: (() => void) | undefined
579648

580-
// Capture the event handler
581-
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
649+
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
582650
if (event === 'execution_success') {
583651
executionSuccessHandler = handler as () => void
584652
}
@@ -616,8 +684,7 @@ describe('useRemoteWidget', () => {
616684
const { api } = await import('@/scripts/api')
617685
let executionSuccessHandler: (() => void) | undefined
618686

619-
// Capture the event handler
620-
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
687+
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
621688
if (event === 'execution_success') {
622689
executionSuccessHandler = handler as () => void
623690
}
@@ -650,13 +717,14 @@ describe('useRemoteWidget', () => {
650717
const { api } = await import('@/scripts/api')
651718
let executionSuccessHandler: (() => void) | undefined
652719

653-
// Capture the event handler
654-
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
720+
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
655721
if (event === 'execution_success') {
656722
executionSuccessHandler = handler as () => void
657723
}
658724
})
659725

726+
const removeEventListenerSpy = vi.spyOn(api, 'removeEventListener')
727+
660728
const mockNode = {
661729
addWidget: vi.fn(),
662730
widgets: [],
@@ -676,7 +744,7 @@ describe('useRemoteWidget', () => {
676744
// Simulate node removal
677745
mockNode.onRemoved?.()
678746

679-
expect(api.removeEventListener).toHaveBeenCalledWith(
747+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
680748
'execution_success',
681749
executionSuccessHandler
682750
)

0 commit comments

Comments
 (0)