parity.test.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915
  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. test("only canonical string index keys access characters", async () => {
  41. expect(
  42. await value(`
  43. const text = "abc"
  44. return [text[1], text["1"], text["01"], text["1.0"], text[-0], text["-0"]]
  45. `),
  46. ).toEqual(["b", "b", null, null, "a", null])
  47. })
  48. })
  49. describe("H3: array property access reads as undefined (not a throw)", () => {
  50. test("unknown property on an array is undefined", async () => {
  51. expect(await value(`return [1,2,3].foo === undefined`)).toBe(true)
  52. expect(await value(`return [1,2,3].foo`)).toBeNull()
  53. })
  54. test("optional chaining on an array does not throw", async () => {
  55. expect(await value(`return [1,2,3]?.foo ?? "fb"`)).toBe("fb")
  56. })
  57. test("unknown property reads stay undefined for methods CodeMode does not implement", async () => {
  58. expect(await value(`return [1,2,3].unknownMethod === undefined`)).toBe(true)
  59. })
  60. test("array indexing still works", async () => {
  61. expect(await value(`return [1,2,3][9] === undefined`)).toBe(true)
  62. expect(await value(`return [1,2,3][9]`)).toBeNull()
  63. })
  64. test("only canonical array index keys access elements", async () => {
  65. expect(
  66. await value(`
  67. const values = ["a", "b"]
  68. return [values[1], values["1"], values["01"], values["1.0"], values[-0], values["-0"]]
  69. `),
  70. ).toEqual(["b", "b", null, null, "a", null])
  71. })
  72. test("noncanonical keys cannot mutate or delete an aliased element", async () => {
  73. expect(
  74. await value(`
  75. const values = ["a", "b"]
  76. let writes = 0
  77. try { values["01"] = ++writes } catch {}
  78. const removed = delete values["01"]
  79. return [writes, removed, values]
  80. `),
  81. ).toEqual([0, true, ["a", "b"]])
  82. })
  83. test("the maximum array length is not accepted as an array index", async () => {
  84. expect(
  85. await value(`
  86. const values = []
  87. let writes = 0
  88. try { values["4294967295"] = ++writes } catch {}
  89. return [writes, values.length]
  90. `),
  91. ).toEqual([0, 0])
  92. })
  93. })
  94. describe("H6: object spread of null/undefined is a no-op", () => {
  95. test("spreading null is a no-op", async () => {
  96. expect(await value(`const o = null; return { ...o, a: 1 }`)).toEqual({ a: 1 })
  97. })
  98. test("spreading an absent argument merges cleanly", async () => {
  99. expect(await value(`function f(opts){ return { ...opts, a: 1 } } return f(undefined)`)).toEqual({ a: 1 })
  100. })
  101. test("spreading a real object still works", async () => {
  102. expect(await value(`const o = { a: 1 }; return { ...o, b: 2 }`)).toEqual({ a: 1, b: 2 })
  103. })
  104. test("spreading an array into an object still errors", async () => {
  105. const err = await error(`return { ...[1,2], a: 1 }`)
  106. expect(err.kind).toBe("InvalidDataValue")
  107. })
  108. })
  109. describe("H4: typeof on an undeclared identifier is 'undefined'", () => {
  110. test("feature-detection guard does not throw", async () => {
  111. expect(await value(`return typeof foo === "undefined" ? "safe" : "no"`)).toBe("safe")
  112. })
  113. test("typeof of a declared binding is unaffected", async () => {
  114. expect(await value(`const x = 5; return typeof x`)).toBe("number")
  115. expect(await value(`const s = "a"; return typeof s`)).toBe("string")
  116. })
  117. test("referencing an undeclared identifier outside typeof still throws", async () => {
  118. const err = await error(`return foo + 1`)
  119. expect(err.message).toContain("foo")
  120. })
  121. })
  122. describe("CodeMode lexical scope integration", () => {
  123. test("keeps self, cross, and destructuring defaults in the TDZ", async () => {
  124. expect(
  125. await value(`
  126. const outer = 1
  127. const errors = []
  128. try { const first = second, second = 2 } catch (error) { errors.push(error.name) }
  129. try { const [first = second, second = 2] = [] } catch (error) { errors.push(error.name) }
  130. return errors
  131. `),
  132. ).toEqual(["ReferenceError", "ReferenceError"])
  133. })
  134. test("keeps typeof and constant assignment inside the TDZ", async () => {
  135. expect(
  136. await value(`
  137. const errors = []
  138. try { { errors.push(typeof item); let item } } catch (error) { errors.push(error.name) }
  139. try { { constant = 1; const constant = 2 } } catch (error) { errors.push(error.name) }
  140. return errors
  141. `),
  142. ).toEqual(["ReferenceError", "ReferenceError"])
  143. })
  144. test("shadows builtins from the start of the program scope", async () => {
  145. expect(
  146. await value(`
  147. let observed
  148. try { observed = typeof Promise } catch (error) { observed = error.name }
  149. const Promise = 1
  150. return observed
  151. `),
  152. ).toBe("ReferenceError")
  153. })
  154. test("keeps classic for initializers inside the header TDZ", async () => {
  155. expect(
  156. await value(`
  157. let index = 1
  158. try { for (let index = index; index < 2; index++) {} } catch (error) { return error.name }
  159. `),
  160. ).toBe("ReferenceError")
  161. })
  162. test("removes loop scopes when per-iteration initialization fails", async () => {
  163. expect(
  164. await value(`
  165. const value = "outer"
  166. try { for (let [value] of [1]) {} } catch {}
  167. return value
  168. `),
  169. ).toBe("outer")
  170. })
  171. })
  172. describe("unary void", () => {
  173. test("evaluates its operand and returns undefined", async () => {
  174. expect(
  175. await value(`let count = 0; const result = void (count += 1); return [count, result === undefined]`),
  176. ).toEqual([1, true])
  177. })
  178. test("discards opaque values", async () => {
  179. expect(await value(`return void tools === undefined`)).toBe(true)
  180. })
  181. })
  182. describe("property deletion", () => {
  183. test("deletes plain object fields and reports missing fields as successful", async () => {
  184. expect(
  185. await value(`
  186. const object = { keep: 1, remove: 2 }
  187. return [delete object.remove, delete object.missing, object]
  188. `),
  189. ).toEqual([true, true, { keep: 1 }])
  190. })
  191. test("evaluates computed object and key expressions once", async () => {
  192. expect(
  193. await value(`
  194. const object = { remove: true }
  195. let objectReads = 0
  196. let keyReads = 0
  197. function getObject() { objectReads++; return object }
  198. function getKey() { keyReads++; return "remove" }
  199. const removed = delete getObject()[getKey()]
  200. return [removed, objectReads, keyReads, Object.hasOwn(object, "remove")]
  201. `),
  202. ).toEqual([true, 1, 1, false])
  203. })
  204. test("deleting an array index creates a hole without changing its length", async () => {
  205. expect(
  206. await value(
  207. `const values = [1, 2, 3]; const removed = delete values[1]; return [removed, values.length, 1 in values, values]`,
  208. ),
  209. ).toEqual([true, 3, false, [1, null, 3]])
  210. })
  211. test("array length is not configurable", async () => {
  212. expect(await value(`const values = [1, 2]; return [delete values.length, values.length]`)).toEqual([false, 2])
  213. })
  214. test("does not broaden unsupported array property assignment", async () => {
  215. expect(
  216. await value(`
  217. const values = []
  218. let rightHandSideRuns = 0
  219. function next() { rightHandSideRuns++; return 1 }
  220. try { values.field = next() } catch {}
  221. return rightHandSideRuns
  222. `),
  223. ).toBe(0)
  224. })
  225. test("optional deletion short-circuits without evaluating the key", async () => {
  226. expect(
  227. await value(`let keyReads = 0; const object = null; return [delete object?.[keyReads++], keyReads]`),
  228. ).toEqual([true, 0])
  229. })
  230. test("rejects deletion from opaque runtime references", async () => {
  231. expect((await error(`return delete tools.example`)).kind).toBe("InvalidDataValue")
  232. })
  233. test("keeps blocked property names unavailable", async () => {
  234. expect((await error(`const object = {}; return delete object.__proto__`)).kind).toBe("ExecutionFailure")
  235. expect((await error(`const values = []; return delete values["constructor"]`)).kind).toBe("ExecutionFailure")
  236. })
  237. })
  238. describe("H1: NaN/Infinity flow as intermediates and normalize to null at the boundary", () => {
  239. test("guards run instead of the program crashing on a transient NaN", async () => {
  240. expect(await value(`return parseInt("abc") || 0`)).toBe(0)
  241. expect(await value(`const x = Number("abc"); return Number.isNaN(x) ? 0 : x`)).toBe(0)
  242. expect(await value(`const o = {}; o.count = (o.count || 0) + 1; return o.count`)).toBe(1)
  243. // average of an empty list, guarded - the classic divide-by-zero that used to throw pre-guard
  244. expect(await value(`const a = []; return a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0`)).toBe(0)
  245. })
  246. test("a non-finite value becomes null when it leaves CodeMode", async () => {
  247. expect(await value(`return 5/0`)).toBeNull()
  248. expect(await value(`return 0/0`)).toBeNull()
  249. expect(await value(`return Math.max()`)).toBeNull()
  250. // nested, too - normalization walks the returned structure
  251. expect(await value(`return { a: Number("x"), b: 2, c: [1/0] }`)).toEqual({ a: null, b: 2, c: [null] })
  252. })
  253. test("NaN and Infinity are usable identifiers and inspectable in-CodeMode", async () => {
  254. expect(await value(`return Number.isNaN(NaN)`)).toBe(true)
  255. expect(await value(`return Infinity > 1e9`)).toBe(true)
  256. expect(await value(`return Number.isFinite(1/0)`)).toBe(false)
  257. expect(await value(`return [3,1,2].reduce((a,b)=>Math.max(a,b), -Infinity)`)).toBe(3)
  258. // JSON.stringify inside CodeMode matches JS: non-finite serializes to null
  259. expect(await value(`return JSON.stringify({ x: Number("z") })`)).toBe('{"x":null}')
  260. })
  261. test("copyOut normalizes non-finite numbers to null (the shared return + tool-arg boundary)", () => {
  262. // Tool-call arguments funnel through copyOut too, so this one function pins both boundaries.
  263. expect(ToolRuntime.copyOut(NaN, "json")).toBeNull()
  264. expect(ToolRuntime.copyOut(Infinity, "json")).toBeNull()
  265. expect(ToolRuntime.copyOut(-Infinity, "nullify")).toBeNull()
  266. expect(ToolRuntime.copyOut(42, "json")).toBe(42)
  267. expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] }, "json")).toEqual({ a: null, b: [null, 1] })
  268. })
  269. })
  270. describe("copyOut undefined handling per boundary mode", () => {
  271. test("json mode mirrors JSON.stringify for undefined", () => {
  272. expect(ToolRuntime.copyOut({ q: undefined, keep: 1 }, "json")).toStrictEqual({ keep: 1 })
  273. expect(ToolRuntime.copyOut([1, undefined, 2], "json")).toStrictEqual([1, null, 2])
  274. expect(ToolRuntime.copyOut({ nested: { a: undefined, b: [undefined] } }, "json")).toStrictEqual({
  275. nested: { b: [null] },
  276. })
  277. expect(ToolRuntime.copyOut(undefined, "json")).toBeUndefined()
  278. expect(ToolRuntime.copyOut({ a: undefined }, "nullify")).toStrictEqual({ a: null })
  279. })
  280. })
  281. describe("Error values and instanceof", () => {
  282. test("new Error carries name/message and is instanceof Error", async () => {
  283. expect(await value(`const e = new Error("boom"); return [e instanceof Error, e.name, e.message]`)).toEqual([
  284. true,
  285. "Error",
  286. "boom",
  287. ])
  288. })
  289. test("Error without new behaves like new Error", async () => {
  290. expect(await value(`const e = Error("plain"); return [e instanceof Error, e.name, e.message]`)).toEqual([
  291. true,
  292. "Error",
  293. "plain",
  294. ])
  295. expect(await value(`const e = new Error(); return [e.name, e.message, e instanceof Error]`)).toEqual([
  296. "Error",
  297. "",
  298. true,
  299. ])
  300. })
  301. test("specific error types are instanceof themselves and Error, not each other", async () => {
  302. expect(
  303. await value(
  304. `const e = new TypeError("t"); return [e instanceof TypeError, e instanceof Error, e instanceof RangeError]`,
  305. ),
  306. ).toEqual([true, true, false])
  307. expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false)
  308. })
  309. test("thrown errors keep instanceof through try/catch", async () => {
  310. expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([
  311. true,
  312. "x",
  313. ])
  314. })
  315. test("interpreter runtime failures are caught as Error values", async () => {
  316. expect(await value(`try { JSON.parse("nope") } catch (e) { return e instanceof Error }`)).toBe(true)
  317. expect(await value(`try { undeclared() } catch (e) { return e instanceof Error }`)).toBe(true)
  318. })
  319. test("caught failures carry the constructor name the real-JS failure would have", async () => {
  320. // JSON.parse throws SyntaxError: name and specific-instanceof both carry through, and the
  321. // message keeps the engine's position detail.
  322. expect(
  323. await value(`
  324. try { JSON.parse("{oops") } catch (e) {
  325. return [e.name, e instanceof SyntaxError, e instanceof Error, e instanceof TypeError, e.message.includes("JSON")]
  326. }
  327. `),
  328. ).toEqual(["SyntaxError", true, true, false, true])
  329. expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`)).toEqual([
  330. "ReferenceError",
  331. true,
  332. ])
  333. expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`)).toEqual([
  334. "TypeError",
  335. true,
  336. ])
  337. expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([
  338. "SyntaxError",
  339. true,
  340. ])
  341. expect(await value(`try { new RegExp("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([
  342. "SyntaxError",
  343. true,
  344. ])
  345. })
  346. test("diagnostics without a specific real-JS analogue are named plain Error", async () => {
  347. expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`)).toEqual([
  348. "Error",
  349. true,
  350. ])
  351. })
  352. test("Promise.allSettled rejection reasons are Error values", async () => {
  353. expect(
  354. await value(`
  355. const settled = await Promise.allSettled([Promise.reject(new Error("b"))])
  356. return [settled[0].reason instanceof Error, settled[0].reason.message]
  357. `),
  358. ).toEqual([true, "b"])
  359. })
  360. test("non-error thrown values are not instanceof Error", async () => {
  361. expect(await value(`try { throw "raw" } catch (e) { return e instanceof Error }`)).toBe(false)
  362. expect(await value(`try { throw { message: "shaped" } } catch (e) { return e instanceof Error }`)).toBe(false)
  363. })
  364. test("plain data is never instanceof Error", async () => {
  365. expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([
  366. false,
  367. false,
  368. false,
  369. ])
  370. })
  371. test("error values still serialize as plain { name, message } data", async () => {
  372. expect(await value(`return new Error("m")`)).toEqual({ name: "Error", message: "m" })
  373. expect(await value(`return JSON.stringify(new Error("m"))`)).toBe('{"name":"Error","message":"m"}')
  374. expect(await value(`try { throw new Error("m") } catch (e) { return Object.keys(e) }`)).toEqual(["name", "message"])
  375. })
  376. test("spreading an error loses the brand, like losing the prototype in JS", async () => {
  377. expect(await value(`const e = new Error("m"); return ({ ...e }) instanceof Error`)).toBe(false)
  378. expect(await value(`const e = new Error("m"); return { ...e }`)).toEqual({ name: "Error", message: "m" })
  379. })
  380. test("typeof Error is function; an unknown instanceof right-hand side is a catchable error", async () => {
  381. expect(await value(`return typeof Error`)).toBe("function")
  382. expect(await value(`try { return 1 instanceof 5 } catch (e) { return "caught" }`)).toBe("caught")
  383. const err = await error(`return 1 instanceof 5`)
  384. expect(err.message).toContain("right-hand side of 'instanceof'")
  385. })
  386. })
  387. describe("CodeMode-specific array behavior", () => {
  388. test("sort with a comparator mutates and returns the receiver", async () => {
  389. expect(
  390. await value(`
  391. const input = [3, 1, 2]
  392. const result = input.sort((a, b) => a - b)
  393. return { input, same: input === result }
  394. `),
  395. ).toEqual({ input: [1, 2, 3], same: true })
  396. })
  397. test("splice can replace and insert elements", async () => {
  398. expect(await value(`const a = ["a","d"]; a.splice(1, 0, "b", "c"); return a`)).toEqual(["a", "b", "c", "d"])
  399. expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({
  400. removed: [2],
  401. a: [1, "x", 3],
  402. })
  403. })
  404. test("splice rejects inserting a container into itself", async () => {
  405. const err = await error(`const a = [1]; a.splice(0, 0, [a]); return a`)
  406. expect(err.kind).toBe("InvalidDataValue")
  407. expect(err.message).toContain("circular")
  408. })
  409. test("keys/values/entries return arrays usable with for...of and spread", async () => {
  410. expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2])
  411. expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"])
  412. expect(
  413. await value(`
  414. const out = []
  415. for (const [index, item] of ["a","b"].entries()) out.push(index + ":" + item)
  416. return out
  417. `),
  418. ).toEqual(["0:a", "1:b"])
  419. expect(await value(`return [...[7].entries()]`)).toEqual([[0, 7]])
  420. })
  421. })
  422. describe("CodeMode-specific string behavior", () => {
  423. test("localeCompare orders strings for sorting", async () => {
  424. expect(await value(`return ["b","a","c"].sort((x, y) => x.localeCompare(y))`)).toEqual(["a", "b", "c"])
  425. })
  426. test("an invalid normalize form is a clear catchable error", async () => {
  427. expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"')
  428. })
  429. test("does not expose obsolete string aliases", async () => {
  430. expect(await value(`return [typeof "x".trimLeft, typeof "x".trimRight, typeof "x".substr]`)).toEqual([
  431. "undefined",
  432. "undefined",
  433. "undefined",
  434. ])
  435. })
  436. })
  437. describe("compound assignment matches its binary operator", () => {
  438. // `x op= y` must behave exactly like `x = x op y`, sharing the binary operator's coercion
  439. // semantics (Dates string-coerce for `+` and use their time value for arithmetic; data
  440. // objects/arrays coerce to their JS string form).
  441. const pair = async (compound: string, expanded: string) => {
  442. const [a, b] = await Promise.all([value(compound), value(expanded)])
  443. expect(a).toEqual(b)
  444. return a
  445. }
  446. test("CodeMode Date += concatenates its string form, like d = d + 1", async () => {
  447. const result = await pair(`let d = new Date(1000); d += 1; return d`, `let d = new Date(1000); d = d + 1; return d`)
  448. expect(result).toBe("1970-01-01T00:00:01.000Z1")
  449. })
  450. test("CodeMode Date numeric compound ops use its time value", async () => {
  451. expect(
  452. await pair(`let d = new Date(1000); d -= 400; return d`, `let d = new Date(1000); d = d - 400; return d`),
  453. ).toBe(600)
  454. expect(await pair(`let d = new Date(1000); d /= 4; return d`, `let d = new Date(1000); d = d / 4; return d`)).toBe(
  455. 250,
  456. )
  457. })
  458. test("string += object/array matches x = x + obj", async () => {
  459. expect(await pair(`let x = "a"; x += { b: 1 }; return x`, `let x = "a"; x = x + { b: 1 }; return x`)).toBe(
  460. "a[object Object]",
  461. )
  462. expect(await pair(`let x = "a"; x += [1, 2]; return x`, `let x = "a"; x = x + [1, 2]; return x`)).toBe("a1,2")
  463. })
  464. test("compound assignment through a member target coerces the same way", async () => {
  465. expect(
  466. await pair(
  467. `const o = { s: "t" }; o.s += new Date(0); return o.s`,
  468. `const o = { s: "t" }; o.s = o.s + new Date(0); return o.s`,
  469. ),
  470. ).toBe("t1970-01-01T00:00:00.000Z")
  471. })
  472. test("numeric and string compound operators sweep identically to their expansions", async () => {
  473. const cases: Array<[string, number | string]> = [
  474. [`let x = 7; x += 3; return x`, 7 + 3],
  475. [`let x = 7; x -= 3; return x`, 7 - 3],
  476. [`let x = 7; x *= 3; return x`, 7 * 3],
  477. [`let x = 7; x /= 2; return x`, 7 / 2],
  478. [`let x = 7; x %= 3; return x`, 7 % 3],
  479. [`let x = 7; x **= 2; return x`, 7 ** 2],
  480. [`let x = 7; x &= 3; return x`, 7 & 3],
  481. [`let x = 7; x |= 8; return x`, 7 | 8],
  482. [`let x = 7; x ^= 2; return x`, 7 ^ 2],
  483. [`let x = 7; x <<= 2; return x`, 7 << 2],
  484. [`let x = -7; x >>= 1; return x`, -7 >> 1],
  485. [`let x = -7; x >>>= 1; return x`, -7 >>> 1],
  486. [`let x = "a"; x += "b"; return x`, "ab"],
  487. ]
  488. for (const [compound, expected] of cases) {
  489. expect(await value(compound)).toBe(expected)
  490. expect(await value(compound.replace(/x (\S+)= /, (_, op) => `x = x ${op} `))).toBe(expected)
  491. }
  492. })
  493. })
  494. describe("H5: builtin coercion functions work as array callbacks", () => {
  495. test("filter(Boolean) drops falsy values", async () => {
  496. expect(await value(`return [0, 1, "", 2, null, 3].filter(Boolean)`)).toEqual([1, 2, 3])
  497. })
  498. test("map(String) coerces each element", async () => {
  499. expect(await value(`return [1, 2, 3].map(String)`)).toEqual(["1", "2", "3"])
  500. })
  501. test("a non-callable callback is still rejected", async () => {
  502. const err = await error(`return [1,2,3].map(42)`)
  503. expect(err.message).toContain("callback")
  504. })
  505. })
  506. describe("for...of assignment destructuring", () => {
  507. test("assigns entry pairs into predeclared variables", async () => {
  508. expect(
  509. await value(`
  510. let key
  511. let item
  512. const out = []
  513. for ([key, item] of Object.entries({ a: 1, b: 2 })) out.push(key + item)
  514. return { key, item, out }
  515. `),
  516. ).toEqual({ key: "b", item: 2, out: ["a1", "b2"] })
  517. })
  518. test("assigns object patterns and defaults", async () => {
  519. expect(
  520. await value(`
  521. let id
  522. let label
  523. const labels = []
  524. for ({ id, label = "unknown" } of [{ id: 1 }, { id: 2, label: "two" }]) labels.push(label)
  525. return { id, label, labels }
  526. `),
  527. ).toEqual({ id: 2, label: "two", labels: ["unknown", "two"] })
  528. })
  529. })
  530. describe("sequence expressions", () => {
  531. test("evaluate left to right and return the final value", async () => {
  532. expect(await value(`let x = 0; const result = (x += 1, x *= 3, x + 2); return { x, result }`)).toEqual({
  533. x: 3,
  534. result: 5,
  535. })
  536. })
  537. test("support comma-separated for-loop updates", async () => {
  538. expect(
  539. await value(`
  540. const pairs = []
  541. for (let left = 0, right = 3; left < right; left++, right--) pairs.push([left, right])
  542. return pairs
  543. `),
  544. ).toEqual([
  545. [0, 3],
  546. [1, 2],
  547. ])
  548. })
  549. })
  550. describe("destructuring assignment", () => {
  551. test("assigns object and array patterns to existing bindings", async () => {
  552. expect(
  553. await value(`
  554. let a = 0
  555. let b = 0
  556. ;({ a } = { a: 2 })
  557. ;[a, b] = [3, 4]
  558. return [a, b]
  559. `),
  560. ).toEqual([3, 4])
  561. })
  562. test("supports defaults, nesting, rest, and member targets", async () => {
  563. expect(
  564. await value(`
  565. let first = 0
  566. let fallback = 0
  567. let rest = {}
  568. const target = {}
  569. ;[first, fallback = 2, ...target.tail] = [1]
  570. ;({ nested: { value: target.value }, kept: target.kept = 3, ...rest } = {
  571. nested: { value: 4 },
  572. extra: 5,
  573. })
  574. return { first, fallback, target, rest }
  575. `),
  576. ).toEqual({ first: 1, fallback: 2, target: { tail: [], value: 4, kept: 3 }, rest: { extra: 5 } })
  577. })
  578. test("returns the assigned value", async () => {
  579. expect(await value(`let a = 0; const result = ([a] = [7]); return [a, result]`)).toEqual([7, [7]])
  580. })
  581. test("supports computed object keys and evaluates them once", async () => {
  582. expect(
  583. await value(`
  584. let calls = 0
  585. const field = () => { calls++; return "name" }
  586. const { [field()]: name, ...rest } = { name: "Ada", role: "engineer" }
  587. return { calls, name, rest }
  588. `),
  589. ).toEqual({ calls: 1, name: "Ada", rest: { role: "engineer" } })
  590. })
  591. test("supports object patterns over arrays", async () => {
  592. expect(
  593. await value(`
  594. const { 0: first, length, slice, ...rest } = ["a", "b", "c"]
  595. return { first, length, sliced: slice(1), rest }
  596. `),
  597. ).toEqual({ first: "a", length: 3, sliced: ["b", "c"], rest: { 1: "b", 2: "c" } })
  598. })
  599. test("preserves exact computed property names on arrays", async () => {
  600. expect(
  601. await value(`
  602. const { ["01"]: item, ...rest } = [10, 20]
  603. return { missing: item === undefined, rest }
  604. `),
  605. ).toEqual({ missing: true, rest: { 0: 10, 1: 20 } })
  606. })
  607. test("supports array patterns over strings, Maps, Sets, and URLSearchParams", async () => {
  608. expect(
  609. await value(`
  610. const [letter, ...letters] = "A😀B"
  611. const [[mapKey, mapValue]] = new Map([["key", 1]])
  612. const [setFirst, setSecond] = new Set([2, 3])
  613. const [[queryKey, queryValue]] = new URLSearchParams("q=test&page=2")
  614. return { letter, letters, mapKey, mapValue, setFirst, setSecond, queryKey, queryValue }
  615. `),
  616. ).toEqual({
  617. letter: "A",
  618. letters: ["😀", "B"],
  619. mapKey: "key",
  620. mapValue: 1,
  621. setFirst: 2,
  622. setSecond: 3,
  623. queryKey: "q",
  624. queryValue: "test",
  625. })
  626. })
  627. test("supports iterable patterns in assignment and parameters", async () => {
  628. expect(
  629. await value(`
  630. let first
  631. let rest
  632. ;[first, ...rest] = new Set([1, 2, 3])
  633. const read = ([[key, value]]) => key + value
  634. return { first, rest, entry: read(new Map([["a", 4]])) }
  635. `),
  636. ).toEqual({ first: 1, rest: [2, 3], entry: "a4" })
  637. })
  638. test("excludes computed numeric keys from object rest", async () => {
  639. expect(
  640. await value(`
  641. const { [0]: declared, ...declarationRest } = { 0: "a", 1: "b" }
  642. let assigned
  643. let assignmentRest
  644. ;({ [0]: assigned, ...assignmentRest } = { 0: "c", 1: "d" })
  645. return { declared, declarationRest, assigned, assignmentRest }
  646. `),
  647. ).toEqual({ declared: "a", declarationRest: { 1: "b" }, assigned: "c", assignmentRest: { 1: "d" } })
  648. })
  649. test("rejects computed keys that are not confined property keys", async () => {
  650. const err = await error(`const key = {}; const { [key]: value } = {}`)
  651. expect(err.message).toContain("Property key must be a string or number")
  652. })
  653. })
  654. describe("coercion parity: zero-argument coercion functions", () => {
  655. test("Number() is 0 and String() is empty, unlike their undefined-argument forms", async () => {
  656. expect(await value(`return Number()`)).toBe(0)
  657. expect(await value(`return String()`)).toBe("")
  658. expect(await value(`return Boolean()`)).toBe(false)
  659. expect(await value(`return Number.isNaN(Number(undefined))`)).toBe(true)
  660. expect(await value(`return String(undefined)`)).toBe("undefined")
  661. })
  662. test("parseInt() and parseFloat() stay NaN with no argument", async () => {
  663. expect(await value(`return Number.isNaN(parseInt())`)).toBe(true)
  664. expect(await value(`return Number.isNaN(parseFloat())`)).toBe(true)
  665. })
  666. })
  667. describe("coercion parity: global isFinite and isNaN", () => {
  668. test("coerce their argument like native JS, unlike the Number statics", async () => {
  669. expect(await value(`return isFinite("42")`)).toBe(true)
  670. expect(await value(`return Number.isFinite("42")`)).toBe(false)
  671. expect(await value(`return isNaN("oops")`)).toBe(true)
  672. expect(await value(`return isNaN("42")`)).toBe(false)
  673. expect(await value(`return isFinite(Infinity)`)).toBe(false)
  674. expect(await value(`return isNaN(null)`)).toBe(false)
  675. })
  676. test("zero-argument forms match native", async () => {
  677. expect(await value(`return isFinite()`)).toBe(false)
  678. expect(await value(`return isNaN()`)).toBe(true)
  679. })
  680. test("read as functions", async () => {
  681. expect(await value(`return typeof isFinite`)).toBe("function")
  682. expect(await value(`return typeof isNaN`)).toBe("function")
  683. })
  684. test("work as array callbacks", async () => {
  685. expect(await value(`return [1, "2", "x", Infinity].filter(isFinite)`)).toEqual([1, "2"])
  686. expect(await value(`return ["1", "x"].map(isNaN)`)).toEqual([false, true])
  687. })
  688. })
  689. describe("coercion parity: arrays coerce to numbers through their string form", () => {
  690. test("arrays with objects become NaN instead of crashing on host ToPrimitive", async () => {
  691. expect(await value(`let x = [{}]; x++; return Number.isNaN(x)`)).toBe(true)
  692. expect(await value(`return isFinite([{}])`)).toBe(false)
  693. expect(await value(`return "abc".slice([{}])`)).toBe("abc")
  694. })
  695. test("single-element and empty arrays match native Number()", async () => {
  696. expect(await value(`return Number([5])`)).toBe(5)
  697. expect(await value(`return Number([])`)).toBe(0)
  698. expect(await value(`return Number.isNaN(Number([1, 2]))`)).toBe(true)
  699. })
  700. })
  701. describe("coercion parity: String method arguments coerce like native JS", () => {
  702. test("includes and indexOf coerce numbers", async () => {
  703. expect(await value(`return "v1.2".includes(1)`)).toBe(true)
  704. expect(await value(`return "a2b".indexOf(2)`)).toBe(1)
  705. expect(await value(`return "abc".includes("d")`)).toBe(false)
  706. })
  707. test("slice, repeat, and padStart coerce numeric strings", async () => {
  708. expect(await value(`return "abc".slice("1")`)).toBe("bc")
  709. expect(await value(`return "ab".repeat("2")`)).toBe("abab")
  710. expect(await value(`return "7".padStart("3", 0)`)).toBe("007")
  711. })
  712. test("split coerces separators but treats undefined as absent", async () => {
  713. expect(await value(`return "a1b".split(1)`)).toEqual(["a", "b"])
  714. expect(await value(`return "a,b".split(undefined)`)).toEqual(["a,b"])
  715. expect(await value(`return "a,b".split()`)).toEqual(["a,b"])
  716. expect(await value(`return "a,b".split(undefined, 0)`)).toEqual([])
  717. expect(await value(`return "a,b".split(undefined, 1)`)).toEqual(["a,b"])
  718. })
  719. test("replace coerces search and replacement values", async () => {
  720. expect(await value(`return "a1b".replace(1, 2)`)).toBe("a2b")
  721. expect(await value(`return "a1b".replace(1, () => "x")`)).toBe("axb")
  722. })
  723. test("repeat rejections carry the native RangeError name", async () => {
  724. expect(await value(`try { "a".repeat(-1) } catch (e) { return e.name }`)).toBe("RangeError")
  725. })
  726. test("includes, startsWith, and endsWith reject regular expressions with a TypeError", async () => {
  727. expect(await value(`try { "abc".includes(/a/) } catch (e) { return e.name }`)).toBe("TypeError")
  728. expect(await value(`try { "abc".startsWith(/a/) } catch (e) { return e.name }`)).toBe("TypeError")
  729. expect(await value(`try { "abc".endsWith(/a/) } catch (e) { return e.name }`)).toBe("TypeError")
  730. })
  731. test("opaque runtime references still reject as data errors", async () => {
  732. const err = await error(`const f = () => 1; return "abc".includes(f)`)
  733. expect(err.message).toContain("data value")
  734. const replacerErr = await error(`const f = () => 1; return "a".replace(f, () => "x")`)
  735. expect(replacerErr.message).toContain("data value")
  736. })
  737. })
  738. describe("coercion parity: match() and search() with no argument", () => {
  739. test("behave as an empty pattern like native JS", async () => {
  740. expect(await value(`return "abc".search()`)).toBe(0)
  741. expect(await value(`const m = "abc".match(); return { first: m[0], index: m.index }`)).toEqual({
  742. first: "",
  743. index: 0,
  744. })
  745. })
  746. })
  747. describe("coercion parity: ++ and -- use CodeMode numeric coercion", () => {
  748. test("numeric strings increment like native JS", async () => {
  749. expect(await value(`let x = "5"; x++; return x`)).toBe(6)
  750. expect(await value(`let x = "5"; return ++x`)).toBe(6)
  751. expect(await value(`const o = { n: "2" }; o.n--; return o.n`)).toBe(1)
  752. })
  753. test("dates increment through their epoch time", async () => {
  754. expect(await value(`let d = new Date(5); d++; return d`)).toBe(6)
  755. })
  756. test("plain data objects become NaN instead of crashing", async () => {
  757. expect(await value(`let x = {}; x++; return Number.isNaN(x)`)).toBe(true)
  758. expect(await value(`const o = { a: {} }; o.a++; return Number.isNaN(o.a)`)).toBe(true)
  759. })
  760. test("opaque runtime references reject with a clear error", async () => {
  761. const err = await error(`let f = () => 1; f++`)
  762. expect(err.message).toContain("data value")
  763. })
  764. })
  765. describe("coercion parity: unknown static members read as undefined", () => {
  766. test("feature detection on missing statics works like native JS", async () => {
  767. expect(await value(`return typeof Math.sum`)).toBe("undefined")
  768. expect(await value(`return RegExp.quote === undefined`)).toBe(true)
  769. expect(await value(`return Number.range === undefined`)).toBe(true)
  770. expect(await value(`return String.raw === undefined`)).toBe(true)
  771. expect(await value(`return isFinite.something === undefined`)).toBe(true)
  772. expect(await value(`return console.group === undefined`)).toBe(true)
  773. expect(await value(`return Date.moment === undefined`)).toBe(true)
  774. expect(await value(`return JSON.rawJSON === undefined`)).toBe(true)
  775. expect(await value(`return URL.createObjectURL === undefined`)).toBe(true)
  776. expect(await value(`return Math.sum?.([1]) ?? "fallback"`)).toBe("fallback")
  777. })
  778. test("known statics still resolve and run", async () => {
  779. expect(await value(`return typeof Math.max`)).toBe("function")
  780. expect(await value(`return typeof console.log`)).toBe("function")
  781. expect(await value(`return typeof Date.now`)).toBe("function")
  782. expect(await value(`return typeof Math.sumPrecise`)).toBe("function")
  783. expect(await value(`return typeof RegExp.escape`)).toBe("function")
  784. expect(await value(`return typeof Object.groupBy`)).toBe("function")
  785. expect(await value(`return typeof Map.groupBy`)).toBe("function")
  786. expect(await value(`return Math.max(1, 2)`)).toBe(2)
  787. expect(await value(`return Math.sumPrecise([1, 2])`)).toBe(3)
  788. expect(await value(`return RegExp.escape("a.b")`)).toBe("\\x61\\.b")
  789. expect(await value(`return URL.canParse("https://example.com")`)).toBe(true)
  790. expect(await value(`return Number.isInteger(3)`)).toBe(true)
  791. expect(await value(`return Number.MAX_SAFE_INTEGER`)).toBe(Number.MAX_SAFE_INTEGER)
  792. })
  793. test("calling an unknown static reports a native-style TypeError", async () => {
  794. expect(await value(`try { Math.sum([1]) } catch (e) { return e.name + ": " + e.message }`)).toBe(
  795. "TypeError: Math.sum is not a function.",
  796. )
  797. expect(await value(`try { Math["sum"]([1]) } catch (e) { return e.message }`)).toBe("Math.sum is not a function.")
  798. expect(await value(`try { JSON.rawJSON("1") } catch (e) { return e.message }`)).toBe(
  799. "JSON.rawJSON is not a function.",
  800. )
  801. })
  802. test("blocked members still throw instead of reading as undefined", async () => {
  803. const err = await error(`return Math.constructor`)
  804. expect(err.message).toContain("not available")
  805. const coercionErr = await error(`return Number.constructor`)
  806. expect(coercionErr.message).toContain("Number.constructor is not available")
  807. })
  808. })