Skip to content

Commit 2397d1a

Browse files
bodia-uzclaude
andcommitted
Make createAppHostAndWaitForLoading timeout configurable and report all root unready APIs
- new options argument: createAppHostAndWaitForLoading(packages, pacts, { timeout }) (default 3000 preserves existing behavior) - on timeout, reject with AppHostLoadingTimeoutError carrying the structured UnreadyEntryPointsReport plus a message listing every root unready API with its declarer category, blocked entry points, and a rendered dependency path (via traceAPIDependency/visualizeDependencyTree) - clear the timeout timer once loading wins the race (previously left pending) The timeout message previously surfaced a single, sometimes non-root API from the deprecated getRootUnreadyAPI walk; the existing failure-path test asserted a transitively blocked API name and now asserts the actual root cause. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 86187d6 commit 2397d1a

2 files changed

Lines changed: 128 additions & 20 deletions

File tree

packages/repluggable-core/test/testKit.spec.tsx

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
asyncLoadMockPackage,
1313
dependsOnMockPackageEntryPoint,
1414
MockPublicAPI,
15-
createAppHostAndWaitForLoading
15+
createAppHostAndWaitForLoading,
16+
AppHostLoadingTimeoutError
1617
} from '../testKit'
1718

1819
interface APIKeys {
@@ -147,7 +148,61 @@ describe('App Host TestKit', () => {
147148
it('should throw if failed to load all packages', async () => {
148149
const hostPromise = createAppHostAndWaitForLoading([dependsOnMockPackageEntryPoint], [])
149150
jest.runAllTimers()
150-
await expect(hostPromise).rejects.toThrow(new RegExp(MockPublicAPI.name))
151+
await expect(hostPromise).rejects.toThrow(new RegExp(MockAPI.name))
152+
})
153+
154+
it('should reject with a structured report of all root unready APIs', async () => {
155+
const hostPromise = createAppHostAndWaitForLoading(
156+
[
157+
{
158+
name: 'entryPoint A',
159+
declareAPIs: () => [{ name: 'API A' }],
160+
getDependencyAPIs: () => [{ name: 'missing A' }]
161+
},
162+
{
163+
name: 'entryPoint B',
164+
declareAPIs: () => [{ name: 'API B' }],
165+
getDependencyAPIs: () => [{ name: 'missing B' }]
166+
}
167+
],
168+
[]
169+
)
170+
jest.runAllTimers()
171+
const error: AppHostLoadingTimeoutError = await hostPromise.then(
172+
() => {
173+
throw new Error('expected hostPromise to reject')
174+
},
175+
e => e
176+
)
177+
178+
expect(error).toBeInstanceOf(AppHostLoadingTimeoutError)
179+
expect(error.report.rootUnreadyAPIs.map(info => info.key.name)).toEqual(['missing A', 'missing B'])
180+
expect(error.message).toContain('"missing A"')
181+
expect(error.message).toContain('"missing B"')
182+
expect(error.message).toContain('required by: entryPoint A')
183+
expect(error.message).toContain('required by: entryPoint B')
184+
})
185+
186+
it('should respect a custom loading timeout', async () => {
187+
let settled = false
188+
const hostPromise = createAppHostAndWaitForLoading([dependsOnMockPackageEntryPoint], [], { timeout: 10000 })
189+
hostPromise.catch(() => (settled = true))
190+
191+
jest.advanceTimersByTime(9999)
192+
await Promise.resolve()
193+
await Promise.resolve()
194+
expect(settled).toBe(false)
195+
196+
jest.advanceTimersByTime(1)
197+
await expect(hostPromise).rejects.toBeInstanceOf(AppHostLoadingTimeoutError)
198+
})
199+
200+
it('should not leave a pending timeout after successful loading', async () => {
201+
const hostPromise = createAppHostAndWaitForLoading([dependsOnMockPackageEntryPoint, asyncLoadMockPackage], [])
202+
jest.advanceTimersByTime(500)
203+
const host = await hostPromise
204+
expect(host.getAPI(MockPublicAPI)).toBeDefined()
205+
expect(jest.getTimerCount()).toBe(0)
151206
})
152207
})
153208
})

packages/repluggable-core/testKit/index.ts

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import _ from 'lodash'
22
import { EntryPoint, ObservableState } from '../src/API'
33
import { AnySlotKey, AppHost, createAppHost as _createAppHost, EntryPointOrPackage, Shell, SlotKey } from '../src/index'
4+
import { UnreadyEntryPointsReport } from '../src/repluggableAppDebug'
45
import { createShellLogger } from '../src/loggers'
56
import { emptyLoggerOptions } from './emptyLoggerOptions'
67

@@ -81,34 +82,79 @@ export function createAppHostWithPacts(packages: EntryPointOrPackage[], pacts: P
8182
})
8283
}
8384

84-
export async function createAppHostAndWaitForLoading(packages: EntryPointOrPackage[], pacts: PactAPIBase[]): Promise<AppHost> {
85+
export interface WaitForLoadingOptions {
86+
/** milliseconds to wait for host loading before rejecting with an unready entry points report (default: 3000) */
87+
timeout?: number
88+
}
89+
90+
export class AppHostLoadingTimeoutError extends Error {
91+
constructor(message: string, public readonly report: UnreadyEntryPointsReport) {
92+
super(message)
93+
this.name = 'AppHostLoadingTimeoutError'
94+
}
95+
}
96+
97+
const WAIT_FOR_LOADING_PROBE_SHELL = 'Depends on all declared APIs'
98+
99+
const formatUnreadyReport = (timeout: number, report: UnreadyEntryPointsReport): string => {
100+
const { utils } = globalThis.repluggableAppDebug
101+
const isProbeShell = (name: string) => name === WAIT_FOR_LOADING_PROBE_SHELL
102+
const header = `createAppHostAndWaitForLoading - host not ready within ${timeout}ms.`
103+
const unreadyEntryPoints = report.unreadyEntryPoints.filter(entryPoint => !isProbeShell(entryPoint.name))
104+
const rootUnreadyEntryPoints = report.rootUnreadyEntryPoints.filter(entryPoint => !isProbeShell(entryPoint.name))
105+
106+
if (report.probableDependencyCycle) {
107+
const names = unreadyEntryPoints.map(entryPoint => `"${entryPoint.name}"`).join(', ')
108+
return `${header}
109+
No root unready API found while ${unreadyEntryPoints.length} entry points are unready - probable dependency cycle between: ${names}`
110+
}
111+
112+
const roots = report.rootUnreadyAPIs.map((info, index) => {
113+
const declaredBy =
114+
info.declaredBy === 'nobody'
115+
? 'declared by NO entry point (missing package or pact?)'
116+
: `declared by "${info.declaredBy.entryPointName}" (installed, but its contribution never completed - hung async contributeAPI?)`
117+
const requiredBy = info.requiredBy.filter(name => !isProbeShell(name))
118+
const lines = [`${index + 1}. "${info.key.name}" - ${declaredBy}`]
119+
if (requiredBy.length) {
120+
lines.push(` required by: ${requiredBy.join(', ')}`)
121+
const tree = utils.visualizeDependencyTree(utils.traceAPIDependency(requiredBy[0], info.key.name))
122+
lines.push(...tree.split('\n').map(line => ` ${line}`))
123+
}
124+
return lines.join('\n')
125+
})
126+
127+
return `${header}
128+
129+
Root unready APIs (${report.rootUnreadyAPIs.length}):
130+
${roots.join('\n')}
131+
132+
Unready entry points: ${rootUnreadyEntryPoints.length} blocked directly on root APIs, ${unreadyEntryPoints.length} total.`
133+
}
134+
135+
export async function createAppHostAndWaitForLoading(
136+
packages: EntryPointOrPackage[],
137+
pacts: PactAPIBase[],
138+
{ timeout = 3000 }: WaitForLoadingOptions = {}
139+
): Promise<AppHost> {
85140
const appHost = createAppHostWithPacts(packages, pacts)
86141
const declaredAPIs = _(packages)
87142
.flatten()
88143
.value()
89144
.flatMap((entryPoint: EntryPoint) => (entryPoint.declareAPIs ? entryPoint.declareAPIs() : []))
90145

91-
const timeoutPromise = new Promise((resolve, reject) => {
92-
setTimeout(() => {
93-
const readyAPIs = Array.from(globalThis.repluggableAppDebug.readyAPIs)
94-
const unreadyAPIs = declaredAPIs.filter(api => !readyAPIs.some(readyAPI => readyAPI.name === api.name))
95-
const rootUnreadyAPI = globalThis.repluggableAppDebug.utils.getRootUnreadyAPI()
96-
97-
reject(
98-
new Error(
99-
`createAppHostAndWaitForLoading - waiting for loading timed out.
100-
there's a high chance this missing API is the main reason for it: ${JSON.stringify(rootUnreadyAPI)}
101-
102-
in addition here's the full list of declared APIs that have not been contributed: ${JSON.stringify(unreadyAPIs)}`
103-
)
104-
)
105-
}, 3000)
146+
let timer: ReturnType<typeof setTimeout> | undefined
147+
const timeoutPromise = new Promise<never>((resolve, reject) => {
148+
timer = setTimeout(() => {
149+
const report = globalThis.repluggableAppDebug.utils.getUnreadyEntryPointsReport()
150+
reject(new AppHostLoadingTimeoutError(formatUnreadyReport(timeout, report), report))
151+
}, timeout)
106152
})
107153

108154
const loadingPromise = new Promise<void>(async resolve => {
109155
await appHost.addShells([
110156
{
111-
name: 'Depends on all declared APIs',
157+
name: WAIT_FOR_LOADING_PROBE_SHELL,
112158
getDependencyAPIs() {
113159
return declaredAPIs
114160
},
@@ -119,7 +165,14 @@ in addition here's the full list of declared APIs that have not been contributed
119165
])
120166
})
121167

122-
return Promise.race([timeoutPromise, loadingPromise]).then(() => appHost)
168+
try {
169+
await Promise.race([timeoutPromise, loadingPromise])
170+
return appHost
171+
} finally {
172+
if (timer) {
173+
clearTimeout(timer)
174+
}
175+
}
123176
}
124177

125178
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>

0 commit comments

Comments
 (0)