evaluator.client.spec.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. /**
  2. * @vitest-environment jsdom
  3. *
  4. * Closure evaluation account: the symbol surface a browser half receives, the
  5. * teaching traps shadowing ambient globals, the parse/return diagnostics, and
  6. * the style bookkeeping whose disposal the runner owns.
  7. */
  8. import * as React from 'react'
  9. import { describe, expect, it, vi } from 'vitest'
  10. import type { CordisDynamicPluginId } from '@deepseek-ai/dsh-api-remotes/client'
  11. import {
  12. DynamicCordisStyles,
  13. DYNAMIC_CLIENT_REDIRECTS,
  14. evaluateClientHalf,
  15. isDynamicCordisPlugin,
  16. } from '../src/client/evaluator.ts'
  17. import type { DynamicCordisClosureEnv, DynamicCordisEvaluatedPlugin } from '../src/client/evaluator.ts'
  18. const ID = 'dyn-1' as CordisDynamicPluginId
  19. function env(overrides: Partial<DynamicCordisClosureEnv> = {}): DynamicCordisClosureEnv {
  20. return {
  21. invoke: () => Promise.resolve(null),
  22. noteError: () => {},
  23. ...overrides,
  24. }
  25. }
  26. /** Evaluate one source with fresh style bookkeeping. */
  27. async function run(source: string, closure: DynamicCordisClosureEnv = env()): Promise<{
  28. plugin: DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown)
  29. styles: DynamicCordisStyles
  30. }> {
  31. const styles = new DynamicCordisStyles(ID)
  32. const plugin = await evaluateClientHalf(ID, source, closure, styles)
  33. return { plugin, styles }
  34. }
  35. describe('evaluateClientHalf', () => {
  36. it('returns the object-form plugin and hands the page React instance to the closure', async () => {
  37. const { plugin } = await run(`
  38. if (React.createElement === undefined) throw new Error('React symbol missing')
  39. return { name: 'ignored', inject: ['slots'], apply(ctx) { return React } }
  40. `)
  41. expect(typeof plugin).toBe('object')
  42. const object = plugin as DynamicCordisEvaluatedPlugin
  43. expect(object.inject).toEqual(['slots'])
  44. // Same instance as the page's React: a second copy would break hooks.
  45. expect(object.apply({})).toBe(React)
  46. })
  47. it('accepts the function form', async () => {
  48. const { plugin } = await run('return (ctx) => "applied"')
  49. expect(typeof plugin).toBe('function')
  50. expect((plugin as (ctx: unknown) => unknown)({})).toBe('applied')
  51. })
  52. it('redirects browser timers to the ctx facade', async () => {
  53. for (const timer of ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] as const) {
  54. const { plugin } = await run(`return () => ${timer}(() => {}, 1)`)
  55. expect(() => (plugin as (ctx: unknown) => unknown)({}))
  56. .toThrow(DYNAMIC_CLIENT_REDIRECTS[timer])
  57. }
  58. })
  59. it('redirects fetch to the host half and require to the closure symbols', async () => {
  60. const { plugin: fetcher } = await run('return () => fetch("/x")')
  61. expect(() => (fetcher as (ctx: unknown) => unknown)({})).toThrow(/network belongs to the HOST half/)
  62. const { plugin: importer } = await run('return () => require("react")')
  63. expect(() => (importer as (ctx: unknown) => unknown)({})).toThrow(/React arrives as the `React` closure symbol/)
  64. })
  65. it('teaches the half split on any harness access', async () => {
  66. const { plugin } = await run('return () => harness.handle("m", () => {})')
  67. expect(() => (plugin as (ctx: unknown) => unknown)({}))
  68. .toThrow(/harness\.handle belongs to the HOST half/)
  69. })
  70. it('routes host.call to the runner invoke seam', async () => {
  71. const invoke = vi.fn(() => Promise.resolve({ ok: 1 }))
  72. const { plugin } = await run('return { apply: (ctx) => host.call("ping", { a: 1 }) }', env({ invoke }))
  73. await expect((plugin as DynamicCordisEvaluatedPlugin).apply({})).resolves.toEqual({ ok: 1 })
  74. expect(invoke).toHaveBeenCalledWith('ping', { a: 1 })
  75. })
  76. it('sends null for a host.call written without arguments', async () => {
  77. const invoke = vi.fn(() => Promise.resolve(['fs', 'web']))
  78. // A handler that takes nothing is the natural case ("list the services"), and
  79. // `undefined` is not JSON — so the omission travels as null rather than
  80. // making the wire refuse the call.
  81. const { plugin } = await run('return { apply: (ctx) => host.call("listServices") }', env({ invoke }))
  82. await expect((plugin as DynamicCordisEvaluatedPlugin).apply({})).resolves.toEqual(['fs', 'web'])
  83. expect(invoke).toHaveBeenCalledWith('listServices', null)
  84. })
  85. it('reports a parse failure as a plain-JavaScript teaching error', async () => {
  86. await expect(run('return (')).rejects.toThrow(/client half failed to parse in this browser/)
  87. await expect(run('return (')).rejects.toThrow(/no JSX, no TypeScript/)
  88. })
  89. it('names the missing return, and rejects a non-plugin value', async () => {
  90. await expect(run('const x = 1')).rejects.toThrow(/did you forget `return`/)
  91. await expect(run('return 42')).rejects.toThrow(/must `return` a plugin/)
  92. })
  93. it('propagates a non-syntax construction failure untouched', async () => {
  94. const boom = new TypeError('engine refused')
  95. // The constructor is the only failure seam before evaluation; a
  96. // non-SyntaxError must not be reinterpreted as a source problem.
  97. vi.stubGlobal('Function', function stub(): never { throw boom })
  98. try {
  99. await expect(run('return () => {}')).rejects.toBe(boom)
  100. } finally {
  101. vi.unstubAllGlobals()
  102. }
  103. expect(typeof Function).toBe('function')
  104. })
  105. })
  106. describe('tagged console', () => {
  107. it('mirrors only error lines, and stringifies every argument shape', async () => {
  108. const seen: string[] = []
  109. const closure = env({ noteError: message => seen.push(message) })
  110. const circular: Record<string, unknown> = {}
  111. circular.self = circular
  112. const { plugin } = await run(`
  113. return { apply: (ctx) => {
  114. console.log('quiet')
  115. console.warn('also quiet')
  116. console.error('text', new Error('boom'), { a: 1 }, undefined, ctx.circular)
  117. console.debug('quiet too')
  118. } }
  119. `, closure)
  120. vi.spyOn(console, 'error').mockImplementation(() => {})
  121. vi.spyOn(console, 'log').mockImplementation(() => {})
  122. vi.spyOn(console, 'warn').mockImplementation(() => {})
  123. vi.spyOn(console, 'debug').mockImplementation(() => {})
  124. ;(plugin as DynamicCordisEvaluatedPlugin).apply({ circular })
  125. vi.restoreAllMocks()
  126. expect(seen).toHaveLength(1)
  127. expect(seen[0]).toBe('text boom {"a":1} undefined [unserializable console argument]')
  128. })
  129. it('truncates a long mirrored error', async () => {
  130. const seen: string[] = []
  131. const { plugin } = await run(
  132. 'return { apply: () => console.error("x".repeat(900)) }',
  133. env({ noteError: message => seen.push(message) }),
  134. )
  135. vi.spyOn(console, 'error').mockImplementation(() => {})
  136. ;(plugin as DynamicCordisEvaluatedPlugin).apply({})
  137. vi.restoreAllMocks()
  138. expect(seen[0]).toHaveLength(500)
  139. })
  140. })
  141. describe('DynamicCordisStyles', () => {
  142. it('stamps ownership, counts live tags, and disposes one tag or all of them', () => {
  143. const styles = new DynamicCordisStyles(ID)
  144. const first = styles.insert('.a { color: red }')
  145. styles.insert('.b { color: blue }')
  146. expect(styles.count).toBe(2)
  147. const tags = [...document.querySelectorAll('style[data-dyn="dyn-1"]')]
  148. expect(tags).toHaveLength(2)
  149. expect(tags[0]?.textContent).toBe('.a { color: red }')
  150. first()
  151. expect(styles.count).toBe(1)
  152. expect(document.querySelectorAll('style[data-dyn="dyn-1"]')).toHaveLength(1)
  153. styles.dispose()
  154. expect(styles.count).toBe(0)
  155. expect(document.querySelectorAll('style[data-dyn="dyn-1"]')).toHaveLength(0)
  156. })
  157. it('rejects a non-string stylesheet', () => {
  158. const styles = new DynamicCordisStyles(ID)
  159. expect(() => styles.insert(42 as unknown as string)).toThrow(/needs a CSS string/)
  160. })
  161. it('exposes styles.insert to the closure', async () => {
  162. const { plugin, styles } = await run('return { apply: () => styles.insert(".c {}") }')
  163. ;(plugin as DynamicCordisEvaluatedPlugin).apply({})
  164. expect(styles.count).toBe(1)
  165. styles.dispose()
  166. })
  167. })
  168. describe('isDynamicCordisPlugin', () => {
  169. it('accepts both mountable forms and rejects everything else', () => {
  170. expect(isDynamicCordisPlugin(() => {})).toBe(true)
  171. expect(isDynamicCordisPlugin({ apply: () => {} })).toBe(true)
  172. expect(isDynamicCordisPlugin({})).toBe(false)
  173. expect(isDynamicCordisPlugin(null)).toBe(false)
  174. expect(isDynamicCordisPlugin(42)).toBe(false)
  175. })
  176. })