callbacks.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import { CodeMode, Tool } from "../src/index.js"
  4. // Callback acceptance is one gate shared by array methods, sort, string replacers,
  5. // Array.from mappers, Map/Set/URLSearchParams forEach, and promise reactions:
  6. // interpreter functions, coercion/URI builtins, resolver capabilities, and built-in
  7. // method references are callable; tools and other opaque callables get a wrap hint.
  8. const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
  9. const value = async (code: string) => {
  10. const result = await run(code)
  11. if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
  12. return result.value
  13. }
  14. const error = async (code: string) => {
  15. const result = await run(code)
  16. if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
  17. return result.error
  18. }
  19. const logsOf = async (code: string) => {
  20. const result = await run(code)
  21. if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
  22. return result.logs ?? []
  23. }
  24. const echo = Tool.make({
  25. description: "Echo the input",
  26. input: Schema.Struct({ id: Schema.Number }),
  27. output: Schema.Number,
  28. execute: (input: { id: number }) => Effect.succeed(input.id),
  29. })
  30. const withTool = (code: string) => Effect.runPromise(CodeMode.make({ tools: { host: { echo } } }).execute(code))
  31. const toolError = async (code: string) => {
  32. const result = await withTool(code)
  33. if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
  34. return result.error
  35. }
  36. describe("built-in method references as callbacks", () => {
  37. test("map accepts Math methods", async () => {
  38. expect(await value(`return [-1, 2, -3].map(Math.abs)`)).toEqual([1, 2, 3])
  39. expect(await value(`return [1.5, 2.7].map(Math.floor)`)).toEqual([1, 2])
  40. })
  41. test("map(JSON.stringify) matches JS: the index replacer and array space are ignored", async () => {
  42. expect(await value(`return [{ a: 1 }, [2]].map(JSON.stringify)`)).toEqual(['{"a":1}', "[2]"])
  43. })
  44. test("map(Number.parseInt) reproduces the JS radix footgun", async () => {
  45. // parseInt("2", 1) is NaN in real JS; NaN serializes to null at the result boundary.
  46. expect(await value(`return ["1", "2"].map(Number.parseInt)`)).toEqual([1, null])
  47. })
  48. test("filter and find accept built-in predicates", async () => {
  49. expect(await value(`return [0, 1, NaN, 2].filter(Number.isInteger)`)).toEqual([0, 1, 2])
  50. expect(await value(`return [1.5, 3, 2.5].find(Number.isInteger)`)).toBe(3)
  51. })
  52. test("forEach(console.log) captures one log line per element", async () => {
  53. const logs = await logsOf(`["a", "b"].forEach(console.log); return null`)
  54. expect(logs).toHaveLength(2)
  55. expect(logs[0]).toContain("a")
  56. expect(logs[1]).toContain("b")
  57. })
  58. test("intrinsic method references keep their receiver, unlike detached JS methods", async () => {
  59. expect(await value(`return ["a", "z"].filter("abc".includes)`)).toEqual(["a"])
  60. })
  61. test("promise reactions accept built-in references", async () => {
  62. expect(await value(`return await Promise.resolve(-5).then(Math.abs)`)).toBe(5)
  63. const logs = await logsOf(`await Promise.resolve("done").then(console.log); return null`)
  64. expect(logs).toHaveLength(1)
  65. expect(logs[0]).toContain("done")
  66. })
  67. })
  68. describe("constructors callable without new, like JS", () => {
  69. test("Error constructors work as callbacks and direct calls", async () => {
  70. expect(await value(`return ["boom"].map(Error)[0].message`)).toBe("boom")
  71. expect(await value(`return TypeError("bad").name`)).toBe("TypeError")
  72. })
  73. test("error values stringify like JS Error.prototype.toString", async () => {
  74. expect(await value(`return String(TypeError("bad"))`)).toBe("TypeError: bad")
  75. expect(await value(`return String(Error(""))`)).toBe("Error")
  76. expect(await value(`return "x" + RangeError("oops")`)).toBe("xRangeError: oops")
  77. expect(await value(`return "a1b2".replace(/\\d/, Error)`)).toBe("aError: 1b2")
  78. })
  79. test("literal elisions are real holes, like JS", async () => {
  80. expect(await value(`return (0 in [, 1])`)).toBe(false)
  81. expect(await value(`return Object.keys([, 1, ,])`)).toEqual(["1"])
  82. expect(await value(`return [, 1, ,].filter(() => true).length`)).toBe(1)
  83. expect(await value(`return [, ,].every((x) => false)`)).toBe(true)
  84. })
  85. test("Array constructs from arguments or a length", async () => {
  86. expect(await value(`return Array(1, 2, 3)`)).toEqual([1, 2, 3])
  87. expect(await value(`return Array("3")`)).toEqual(["3"])
  88. expect(await value(`return Array(3).length`)).toBe(3)
  89. expect(await value(`return new Array(2).length`)).toBe(2)
  90. // Holes stay holes, like JS: map skips them (length preserved, normalized to
  91. // null at the host boundary), spread materializes undefined.
  92. expect(await value(`return Array(3).map((x) => 1)`)).toEqual([null, null, null])
  93. expect(await value(`return Array(3).map((x) => 1).length`)).toBe(3)
  94. expect(await value(`return [...Array(3)].map((_, i) => i)`)).toEqual([0, 1, 2])
  95. expect((await error(`return Array(-1)`)).message).toContain("Invalid array length")
  96. expect((await error(`return Array(1.5)`)).message).toContain("Invalid array length")
  97. })
  98. test("Object returns objects unchanged and rejects primitive wrappers", async () => {
  99. expect(await value(`return Object()`)).toEqual({})
  100. expect(await value(`const o = { a: 1 }; return Object(o) === o`)).toBe(true)
  101. expect((await error(`return Object(1)`)).message).toContain("wrapper objects are not supported")
  102. })
  103. test("Date() without new returns a deterministic ISO string and ignores arguments", async () => {
  104. expect(await value(`return /^\\d{4}-\\d{2}-\\d{2}T.*Z$/.test(Date(1000))`)).toBe(true)
  105. expect(await value(`return "abc".replace(RegExp("b"), "x")`)).toBe("axc")
  106. })
  107. test("map(Array) matches the JS 3-argument call", async () => {
  108. expect(await value(`return [7].map(Array)`)).toEqual([[7, 0, [7]]])
  109. })
  110. test("array length boundaries match JS", async () => {
  111. expect(await value(`return Array(4294967295).length`)).toBe(4294967295)
  112. const diagnostic = await error(`return Array(4294967296)`)
  113. expect(diagnostic.message).toContain("Invalid array length")
  114. expect((await error(`try { Array(-1) } catch (e) { throw Error(e.name) }`)).message).toContain("RangeError")
  115. })
  116. test("returned sparse arrays normalize holes to null at the host boundary", async () => {
  117. expect(await value(`return Array(3)`)).toEqual([null, null, null])
  118. })
  119. test("RegExp with non-string flags throws a SyntaxError, like JS", async () => {
  120. expect((await error(`try { RegExp("a", 0) } catch (e) { throw Error(e.name) }`)).message).toContain("SyntaxError")
  121. })
  122. test("new-requiring constructors throw a TypeError when called", async () => {
  123. expect((await error(`return Map()`)).message).toContain("Constructor Map requires 'new'")
  124. expect((await error(`return [1].map(Set)`)).message).toContain("Constructor Set requires 'new'")
  125. expect((await error(`return Promise(() => 1)`)).message).toContain("Constructor Promise requires 'new'")
  126. // As a reaction handler the TypeError rejects the derived promise catchably, like JS.
  127. expect(await value(`return await Promise.resolve(1).then(Map).catch((e) => e.name)`)).toBe("TypeError")
  128. })
  129. })
  130. describe("sort accepts the unified callback set", () => {
  131. test("sort preserves trailing holes while toSorted densifies them", async () => {
  132. expect(
  133. await value(`
  134. const defaultSorted = [2, , 1]
  135. const compared = [2, , 1]
  136. const copied = defaultSorted.toSorted()
  137. defaultSorted.sort()
  138. compared.sort((a, b) => a - b)
  139. return [
  140. Object.hasOwn(defaultSorted, 2),
  141. Object.hasOwn(compared, 2),
  142. Object.hasOwn(copied, 2),
  143. ]
  144. `),
  145. ).toEqual([false, false, true])
  146. expect(await value(`const values = [2, undefined, 1]; values.sort(); return Object.hasOwn(values, 2)`)).toBe(true)
  147. })
  148. test("sort writes its snapshot without discarding comparator length mutations", async () => {
  149. expect(
  150. await value(`
  151. const values = [3, 2, 1]
  152. let first = true
  153. values.sort((a, b) => {
  154. if (first) {
  155. first = false
  156. values.push("kept")
  157. }
  158. return a - b
  159. })
  160. return values
  161. `),
  162. ).toEqual([1, 2, 3, "kept"])
  163. expect(
  164. await value(`
  165. const values = [3, , 1, , 2]
  166. let first = true
  167. values.sort((a, b) => {
  168. if (first) {
  169. first = false
  170. values.splice(0)
  171. }
  172. return a - b
  173. })
  174. return { values, owns: values.map((_, index) => Object.hasOwn(values, index)) }
  175. `),
  176. ).toEqual({ values: [1, 2, 3], owns: [true, true, true] })
  177. })
  178. test("sort and toSorted take built-in comparators", async () => {
  179. expect(await value(`return [0, 1, 0].sort(Boolean)`)).toEqual([0, 0, 1])
  180. expect(await value(`return [0, 1, 0].toSorted(Boolean)`)).toEqual([0, 0, 1])
  181. })
  182. test("a non-callable comparator is rejected", async () => {
  183. expect((await error(`return [2, 1].sort(42)`)).message).toContain("Array.sort expects a function callback")
  184. expect((await error(`return [2, 1].toSorted(42)`)).message).toContain("Array.toSorted expects a function callback")
  185. })
  186. })
  187. describe("Array.from mapper", () => {
  188. test("maps with (value, index) over arrays, strings, and Sets", async () => {
  189. expect(await value(`return Array.from([1, 2, 3], (x) => x * 2)`)).toEqual([2, 4, 6])
  190. expect(await value(`return Array.from("ab", (c, i) => c + i)`)).toEqual(["a0", "b1"])
  191. expect(await value(`return Array.from(new Set([1, 2]), (x) => x * 10)`)).toEqual([10, 20])
  192. })
  193. test("accepts coercion builtins and an explicit undefined mapper", async () => {
  194. expect(await value(`return Array.from(["5", "7"], Number)`)).toEqual([5, 7])
  195. expect(await value(`return Array.from([1, 2], undefined)`)).toEqual([1, 2])
  196. })
  197. test("rejects a non-callable mapper", async () => {
  198. expect((await error(`return Array.from([1], 42)`)).message).toContain("Array.from expects a function callback")
  199. })
  200. })
  201. describe("thisArg is accepted and ignored, like JS arrows", () => {
  202. // CodeMode functions have no `this`, so a thisArg can never change behavior —
  203. // exactly like passing one alongside an arrow function in real JS.
  204. test("iteration methods and Array.from ignore a thisArg", async () => {
  205. expect(await value(`return [1, 2].map((x) => x * 2, {})`)).toEqual([2, 4])
  206. expect(await value(`return [1, 2].map((x) => x, undefined)`)).toEqual([1, 2])
  207. expect(await value(`return Array.from([1], (x) => x + 1, {})`)).toEqual([2])
  208. })
  209. test("Map, Set, and URLSearchParams forEach ignore a thisArg", async () => {
  210. expect(await value(`const o = []; new Map([["a", 1]]).forEach((v, k) => o.push(k), {}); return o`)).toEqual(["a"])
  211. expect(await value(`const o = []; new Set([1]).forEach((v) => o.push(v), "self"); return o`)).toEqual([1])
  212. expect(await value(`const o = []; new URLSearchParams("a=1").forEach((v) => o.push(v), 0); return o`)).toEqual([
  213. "1",
  214. ])
  215. })
  216. })
  217. describe("still-rejected callables get the wrap hint", () => {
  218. test("tool references as callbacks suggest an arrow wrapper", async () => {
  219. const diagnostic = await toolError(`return [1, 2].map(tools.host.echo)`)
  220. expect(diagnostic.message).toContain("wrap it in an arrow function")
  221. expect(await withTool(`return await Promise.all([1, 2].map((id) => tools.host.echo({ id })))`)).toMatchObject({
  222. ok: true,
  223. value: [1, 2],
  224. })
  225. })
  226. test("detached Promise statics as callbacks suggest an arrow wrapper", async () => {
  227. expect((await error(`return [1].map(Promise.resolve)`)).message).toContain("wrap it in an arrow function")
  228. })
  229. test("string replacers reject opaque callables with the wrap hint, not a type error", async () => {
  230. const diagnostic = await toolError(`return "abc".replace(/b/, tools.host.echo)`)
  231. expect(diagnostic.message).toContain("wrap it in an arrow function")
  232. expect(diagnostic.message).not.toContain("argument 2")
  233. })
  234. test("built-in references work as replacers", async () => {
  235. // Like real JS: JSON.stringify(match, offset, string) quotes the match.
  236. expect(await value(`return "abc".replace(/b/, JSON.stringify)`)).toBe('a"b"c')
  237. // Math methods stay strict about consumed arguments: a match string is not coerced.
  238. expect((await error(`return "3.7".replace(/\\d\\.\\d/, Math.floor)`)).message).toContain(
  239. "Math.floor expects number arguments",
  240. )
  241. })
  242. test("non-callables still get the plain callback error", async () => {
  243. expect((await error(`return [1].map(42)`)).message).toContain("Array.map expects a function callback")
  244. })
  245. test("promise handlers reject opaque callables with the wrap hint", async () => {
  246. const diagnostic = await toolError(`return await Promise.resolve(1).then(tools.host.echo)`)
  247. expect(diagnostic.message).toContain("Promise.prototype.then cannot use this callable as a handler")
  248. expect(diagnostic.message).toContain("wrap it in an arrow function")
  249. })
  250. test("JSON callbacks use the unified callback gate", async () => {
  251. expect(
  252. await value(`return JSON.stringify({ a: -1 }, (key, item) => typeof item === "number" ? Math.abs(item) : item)`),
  253. ).toBe('{"a":1}')
  254. expect((await toolError(`return JSON.stringify({ a: 1 }, tools.host.echo)`)).message).toContain(
  255. "wrap it in an arrow function",
  256. )
  257. expect((await toolError(`return JSON.parse('{"a":1}', tools.host.echo)`)).message).toContain(
  258. "wrap it in an arrow function",
  259. )
  260. })
  261. test("non-callable JSON callback arguments are ignored", async () => {
  262. expect(await value(`return JSON.parse('{"a":1}', undefined)`)).toEqual({ a: 1 })
  263. expect(await value(`return JSON.parse('{"a":1}', 42)`)).toEqual({ a: 1 })
  264. expect(await value(`return JSON.stringify({ a: 1 }, 42)`)).toBe('{"a":1}')
  265. })
  266. })