node-stubs.spec.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. /**
  2. * The Node-compatibility layer's refusals and its small answering faces.
  3. *
  4. * Two contracts live here. Every replaced symbol must be PRESENT — a missing
  5. * CommonJS export degrades to `undefined` and fails at call time somewhere
  6. * unrelated — and every symbol the worker cannot honour must refuse while naming
  7. * itself, because these errors are routinely swallowed far from their cause and
  8. * the name is what places them in a worker session's console.
  9. *
  10. * The member lists are the tables the modules are checked against: adding a
  11. * refusing symbol without listing it here leaves it unproven, and listing one
  12. * that starts answering fails.
  13. */
  14. import { describe, expect, it, vi } from 'vitest'
  15. import { notAvailableError, notImplementedFail } from '../../src/node/notImplementedFail.ts'
  16. import * as childProcess from '../../src/node/builtin_modules/implemented/child_process.ts'
  17. import * as net from '../../src/node/builtin_modules/mock/net.ts'
  18. import * as sqlite from '../../src/node/builtin_modules/mock/sqlite.ts'
  19. import * as stream from '../../src/node/builtin_modules/mock/stream.ts'
  20. import * as vm from '../../src/node/builtin_modules/mock/vm.ts'
  21. import * as workerThreads from '../../src/node/builtin_modules/mock/worker_threads.ts'
  22. import * as chokidar from '../../src/node/external_packages/chokidar.ts'
  23. import * as landlock from '../../src/node/external_packages/node-addon-landlock-run.ts'
  24. import * as nodePty from '../../src/node/external_packages/node-pty.ts'
  25. import * as piAi from '../../src/node/external_packages/pi-ai.ts'
  26. import * as ripgrep from '../../src/node/external_packages/ripgrep.ts'
  27. import * as ws from '../../src/node/external_packages/ws.ts'
  28. import { REPLACED_EXTERNAL_PACKAGES } from '../../src/node/external_packages/replaced-externals.ts'
  29. import * as fs from '../../src/node/builtin_modules/implemented/fs.ts'
  30. import * as os from '../../src/node/builtin_modules/implemented/os.ts'
  31. import * as perfHooks from '../../src/node/builtin_modules/implemented/perf_hooks.ts'
  32. import { DSH_HOME, DSH_TMP } from '../../src/storage/paths.ts'
  33. /** Every refusal writes its message to the console before throwing; keep the run quiet. */
  34. const quiet = (): void => { vi.spyOn(console, 'error').mockImplementation(() => {}) }
  35. /** Symbols that refuse when called. */
  36. const CALLED: [string, Record<string, unknown>, readonly string[]][] = [
  37. ['node:net', net, ['createServer', 'connect']],
  38. ['node:sqlite', sqlite, ['backup']],
  39. ['node:vm', vm, ['createContext', 'runInContext', 'runInNewContext', 'runInThisContext', 'isContext']],
  40. ['node:worker_threads', workerThreads, ['MessageChannel', 'MessagePort', 'markAsUntransferable', 'receiveMessageOnPort']],
  41. // The rest of `node:child_process` runs commands (see child-process.spec.ts);
  42. // these three need a real process, so they stay refusals.
  43. ['node:child_process', childProcess, ['execFileSync', 'execSync', 'fork']],
  44. ['node:stream', stream, ['Readable', 'Writable', 'Duplex', 'Transform', 'PassThrough', 'pipeline', 'finished']],
  45. ['node-pty', nodePty, ['spawn', 'open']],
  46. ['@deepseek-ai/node-addon-landlock-run', landlock, ['probe']],
  47. ['@deepseek-ai/pi-ai', piAi, [
  48. 'createProvider', 'createModels', 'openAICompletionsApi', 'openAIResponsesApi', 'anthropicMessagesApi',
  49. 'isContextOverflow', 'getSupportedThinkingLevels',
  50. ]],
  51. ]
  52. /** Classes that refuse when constructed. */
  53. const CONSTRUCTED: [string, Record<string, unknown>, readonly string[]][] = [
  54. ['node:sqlite', sqlite, ['DatabaseSync', 'StatementSync']],
  55. ['node:vm', vm, ['Script']],
  56. ['node:worker_threads', workerThreads, ['Worker']],
  57. ['node:perf_hooks', perfHooks, ['PerformanceObserver']],
  58. ['ws', ws, ['WebSocket']],
  59. ]
  60. describe('not-implemented stubs', () => {
  61. it('names the module and the symbol, and reports before throwing', () => {
  62. const reported = vi.spyOn(console, 'error').mockImplementation(() => {})
  63. const error = notAvailableError('node:zlib', 'gzipSync')
  64. expect(error.message).toBe('web-preview: node:zlib.gzipSync is not available in the worker host')
  65. expect(reported).toHaveBeenCalledWith(error.message)
  66. const stub = notImplementedFail('node:zlib', 'gzipSync')
  67. expect(() => stub()).toThrow(error.message)
  68. })
  69. for (const [module, namespace, members] of CALLED) {
  70. it(`${module} refuses ${String(members.length)} called symbol(s)`, () => {
  71. quiet()
  72. for (const member of members) {
  73. const value = namespace[member]
  74. expect(typeof value, member).toBe('function')
  75. expect(() => (value as () => unknown)(), member).toThrow(new RegExp(`${member}\\b.*not available in the worker host`))
  76. }
  77. })
  78. }
  79. for (const [module, namespace, members] of CONSTRUCTED) {
  80. it(`${module} refuses ${String(members.length)} constructed symbol(s)`, () => {
  81. quiet()
  82. for (const member of members) {
  83. const value = namespace[member]
  84. expect(typeof value, member).toBe('function')
  85. expect(() => new (value as new () => unknown)(), member).toThrow(/not available in the worker host/)
  86. }
  87. })
  88. }
  89. it('keeps the CommonJS interop marker and a default export on every replaced module', () => {
  90. for (const namespace of [net, sqlite, vm, workerThreads, childProcess, stream, chokidar, ws, nodePty, piAi, os, perfHooks]) {
  91. const holder = namespace as { __esModule?: unknown; default?: unknown }
  92. expect(holder.__esModule).toBe(true)
  93. expect(holder.default).toBeDefined()
  94. }
  95. })
  96. })
  97. describe('constructible-but-inert fakes', () => {
  98. // These two are constructed in `[Service.init]` bodies and field initializers,
  99. // so construction must succeed; only the members that would move bytes refuse.
  100. it('chokidar watches nothing and says so by never emitting', async () => {
  101. const watcher = chokidar.watch()
  102. expect(watcher).toBeInstanceOf(chokidar.FSWatcher)
  103. expect(watcher.on()).toBe(watcher)
  104. expect(watcher.once()).toBe(watcher)
  105. expect(watcher.add()).toBe(watcher)
  106. expect(watcher.unwatch()).toBe(watcher)
  107. expect(watcher.getWatched()).toEqual({})
  108. await expect(watcher.close()).resolves.toBeUndefined()
  109. })
  110. it('a ws server constructs, accepts listeners, and refuses to carry an upgrade', () => {
  111. quiet()
  112. expect(ws.Server).toBe(ws.WebSocketServer)
  113. const server = new ws.WebSocketServer()
  114. expect(server.clients.size).toBe(0)
  115. expect(server.on()).toBe(server)
  116. expect(() => server.handleUpgrade()).toThrow(/WebSocketServer.handleUpgrade is not available/)
  117. expect(() => server.emit()).toThrow(/WebSocketServer.emit is not available/)
  118. let closed = false
  119. server.close(() => { closed = true })
  120. expect(closed).toBe(true)
  121. })
  122. })
  123. describe('replaced external packages', () => {
  124. it('lists the packages the loader serves from the bundle', () => {
  125. expect(REPLACED_EXTERNAL_PACKAGES).toContain('chokidar')
  126. expect(REPLACED_EXTERNAL_PACKAGES).toContain('ws')
  127. })
  128. it('answers the values callers read without invoking anything', () => {
  129. // The ripgrep binary path and the landlock launcher are read as data by
  130. // consumers that then fail on their own terms.
  131. expect(typeof ripgrep.rgPath).toBe('string')
  132. expect(typeof landlock.LAUNCHER_BIN).toBe('string')
  133. expect(typeof landlock.LAUNCHER_FAILURE_EXIT).toBe('number')
  134. })
  135. })
  136. describe('node:net address predicates', () => {
  137. it('classifies IPv4, IPv6, and neither', () => {
  138. expect([net.isIPv4('127.0.0.1'), net.isIPv4('255.255.255.255')]).toEqual([true, true])
  139. expect([net.isIPv4('256.0.0.1'), net.isIPv4('::1'), net.isIPv4('nope')]).toEqual([false, false, false])
  140. expect([net.isIPv6('::1'), net.isIPv6('fe80::1'), net.isIPv6('127.0.0.1')]).toEqual([true, true, false])
  141. expect([net.isIP('127.0.0.1'), net.isIP('::1'), net.isIP('nope')]).toEqual([4, 6, 0])
  142. })
  143. it('constructs a Socket but refuses to move bytes through it', () => {
  144. const socket = new net.Socket()
  145. expect(() => socket.write()).toThrow(/Socket.write is not available/)
  146. expect(() => socket.end()).toThrow(/Socket.end is not available/)
  147. // Disposal paths run against sockets that were never connected.
  148. expect(() => { socket.destroy() }).not.toThrow()
  149. })
  150. })
  151. describe('node:os', () => {
  152. it('reports the virtual platform identity and the VFS directories', () => {
  153. expect([os.EOL, os.tmpdir(), os.homedir()]).toEqual(['\n', DSH_TMP, DSH_HOME])
  154. expect([os.platform(), os.type(), os.arch()]).toEqual(['linux', 'Linux', 'x64'])
  155. expect([os.release(), os.hostname()]).toEqual(['0.0.0-dsh-worker', 'dsh-worker'])
  156. })
  157. it('reports no per-core facts and no network interfaces', () => {
  158. expect(os.cpus()).toEqual([])
  159. // The worker webserver binds the loopback literal, so a LAN address is never
  160. // derived — and an empty record keeps it out of the trust snapshot.
  161. expect(os.networkInterfaces()).toEqual({})
  162. expect(os.availableParallelism()).toBeGreaterThanOrEqual(1)
  163. })
  164. it('maps the terminal signal names its consumer reads', () => {
  165. expect(os.constants.signals.SIGTERM).toBe(15)
  166. expect(os.constants.signals.SIGKILL).toBe(9)
  167. })
  168. })
  169. describe('node:perf_hooks', () => {
  170. it("hands over the worker's own clock", () => {
  171. expect(perfHooks.performance).toBe(globalThis.performance)
  172. expect(perfHooks.performance.now()).toBeGreaterThan(0)
  173. })
  174. })
  175. describe('watching', () => {
  176. // Watching stays a loud refusal because `skill-filesystem` AWAITS watcher
  177. // progress rather than merely registering a listener; an inert watcher left
  178. // its discovery hanging. `fs.ts` records the experiment and the mechanism.
  179. it('refuses, naming the member, so an awaiting caller fails fast', () => {
  180. quiet()
  181. expect(() => fs.watchFile('/dsh/config/cordis.yml')).toThrow(/watchFile is not implemented in the worker host/)
  182. })
  183. it('accepts the unconditional teardown call, since nothing was watched', () => {
  184. expect(() => { fs.unwatchFile() }).not.toThrow()
  185. })
  186. })