web-plugins.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { Context } from 'cordis'
  5. import { describe, expect, it, vi } from 'vitest'
  6. import { createHostWebPluginRegistry, injectBootManifest } from '../src/index.ts'
  7. import type { LoaderEntryView, WebPluginRegistryDeps } from '../src/index.ts'
  8. /** Write a fake installed package (package.json + optional client bundle) and return its package.json path. */
  9. function makePkg(root: string, name: string, pkg: Record<string, unknown>, withBundle = true): string {
  10. const dir = join(root, name.replaceAll('/', '__'))
  11. mkdirSync(join(dir, 'lib'), { recursive: true })
  12. writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, ...pkg }))
  13. if (withBundle) writeFileSync(join(dir, 'lib', 'client.js'), `// bundle of ${name}`)
  14. return join(dir, 'package.json')
  15. }
  16. const webDecl = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
  17. dshClient: { inject: [], platform: 'web', ...extra },
  18. exports: { '.': './lib/index.js', './client': './lib/client.js' },
  19. })
  20. interface Fixture {
  21. deps: WebPluginRegistryDeps
  22. entries: LoaderEntryView[]
  23. errors: Error[]
  24. ctx: Context
  25. root: string
  26. }
  27. function makeDeps(
  28. specs: { name: string; pkg: Record<string, unknown>; loaded?: boolean; disabled?: boolean; withBundle?: boolean }[],
  29. ): Fixture {
  30. const root = mkdtempSync(join(tmpdir(), 'dsh-webplugins-'))
  31. const paths = new Map<string, string>()
  32. const entries: LoaderEntryView[] = specs.map((spec) => {
  33. paths.set(spec.name, makePkg(root, spec.name, spec.pkg, spec.withBundle ?? true))
  34. return { options: { name: spec.name }, fiber: spec.loaded === false ? undefined : {}, disabled: spec.disabled ?? false }
  35. })
  36. const ctx = new Context()
  37. const errors: Error[] = []
  38. const deps: WebPluginRegistryDeps = {
  39. ctx,
  40. loader: { entries: () => entries },
  41. resolvePkgJson: (name) => {
  42. const path = paths.get(name)
  43. if (path === undefined) throw new Error(`unresolvable ${name}`)
  44. return path
  45. },
  46. onError: err => void errors.push(err),
  47. }
  48. return { deps, entries, errors, ctx, root }
  49. }
  50. describe('createHostWebPluginRegistry', () => {
  51. it('discovers dshClient rows with rev-stamped urls, manifest inject edges, and the declared immediately mark', () => {
  52. const { deps } = makeDeps([
  53. { name: '@deepseek-ai/dsh-client-connection', pkg: webDecl({ immediately: true }) },
  54. { name: '@deepseek-ai/dsh-client-ui-layout', pkg: webDecl({ inject: ['@deepseek-ai/dsh-client-runtime'] }) },
  55. { name: '@deepseek-ai/dsh-agent', pkg: { exports: { '.': './lib/index.js' } } }, // no dshClient: skipped
  56. ])
  57. const registry = createHostWebPluginRegistry(deps)
  58. const graph = registry.graph()
  59. expect(graph.rev).toMatch(/^[0-9a-f]{12}$/)
  60. const connection = graph.entries[0]
  61. expect(connection?.id).toBe('@deepseek-ai/dsh-client-connection')
  62. expect(connection?.rev).toMatch(/^[0-9a-f]{12}$/)
  63. expect(connection?.url).toBe(`/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=${connection?.rev ?? ''}`)
  64. expect(connection?.immediately).toBe(true)
  65. const layout = graph.entries[1]
  66. expect(layout?.id).toBe('@deepseek-ai/dsh-client-ui-layout')
  67. expect(layout?.inject).toEqual(['@deepseek-ai/dsh-client-runtime'])
  68. expect(layout?.immediately).toBeUndefined()
  69. expect(graph.entries).toHaveLength(2)
  70. expect(registry.clientPath('@deepseek-ai/dsh-client-ui-layout')).toMatch(/lib[/\\]client\.js$/)
  71. expect(registry.clientPath('@deepseek-ai/dsh-agent')).toBeUndefined()
  72. registry.dispose()
  73. })
  74. it('skips entries that are unloaded, disabled, or declare another platform', () => {
  75. const { deps } = makeDeps([
  76. { name: 'not-loaded', pkg: webDecl(), loaded: false },
  77. { name: 'disabled', pkg: webDecl(), disabled: true },
  78. { name: 'electron-only', pkg: { dshClient: { platform: 'electron' }, exports: { './client': './lib/client.js' } } },
  79. ])
  80. const registry = createHostWebPluginRegistry(deps)
  81. expect(registry.graph().entries).toEqual([])
  82. registry.dispose()
  83. })
  84. it('fails loud at build time on a dshClient declaration without a "./client" export', () => {
  85. const { deps } = makeDeps([
  86. { name: 'broken', pkg: { dshClient: { platform: 'web' }, exports: { '.': './lib/index.js' } } },
  87. ])
  88. expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/)
  89. })
  90. it('fails loud at build time on a registered bundle that is not built (rev hashing reads the file)', () => {
  91. const { deps } = makeDeps([{ name: 'unbuilt', pkg: webDecl(), withBundle: false }])
  92. expect(() => createHostWebPluginRegistry(deps)).toThrow(/ENOENT/)
  93. })
  94. it('fails loud on malformed declaration fields', () => {
  95. for (const dshClient of [42, { platform: 7 }, { platform: 'web', inject: 'nope' }, { platform: 'web', immediately: 'yes' }]) {
  96. const { deps } = makeDeps([{ name: 'bad', pkg: { dshClient, exports: { './client': './lib/client.js' } } }])
  97. expect(() => createHostWebPluginRegistry(deps)).toThrow(/dshClient/)
  98. }
  99. })
  100. it('rebuilt(id) re-hashes the bundle, updates the row and graph rev, and keeps the immediately mark', () => {
  101. const { deps, root } = makeDeps([{ name: 'hot', pkg: webDecl({ immediately: true }) }])
  102. const registry = createHostWebPluginRegistry(deps)
  103. const before = registry.graph()
  104. const beforeRow = before.entries.find(e => e.id === 'hot')
  105. writeFileSync(join(root, 'hot', 'lib', 'client.js'), '// rebuilt bundle contents')
  106. const rev = registry.rebuilt('hot')
  107. expect(rev).toMatch(/^[0-9a-f]{12}$/)
  108. expect(rev).not.toBe(beforeRow?.rev)
  109. const after = registry.graph()
  110. const afterRow = after.entries.find(e => e.id === 'hot')
  111. expect(afterRow?.rev).toBe(rev)
  112. expect(afterRow?.url).toBe(`/plugins/hot/client.js?rev=${rev ?? ''}`)
  113. expect(afterRow?.immediately).toBe(true)
  114. expect(after.rev).not.toBe(before.rev)
  115. // Unknown ids are not rebuildable.
  116. expect(registry.rebuilt('nope')).toBeUndefined()
  117. registry.dispose()
  118. })
  119. it('watch mode: a bundle content change re-hashes the row and notifies onRebuilt; dispose stops the watch', async () => {
  120. const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }])
  121. deps.watch = { intervalMs: 20 }
  122. const registry = createHostWebPluginRegistry(deps)
  123. const before = registry.graph().entries[0]?.rev
  124. const rebuilds: { id: string; rev: string }[] = []
  125. registry.onRebuilt((id, rev) => rebuilds.push({ id, rev }))
  126. writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// new bundle contents')
  127. await vi.waitFor(() => { expect(rebuilds).toHaveLength(1) }, { timeout: 5000 })
  128. expect(rebuilds[0]?.id).toBe('watched')
  129. expect(rebuilds[0]?.rev).not.toBe(before)
  130. expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev)
  131. registry.dispose()
  132. writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// post-dispose contents')
  133. await new Promise((resolve) => { setTimeout(resolve, 100) })
  134. expect(rebuilds).toHaveLength(1)
  135. })
  136. it('rejects a non-positive or non-integer watch interval at build time', () => {
  137. for (const intervalMs of [0, -5, 1.5]) {
  138. const { deps } = makeDeps([{ name: 'p', pkg: webDecl() }])
  139. deps.watch = { intervalMs }
  140. expect(() => createHostWebPluginRegistry(deps)).toThrow(/watch\.intervalMs/)
  141. }
  142. })
  143. it('rescans on internal/plugin (debounced) and keeps the old graph when a rescan fails', async () => {
  144. const { deps, entries, errors, ctx } = makeDeps([
  145. { name: 'late-loader', pkg: webDecl(), loaded: false },
  146. ])
  147. const registry = createHostWebPluginRegistry(deps)
  148. expect(registry.graph().entries).toEqual([])
  149. // Entry finishes loading; a fiber lifecycle event triggers the debounced rescan.
  150. ;(entries[0] as { fiber?: unknown }).fiber = {}
  151. ctx.emit('internal/plugin', ctx.fiber)
  152. ctx.emit('internal/plugin', ctx.fiber) // debounce: two emissions, one rescan
  153. await Promise.resolve()
  154. expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader'])
  155. // A failing rescan reports the error and keeps serving the previous graph.
  156. entries.push({ options: { name: 'ghost' }, fiber: {}, disabled: false })
  157. ctx.emit('internal/plugin', ctx.fiber)
  158. await Promise.resolve()
  159. expect(errors).toHaveLength(1)
  160. expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader'])
  161. // After dispose, further fiber events no longer rescan.
  162. registry.dispose()
  163. entries.pop()
  164. ctx.emit('internal/plugin', ctx.fiber)
  165. await Promise.resolve()
  166. expect(errors).toHaveLength(1)
  167. })
  168. })
  169. describe('injectBootManifest', () => {
  170. it('injects the graph as the first script inside <head> and escapes </script> breakouts', () => {
  171. const html = '<html><head><script src="app.js"></script></head><body></body></html>'
  172. const out = injectBootManifest(html, {
  173. rev: 'r1',
  174. entries: [{ id: 'x</script><script>alert(1)', url: '/plugins/x/client.js?rev=r2', rev: 'r2' }],
  175. })
  176. expect(out.indexOf('window.__DSH_BOOT__')).toBeLessThan(out.indexOf('app.js'))
  177. expect(out).not.toContain('</script><script>alert(1)')
  178. expect(out).toContain('\\u003c/script')
  179. })
  180. it('prepends when the page has no <head>', () => {
  181. const out = injectBootManifest('<body>x</body>', { rev: 'r0', entries: [] })
  182. expect(out.startsWith('<script>window.__DSH_BOOT__')).toBe(true)
  183. })
  184. })
  185. describe('clientExportOf shapes (through the registry build)', () => {
  186. it('accepts the conditional {types, default} export form', () => {
  187. const { deps } = makeDeps([{
  188. name: 'conditional',
  189. pkg: {
  190. dshClient: { platform: 'web' },
  191. exports: { './client': { types: './lib/types/client/index.d.ts', default: './lib/client.js' } },
  192. },
  193. }])
  194. const registry = createHostWebPluginRegistry(deps)
  195. expect(registry.clientPath('conditional')).toMatch(/lib[/\\]client\.js$/)
  196. registry.dispose()
  197. })
  198. it('rejects a conditional form without a string default, an array form, and a non-object exports field', () => {
  199. for (const exportsField of [
  200. { './client': { types: './x.d.ts' } },
  201. { './client': ['./a.js'] },
  202. ]) {
  203. const { deps } = makeDeps([{ name: 'bad-shape', pkg: { dshClient: { platform: 'web' }, exports: exportsField } }])
  204. expect(() => createHostWebPluginRegistry(deps)).toThrow(/unsupported shape/)
  205. }
  206. // Non-object exports: treated as "no ./client export" → the declares-but-no-bundle throw.
  207. const { deps } = makeDeps([{ name: 'no-exports', pkg: { dshClient: { platform: 'web' }, exports: './single.js' } }])
  208. expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/)
  209. })
  210. it('skips duplicate loader entries for the same package name (first wins)', () => {
  211. const { deps, entries } = makeDeps([{ name: 'dup-entry', pkg: webDecl() }])
  212. const first = entries[0] as LoaderEntryView
  213. entries.push({ options: { name: 'dup-entry' }, fiber: {}, disabled: false })
  214. void first
  215. const registry = createHostWebPluginRegistry(deps)
  216. expect(registry.graph().entries.filter(r => r.id === 'dup-entry')).toHaveLength(1)
  217. registry.dispose()
  218. })
  219. it('rejects a null conditional form and wraps a non-Error rescan throw', async () => {
  220. // client: null → the object-form branch's null guard.
  221. const nulled = makeDeps([{ name: 'null-client', pkg: { dshClient: { platform: 'web' }, exports: { './client': null } } }])
  222. expect(() => createHostWebPluginRegistry(nulled.deps)).toThrow(/unsupported shape/)
  223. // Non-Error rescan throw: resolvePkgJson throws a string; onError must get a wrapped Error.
  224. const { deps, entries, errors, ctx } = makeDeps([{ name: 'ok-one', pkg: webDecl() }])
  225. const registry = createHostWebPluginRegistry(deps)
  226. entries.push({ options: { name: 'ghost-two' }, fiber: {}, disabled: false })
  227. const original = deps.resolvePkgJson
  228. deps.resolvePkgJson = (name) => {
  229. if (name === 'ghost-two') throw 'string failure'
  230. return original(name)
  231. }
  232. ctx.emit('internal/plugin', ctx.fiber)
  233. await Promise.resolve()
  234. expect(errors[0]).toBeInstanceOf(Error)
  235. expect(String(errors[0])).toContain('string failure')
  236. registry.dispose()
  237. })
  238. })