From 9a6e50e980d1d92400e6bf53c4d7fb8102d0c2c6 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 9 Sep 2026 10:56:45 +0200 Subject: [PATCH] fix(hub-ui): retry failed client scripts --- .../state/client-script.integration.test.ts | 83 +++++++++++++++++++ .../hub-ui/src/client/state/setup-script.ts | 9 +- 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 packages/hub-ui/src/client/state/client-script.integration.test.ts diff --git a/packages/hub-ui/src/client/state/client-script.integration.test.ts b/packages/hub-ui/src/client/state/client-script.integration.test.ts new file mode 100644 index 000000000..9c60bd1e9 --- /dev/null +++ b/packages/hub-ui/src/client/state/client-script.integration.test.ts @@ -0,0 +1,83 @@ +import type { DevframeDockEntry } from '@devframes/hub' +import type { DevframeRpcClient } from '@devframes/hub/client' +import type { SharedState } from 'devframe/utils/shared-state' +import { createEventEmitter } from 'devframe/utils/events' +import { createSharedState } from 'devframe/utils/shared-state' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { createDocksContext } from './context' + +interface StubSharedState extends SharedState { + push: (value: Value) => void +} + +function createStubSharedState(initialValue: Value): StubSharedState { + const state = createSharedState({ initialValue }) as StubSharedState + state.push = value => state.mutate(() => value) + return state +} + +function createStubRpc() { + const sharedStates = new Map>() + // eslint-disable-next-line slop/no-chained-type-assertions -- integration test double implements only the RPC surface createDocksContext exercises + const rpc = { + isTrusted: true, + status: 'connected', + connectionError: null, + connectionMeta: { backend: 'live', configs: {} }, + connection: {}, + events: createEventEmitter(), + sharedState: { + async get(key: string, options?: { initialValue?: object }) { + if (!sharedStates.has(key)) + sharedStates.set(key, createStubSharedState(options?.initialValue ?? {})) + return sharedStates.get(key)! + }, + }, + client: { register: vi.fn() }, + call: vi.fn(), + } as unknown as DevframeRpcClient + return { rpc, sharedStates } +} + +declare global { + // eslint-disable-next-line vars-on-top -- test hook called by the dynamically imported client module + var __DEVFRAME_CLIENT_SCRIPT_ATTEMPT__: (() => void) | undefined +} + +afterEach(() => { + delete globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ + vi.restoreAllMocks() +}) + +describe('dock client scripts', () => { + it('retries setup on a later activation after it fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + let attempts = 0 + globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => { + attempts++ + if (attempts === 1) + throw new Error('setup failed') + } + const { rpc, sharedStates } = createStubRpc() + const context = await createDocksContext('embedded', rpc) + const entry = { + id: 'retry-client-script', + type: 'iframe', + title: 'Retry client script', + icon: 'ph:play', + url: '/retry', + clientScript: { + importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()', + }, + } satisfies DevframeDockEntry + + sharedStates.get('devframe:docks')!.push([entry]) + await nextTick() + + await expect(context.docks.switchEntry(entry.id)).rejects.toThrow('setup failed') + await context.docks.switchEntry(null) + await expect(context.docks.switchEntry(entry.id)).resolves.toBe(true) + expect(attempts).toBe(2) + }) +}) diff --git a/packages/hub-ui/src/client/state/setup-script.ts b/packages/hub-ui/src/client/state/setup-script.ts index 299152757..eb6642c2b 100644 --- a/packages/hub-ui/src/client/state/setup-script.ts +++ b/packages/hub-ui/src/client/state/setup-script.ts @@ -62,7 +62,14 @@ export function executeSetupScript( if (entry.type !== 'action' && _setupPromises.has(entry.id)) return _setupPromises.get(entry.id)! const promise = _executeSetupScript(entry, context) - if (entry.type !== 'action') + if (entry.type !== 'action') { _setupPromises.set(entry.id, promise) + promise.catch(() => { + // A failed setup must not poison this entry permanently. The caller still + // receives the rejection, while a later activation or update may retry. + if (_setupPromises.get(entry.id) === promise) + _setupPromises.delete(entry.id) + }) + } return promise }