mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 22:25:02 +03:00
365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import path from "node:path"
|
|
import { parseProgram } from "../../src/vm/runtime/execute.js"
|
|
import { Interpreter } from "../../src/vm/runtime/interpreter.js"
|
|
import { createBuiltins } from "../../src/vm/runtime/intrinsics.js"
|
|
import { getOwn, Native, nativeRule, Obj, remove, set } from "../../src/vm/runtime/objects.js"
|
|
import { CodeMode } from "../lib/index.js"
|
|
import { decode, encode } from "../../src/vm/snapshot/codec.js"
|
|
import { Lost, PendingThrow } from "../../src/vm/runtime/model.js"
|
|
import { normalizeError } from "../../src/vm/runtime/errors.js"
|
|
import { Effect } from "effect"
|
|
import { ToolRuntime } from "../../src/tool-runtime.js"
|
|
import { nodeId, sourceOf } from "../../src/vm/runtime/source.js"
|
|
|
|
// Groundwork for suspending and resuming a run: everything the state refers to has a stable name.
|
|
|
|
const interpreter = () =>
|
|
new Interpreter<never>({
|
|
tools: ToolRuntime.make(ToolRuntime.prepare<never>({}), undefined, {}),
|
|
builtins: createBuiltins(),
|
|
})
|
|
|
|
const parse = (code: string) => {
|
|
const parsed = parseProgram(code)
|
|
if (!parsed.ok) throw new Error(parsed.error.message)
|
|
return parsed
|
|
}
|
|
|
|
describe("node ids", () => {
|
|
const code = `const add = (a, b) => a + b\nfor (const x of [1, 2]) { if (x) add(x, \`t\${x}\`) }\nreturn /a/g`
|
|
|
|
test("number every node in pre-order and resolve back to it", () => {
|
|
const parsed = parse(code)
|
|
const nodes = parsed.source.nodes
|
|
expect(nodes[0]).toBe(parsed.program)
|
|
expect(nodes.map((node) => node.type).slice(0, 6)).toEqual([
|
|
"Program",
|
|
"VariableDeclaration",
|
|
"VariableDeclarator",
|
|
"Identifier",
|
|
"ArrowFunctionExpression",
|
|
"Identifier",
|
|
])
|
|
nodes.forEach((node, index) => {
|
|
expect(nodeId(node)).toEqual({ source: "main", index })
|
|
expect(parsed.source.node({ source: "main", index })).toBe(node)
|
|
expect(sourceOf(node)).toBe(parsed.source)
|
|
})
|
|
})
|
|
|
|
test("give the same ids when the same code is parsed again", () => {
|
|
const first = parse(code).source.nodes
|
|
const second = parse(code).source.nodes
|
|
expect(second.map((node) => [node.type, node.start, node.end])).toEqual(
|
|
first.map((node) => [node.type, node.start, node.end]),
|
|
)
|
|
})
|
|
|
|
test("do not resolve ids from another source", () => {
|
|
expect(parse(code).source.node({ source: "other", index: 0 })).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe("intrinsic names", () => {
|
|
test("name every intrinsic once, from the globals first", () => {
|
|
const names = interpreter().intrinsics
|
|
const all = names.all()
|
|
expect(new Set(all).size).toBe(all.length)
|
|
expect(new Set(names.values).size).toBe(names.values.length)
|
|
for (const name of [
|
|
"Array.prototype.map",
|
|
"Promise.prototype.then",
|
|
"String.prototype[Symbol.iterator]",
|
|
"JSON.parse",
|
|
]) {
|
|
expect(all).toContain(name)
|
|
}
|
|
for (const name of ["%Generator%", "%Generator%.next", "%AsyncGenerator%.return", "%IteratorHelper%.return"]) {
|
|
expect(all).toContain(name)
|
|
}
|
|
all.forEach((name, index) => {
|
|
expect(names.valueOf(name)).toBe(names.values[index]!)
|
|
expect(names.nameOf(names.values[index]!)).toBe(name)
|
|
})
|
|
})
|
|
|
|
test("cover everything reachable from a named intrinsic", () => {
|
|
const names = interpreter().intrinsics
|
|
for (const value of names.values) {
|
|
for (const slot of value.props.values()) {
|
|
if ("value" in slot && slot.value instanceof Obj) expect(names.nameOf(slot.value)).toBeDefined()
|
|
}
|
|
if (value.proto !== null) expect(names.nameOf(value.proto)).toBeDefined()
|
|
}
|
|
expect(names.values.filter((value) => value instanceof Native).length).toBeGreaterThan(300)
|
|
})
|
|
|
|
test("are identical across fresh runtimes", () => {
|
|
const shape = (names: Interpreter<never>["intrinsics"]) =>
|
|
names.all().map((name, index) => {
|
|
const value = names.values[index]!
|
|
return [name, value.constructor.name, [...value.props.keys()].map(String)]
|
|
})
|
|
expect(shape(interpreter().intrinsics)).toEqual(shape(interpreter().intrinsics))
|
|
})
|
|
|
|
test("are taken before the program can change a built-in", () => {
|
|
const ctx = interpreter()
|
|
const map = ctx.intrinsics.valueOf("Array.prototype.map")!
|
|
const array = ctx.intrinsics.valueOf("Array.prototype")!
|
|
array.props.delete("map")
|
|
expect(ctx.intrinsics.nameOf(map)).toBe("Array.prototype.map")
|
|
})
|
|
|
|
test("only the intrinsics a write reaches are changed", () => {
|
|
const ctx = interpreter()
|
|
expect(ctx.intrinsics.changed()).toEqual([])
|
|
const array = ctx.intrinsics.valueOf("Array.prototype")!
|
|
const math = ctx.intrinsics.valueOf("Math")!
|
|
set(array, "extra", 1)
|
|
remove(math, "max")
|
|
set(new Obj(array), "own", 1)
|
|
expect(ctx.intrinsics.changed().map((value) => ctx.intrinsics.nameOf(value))).toEqual(["Math", "Array.prototype"])
|
|
})
|
|
})
|
|
|
|
describe("natives only at intrinsic creation", () => {
|
|
test("every native made with the intrinsics has a name", () => {
|
|
const made: Array<Native> = []
|
|
nativeRule.observe = (native) => made.push(native)
|
|
const ctx = (() => {
|
|
try {
|
|
return interpreter()
|
|
} finally {
|
|
nativeRule.observe = undefined
|
|
}
|
|
})()
|
|
expect(made.length).toBeGreaterThan(300)
|
|
expect(
|
|
made.filter((native) => ctx.intrinsics.nameOf(native) === undefined).map((native) => getOwn(native, "name")),
|
|
).toEqual([])
|
|
})
|
|
|
|
test("making a native once the program runs breaks the rule", () => {
|
|
const ctx = interpreter()
|
|
expect(() => new Native(ctx.builtins.Function, { name: "late", call: () => undefined })).toThrow(
|
|
'Native "late" was made after the intrinsics',
|
|
)
|
|
})
|
|
|
|
const value = async (code: string) => {
|
|
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {}, engine: "vm" }))
|
|
if (!result.ok) throw new Error(result.error.message)
|
|
return result.value
|
|
}
|
|
|
|
test("bound functions and resolvers behave as functions", async () => {
|
|
expect(
|
|
await value(`
|
|
function add(a, b, c) { return [this?.k, a, b, c] }
|
|
const bound = add.bind({ k: 1 }, 2)
|
|
let resolve, reject
|
|
const promise = new Promise((res, rej) => { resolve = res; reject = rej })
|
|
const thenable = await new Promise((res) => res({ then(a, b) { a([a.name, a.length, typeof b]) } }))
|
|
resolve(5)
|
|
reject(6)
|
|
return [bound.name, bound.length, typeof bound, bound(3, 4), bound.bind(null, 3).name, resolve.name, resolve.length,
|
|
reject.name, typeof resolve, await promise, thenable]
|
|
`),
|
|
).toEqual([
|
|
"bound add",
|
|
2,
|
|
"function",
|
|
[1, 2, 3, 4],
|
|
"bound bound add",
|
|
"resolve",
|
|
1,
|
|
"reject",
|
|
"function",
|
|
5,
|
|
["", 1, "function"],
|
|
])
|
|
})
|
|
})
|
|
|
|
describe("heap as data", () => {
|
|
// Each program's result must match the host engine's.
|
|
const programs = {
|
|
"map iteration is live": `
|
|
const m = new Map([[1, 1], [2, 2], [3, 3]])
|
|
const out = []
|
|
for (const [k] of m) {
|
|
out.push(k)
|
|
if (k === 1) { m.delete(2); m.set(4, 4) }
|
|
if (k === 3) { m.clear(); m.set(5, 5) }
|
|
}
|
|
return [out, [...m]]`,
|
|
"a live iterator survives compaction": `
|
|
const m = new Map(Array.from({ length: 100 }, (_, i) => [i, i]))
|
|
const it = m.keys()
|
|
const seen = []
|
|
for (let i = 0; i < 10; i++) seen.push(it.next().value)
|
|
for (let i = 0; i < 80; i++) m.delete(i)
|
|
seen.push(...it)
|
|
const s = new Set(Array.from({ length: 100 }, (_, i) => i))
|
|
const values = s.values()
|
|
for (let i = 0; i < 50; i++) values.next()
|
|
for (let i = 50; i < 90; i++) s.delete(i)
|
|
for (let i = 0; i < 40; i++) s.delete(i)
|
|
return [seen, values.next().value, [...s].length, m.size]`,
|
|
"clear mid-iteration continues with new entries": `
|
|
const s = new Set([1, 2, 3])
|
|
const it = s.values()
|
|
const out = [it.next().value]
|
|
s.clear()
|
|
s.add(7)
|
|
out.push(...it)
|
|
s.add(8)
|
|
return [out, it.next().done, [...s.entries()]]`,
|
|
"keys compare by SameValueZero": `
|
|
const s = new Set([-0, NaN, NaN, 0])
|
|
const m = new Map([[-0, "a"]])
|
|
return [s.size, 1 / [...s][0], 1 / [...m.keys()][0], m.get(0), s.has(NaN)]`,
|
|
"forEach visits entries added during the walk": `
|
|
const m = new Map([["a", 1]])
|
|
const out = []
|
|
m.forEach((v, k) => { out.push(k); if (m.size < 4) m.set(k + "x", v + 1) })
|
|
const s = new Set([1, 2, 3])
|
|
const seen = []
|
|
s.forEach((v) => { seen.push(v); s.delete(v + 1) })
|
|
return [out, seen]`,
|
|
"set operations read the live table": `
|
|
const a = new Set([1, 2, 3, 4])
|
|
const other = { size: 10, has(v) { a.delete(v + 1); return true }, keys() { return [][Symbol.iterator]() } }
|
|
return [[...a.intersection(other)], [...a]]`,
|
|
"array, string and bytes iterators keep a position": `
|
|
const a = [1, 2]
|
|
const it = a.values()
|
|
it.next()
|
|
a.push(3)
|
|
const rest = [...it]
|
|
a.push(4)
|
|
const done = it.next().done
|
|
const entries = [...[5, , 6].entries()]
|
|
return [rest, done, entries, [..."a😀b"], [...new Uint8Array([1, 2]).entries()], [...[7, 8].keys()]]`,
|
|
"headers and search params iterate the live sorted list": `
|
|
const h = new Headers({ b: "1", d: "2" })
|
|
const hk = h.keys()
|
|
const first = hk.next().value
|
|
h.append("a", "x")
|
|
h.append("c", "y")
|
|
const p = new URLSearchParams("b=1&d=2")
|
|
const pk = p.keys()
|
|
const pFirst = pk.next().value
|
|
p.append("a", "x")
|
|
p.delete("b")
|
|
return [first, [...hk], pFirst, [...pk], [...h.entries()], [...p]]`,
|
|
"a RegExp keeps lastIndex as a field shared with the String methods": `
|
|
const r = /a/g
|
|
const out = [r.exec("aaa").index, r.lastIndex]
|
|
out.push("xaxa".replace(r, "b"), r.lastIndex)
|
|
r.lastIndex = 1
|
|
out.push(r.test("ab"), r.lastIndex, r.test("ab"), r.lastIndex)
|
|
const y = /a/y
|
|
y.lastIndex = 1
|
|
out.push("ba".search(y), y.lastIndex, "ba".match(y)?.[0], y.lastIndex, "ba".split(y), y.lastIndex)
|
|
r.lastIndex = 2
|
|
out.push([..."aaaa".matchAll(r)].length, r.lastIndex, new RegExp(r).lastIndex, String(new RegExp(r, "i")))
|
|
return out`,
|
|
}
|
|
|
|
for (const [name, code] of Object.entries(programs)) {
|
|
test(name, async () => {
|
|
const expected: unknown = await new Function(`return (async () => {${code}})()`)()
|
|
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {}, engine: "vm" }))
|
|
if (!result.ok) throw new Error(result.error.message)
|
|
expect(result.value).toEqual(JSON.parse(JSON.stringify(expected)))
|
|
})
|
|
}
|
|
})
|
|
|
|
describe("prelude", () => {
|
|
test("a run saved and restored while prelude built-ins run continues in their frames", async () => {
|
|
const child = Bun.spawn(["bun", path.join(import.meta.dir, "../fixtures/prelude-roundtrip.ts")], {
|
|
stdout: "pipe",
|
|
stderr: "inherit",
|
|
})
|
|
const output = JSON.parse(await new Response(child.stdout).text())
|
|
expect(await child.exited).toBe(0)
|
|
expect(output.result).toMatchObject({
|
|
ok: true,
|
|
value: [{ odd: [1, 3, 5], even: [2, 4, 6] }, [1, 2, 3, 7, 8], { a: [10, 20, { b: 30 }] }, 6],
|
|
})
|
|
expect(output.saved).toBeGreaterThan(0)
|
|
})
|
|
|
|
test("prelude functions look like built-ins and are named by their path", () => {
|
|
const names = interpreter().intrinsics
|
|
const groupBy = names.valueOf("Object.groupBy")!
|
|
expect(getOwn(groupBy, "name")).toBe("groupBy")
|
|
expect(getOwn(groupBy, "length")).toBe(2)
|
|
expect(names.all()).toContain("%Prelude%.fillCollection")
|
|
expect(names.all()).toContain("%PreludeHelpers%.$Call")
|
|
})
|
|
test("programs can't see the helpers or internal functions, and failures inside are located at the call", async () => {
|
|
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {}, engine: "vm" }))
|
|
expect(await run("return [typeof $Call, typeof $Throw, typeof fillCollection]")).toMatchObject({
|
|
value: ["undefined", "undefined", "undefined"],
|
|
})
|
|
expect(await run("const x = 1\nreturn Object.groupBy(null, (v) => v)")).toMatchObject({
|
|
ok: false,
|
|
error: { location: { line: 2, column: 8 } },
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("lost values", () => {
|
|
const encodeEnv = (lost: Array<string>) => ({
|
|
nameOf: () => undefined,
|
|
unchanged: () => false,
|
|
anchorOf: () => undefined,
|
|
nodeId: () => undefined,
|
|
astArray: () => undefined,
|
|
lost,
|
|
strict: false,
|
|
})
|
|
const decodeEnv = {
|
|
named: () => undefined,
|
|
anchor: () => undefined,
|
|
node: () => undefined,
|
|
astArray: () => undefined,
|
|
interruption: () => {
|
|
throw new Error("no interruption is saved")
|
|
},
|
|
}
|
|
|
|
test("a host function, a host object, and an unknown host error decode to what using a lost value throws", () => {
|
|
class Handle {}
|
|
class SocketError extends Error {}
|
|
const lost: Array<string> = []
|
|
const saved = encode(
|
|
[{ data: [1, "two", { three: 3 }], fn: () => 1, host: new Handle(), error: new SocketError("boom") }],
|
|
encodeEnv(lost),
|
|
)
|
|
expect(lost).toEqual(["a host function", "a host Handle", "a host SocketError"])
|
|
const [root] = decode(JSON.parse(JSON.stringify(saved)), decodeEnv) as [Record<string, unknown>]
|
|
expect(root.data).toEqual([1, "two", { three: 3 }])
|
|
expect(root.fn).toBeInstanceOf(Lost)
|
|
expect((root.fn as Lost).description).toBe("a host function")
|
|
expect(root.host).toBeInstanceOf(Lost)
|
|
expect((root.host as Lost).description).toBe("a host Handle")
|
|
expect(root.error).toBeInstanceOf(PendingThrow)
|
|
expect(normalizeError(root.error)).toEqual({
|
|
kind: "ExecutionFailure",
|
|
message:
|
|
"TypeError: An error of class SocketError came from the host and was lost when the run was suspended; get it again after resuming.",
|
|
})
|
|
// A restored placeholder saves and restores again the same way.
|
|
const again = decode(JSON.parse(JSON.stringify(encode([root], encodeEnv([])))), decodeEnv) as [
|
|
Record<string, unknown>,
|
|
]
|
|
expect((again[0].fn as Lost).description).toBe("a host function")
|
|
})
|
|
})
|