parity.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect } from "effect"
  3. import { CodeMode } from "../src/index.js"
  4. import { ToolRuntime } from "../src/tool-runtime.js"
  5. // Runs a CodeMode program with no host tools and returns the CodeMode.Result. These tests pin the
  6. // JS-parity behaviors for the "99% of ordinary defensive JavaScript just works" goal: cases where
  7. // a strict interpreter would throw but idiomatic JS yields undefined / succeeds.
  8. //
  9. // Note on the result boundary: this package normalizes a bare `undefined` result to `null` when
  10. // it crosses out of CodeMode (results are JSON data), so tests asserting an in-CodeMode
  11. // `undefined` read check `=== undefined` inside the program and `null` at the boundary.
  12. const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
  13. const value = async (code: string) => {
  14. const result = await run(code)
  15. if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
  16. return result.value
  17. }
  18. const error = async (code: string) => {
  19. const result = await run(code)
  20. if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
  21. return result.error
  22. }
  23. describe("H2: string property access reads as undefined (not a throw)", () => {
  24. test("unknown property on a string is undefined", async () => {
  25. expect(await value(`const s = "hi"; return s.login === undefined`)).toBe(true)
  26. expect(await value(`const s = "hi"; return s.login`)).toBeNull()
  27. })
  28. test("optional chaining + fallback on a string does not throw", async () => {
  29. expect(await value(`const s = "hi"; return s?.login ?? "fallback"`)).toBe("fallback")
  30. })
  31. test("the real MCP pattern: result is a JSON string, defensive read falls through", async () => {
  32. // me.result is a string; me.result?.login is undefined, so we fall back to the raw string.
  33. expect(await value(`const me = { result: '{"login":"x"}' }; return me.result?.login ?? me.result`)).toBe(
  34. '{"login":"x"}',
  35. )
  36. })
  37. test("unknown property on a number is undefined", async () => {
  38. expect(await value(`return (5).foo ?? "n"`)).toBe("n")
  39. })
  40. })
  41. describe("H3: array property access reads as undefined (not a throw)", () => {
  42. test("unknown property on an array is undefined", async () => {
  43. expect(await value(`return [1,2,3].foo === undefined`)).toBe(true)
  44. expect(await value(`return [1,2,3].foo`)).toBeNull()
  45. })
  46. test("optional chaining on an array does not throw", async () => {
  47. expect(await value(`return [1,2,3]?.foo ?? "fb"`)).toBe("fb")
  48. })
  49. test("unknown property reads stay undefined for methods CodeMode does not implement", async () => {
  50. expect(await value(`return [1,2,3].toSpliced === undefined`)).toBe(true)
  51. })
  52. test("array indexing still works", async () => {
  53. expect(await value(`return [1,2,3][9] === undefined`)).toBe(true)
  54. expect(await value(`return [1,2,3][9]`)).toBeNull()
  55. })
  56. })
  57. describe("H6: object spread of null/undefined is a no-op", () => {
  58. test("spreading null is a no-op", async () => {
  59. expect(await value(`const o = null; return { ...o, a: 1 }`)).toEqual({ a: 1 })
  60. })
  61. test("spreading an absent argument merges cleanly", async () => {
  62. expect(await value(`function f(opts){ return { ...opts, a: 1 } } return f(undefined)`)).toEqual({ a: 1 })
  63. })
  64. test("spreading a real object still works", async () => {
  65. expect(await value(`const o = { a: 1 }; return { ...o, b: 2 }`)).toEqual({ a: 1, b: 2 })
  66. })
  67. test("spreading an array into an object still errors", async () => {
  68. const err = await error(`return { ...[1,2], a: 1 }`)
  69. expect(err.kind).toBe("InvalidDataValue")
  70. })
  71. })
  72. describe("H4: typeof on an undeclared identifier is 'undefined'", () => {
  73. test("feature-detection guard does not throw", async () => {
  74. expect(await value(`return typeof foo === "undefined" ? "safe" : "no"`)).toBe("safe")
  75. })
  76. test("typeof of a declared binding is unaffected", async () => {
  77. expect(await value(`const x = 5; return typeof x`)).toBe("number")
  78. expect(await value(`const s = "a"; return typeof s`)).toBe("string")
  79. })
  80. test("referencing an undeclared identifier outside typeof still throws", async () => {
  81. const err = await error(`return foo + 1`)
  82. expect(err.message).toContain("foo")
  83. })
  84. })
  85. describe("H1: NaN/Infinity flow as intermediates and normalize to null at the boundary", () => {
  86. test("guards run instead of the program crashing on a transient NaN", async () => {
  87. expect(await value(`return parseInt("abc") || 0`)).toBe(0)
  88. expect(await value(`const x = Number("abc"); return Number.isNaN(x) ? 0 : x`)).toBe(0)
  89. expect(await value(`const o = {}; o.count = (o.count || 0) + 1; return o.count`)).toBe(1)
  90. // average of an empty list, guarded - the classic divide-by-zero that used to throw pre-guard
  91. expect(await value(`const a = []; return a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0`)).toBe(0)
  92. })
  93. test("a non-finite value becomes null when it leaves CodeMode", async () => {
  94. expect(await value(`return 5/0`)).toBeNull()
  95. expect(await value(`return 0/0`)).toBeNull()
  96. expect(await value(`return Math.max()`)).toBeNull()
  97. // nested, too - normalization walks the returned structure
  98. expect(await value(`return { a: Number("x"), b: 2, c: [1/0] }`)).toEqual({ a: null, b: 2, c: [null] })
  99. })
  100. test("NaN and Infinity are usable identifiers and inspectable in-CodeMode", async () => {
  101. expect(await value(`return Number.isNaN(NaN)`)).toBe(true)
  102. expect(await value(`return Infinity > 1e9`)).toBe(true)
  103. expect(await value(`return Number.isFinite(1/0)`)).toBe(false)
  104. expect(await value(`return [3,1,2].reduce((a,b)=>Math.max(a,b), -Infinity)`)).toBe(3)
  105. // JSON.stringify inside CodeMode matches JS: non-finite serializes to null
  106. expect(await value(`return JSON.stringify({ x: Number("z") })`)).toBe('{"x":null}')
  107. })
  108. test("copyOut normalizes non-finite numbers to null (the shared return + tool-arg boundary)", () => {
  109. // Tool-call arguments funnel through copyOut too, so this one function pins both boundaries.
  110. expect(ToolRuntime.copyOut(NaN)).toBeNull()
  111. expect(ToolRuntime.copyOut(Infinity)).toBeNull()
  112. expect(ToolRuntime.copyOut(-Infinity)).toBeNull()
  113. expect(ToolRuntime.copyOut(42)).toBe(42)
  114. expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] })).toEqual({ a: null, b: [null, 1] })
  115. })
  116. })
  117. describe("Error values and instanceof", () => {
  118. test("new Error carries name/message and is instanceof Error", async () => {
  119. expect(await value(`const e = new Error("boom"); return [e instanceof Error, e.name, e.message]`)).toEqual([
  120. true,
  121. "Error",
  122. "boom",
  123. ])
  124. })
  125. test("Error without new behaves like new Error", async () => {
  126. expect(await value(`const e = Error("plain"); return [e instanceof Error, e.name, e.message]`)).toEqual([
  127. true,
  128. "Error",
  129. "plain",
  130. ])
  131. expect(await value(`const e = new Error(); return [e.name, e.message, e instanceof Error]`)).toEqual([
  132. "Error",
  133. "",
  134. true,
  135. ])
  136. })
  137. test("specific error types are instanceof themselves and Error, not each other", async () => {
  138. expect(
  139. await value(
  140. `const e = new TypeError("t"); return [e instanceof TypeError, e instanceof Error, e instanceof RangeError]`,
  141. ),
  142. ).toEqual([true, true, false])
  143. expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false)
  144. })
  145. test("thrown errors keep instanceof through try/catch", async () => {
  146. expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([
  147. true,
  148. "x",
  149. ])
  150. })
  151. test("interpreter runtime failures are caught as Error values", async () => {
  152. expect(await value(`try { JSON.parse("nope") } catch (e) { return e instanceof Error }`)).toBe(true)
  153. expect(await value(`try { undeclared() } catch (e) { return e instanceof Error }`)).toBe(true)
  154. })
  155. test("caught failures carry the constructor name the real-JS failure would have", async () => {
  156. // JSON.parse throws SyntaxError: name and specific-instanceof both carry through, and the
  157. // message keeps the engine's position detail.
  158. expect(
  159. await value(`
  160. try { JSON.parse("{oops") } catch (e) {
  161. return [e.name, e instanceof SyntaxError, e instanceof Error, e instanceof TypeError, e.message.includes("JSON")]
  162. }
  163. `),
  164. ).toEqual(["SyntaxError", true, true, false, true])
  165. expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`)).toEqual([
  166. "ReferenceError",
  167. true,
  168. ])
  169. expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`)).toEqual([
  170. "TypeError",
  171. true,
  172. ])
  173. expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([
  174. "SyntaxError",
  175. true,
  176. ])
  177. expect(await value(`try { new RegExp("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([
  178. "SyntaxError",
  179. true,
  180. ])
  181. })
  182. test("diagnostics without a specific real-JS analogue are named plain Error", async () => {
  183. expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`)).toEqual([
  184. "Error",
  185. true,
  186. ])
  187. })
  188. test("Promise.allSettled rejection reasons are Error values", async () => {
  189. expect(
  190. await value(`
  191. const settled = await Promise.allSettled([Promise.reject(new Error("b"))])
  192. return [settled[0].reason instanceof Error, settled[0].reason.message]
  193. `),
  194. ).toEqual([true, "b"])
  195. })
  196. test("non-error thrown values are not instanceof Error", async () => {
  197. expect(await value(`try { throw "raw" } catch (e) { return e instanceof Error }`)).toBe(false)
  198. expect(await value(`try { throw { message: "shaped" } } catch (e) { return e instanceof Error }`)).toBe(false)
  199. })
  200. test("plain data is never instanceof Error", async () => {
  201. expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([
  202. false,
  203. false,
  204. false,
  205. ])
  206. })
  207. test("error values still serialize as plain { name, message } data", async () => {
  208. expect(await value(`return new Error("m")`)).toEqual({ name: "Error", message: "m" })
  209. expect(await value(`return JSON.stringify(new Error("m"))`)).toBe('{"name":"Error","message":"m"}')
  210. expect(await value(`try { throw new Error("m") } catch (e) { return Object.keys(e) }`)).toEqual(["name", "message"])
  211. })
  212. test("spreading an error loses the brand, like losing the prototype in JS", async () => {
  213. expect(await value(`const e = new Error("m"); return ({ ...e }) instanceof Error`)).toBe(false)
  214. expect(await value(`const e = new Error("m"); return { ...e }`)).toEqual({ name: "Error", message: "m" })
  215. })
  216. test("typeof Error is function; an unknown instanceof right-hand side is a catchable error", async () => {
  217. expect(await value(`return typeof Error`)).toBe("function")
  218. expect(await value(`try { return 1 instanceof 5 } catch (e) { return "caught" }`)).toBe("caught")
  219. const err = await error(`return 1 instanceof 5`)
  220. expect(err.message).toContain("right-hand side of 'instanceof'")
  221. })
  222. })
  223. describe("CodeMode-specific array behavior", () => {
  224. test("sort with a comparator mutates and returns the receiver", async () => {
  225. expect(
  226. await value(`
  227. const input = [3, 1, 2]
  228. const result = input.sort((a, b) => a - b)
  229. return { input, same: input === result }
  230. `),
  231. ).toEqual({ input: [1, 2, 3], same: true })
  232. })
  233. test("splice can replace and insert elements", async () => {
  234. expect(await value(`const a = ["a","d"]; a.splice(1, 0, "b", "c"); return a`)).toEqual(["a", "b", "c", "d"])
  235. expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({
  236. removed: [2],
  237. a: [1, "x", 3],
  238. })
  239. })
  240. test("splice rejects inserting a container into itself", async () => {
  241. const err = await error(`const a = [1]; a.splice(0, 0, [a]); return a`)
  242. expect(err.kind).toBe("InvalidDataValue")
  243. expect(err.message).toContain("circular")
  244. })
  245. test("keys/values/entries return arrays usable with for...of and spread", async () => {
  246. expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2])
  247. expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"])
  248. expect(
  249. await value(`
  250. const out = []
  251. for (const [index, item] of ["a","b"].entries()) out.push(index + ":" + item)
  252. return out
  253. `),
  254. ).toEqual(["0:a", "1:b"])
  255. expect(await value(`return [...[7].entries()]`)).toEqual([[0, 7]])
  256. })
  257. })
  258. describe("CodeMode-specific string behavior", () => {
  259. test("localeCompare orders strings for sorting", async () => {
  260. expect(await value(`return ["b","a","c"].sort((x, y) => x.localeCompare(y))`)).toEqual(["a", "b", "c"])
  261. })
  262. test("an invalid normalize form is a clear catchable error", async () => {
  263. expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"')
  264. })
  265. test("does not expose obsolete string aliases", async () => {
  266. expect(await value(`return [typeof "x".trimLeft, typeof "x".trimRight, typeof "x".substr]`)).toEqual([
  267. "undefined",
  268. "undefined",
  269. "undefined",
  270. ])
  271. })
  272. })
  273. describe("compound assignment matches its binary operator", () => {
  274. // `x op= y` must behave exactly like `x = x op y`, sharing the binary operator's coercion
  275. // semantics (Dates string-coerce for `+` and use their time value for arithmetic; data
  276. // objects/arrays coerce to their JS string form).
  277. const pair = async (compound: string, expanded: string) => {
  278. const [a, b] = await Promise.all([value(compound), value(expanded)])
  279. expect(a).toEqual(b)
  280. return a
  281. }
  282. test("CodeMode Date += concatenates its string form, like d = d + 1", async () => {
  283. const result = await pair(`let d = new Date(1000); d += 1; return d`, `let d = new Date(1000); d = d + 1; return d`)
  284. expect(result).toBe("1970-01-01T00:00:01.000Z1")
  285. })
  286. test("CodeMode Date numeric compound ops use its time value", async () => {
  287. expect(
  288. await pair(`let d = new Date(1000); d -= 400; return d`, `let d = new Date(1000); d = d - 400; return d`),
  289. ).toBe(600)
  290. expect(await pair(`let d = new Date(1000); d /= 4; return d`, `let d = new Date(1000); d = d / 4; return d`)).toBe(
  291. 250,
  292. )
  293. })
  294. test("string += object/array matches x = x + obj", async () => {
  295. expect(await pair(`let x = "a"; x += { b: 1 }; return x`, `let x = "a"; x = x + { b: 1 }; return x`)).toBe(
  296. "a[object Object]",
  297. )
  298. expect(await pair(`let x = "a"; x += [1, 2]; return x`, `let x = "a"; x = x + [1, 2]; return x`)).toBe("a1,2")
  299. })
  300. test("compound assignment through a member target coerces the same way", async () => {
  301. expect(
  302. await pair(
  303. `const o = { s: "t" }; o.s += new Date(0); return o.s`,
  304. `const o = { s: "t" }; o.s = o.s + new Date(0); return o.s`,
  305. ),
  306. ).toBe("t1970-01-01T00:00:00.000Z")
  307. })
  308. test("numeric and string compound operators sweep identically to their expansions", async () => {
  309. const cases: Array<[string, number | string]> = [
  310. [`let x = 7; x += 3; return x`, 7 + 3],
  311. [`let x = 7; x -= 3; return x`, 7 - 3],
  312. [`let x = 7; x *= 3; return x`, 7 * 3],
  313. [`let x = 7; x /= 2; return x`, 7 / 2],
  314. [`let x = 7; x %= 3; return x`, 7 % 3],
  315. [`let x = 7; x **= 2; return x`, 7 ** 2],
  316. [`let x = 7; x &= 3; return x`, 7 & 3],
  317. [`let x = 7; x |= 8; return x`, 7 | 8],
  318. [`let x = 7; x ^= 2; return x`, 7 ^ 2],
  319. [`let x = 7; x <<= 2; return x`, 7 << 2],
  320. [`let x = -7; x >>= 1; return x`, -7 >> 1],
  321. [`let x = -7; x >>>= 1; return x`, -7 >>> 1],
  322. [`let x = "a"; x += "b"; return x`, "ab"],
  323. ]
  324. for (const [compound, expected] of cases) {
  325. expect(await value(compound)).toBe(expected)
  326. expect(await value(compound.replace(/x (\S+)= /, (_, op) => `x = x ${op} `))).toBe(expected)
  327. }
  328. })
  329. })
  330. describe("H5: builtin coercion functions work as array callbacks", () => {
  331. test("filter(Boolean) drops falsy values", async () => {
  332. expect(await value(`return [0, 1, "", 2, null, 3].filter(Boolean)`)).toEqual([1, 2, 3])
  333. })
  334. test("map(String) coerces each element", async () => {
  335. expect(await value(`return [1, 2, 3].map(String)`)).toEqual(["1", "2", "3"])
  336. })
  337. test("a non-callable callback is still rejected", async () => {
  338. const err = await error(`return [1,2,3].map(42)`)
  339. expect(err.message).toContain("callback")
  340. })
  341. })
  342. describe("for...of assignment destructuring", () => {
  343. test("assigns entry pairs into predeclared variables", async () => {
  344. expect(
  345. await value(`
  346. let key
  347. let item
  348. const out = []
  349. for ([key, item] of Object.entries({ a: 1, b: 2 })) out.push(key + item)
  350. return { key, item, out }
  351. `),
  352. ).toEqual({ key: "b", item: 2, out: ["a1", "b2"] })
  353. })
  354. test("assigns object patterns and defaults", async () => {
  355. expect(
  356. await value(`
  357. let id
  358. let label
  359. const labels = []
  360. for ({ id, label = "unknown" } of [{ id: 1 }, { id: 2, label: "two" }]) labels.push(label)
  361. return { id, label, labels }
  362. `),
  363. ).toEqual({ id: 2, label: "two", labels: ["unknown", "two"] })
  364. })
  365. })
  366. describe("sequence expressions", () => {
  367. test("evaluate left to right and return the final value", async () => {
  368. expect(await value(`let x = 0; const result = (x += 1, x *= 3, x + 2); return { x, result }`)).toEqual({
  369. x: 3,
  370. result: 5,
  371. })
  372. })
  373. test("support comma-separated for-loop updates", async () => {
  374. expect(
  375. await value(`
  376. const pairs = []
  377. for (let left = 0, right = 3; left < right; left++, right--) pairs.push([left, right])
  378. return pairs
  379. `),
  380. ).toEqual([
  381. [0, 3],
  382. [1, 2],
  383. ])
  384. })
  385. })
  386. describe("destructuring assignment", () => {
  387. test("assigns object and array patterns to existing bindings", async () => {
  388. expect(
  389. await value(`
  390. let a = 0
  391. let b = 0
  392. ;({ a } = { a: 2 })
  393. ;[a, b] = [3, 4]
  394. return [a, b]
  395. `),
  396. ).toEqual([3, 4])
  397. })
  398. test("supports defaults, nesting, rest, and member targets", async () => {
  399. expect(
  400. await value(`
  401. let first = 0
  402. let fallback = 0
  403. let rest = {}
  404. const target = {}
  405. ;[first, fallback = 2, ...target.tail] = [1]
  406. ;({ nested: { value: target.value }, kept: target.kept = 3, ...rest } = {
  407. nested: { value: 4 },
  408. extra: 5,
  409. })
  410. return { first, fallback, target, rest }
  411. `),
  412. ).toEqual({ first: 1, fallback: 2, target: { tail: [], value: 4, kept: 3 }, rest: { extra: 5 } })
  413. })
  414. test("returns the assigned value", async () => {
  415. expect(await value(`let a = 0; const result = ([a] = [7]); return [a, result]`)).toEqual([7, [7]])
  416. })
  417. })