entry-issues.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. /** Native entries preserve siblings and report requested options separately from live fibers. */
  2. import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterEach, describe, expect, it, vi } from 'vitest'
  6. import { Context, type Plugin } from '@deepseek-ai/cordis'
  7. import Loader, { type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
  8. import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
  9. import { boot, composeProfileStack, entryIssue, inspectEntryIssues, ProfileRuntime, rootIncludeEntry, warnNestedFiberFailures, type Profile, type ProfileLayer } from '../src/index.ts'
  10. const roots: string[] = []
  11. const contexts: Context[] = []
  12. afterEach(async () => {
  13. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  14. roots.splice(0).forEach((root) => { rmSync(root, { recursive: true, force: true }) })
  15. })
  16. const good: Plugin.Function = () => {}
  17. const bad: Plugin.Function = () => { throw new Error('apply failed') }
  18. const asyncBad: Plugin.Function = async () => { await Promise.resolve(); throw new Error('async apply failed') }
  19. const configured: Plugin.Object<{ value: number }> = {
  20. Config: { '~standard': { version: 1, vendor: 'entry-test', validate: (input) => {
  21. const config = input as { value: number }
  22. return config.value < 0 ? { issues: [{ message: 'negative value' }] } : { value: config }
  23. } } },
  24. apply() {},
  25. }
  26. const nested: Plugin.Function = (ctx) => { ctx.inject([], () => { throw new Error('nested failure') }) }
  27. const waiting: Plugin.Object = { inject: ['testService'], apply() {} }
  28. function layer(trust: ProfileLayer['trust'], stage: ProfileLayer['stage'], patches: PatchOptions[]): ProfileLayer {
  29. return { packageName: 'pkg', version: undefined, packageDir: '/pkg', patchPath: '/pkg/patch.yml', trust, stage, patches }
  30. }
  31. async function profileBoot(layers: ProfileLayer[], user: PatchOptions[] = []): Promise<Context> {
  32. const dir = mkdtempSync(join(tmpdir(), 'dsh-entry-policy-'))
  33. roots.push(dir)
  34. const path = join(dir, 'cordis.yml')
  35. writeFileSync(path, '[]\n')
  36. const profile: Profile = { name: 'test', dir, layers: [...layers], patches: [], patchPath: join(dir, 'cordis.patch.yml'), patchReload: 'live' }
  37. const compose = (current: Profile) => composeProfileStack('test', current.layers, [{ label: profile.patchPath, patches: user }])
  38. const stack = compose(profile)
  39. const ctx = await boot('test', path, stack.patches, async (ctx) => {
  40. Object.assign(ctx.loader.builtins, { good, bad, asyncBad, configured, waiting, nested })
  41. await ctx.plugin(ProfileRuntime, {
  42. profile, stack, compose, installAnchor: join(dir, 'package.json'), loadProfile: () => ({ ...profile, layers: [...layers] }), rootEntry: () => rootIncludeEntry(ctx),
  43. })
  44. })
  45. contexts.push(ctx)
  46. return ctx
  47. }
  48. const failures: EntryOptions[] = [
  49. { id: 'missing', name: './dsh-missing-fixture.mjs' },
  50. { id: 'bad', name: 'cordis:bad' },
  51. { id: 'asyncBad', name: 'cordis:asyncBad' },
  52. { id: 'configured', name: 'cordis:configured', config: { value: -1 } },
  53. { id: 'waiting', name: 'cordis:waiting' },
  54. { id: 'gate', name: 'cordis:good', disabled: { __jsExpr: 'missing.disabled' } as unknown as boolean },
  55. ]
  56. describe('entry startup policy', () => {
  57. it.each(failures)('keeps a failing external runtime row and its successful sibling: $id', async (row) => {
  58. const ctx = await profileBoot([layer('external', 'runtime', [{ insert: [row, { id: 'ok', name: 'cordis:good' }] }])])
  59. expect(ctx.loader.resolve('include:ok').fiber?.state).toBe(2)
  60. const entry = ctx.loader.resolve(`include:${row.id}`)
  61. expect((await entryIssue(entry))?.message).toBeTruthy()
  62. expect(ctx.profileRuntime.originOfEntry(entry)).toMatchObject({ trust: 'external', stage: 'runtime' })
  63. expect([...ctx.loader.entries()].some(e => e.options.name === 'cordis:contained-group')).toBe(false)
  64. })
  65. it.each([
  66. ['builtin', 'boot'], ['builtin', 'runtime'], ['external', 'boot'],
  67. ] as const)('rejects %s/%s failures regardless of module id', async (trust, stage) => {
  68. for (const row of failures) {
  69. await expect(profileBoot([layer(trust, stage, [{ insert: [row] }])])).rejects.toThrow('required startup failure')
  70. }
  71. })
  72. it('keeps user rows strict and does not transfer ownership with a config override', async () => {
  73. await expect(profileBoot([], [{ insert: [{ id: 'user', name: 'cordis:bad' }] }])).rejects.toThrow('required startup failure')
  74. const builtin = layer('builtin', 'runtime', [{ insert: [{ id: 'webserver', name: 'cordis:configured', config: { value: 1 } }] }])
  75. const ext = { ...layer('external', 'runtime', [{ id: 'webserver', config: { value: -1 } }]), packageName: 'ext' }
  76. await expect(profileBoot([builtin, ext])).rejects.toThrow('negative value')
  77. })
  78. it('rejects a builtin consumer left pending by an optional provider failure', async () => {
  79. await expect(profileBoot([
  80. layer('builtin', 'boot', [{ insert: [{ id: 'consumer', name: 'cordis:waiting' }] }]),
  81. { ...layer('external', 'runtime', [{ insert: [{ id: 'provider', name: 'cordis:bad' }] }]), packageName: 'ext' },
  82. ])).rejects.toThrow('consumer')
  83. })
  84. it('preserves anonymous provenance and nested Include provenance without matching unrelated root ids', async () => {
  85. const dir = mkdtempSync(join(tmpdir(), 'dsh-nested-entry-'))
  86. roots.push(dir)
  87. const path = join(dir, 'nested.yml')
  88. writeFileSync(path, '- id: shared\n name: cordis:bad\n')
  89. const ctx = await profileBoot([
  90. layer('builtin', 'boot', [{ insert: [{ id: 'shared', name: 'cordis:good' }] }]),
  91. { ...layer('external', 'runtime', [{ insert: [
  92. { name: 'cordis:good' } as EntryOptions,
  93. { id: 'nested', name: 'cordis:include', config: { path } },
  94. ] }]), packageName: 'ext' },
  95. ])
  96. expect(ctx.profileRuntime.originOfEntry(ctx.loader.resolve('include:shared'))?.trust).toBe('builtin')
  97. expect(ctx.profileRuntime.originOfEntry(ctx.loader.resolve('include:nested:shared'))?.trust).toBe('external')
  98. const anonymous = [...ctx.loader.entries()].find(e => e.options.id.startsWith('anonymous/'))!
  99. expect(ctx.profileRuntime.originOfEntry(anonymous)?.packageName).toBe('ext')
  100. })
  101. })
  102. describe('entry diagnostics', () => {
  103. it('disables every part of a bundle layer and restores overrides while preserving user row choices', async () => {
  104. const base = layer('builtin', 'boot', [{ insert: [
  105. { id: 'webserver', name: 'cordis:configured', config: { value: 1 } },
  106. { id: 'tools', name: 'cordis:group', group: true, config: [] },
  107. { id: 'ui', name: 'cordis:group', group: true, config: [] },
  108. ] }])
  109. const ext = { ...layer('external', 'runtime', [
  110. { insert: [{ id: 'own', name: 'cordis:group', group: true, config: [{ id: 'child', name: 'cordis:good' }] }] },
  111. { id: 'tools', insert: [{ id: 'tool', name: 'cordis:good' }] },
  112. { id: 'ui', insert: [{ id: 'panel', name: 'cordis:good' }] },
  113. { id: 'webserver', config: { value: 2 } },
  114. ]), packageName: 'ext' }
  115. const layers = [base, ext]
  116. const ctx = await profileBoot(layers, [{ id: 'tool', disabled: true }])
  117. expect(ctx.loader.resolve('include:webserver').fiber?.config).toEqual({ value: 2 })
  118. expect(ctx.loader.resolve('include:tool').disabled).toBe(true)
  119. layers.pop()
  120. expect(await ctx.profileRuntime.recompose({ reloadBundles: true })).toEqual([])
  121. const ids = [...ctx.loader.entries()].map(entry => entry.options.id)
  122. for (const id of ['own', 'child', 'tool', 'panel']) expect(ids).not.toContain(id)
  123. expect(ctx.loader.resolve('include:webserver').fiber?.config).toEqual({ value: 1 })
  124. layers.push(ext)
  125. expect(await ctx.profileRuntime.recompose({ reloadBundles: true })).toEqual([])
  126. expect(ctx.loader.resolve('include:panel').fiber?.state).toBe(2)
  127. expect(ctx.loader.resolve('include:child').fiber?.state).toBe(2)
  128. expect(ctx.loader.resolve('include:tool').disabled).toBe(true)
  129. expect(ctx.loader.resolve('include:webserver').fiber?.config).toEqual({ value: 2 })
  130. })
  131. it('retains an active old config on invalid update, reports the failure, and clears it on a valid change', async () => {
  132. const ctx = new Context()
  133. contexts.push(ctx)
  134. await ctx.plugin(Loader)
  135. ctx.loader.builtins.configured = configured
  136. const id = await ctx.loader.create({ name: 'cordis:configured', config: { value: 1 } })
  137. const entry = ctx.loader.resolve(id)
  138. await expect(entry.update({ config: { value: -1 } })).rejects.toThrow('negative value')
  139. expect(entry.options.config).toEqual({ value: -1 })
  140. expect(entry.fiber?.config).toEqual({ value: 1 })
  141. expect(entry.fiber?.state).toBe(2)
  142. expect((await entryIssue(entry))?.stage).toBe('update')
  143. expect((await entryIssue(entry))?.message).toContain('negative value')
  144. await entry.update({ config: { value: 2 } })
  145. await ctx.loader.await()
  146. expect(entry.fiber?.config).toEqual({ value: 2 })
  147. expect(await entryIssue(entry)).toBeUndefined()
  148. })
  149. it('clears import and pending issues after recovery and ignores disabled failures', async () => {
  150. const ctx = new Context()
  151. contexts.push(ctx)
  152. await ctx.plugin(Loader)
  153. ctx.loader.builtins.waiting = waiting
  154. const broken = await ctx.loader.create({ name: './dsh-missing-fixture.mjs' })
  155. const pending = await ctx.loader.create({ name: 'cordis:waiting' })
  156. expect((await inspectEntryIssues(ctx)).map(issue => issue.stage).sort()).toEqual(['import', 'inject-pending'])
  157. ctx.loader.builtins.later = good
  158. await ctx.loader.resolve(broken).update({ disabled: true })
  159. expect(await entryIssue(ctx.loader.resolve(broken))).toBeUndefined()
  160. await ctx.loader.resolve(broken).update({ name: 'cordis:later', disabled: false })
  161. ctx.provide('testService', {})
  162. await ctx.loader.await()
  163. expect(await entryIssue(ctx.loader.resolve(pending))).toBeUndefined()
  164. expect(await inspectEntryIssues(ctx)).toEqual([])
  165. })
  166. it('reports partial recompose, preserves successful siblings, and waits for removed fibers', async () => {
  167. const patches: PatchOptions[] = [{ insert: [
  168. { id: 'config', name: 'cordis:configured', config: { value: 1 } },
  169. { id: 'ok', name: 'cordis:good' },
  170. ] }]
  171. const ctx = await profileBoot([layer('external', 'runtime', patches)])
  172. let release!: () => void
  173. let started!: () => void
  174. const removing = new Promise<void>((resolve) => { started = resolve })
  175. const gate = new Promise<void>((resolve) => { release = resolve })
  176. ctx.loader.builtins.slow = (ctx: Context) => { ctx.effect(() => async () => { started(); await gate }) }
  177. patches[0]!.insert!.push({ id: 'slow', name: 'cordis:slow' })
  178. await ctx.profileRuntime.recompose({ reloadBundles: true })
  179. patches[0]!.insert![0]!.config = { value: -1 }
  180. patches[0]!.insert!.pop()
  181. const done = vi.fn()
  182. const updating = ctx.profileRuntime.recompose({ reloadBundles: true }).then((issues) => { done(); return issues })
  183. try {
  184. await removing
  185. expect(done).not.toHaveBeenCalled()
  186. } finally { release() }
  187. expect((await updating).map(issue => issue.stage)).toContain('update')
  188. expect(ctx.loader.resolve('include:ok').fiber?.state).toBe(2)
  189. expect(ctx.loader.resolve('include:config').fiber?.config).toEqual({ value: 1 })
  190. })
  191. })
  192. describe('nested continuation diagnostics', () => {
  193. it('reports required nested failures without treating them as entry activation failures', async () => {
  194. const ctx = await profileBoot([
  195. layer('builtin', 'boot', [{ insert: [{ id: 'required', name: 'cordis:nested' }] }]),
  196. { ...layer('external', 'runtime', [{ insert: [
  197. { id: 'optional', name: 'cordis:nested' }, { id: 'failed-entry', name: 'cordis:bad' },
  198. ] }]), packageName: 'ext' },
  199. ])
  200. await expect(ctx.plugin(() => { throw new Error('unowned failure') })).rejects.toThrow('unowned failure')
  201. await Promise.allSettled([...ctx.registry.values()].flatMap(runtime => [...runtime.fibers].map(fiber => fiber.await())))
  202. const lines: string[] = []
  203. expect(warnNestedFiberFailures(ctx, 'test', (line) => { lines.push(line) })).toBe(1)
  204. expect(lines[0]).toContain('cordis:nested')
  205. expect(ctx.loader.resolve('include:required').fiber?.state).toBe(2)
  206. expect(warnNestedFiberFailures(ctx, 'test')).toBe(1)
  207. })
  208. })