| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
- /**
- * @vitest-environment jsdom
- *
- * Closure evaluation account: the symbol surface a browser half receives, the
- * teaching traps shadowing ambient globals, the parse/return diagnostics, and
- * the style bookkeeping whose disposal the runner owns.
- */
- import * as React from 'react'
- import { describe, expect, it, vi } from 'vitest'
- import type { CordisDynamicPluginId } from '@deepseek-ai/dsh-api-remotes/client'
- import {
- DynamicCordisStyles,
- DYNAMIC_CLIENT_REDIRECTS,
- evaluateClientHalf,
- isDynamicCordisPlugin,
- } from '../src/client/evaluator.ts'
- import type { DynamicCordisClosureEnv, DynamicCordisEvaluatedPlugin } from '../src/client/evaluator.ts'
- const ID = 'dyn-1' as CordisDynamicPluginId
- function env(overrides: Partial<DynamicCordisClosureEnv> = {}): DynamicCordisClosureEnv {
- return {
- invoke: () => Promise.resolve(null),
- noteError: () => {},
- ...overrides,
- }
- }
- /** Evaluate one source with fresh style bookkeeping. */
- async function run(source: string, closure: DynamicCordisClosureEnv = env()): Promise<{
- plugin: DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown)
- styles: DynamicCordisStyles
- }> {
- const styles = new DynamicCordisStyles(ID)
- const plugin = await evaluateClientHalf(ID, source, closure, styles)
- return { plugin, styles }
- }
- describe('evaluateClientHalf', () => {
- it('returns the object-form plugin and hands the page React instance to the closure', async () => {
- const { plugin } = await run(`
- if (React.createElement === undefined) throw new Error('React symbol missing')
- return { name: 'ignored', inject: ['slots'], apply(ctx) { return React } }
- `)
- expect(typeof plugin).toBe('object')
- const object = plugin as DynamicCordisEvaluatedPlugin
- expect(object.inject).toEqual(['slots'])
- // Same instance as the page's React: a second copy would break hooks.
- expect(object.apply({})).toBe(React)
- })
- it('accepts the function form', async () => {
- const { plugin } = await run('return (ctx) => "applied"')
- expect(typeof plugin).toBe('function')
- expect((plugin as (ctx: unknown) => unknown)({})).toBe('applied')
- })
- it('redirects browser timers to the ctx facade', async () => {
- for (const timer of ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] as const) {
- const { plugin } = await run(`return () => ${timer}(() => {}, 1)`)
- expect(() => (plugin as (ctx: unknown) => unknown)({}))
- .toThrow(DYNAMIC_CLIENT_REDIRECTS[timer])
- }
- })
- it('redirects fetch to the host half and require to the closure symbols', async () => {
- const { plugin: fetcher } = await run('return () => fetch("/x")')
- expect(() => (fetcher as (ctx: unknown) => unknown)({})).toThrow(/network belongs to the HOST half/)
- const { plugin: importer } = await run('return () => require("react")')
- expect(() => (importer as (ctx: unknown) => unknown)({})).toThrow(/React arrives as the `React` closure symbol/)
- })
- it('teaches the half split on any harness access', async () => {
- const { plugin } = await run('return () => harness.handle("m", () => {})')
- expect(() => (plugin as (ctx: unknown) => unknown)({}))
- .toThrow(/harness\.handle belongs to the HOST half/)
- })
- it('routes host.call to the runner invoke seam', async () => {
- const invoke = vi.fn(() => Promise.resolve({ ok: 1 }))
- const { plugin } = await run('return { apply: (ctx) => host.call("ping", { a: 1 }) }', env({ invoke }))
- await expect((plugin as DynamicCordisEvaluatedPlugin).apply({})).resolves.toEqual({ ok: 1 })
- expect(invoke).toHaveBeenCalledWith('ping', { a: 1 })
- })
- it('sends null for a host.call written without arguments', async () => {
- const invoke = vi.fn(() => Promise.resolve(['fs', 'web']))
- // A handler that takes nothing is the natural case ("list the services"), and
- // `undefined` is not JSON — so the omission travels as null rather than
- // making the wire refuse the call.
- const { plugin } = await run('return { apply: (ctx) => host.call("listServices") }', env({ invoke }))
- await expect((plugin as DynamicCordisEvaluatedPlugin).apply({})).resolves.toEqual(['fs', 'web'])
- expect(invoke).toHaveBeenCalledWith('listServices', null)
- })
- it('reports a parse failure as a plain-JavaScript teaching error', async () => {
- await expect(run('return (')).rejects.toThrow(/client half failed to parse in this browser/)
- await expect(run('return (')).rejects.toThrow(/no JSX, no TypeScript/)
- })
- it('names the missing return, and rejects a non-plugin value', async () => {
- await expect(run('const x = 1')).rejects.toThrow(/did you forget `return`/)
- await expect(run('return 42')).rejects.toThrow(/must `return` a plugin/)
- })
- it('propagates a non-syntax construction failure untouched', async () => {
- const boom = new TypeError('engine refused')
- // The constructor is the only failure seam before evaluation; a
- // non-SyntaxError must not be reinterpreted as a source problem.
- vi.stubGlobal('Function', function stub(): never { throw boom })
- try {
- await expect(run('return () => {}')).rejects.toBe(boom)
- } finally {
- vi.unstubAllGlobals()
- }
- expect(typeof Function).toBe('function')
- })
- })
- describe('tagged console', () => {
- it('mirrors only error lines, and stringifies every argument shape', async () => {
- const seen: string[] = []
- const closure = env({ noteError: message => seen.push(message) })
- const circular: Record<string, unknown> = {}
- circular.self = circular
- const { plugin } = await run(`
- return { apply: (ctx) => {
- console.log('quiet')
- console.warn('also quiet')
- console.error('text', new Error('boom'), { a: 1 }, undefined, ctx.circular)
- console.debug('quiet too')
- } }
- `, closure)
- vi.spyOn(console, 'error').mockImplementation(() => {})
- vi.spyOn(console, 'log').mockImplementation(() => {})
- vi.spyOn(console, 'warn').mockImplementation(() => {})
- vi.spyOn(console, 'debug').mockImplementation(() => {})
- ;(plugin as DynamicCordisEvaluatedPlugin).apply({ circular })
- vi.restoreAllMocks()
- expect(seen).toHaveLength(1)
- expect(seen[0]).toBe('text boom {"a":1} undefined [unserializable console argument]')
- })
- it('truncates a long mirrored error', async () => {
- const seen: string[] = []
- const { plugin } = await run(
- 'return { apply: () => console.error("x".repeat(900)) }',
- env({ noteError: message => seen.push(message) }),
- )
- vi.spyOn(console, 'error').mockImplementation(() => {})
- ;(plugin as DynamicCordisEvaluatedPlugin).apply({})
- vi.restoreAllMocks()
- expect(seen[0]).toHaveLength(500)
- })
- })
- describe('DynamicCordisStyles', () => {
- it('stamps ownership, counts live tags, and disposes one tag or all of them', () => {
- const styles = new DynamicCordisStyles(ID)
- const first = styles.insert('.a { color: red }')
- styles.insert('.b { color: blue }')
- expect(styles.count).toBe(2)
- const tags = [...document.querySelectorAll('style[data-dyn="dyn-1"]')]
- expect(tags).toHaveLength(2)
- expect(tags[0]?.textContent).toBe('.a { color: red }')
- first()
- expect(styles.count).toBe(1)
- expect(document.querySelectorAll('style[data-dyn="dyn-1"]')).toHaveLength(1)
- styles.dispose()
- expect(styles.count).toBe(0)
- expect(document.querySelectorAll('style[data-dyn="dyn-1"]')).toHaveLength(0)
- })
- it('rejects a non-string stylesheet', () => {
- const styles = new DynamicCordisStyles(ID)
- expect(() => styles.insert(42 as unknown as string)).toThrow(/needs a CSS string/)
- })
- it('exposes styles.insert to the closure', async () => {
- const { plugin, styles } = await run('return { apply: () => styles.insert(".c {}") }')
- ;(plugin as DynamicCordisEvaluatedPlugin).apply({})
- expect(styles.count).toBe(1)
- styles.dispose()
- })
- })
- describe('isDynamicCordisPlugin', () => {
- it('accepts both mountable forms and rejects everything else', () => {
- expect(isDynamicCordisPlugin(() => {})).toBe(true)
- expect(isDynamicCordisPlugin({ apply: () => {} })).toBe(true)
- expect(isDynamicCordisPlugin({})).toBe(false)
- expect(isDynamicCordisPlugin(null)).toBe(false)
- expect(isDynamicCordisPlugin(42)).toBe(false)
- })
- })
|