Compare commits

...
31 changed files with 1567 additions and 314 deletions
@@ -2,7 +2,7 @@ import { DialogProvider } from "@opencode/ui/context/dialog"
import { Browser } from "@opencode/plugin-browser/rpc"
import { For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { Portal, render } from "solid-js/web"
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
import type { createSessionBrowser } from "../src/session/browser/model"
@@ -27,7 +27,12 @@ export function mountBrowserPane() {
loadErrors: {} as Record<string, string | undefined>,
error: undefined as string | undefined,
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
covered: false,
captures: 0,
holdCapture: false,
})
// Each capture waits until the fixture releases it, so a spec can observe the pending state.
const held: (() => void)[] = []
const tabs = ["Alpha", "Beta"].map((name) => ({
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
title: name,
@@ -44,6 +49,17 @@ export function mountBrowserPane() {
{
setLayout: (layout) => setStore("layouts", tab.title, layout),
command: async () => undefined,
capture: async () => {
setStore("captures", (count) => count + 1)
if (store.holdCapture) await new Promise<void>((resolve) => held.push(resolve))
const canvas = new OffscreenCanvas(4, 4)
const paint = canvas.getContext("2d")
if (paint) {
paint.fillStyle = "#3b82f6"
paint.fillRect(0, 0, 4, 4)
}
return canvas.convertToBlob()
},
close: () => undefined,
},
]),
@@ -118,12 +134,34 @@ export function mountBrowserPane() {
Complete navigation
</button>
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
<button onClick={() => setStore("holdCapture", true)}>Hold capture</button>
<button onClick={() => held.splice(0).forEach((resolve) => resolve())}>Release capture</button>
<button onClick={() => setStore("covered", (covered) => !covered)}>Toggle popover</button>
</nav>
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
<p>Captures: {store.captures}</p>
<div style={{ position: "relative", width: "640px", height: "360px", border: "1px solid #555" }}>
<Show when={store.mounted}>
<SessionBrowserPane browser={browser} visible={store.visible} />
</Show>
</div>
<Show when={store.covered}>
{/* Floating content portals into <body> like a menu or hover card over the page. */}
<Portal mount={document.body}>
<div
data-popper-positioner
data-testid="fixture-popover"
style={{
position: "fixed",
top: "0",
left: "0",
width: "320px",
height: "480px",
"z-index": "1001",
"pointer-events": "none",
}}
/>
</Portal>
</Show>
<h2 style={{ "font-size": "18px", margin: "20px 0 12px" }}>Native layout recorder</h2>
<p>The desktop boundary keeps each session's page visible until its registration is hidden.</p>
<For each={tabs}>
@@ -58,6 +58,27 @@ story("hides the native view immediately while the pane stays mounted", async ({
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("keeps a still of the page under floating content that covers it", async ({ page }, testInfo) => {
const root = page.getByTestId("browser-pane-fixture")
const still = root.locator("#browser-panel img")
await root.getByRole("button", { name: "Hold capture", exact: true }).click()
await root.getByRole("button", { name: "Toggle popover", exact: true }).click()
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
// The native page stays up until its still is ready, so the pane never shows blank.
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(still).toHaveCount(0)
await root.getByRole("button", { name: "Release capture", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(still).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("covered.png") })
await root.getByRole("button", { name: "Toggle popover", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(still).toHaveCount(0)
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
})
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
@@ -25,6 +25,8 @@ export type BrowserPaneEvent =
export type BrowserPaneRegistration = {
setLayout(layout?: BrowserPaneLayout): void
command(command: BrowserPaneCommand): Promise<void>
/** Captures the shown page, or resolves null when nothing is on screen. */
capture(tabID: Browser.TabID): Promise<Blob | null>
close(): void
}
@@ -43,6 +43,9 @@ function fixture() {
async command(command) {
call.commands.push(command)
},
async capture() {
return null
},
close() {
call.closed = true
},
+63 -2
View File
@@ -11,6 +11,7 @@ import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCommand } from "@/shell/commands/command"
import type { Browser } from "@opencode/plugin-browser/rpc"
import type { createSessionBrowser } from "./model"
export function SessionBrowserPane(props: { browser: ReturnType<typeof createSessionBrowser>; visible: boolean }) {
@@ -30,6 +31,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
navigating: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
// A still of the page shown in the DOM while floating content covers the hidden native view.
snapshot: undefined as { tabID: Browser.TabID; url: string } | undefined,
})
const empty = () => !address() && !state()?.loading && !store.navigating
let surface: HTMLDivElement | undefined
@@ -37,6 +40,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
let frame: number | undefined
let layout: string | undefined
let until = 0
let capturing: Browser.TabID | undefined
let release: ReturnType<typeof setTimeout> | undefined
const canvas = document.createElement("canvas")
canvas.width = canvas.height = 1
const paint = canvas.getContext("2d", { willReadFrequently: true })
@@ -69,6 +74,45 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const r = el.getBoundingClientRect()
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
})
const replaceSnapshot = (next?: { tabID: Browser.TabID; url: string }) => {
if (store.snapshot?.url) URL.revokeObjectURL(store.snapshot.url)
setStore("snapshot", next)
}
// Keep the page on screen as a still under the floating content. The native view
// stays visible until the still has decoded, so the pane never flashes blank.
const freeze = (tabID: Browser.TabID) => {
clearTimeout(release)
release = undefined
if (store.snapshot?.tabID === tabID || capturing === tabID) return
capturing = tabID
void (registration()?.capture(tabID) ?? Promise.resolve(null))
.catch(() => null)
.then(async (blob) => {
const url = blob ? URL.createObjectURL(blob) : ""
if (url) {
const image = new Image()
image.src = url
await image.decode().catch(() => undefined)
}
if (capturing !== tabID) {
if (url) URL.revokeObjectURL(url)
return
}
capturing = undefined
// A failed capture still hides the page; the pane shows its background as before.
replaceSnapshot({ tabID, url })
schedule()
})
}
const thaw = () => {
capturing = undefined
if (!store.snapshot || release !== undefined) return
// Keep the still under the native view until the view has painted again.
release = setTimeout(() => {
release = undefined
replaceSnapshot()
}, 150)
}
const measure = () => {
if (!surface) return
const tab = state()
@@ -84,7 +128,11 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const bottom = Math.round(rect.bottom * zoom)
// The desktop page hides blank and loading documents itself; only hide here
// while the pane shows its own empty or failed state over the surface.
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
const shown = props.visible && store.visible && !empty() && !failed() && !dialog.active
const cover = covered(rect)
if (shown && cover) freeze(tab.id)
if (!cover) thaw()
const visible = shown && !(cover && store.snapshot?.tabID === tab.id)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
@@ -186,6 +234,9 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
clearTimeout(release)
capturing = undefined
replaceSnapshot()
})
return (
@@ -296,7 +347,17 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
{error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
<div ref={surface} class="relative min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
<Show when={store.snapshot?.tabID === state()?.id && !empty() && !failed() && store.snapshot?.url}>
{(url) => (
<img
src={url()}
alt=""
draggable={false}
class="absolute inset-0 size-full pointer-events-none select-none"
/>
)}
</Show>
<Show when={(empty() || failed()) && !props.browser.suspended()}>
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
<div
+22 -15
View File
@@ -85,8 +85,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Array binding and assignment destructuring from strings, Maps, Sets, URLSearchParams, custom synchronous
iterators, and synchronous generators, including stepwise elisions/rest and `IteratorClose` on early completion
or binding/default failure.
- [ ] Object destructuring from primitives follows ToObject (`const { length } = "abc"`, `const {} = 1`); non-object
sources are rejected.
- [x] Object destructuring from primitives follows ToObject: `const { length } = "abc"` is `3`, `const { toFixed } = 1`
finds the built-in, `const {} = 1` is a no-op, and a rest element copies a string's indexes (`{ 1: "y", 2: "z" }`).
Only `null` and `undefined` sources throw (`Cannot destructure null as it is null.`).
- [x] Destructuring reads through the prototype chain like member access: `const { constructor } = error` and
`const { slice } = values` find the inherited built-in.
- [x] Any assignment target as a `for...in` head, like `for...of`: `for (x.y in obj)`, `for (a[i++] in obj)`, and
@@ -224,8 +225,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Coercion helpers and template interpolation accept functions and namespaces: `String(fn)` and `${fn}` give
`"[object Function]"` rather than the source text, `isNaN(fn)` is `true`.
- [x] `==` and `!=` follow IsLooselyEqual: objects (including functions and tool references) compare by identity, a
nullish operand never coerces the other side, and a data object facing a primitive coerces through its built-in
primitive form (`fn == null` is `false`, `fn == fn` is `true`, `[1] == 1` and `[1, 2] == "1,2"` are `true`).
nullish operand never coerces the other side, and a data object facing a primitive converts through its own
`valueOf`/`toString` (default hint) (`fn == null` is `false`, `fn == fn` is `true`, `[1] == 1` and `[1, 2] == "1,2"` are `true`).
`switch` matches cases with `===`, so `switch (fn) { case fn: }` selects, and `Object.is` compares any two
values. Operators inspect only their direct operands, so `rows == null` on a large array costs the same as
`rows === null`, and an object merely holding a function inside (`[fn] + ""`, `-[fn]`) coerces like any other
@@ -233,11 +234,16 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [ ] Coercing a function, promise, generator, or tool reference itself: `fn + ""`, `-fn`, `fn++`, and `fn == 1`
throw `TypeError: Binary operators require data values.` (or the unary/update form) where JavaScript would use
the source text or `NaN`.
- [ ] ToPrimitive on program objects: operators, `Number`/`String`, `Error(message)`, `parseInt` radix, multi-argument
`Date` construction and `Date.UTC`, and numeric built-in arguments (`Math.max`, `at`, `indexOf` start) should call
the object's own `valueOf`/`toString` in spec order and surface their throws. Today they use the built-in form
(`NaN`, `"[object Object]"`) and ignore own methods. Date setters and one-argument `Date` construction already
follow ToPrimitive.
- [x] ToPrimitive on program objects: `+ - * / % **`, the relational and bitwise operators, unary `+ - ~`, `++`/`--`,
compound assignment, `${x}`, `Number`/`String`/`isNaN`/`isFinite`, `parseInt`/`parseFloat` (text and radix),
`Math.*` arguments, `Error(message)`, and `Array.prototype.join`/`toString` elements call the object's own
`valueOf`/`toString` in spec order (both operands left then right, `+` with the default hint) and surface their
throws: `{ valueOf() { return 7 } } * 2` is `14`, `` `${{ toString() { return "x" } }}` `` is `"x"`, and
`[1, 2]` with `arr.toString = () => "x"` makes `arr + ""` `"x"`. Dates keep their `Symbol.toPrimitive`
behavior (`date + 1` concatenates, `date - date` subtracts).
- [ ] ToPrimitive elsewhere: `Error.prototype.toString` on an object `message` and numeric built-in arguments outside
`Math` and `Date` (`at`, `indexOf` start, `toFixed` digits) still use the built-in form (`NaN`,
`"[object Object]"`) and ignore own methods.
- [x] Property keys follow ToPropertyKey: `x[null]`, `x[true]`, and objects (via their built-in string form) become
string keys.
@@ -375,8 +381,9 @@ reject }` object.
`flat(1.9)`, `with(1.5, v)`, `Math.max("3", "2")`, `parseInt("11", "2")`, `(1.5).toFixed("2")`,
`String.fromCharCode("65")`, and the Uint8Array equivalents. `join(sep)` and `JSON.parse(text)` apply ToString
(`join(null)` is `"1null2"`, `JSON.parse(123)` is `123`). `Array.from({ length: "2" })` applies ToLength; a
promise source still throws with an `await` hint rather than JS's silent `[]`. A program object's own
`valueOf`/`toString` is not consulted yet (see ToPrimitive above).
promise source still throws with an `await` hint rather than JS's silent `[]`. `join`, `Math.*`, and
`parseInt` consult a program object's own `valueOf`/`toString`; the array and number methods do not yet (see
ToPrimitive above).
## Strings
@@ -454,15 +461,15 @@ reject }` object.
- [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`.
- [x] Date values serialize to ISO strings; invalid dates serialize to `null`.
- [x] Local and UTC Date setters, including native argument coercion, mutation, rollover, invalid-Date recovery, and
`TimeClip` behavior.
`TimeClip` behavior. On an invalid Date every setter but `setTime` and `set(UTC)FullYear` answers `NaN` without
writing, so a time set inside an argument's `valueOf` survives.
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
- [x] `toDateString` and `toTimeString` in the host's local timezone.
- [x] `toLocaleString`, `toLocaleDateString`, and `toLocaleTimeString` always format as `en-US` in UTC
(`"1/1/1970, 12:00:00 AM"`) so output does not depend on the host.
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
- [x] Date setters and one-argument construction coerce object arguments through their own `valueOf`/`toString` and
surface their throws.
- [ ] Multi-argument construction and `Date.UTC` coerce object arguments the same way (see ToPrimitive above).
- [x] Date setters, construction, and `Date.UTC` coerce object arguments through their own `valueOf`/`toString` in
argument order and surface their throws; only the first seven components are converted.
- [x] Native Date loose-equality and default primitive-coercion semantics, using CodeMode's deterministic ISO string
representation for the string primitive.
- [x] Native `RangeError` branding for invalid `toISOString()` calls.
+26 -8
View File
@@ -13,7 +13,7 @@ import {
type Cursor,
type Value,
} from "./objects.js"
import { typeofValue } from "./references.js"
import { isOpaque, typeofValue } from "./references.js"
/** IteratorClose: a consumer failure closes the iterator and wins over any close failure, except that a generator's
* return() is a return completion, so a failing close wins over it, as after `break`. */
@@ -31,16 +31,15 @@ export const preserveConsumerError = <A, R>(
})
})
export type Hint = "number" | "string" | "default"
/**
* ToPrimitive: calls `valueOf`/`toString` in hint order and returns the first primitive result. Dates treat the
* default hint as "string", like their `Symbol.toPrimitive`.
* default hint as "string", like their `Symbol.toPrimitive`. Opaque values (functions, promises, generators, tool
* references) pass through unchanged so callers reject or describe them in their built-in form.
*/
export const toPrimitive = <R>(
ctx: Interpreter<R>,
value: Value,
hint: "number" | "string" | "default",
): Effect.Effect<Value, unknown, R> => {
if (!(value instanceof Obj)) return Effect.succeed(value)
export const toPrimitive = <R>(ctx: Interpreter<R>, value: Value, hint: Hint): Effect.Effect<Value, unknown, R> => {
if (!(value instanceof Obj) || isOpaque(value)) return Effect.succeed(value)
const asString = hint === "string" || (hint === "default" && value instanceof DateObj)
const order = asString ? ["toString", "valueOf"] : ["valueOf", "toString"]
return Effect.gen(function* () {
@@ -67,6 +66,25 @@ export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: Value) =>
export const toPrimitiveNumber = <R>(ctx: Interpreter<R>, value: Value) =>
Effect.map(toPrimitive(ctx, value, "number"), coerceToNumber)
/**
* Runs a synchronous native body on its arguments after ToPrimitive, in order, with one hint for all positions or
* one per position. Primitive arguments skip the Effect entirely.
*/
export const withPrimitives = <R>(
ctx: Interpreter<R>,
hints: Hint | ReadonlyArray<Hint>,
values: Array<Value>,
body: (primitives: Array<Value>) => Value,
): Value | Effect.Effect<Value, unknown, R> => {
if (!values.some((value) => value instanceof Obj)) return body(values)
return Effect.map(
Effect.forEach(values, (value, index) =>
toPrimitive(ctx, value, typeof hints === "string" ? hints : hints[index]!),
),
body,
)
}
// The single acceptance list for callbacks: collections, sort, string replacers,
// Array.from mappers, and promise reactions all admit exactly these callables.
// Admission means dispatchable, not necessarily invocable: new-requiring
+6 -4
View File
@@ -20,6 +20,7 @@ import {
type Value,
} from "./objects.js"
import type { Interpreter } from "./interpreter.js"
import { toPrimitiveString } from "./callback.js"
import { formatValue } from "../stdlib/console.js"
export const normalizeError = (error: unknown): Diagnostic => {
@@ -139,14 +140,13 @@ const constructAggregateErrorValue = <R>(
proto: Obj,
): Effect.Effect<ErrorObj, unknown, R> =>
Effect.gen(function* () {
const message = args[1] === undefined ? "" : yield* toPrimitiveString(ctx, args[1])
const cursor = yield* ctx.iterate(args[0])
if (cursor === undefined) throw typeError("new AggregateError(...) expects a synchronous iterable of errors.")
const errors: Array<Value> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
return createAggregateErrorValue(ctx, errors, args[1] === undefined ? "" : coerceToString(args[1]), proto)
}
if (step.done) return createAggregateErrorValue(ctx, errors, message, proto)
errors.push(step.value)
}
})
@@ -160,7 +160,9 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
const created =
type === "AggregateError"
? constructAggregateErrorValue(ctx, args, proto)
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
: Effect.map(args[0] === undefined ? Effect.undefined : toPrimitiveString(ctx, args[0]), (message) =>
createErrorValue(proto, message),
)
// ES2022 `new Error(message, { cause })`: installed only when the options object has the property at all.
const options = args[type === "AggregateError" ? 2 : 1]
if (!(options instanceof Obj) || !has(options, "cause")) return created
+158 -99
View File
@@ -95,7 +95,7 @@ import {
coerceToString,
type Value,
} from "./objects.js"
import { preserveConsumerError } from "./callback.js"
import { type Hint, preserveConsumerError, toPrimitive } from "./callback.js"
import { Pending, resolvePromise, resolvePromiseValue } from "./promises.js"
import { describeValue, isOpaque, rejectCircularInsertion, typeofValue } from "./references.js"
import { ScopeStack } from "./scope.js"
@@ -103,6 +103,26 @@ import { constructRegExp } from "../stdlib/regexp.js"
import { enumerableSource } from "../stdlib/object.js"
import { compoundOperators } from "../stdlib/value.js"
/** The binary operators that convert object operands through ToPrimitive before acting on primitives. */
const primitiveOperators = new Set([
"+",
"-",
"*",
"/",
"%",
"**",
"<",
"<=",
">",
">=",
"&",
"|",
"^",
"<<",
">>",
">>>",
])
// What a loop does with its body's result: exit with a StatementResult, or undefined to keep iterating.
// Unlabelled break ends this loop; a label the loop does not carry propagates outward.
const loopExit = (result: StatementResult, labels: ReadonlySet<string> | undefined): StatementResult | undefined => {
@@ -1116,18 +1136,15 @@ class Frame<R> {
}
if (pattern.type === "ObjectPattern") {
if (!(value instanceof Obj)) {
throw typeError(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
)
if (value === null || value === undefined) {
throw typeError(`Cannot destructure ${describeValue(value)} as it is ${value}.`, pattern)
}
const consumed = new Set<PropertyKey>()
for (const property of pattern.properties) {
if (property.type === "RestElement") {
const rest = new Obj(self.ctx.builtins.Object)
assign(rest, value, consumed)
assign(rest, enumerableSource(self.ctx, "Object destructuring", value, pattern), consumed)
yield* self.declarePattern(property.argument, rest, mutable, property, initialize)
continue
}
@@ -1136,7 +1153,7 @@ class Frame<R> {
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern(
property.value,
self.readProperty(value, key, property),
self.destructuredProperty(value, key, property),
mutable,
property,
initialize,
@@ -1175,24 +1192,21 @@ class Frame<R> {
}
if (pattern.type === "ObjectPattern") {
if (!(value instanceof Obj)) {
throw invalidData(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
)
if (value === null || value === undefined) {
throw typeError(`Cannot destructure ${describeValue(value)} as it is ${value}.`, pattern)
}
const consumed = new Set<PropertyKey>()
for (const property of pattern.properties) {
if (property.type === "RestElement") {
const rest = new Obj(self.ctx.builtins.Object)
assign(rest, value, consumed)
assign(rest, enumerableSource(self.ctx, "Object destructuring", value, pattern), consumed)
yield* self.assignPattern(property.argument, rest, property)
continue
}
const key = yield* self.destructuringPropertyKey(property)
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.assignPattern(property.value, self.readProperty(value, key, property), property)
yield* self.assignPattern(property.value, self.destructuredProperty(value, key, property), property)
}
return
}
@@ -1368,10 +1382,38 @@ class Frame<R> {
const lhs = yield* self.evaluateExpression(left)
const rhs = yield* self.evaluateExpression(node.right)
if (operator === "instanceof") return instanceofValue(lhs, rhs, node)
if (lhs instanceof Obj || rhs instanceof Obj) return yield* self.applyOperator(operator, lhs, rhs, node)
return self.applyBinaryOperator(operator, lhs, rhs, node)
})
}
/** ToPrimitive for an operand: data objects run their own methods; opaque values stay for the data gates below. */
private toPrimitive(value: Value, hint: Hint, node: AstNode) {
return this.native(() => toPrimitive(this.ctx, value, hint), node)
}
// Arithmetic, relational, and bitwise operators convert both operands first, left then right, so a `valueOf`
// runs (and throws) in spec order; `+` asks for the default hint and the rest for a number.
private applyOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Effect.Effect<Value, unknown, R> {
if (!(lhs instanceof Obj || rhs instanceof Obj))
return Effect.succeed(this.applyBinaryOperator(operator, lhs, rhs, node))
// IsLooselyEqual converts only an object facing a non-nullish primitive; two objects (including tool
// references, which are not Obj) compare by identity.
const equality = operator === "==" || operator === "!="
const other = lhs instanceof Obj ? rhs : lhs
const converts =
primitiveOperators.has(operator) ||
(equality && other !== null && other !== undefined && typeof other !== "object")
if (!converts) return Effect.succeed(this.applyBinaryOperator(operator, lhs, rhs, node))
const hint = operator === "+" || equality ? "default" : "number"
const self = this
return Effect.gen(function* () {
const l = yield* self.toPrimitive(lhs, hint, node)
const r = yield* self.toPrimitive(rhs, hint, node)
return self.applyBinaryOperator(operator, l, r, node)
})
}
private applyBinaryOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Value {
if (operator === "===") return lhs === rhs
if (operator === "!==") return lhs !== rhs
@@ -1383,70 +1425,60 @@ class Frame<R> {
if (isOpaque(lhs) || isOpaque(rhs)) {
throw invalidData("Binary operators require data values.", node)
}
// Addition uses the default hint; every other operator asks for a number.
const hint = operator === "+" ? "default" : "number"
const coerceOperand = (operand: Value) => (operand instanceof Obj ? operand.toPrimitive(hint) : operand)
const l = coerceOperand(lhs)
const r = coerceOperand(rhs)
// Object operands were already converted by applyOperator; only primitives reach the arithmetic below.
switch (operator) {
case "+": {
const sum = (l as string) + (r as string)
const sum = (lhs as string) + (rhs as string)
if (typeof sum === "string") checkStringLength(sum.length)
return sum
}
case "-":
return (l as number) - (r as number)
return (lhs as number) - (rhs as number)
case "*":
return (l as number) * (r as number)
return (lhs as number) * (rhs as number)
case "/":
return (l as number) / (r as number)
return (lhs as number) / (rhs as number)
case "%":
return (l as number) % (r as number)
return (lhs as number) % (rhs as number)
case "**":
return (l as number) ** (r as number)
return (lhs as number) ** (rhs as number)
case "<":
return (l as string) < (r as string)
return (lhs as string) < (rhs as string)
case "<=":
return (l as string) <= (r as string)
return (lhs as string) <= (rhs as string)
case ">":
return (l as string) > (r as string)
return (lhs as string) > (rhs as string)
case ">=":
return (l as string) >= (r as string)
return (lhs as string) >= (rhs as string)
case "&":
return (l as number) & (r as number)
return (lhs as number) & (rhs as number)
case "|":
return (l as number) | (r as number)
return (lhs as number) | (rhs as number)
case "^":
return (l as number) ^ (r as number)
return (lhs as number) ^ (rhs as number)
case "<<":
return (l as number) << (r as number)
return (lhs as number) << (rhs as number)
case ">>":
return (l as number) >> (r as number)
return (lhs as number) >> (rhs as number)
case ">>>":
return (l as number) >>> (r as number)
return (lhs as number) >>> (rhs as number)
case "in":
if (!(rhs instanceof Obj)) {
throw typeError("The 'in' operator requires a data object on the right-hand side.", node)
}
return has(rhs, coerceOperand(lhs) as PropertyKey)
throw typeError("The 'in' operator requires a data object on the right-hand side.", node)
default:
throw typeError(`Unsupported binary operator '${operator}'.`, node)
}
}
// IsLooselyEqual: objects (including functions and tool references) compare by identity, and only a
// data object facing a non-nullish primitive needs to coerce, so an opaque value is rejected only there.
// IsLooselyEqual: objects (including functions and tool references) compare by identity, and a nullish
// primitive never equals an object.
private looselyEqual(lhs: Value, rhs: Value, node: AstNode): boolean {
const lhsObject = lhs !== null && typeof lhs === "object"
const rhsObject = rhs !== null && typeof rhs === "object"
if (lhsObject === rhsObject) return lhsObject ? lhs === rhs : lhs == rhs
const object = lhsObject ? lhs : rhs
const primitive = lhsObject ? rhs : lhs
if (primitive === null || primitive === undefined) return false
if (!(object instanceof Obj) || isOpaque(object)) {
throw invalidData("Binary operators require data values.", node)
}
return object.toPrimitive("default") == primitive
// Data objects were converted by applyOperator, so only an opaque reference facing a primitive gets here.
throw invalidData("Binary operators require data values.", node)
}
private evaluateLogicalExpression(node: LogicalExpression): Effect.Effect<Value, unknown, R> {
@@ -1468,14 +1500,16 @@ class Frame<R> {
if (operator === "typeof" && argument.type === "Identifier" && !this.scopes.resolve(argument.name)) {
return Effect.succeed("undefined")
}
return Effect.map(this.evaluateExpression(argument), (value) => {
const self = this
return Effect.gen(function* () {
const value = yield* self.evaluateExpression(argument)
if (operator === "typeof") return typeofValue(value)
if (operator === "!") return !value
if (operator === "void") return undefined
if (isOpaque(value)) {
const operand = yield* self.toPrimitive(value, "number", node)
if (isOpaque(operand)) {
throw invalidData("Unary operators require data values.", node)
}
const operand = value instanceof Obj ? value.toPrimitive("number") : value
let result: Value
switch (operator) {
case "+":
@@ -1497,11 +1531,16 @@ class Frame<R> {
private evaluateAssignmentExpression(node: AssignmentExpression): Effect.Effect<Value, unknown, R> {
const left = node.left
const operator = node.operator
// The binary operator a compound assignment applies: `+=` is `+`.
const binary = operator.slice(0, -1)
const self = this
return Effect.gen(function* () {
if (operator === "??=" || operator === "||=" || operator === "&&=") {
return yield* self.evaluateLogicalAssignment(node, left, operator)
}
if (operator !== "=" && !compoundOperators.has(operator)) {
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
}
if (operator === "=" && (left.type === "ObjectPattern" || left.type === "ArrayPattern")) {
const rightValue = yield* self.evaluateExpression(node.right)
yield* self.assignPattern(left, rightValue, node)
@@ -1512,17 +1551,24 @@ class Frame<R> {
if (operator !== "=") {
const current = self.scopes.get(name, left)
const rightValue = yield* self.evaluateExpression(node.right)
return self.scopes.set(name, self.applyCompoundAssignment(operator, current, rightValue, node), left)
const next =
current instanceof Obj || rightValue instanceof Obj
? yield* self.applyOperator(binary, current, rightValue, node)
: self.applyBinaryOperator(binary, current, rightValue, node)
return self.scopes.set(name, next, left)
}
const rightValue = yield* self.evaluateNamed(node.right, name)
return self.scopes.set(name, rightValue, left)
}
if (left.type === "MemberExpression") {
return yield* self.modifyMember(left, (current) =>
Effect.map(self.evaluateExpression(node.right), (rightValue) => {
if (operator === "=") return { write: true, next: rightValue, result: rightValue }
const next = self.applyCompoundAssignment(operator, current, rightValue, node)
return { write: true, next, result: next }
Effect.flatMap(self.evaluateExpression(node.right), (rightValue) => {
if (operator === "=") return Effect.succeed({ write: true, next: rightValue, result: rightValue })
return Effect.map(self.applyOperator(binary, current, rightValue, node), (next) => ({
write: true,
next,
result: next,
}))
}),
)
}
@@ -1572,8 +1618,7 @@ class Frame<R> {
throw typeError(`Unsupported update operator '${operator}'.`, node)
}
// CodeMode numeric coercion, not host Number(): null-prototype data objects would make
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
// CodeMode numeric coercion, not host Number(), so opaque runtime references reject clearly.
const operand = (current: Value): number => {
if (isOpaque(current)) {
throw invalidData(`'${operator}' requires a data value.`, argument)
@@ -1582,21 +1627,26 @@ class Frame<R> {
}
if (argument.type === "Identifier") {
return Effect.sync(() => {
const name = argument.name
const current = operand(this.scopes.get(name, argument))
const next = current + increment
const name = argument.name
const current = this.scopes.get(name, argument)
const update = (value: Value) => {
const before = operand(value)
const next = before + increment
this.scopes.set(name, next, argument)
return prefix ? next : current
})
return prefix ? next : before
}
if (!(current instanceof Obj)) return Effect.sync(() => update(current))
return Effect.map(this.toPrimitive(current, "number", argument), update)
}
if (argument.type === "MemberExpression") {
return this.modifyMember(argument, (current) => {
const value = operand(current)
const next = value + increment
return Effect.succeed({ write: true, next, result: prefix ? next : value })
})
return this.modifyMember(argument, (current) =>
Effect.map(this.toPrimitive(current, "number", argument), (primitive) => {
const value = operand(primitive)
const next = value + increment
return { write: true, next, result: prefix ? next : value }
}),
)
}
throw typeError("Update target must be an Identifier or MemberExpression.", argument)
@@ -2065,7 +2115,7 @@ class Frame<R> {
if (index < expressions.length) {
const raw = yield* self.evaluateExpression(expressions[index])
output += coerceToString(raw)
output += coerceToString(yield* self.toPrimitive(raw, "string", expressions[index]))
checkStringLength(output.length)
}
}
@@ -2115,13 +2165,6 @@ class Frame<R> {
)
}
private applyCompoundAssignment(operator: string, current: Value, incoming: Value, node: AstNode): Value {
if (!compoundOperators.has(operator)) {
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
}
return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node)
}
private getMemberReference(
node: MemberExpression,
): Effect.Effect<MemberReference | ToolReference | { value: Value } | typeof OptionalShortCircuit, unknown, R> {
@@ -2140,32 +2183,48 @@ class Frame<R> {
: propertyNode.type === "Identifier"
? propertyNode.name
: self.toPropertyKey(yield* self.evaluateExpression(propertyNode))
if (objectValue instanceof ToolReference) {
if (typeof key !== "string") {
throw typeError("Tool paths must use string property names.", propertyNode)
}
return new ToolReference([...objectValue.path, key])
}
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
if (typeof objectValue === "string") {
if (key === "length") return { value: objectValue.length }
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return { value: objectValue[index] }
}
const proto = primitivePrototype(self.ctx.builtins, objectValue)
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
if (objectValue === null || objectValue === undefined) {
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
}
throw typeError("Cannot access a property on a non-object value.", objectNode)
return self.resolveProperty(objectValue, key, objectNode, propertyNode)
})
}
private resolveProperty(
objectValue: Value,
key: PropertyKey,
objectNode: AstNode,
propertyNode: AstNode,
): MemberReference | ToolReference | { value: Value } {
if (objectValue instanceof ToolReference) {
if (typeof key !== "string") {
throw typeError("Tool paths must use string property names.", propertyNode)
}
return new ToolReference([...objectValue.path, key])
}
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
if (typeof objectValue === "string") {
if (key === "length") return { value: objectValue.length }
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return { value: objectValue[index] }
}
const proto = primitivePrototype(this.ctx.builtins, objectValue)
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
if (objectValue === null || objectValue === undefined) {
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
}
throw typeError("Cannot access a property on a non-object value.", objectNode)
}
// One destructured property, read the way a member expression would read it (primitives use their prototype).
private destructuredProperty(source: Value, key: PropertyKey, node: AstNode): Value {
const reference = this.resolveProperty(source, key, node, node)
if (reference instanceof ToolReference) return reference
if ("value" in reference) return reference.value
return this.readProperty(reference.target, reference.key, node, reference.receiver)
}
private readReference(reference: MemberReference, node: MemberExpression): Value {
// Reject unknown promise properties so a missing await cannot hide.
if (reference.target instanceof PromiseObj && !has(reference.target, reference.key)) {
+19 -9
View File
@@ -18,7 +18,7 @@ import {
type Value,
} from "../interpreter/objects.js"
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
import { applyCollectionCallback, invoke, preserveConsumerError } from "../interpreter/callback.js"
import { applyCollectionCallback, invoke, preserveConsumerError, withPrimitives } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { compareText } from "../tool-runtime.js"
@@ -146,20 +146,30 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
"join",
1,
(thisValue, args) => {
const joined = self(thisValue, "join")
.items.map((item) => coerceToString(item ?? ""))
.join(args[0] === undefined ? "," : coerceToString(args[0]))
checkStringLength(joined.length)
return joined
// .map would keep holes, which Effect.forEach would then hand to the body as undefined.
const parts = Array.from(self(thisValue, "join").items, (item) => item ?? "")
return withPrimitives(
ctx,
"string",
[args[0] === undefined ? "," : args[0], ...parts],
([separator, ...items]) => {
const joined = items.map(coerceToString).join(coerceToString(separator))
checkStringLength(joined.length)
return joined
},
)
},
],
[
"toString",
0,
(thisValue) =>
self(thisValue, "toString")
.items.map((item) => coerceToString(item ?? ""))
.join(","),
withPrimitives(
ctx,
"string",
Array.from(self(thisValue, "toString").items, (item) => item ?? ""),
(items) => items.map(coerceToString).join(","),
),
],
[
"includes",
+16 -3
View File
@@ -16,8 +16,11 @@ const constructDate = <R>(ctx: Interpreter<R>, args: Array<Value>, proto: Obj) =
: new DateObj(proto, new Date(coerceToNumber(value)).getTime()),
)
}
const parts = args.map((arg) => coerceToNumber(arg))
return Effect.succeed(new DateObj(proto, new Date(...(parts as [number, number])).getTime()))
// The spec converts at most seven components, in order, so extra arguments never run program code.
return Effect.map(
Effect.forEach(args.slice(0, 7), (arg) => toPrimitiveNumber(ctx, arg), { concurrency: 1 }),
(parts) => new DateObj(proto, new Date(...(parts as [number, number])).getTime()),
)
}
type Getter = keyof {
@@ -79,7 +82,15 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
methods(builtins, date, [
["now", 0, () => Date.now()],
["parse", 1, (_, args) => Date.parse(coerceToString(args[0]))],
["UTC", 7, (_, args) => Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))],
[
"UTC",
7,
(_, args) =>
Effect.map(
Effect.forEach(args.slice(0, 7), (arg) => toPrimitiveNumber(ctx, arg), { concurrency: 1 }),
(parts) => Date.UTC(...(parts as Parameters<typeof Date.UTC>)),
),
],
])
const self = (thisValue: Value, name: string) => receiver(DateObj, thisValue, `Date.prototype.${name}`)
@@ -125,6 +136,8 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
concurrency: 1,
}),
(values) => {
// Every setter but setTime and setFullYear leaves an invalid Date untouched and answers NaN.
if (Number.isNaN(hosted.getTime()) && name !== "setTime" && !name.endsWith("FullYear")) return NaN
target.time = hosted[name](...(values as [number, number, number, number]))
return target.time
},
+19 -17
View File
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import { constants, type Method, methods } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import { Obj, coerceToNumber } from "../interpreter/objects.js"
import { preserveConsumerError } from "../interpreter/callback.js"
import { preserveConsumerError, withPrimitives } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
// Bun exposes ES2026 Math.sumPrecise before TypeScript's standard library types.
@@ -12,25 +12,27 @@ declare global {
}
}
// Validate only the arguments a method consumes; like JS, extras are ignored
// (so built-ins work as callbacks receiving (element, index, array)).
const unary = (name: string, op: (a: number) => number): Method => [name, 1, (_, args) => op(coerceToNumber(args[0]))]
const binary = (name: string, op: (a: number, b: number) => number): Method => [
name,
2,
(_, args) => op(coerceToNumber(args[0]), coerceToNumber(args[1])),
]
const variadic = (name: string, op: (...values: Array<number>) => number): Method => [
name,
2,
(_, args) => op(...args.map(coerceToNumber)),
]
export const mathGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const math = new Obj(builtins.Object)
// Convert only the arguments a method consumes; like JS, extras are ignored
// (so built-ins work as callbacks receiving (element, index, array)).
const unary = (name: string, op: (a: number) => number): Method => [
name,
1,
(_, args) => withPrimitives(ctx, "number", [args[0]], ([a]) => op(coerceToNumber(a))),
]
const binary = (name: string, op: (a: number, b: number) => number): Method => [
name,
2,
(_, args) =>
withPrimitives(ctx, "number", [args[0], args[1]], ([a, b]) => op(coerceToNumber(a), coerceToNumber(b))),
]
const variadic = (name: string, op: (...values: Array<number>) => number): Method => [
name,
2,
(_, args) => withPrimitives(ctx, "number", args, (values) => op(...values.map(coerceToNumber))),
]
constants(math, {
PI: Math.PI,
E: Math.E,
+4 -10
View File
@@ -1,8 +1,8 @@
import { constructor, constants, methods } from "../interpreter/native.js"
import { coerceToNumber, coerceToString, type Value } from "../interpreter/objects.js"
import { coerceToNumber, type Value } from "../interpreter/objects.js"
import { rangeError, typeError } from "../interpreter/model.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coercion } from "./value.js"
import { coerce, coercion } from "./value.js"
export const numberGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
@@ -26,14 +26,8 @@ export const numberGlobal = <R>(ctx: Interpreter<R>) => {
["isFinite", 1, (_, args) => Number.isFinite(args[0])],
["isNaN", 1, (_, args) => Number.isNaN(args[0])],
["isSafeInteger", 1, (_, args) => Number.isSafeInteger(args[0])],
[
"parseInt",
2,
(_, args) => {
return parseInt(coerceToString(args[0]), coerceToNumber(args[1]))
},
],
["parseFloat", 1, (_, args) => parseFloat(coerceToString(args[0]))],
["parseInt", 2, (_, args) => coerce(ctx, "parseInt", args)],
["parseFloat", 1, (_, args) => coerce(ctx, "parseFloat", args)],
])
const self = (thisValue: Value, name: string): number => {
+12 -7
View File
@@ -1,12 +1,13 @@
import { fn } from "../interpreter/native.js"
import { coerceToNumber, coerceToString, type Native, type Value } from "../interpreter/objects.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { withPrimitives } from "../interpreter/callback.js"
export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="])
export type Coercion = "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN"
const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>): Value => {
export const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>) => {
// Native: Number() is 0 and String() is "", unlike their undefined-argument forms; the
// other coercers match native through the undefined-argument path below.
if (args.length === 0) {
@@ -14,15 +15,19 @@ const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>): Val
if (name === "String") return ""
}
const raw = args[0]
if (name === "Number") return coerceToNumber(raw)
if (name === "Boolean") return Boolean(raw)
if (name === "isFinite") return Number.isFinite(coerceToNumber(raw))
if (name === "isNaN") return Number.isNaN(coerceToNumber(raw))
if (name === "parseInt") {
return parseInt(coerceToString(raw), coerceToNumber(args[1]))
return withPrimitives(ctx, ["string", "number"], [raw, args[1]], ([text, radix]) =>
parseInt(coerceToString(text), coerceToNumber(radix)),
)
}
if (name === "parseFloat") return parseFloat(coerceToString(raw))
return coerceToString(raw)
return withPrimitives(ctx, name === "String" || name === "parseFloat" ? "string" : "number", [raw], ([value]) => {
if (name === "Number") return coerceToNumber(value)
if (name === "isFinite") return Number.isFinite(coerceToNumber(value))
if (name === "isNaN") return Number.isNaN(coerceToNumber(value))
if (name === "parseFloat") return parseFloat(coerceToString(value))
return coerceToString(value)
})
}
/** A global coercion function such as `Number` or `parseInt`. */
+166
View File
@@ -1571,3 +1571,169 @@ describe("this, arguments, and Function.prototype.call/apply/bind", () => {
)
})
})
describe("ToPrimitive: operators and conversions honor program valueOf and toString", () => {
test("program-installed valueOf and toString on opaque values are ignored at every site", async () => {
expect(
await value(`
const f = () => 1
f.toString = () => "custom"
f.valueOf = () => 5
return [String(f), \`\${f}\`, [f].join(), new Error(f).message, isNaN(Number(f)), isNaN(Math.abs(f))]
`),
).toEqual(["[object Function]", "[object Function]", "[object Function]", "[object Function]", true, true])
})
test("== converts an object facing a non-nullish primitive through its own valueOf", async () => {
expect(
await value(`
const one = { valueOf() { return 1 } }
return [one == 1, 1 == one, one == true, one == "1", one == null, one == one, one == { valueOf() { return 1 } }, [1] == 1]
`),
).toEqual([true, true, true, true, false, true, false, true])
expect((await error(`(() => 1) == 1`)).message).toContain("Binary operators require data values")
})
test("operators, unary, template literals, and conversion functions use the object's own methods", async () => {
expect(
await value(`
const money = { valueOf() { return 7 } }
return [money * 2, money + 1, money + "", -money, +money, ~money, money < 8, money ** 2, money | 8,
Number(money), Math.max(money, 1), \`\${money}\`, String(money), isNaN(money), isFinite(money),
parseInt({ toString() { return "42px" } }), parseInt("ff", { valueOf() { return 16 } }),
Number.parseFloat({ toString() { return "1.5" } })]
`),
).toEqual([
14,
8,
"7",
-7,
7,
-8,
true,
49,
15,
7,
7,
"[object Object]",
"[object Object]",
false,
true,
42,
255,
1.5,
])
})
test("the hint picks the method: + and Number prefer valueOf, template literals and String prefer toString", async () => {
expect(
await value(`
const both = { valueOf() { return 1 }, toString() { return "s" } }
return [both + "", \`\${both}\`, String(both), both * 2, new Error(both).message, [both].join(), [both, 2] + ""]
`),
).toEqual(["1", "s", "s", 2, "s", "s", "s,2"])
})
test("operands convert left then right, and a throwing valueOf surfaces as the program error", async () => {
expect(
await value(`
const order = []
const a = { valueOf() { order.push("a"); return 1 } }, b = { valueOf() { order.push("b"); return 2 } }
a + b; a < b; a - b
return order
`),
).toEqual(["a", "b", "a", "b", "a", "b"])
expect(
await value(`
const bad = { valueOf() { throw new RangeError("nope") } }
const names = []
try { bad + 1 } catch (e) { names.push(e.name) }
try { Number(bad) } catch (e) { names.push(e.name) }
try { Math.abs(bad) } catch (e) { names.push(e.name) }
return names
`),
).toEqual(["RangeError", "RangeError", "RangeError"])
})
test("arrays keep their built-in join form unless the program replaces toString", async () => {
expect(
await value(`
const arr = [1, 2]
const before = [arr + "", [] + [], [1, , 3].join("-"), [1, { toString() { return "q" } }].join("-")]
arr.toString = () => "x"
return [...before, arr + "", \`\${arr}\`, String(arr)]
`),
).toEqual(["1,2", "", "1--3", "1-q", "x", "x", "x"])
})
test("update and compound assignment convert the current value", async () => {
expect(
await value(`
let x = { valueOf() { return 5 } }
const o = { n: { valueOf() { return 4 } } }
const after = x++
o.n += 1
o.n++
let s = { valueOf() { return 2 } }
s *= 3
return [after, x, o.n, s]
`),
).toEqual([5, 6, 6, 6])
})
test("functions and other opaque values still reject arithmetic, and an object without a primitive form throws", async () => {
expect((await error(`const f = () => 1; return f + 1`)).message).toContain("Binary operators require data values")
expect((await error(`return -(() => 1)`)).message).toContain("Unary operators require data values")
const failure = await error(`return { valueOf() { return {} }, toString() { return [] } } + 1`)
expect(failure.message).toContain("Cannot convert object to primitive value")
})
})
describe("object destructuring from primitives", () => {
test("reads through the primitive's prototype like member access", async () => {
expect(
await value(`
const { length, 0: first, toUpperCase } = "abc"
const { toFixed } = 1.5
const {} = true
const { 0: a, ...rest } = "xyz"
const { ...none } = 42
let n
;({ length: n } = "hello")
return [length, first, toUpperCase.call("q"), toFixed.call(2.345, 1), a, rest, none, n]
`),
).toEqual([3, "a", "Q", "2.3", "x", { 1: "y", 2: "z" }, {}, 5])
})
test("only null and undefined sources throw", async () => {
expect((await error(`const { a } = null`)).message).toContain("Cannot destructure null as it is null")
expect((await error(`const {} = undefined`)).message).toContain("Cannot destructure undefined")
expect((await error(`let a; ({ a } = undefined)`)).message).toContain("Cannot destructure undefined")
})
})
describe("Date components convert through ToPrimitive", () => {
test("construction and Date.UTC ask each of the first seven arguments in order", async () => {
expect(
await value(`
const seen = []
const part = (n) => ({ valueOf() { seen.push(n); return n } })
const time = new Date(part(2024), part(1), part(2), part(3), part(4), part(5), part(6), part(99)).getTime()
const utc = Date.UTC(2024, { valueOf() { return 0 } }, 15)
return [seen, time === new Date(2024, 1, 2, 3, 4, 5, 6).getTime(), utc === Date.UTC(2024, 0, 15)]
`),
).toEqual([[2024, 1, 2, 3, 4, 5, 6], true, true])
expect((await error(`new Date(2024, { valueOf() { throw new RangeError("boom") } })`)).message).toContain("boom")
})
test("setters on an invalid Date answer NaN without overwriting a time set during coercion", async () => {
expect(
await value(`
const d = new Date(NaN)
const result = d.setDate({ valueOf() { d.setTime(0); return 1 } })
const y = new Date(NaN)
return [Number.isNaN(result), d.getTime(), y.setFullYear(2020) === Date.UTC(2020, 0, 1) - y.getTimezoneOffset() * 60000]
`),
).toEqual([true, 0, true])
})
})
+4 -2
View File
@@ -12,7 +12,8 @@ Without them the runner registers no tests, so CI is unaffected. Licensed under
## Layout
- `manifest.json` — the pinned upstream revision, which upstream directories are copied, and what is left out.
- `manifest.json` — the pinned upstream revision, which upstream directories are copied (every `built-ins` and
`language` directory, about 14,900 files after filtering), and what is left out.
- `built-ins/`, `language/` — the copied files, mirroring upstream `test/`; gitignored.
- `skipped.txt` — vendored files that fail on a known interpreter gap, one `path # reason` per line. They are
skipped, and each gap is listed as unchecked in `interpreter-support.md`.
@@ -26,7 +27,8 @@ Without them the runner registers no tests, so CI is unaffected. Licensed under
manifest marks unsupported, or when its code matches one of the manifest's `boundaries` patterns. The sync checks the
checkout is at the pinned revision, so every machine runs the same files. Boundaries are
intentional limits of the interpreter, not compatibility work: classes, prototype objects, property descriptors,
accessors, boxed primitives, sloppy mode, `eval`, `Symbol()`, and the `$262` host API. If one
accessors, boxed primitives, typed arrays and buffers, weak collections, `Reflect` and `Proxy`, sloppy mode, `eval`,
`Symbol()`, and the `$262` host API. If one
of those decisions changes, delete its entry and re-sync; the tests are upstream, not lost.
## Commands
+5 -22
View File
@@ -1,33 +1,16 @@
{
"revision": "250f204f23a9249ff204be2baec29600faae7b75",
"directories": [
"built-ins/Array/prototype",
"built-ins/Function/prototype/apply",
"built-ins/Function/prototype/bind",
"built-ins/Function/prototype/call",
"built-ins/Iterator",
"built-ins/Object/freeze",
"built-ins/Object/getPrototypeOf",
"built-ins/Object/is",
"built-ins/Object/isExtensible",
"built-ins/Object/isFrozen",
"built-ins/Object/isSealed",
"built-ins/Object/preventExtensions",
"built-ins/String/raw",
"language/arguments-object",
"language/expressions/does-not-equals",
"language/expressions/equals",
"language/expressions/tagged-template",
"language/expressions/this",
"language/statements"
],
"directories": ["built-ins", "language"],
"harness": ["assert.js", "sta.js", "compareArray.js", "doneprintHandle.js"],
"flags": ["module", "raw", "noStrict"],
"boundaries": {
"class": "\\bclass\\s*[A-Za-z_${]",
"accessor properties": "\\b(get|set)\\s+[\\w$\\[][^\\n(]*\\(",
"property descriptors": "Object\\.(defineProperty|defineProperties|getOwnPropertyDescriptors?|getOwnPropertyNames|setPrototypeOf)\\b",
"boxed primitives": "\\bnew\\s+(String|Number|Boolean)\\s*\\(",
"boxed primitives": "\\b(new\\s+(String|Number|Boolean)\\b|Object\\s*\\(\\s*(true|false|-?\\d|['\"]))",
"typed arrays and buffers": "\\b(ArrayBuffer|SharedArrayBuffer|DataView|Int8Array|Uint8ClampedArray|Int16Array|Uint16Array|Int32Array|Uint32Array|Float16Array|Float32Array|Float64Array|BigInt64Array|BigUint64Array)\\b",
"weak collections": "\\b(WeakMap|WeakSet|WeakRef|FinalizationRegistry)\\b",
"Reflect and Proxy": "\\b(Reflect|Proxy)\\b",
"sloppy mode": "\\bwith\\s*\\(",
"eval": "\\b(eval|Function)\\b",
"new.target": "\\bnew\\.target\\b",
File diff suppressed because it is too large Load Diff
+14
View File
@@ -354,3 +354,17 @@ describe("tools.search alias", () => {
expect(await value(runtime, `return await tools.search({})`)).toBe("custom")
})
})
describe("tool references under ==", () => {
test("compare by identity against data objects without converting them", async () => {
const runtime = CodeMode.make({ tools: { probe: echo("Probe", "ok") } })
expect(
await value(
runtime,
`let calls = 0
const o = { valueOf() { calls++; return 1 } }
return [o == tools.probe, tools == { a: 1 }, tools.probe == null, calls]`,
),
).toEqual([false, false, false, 0])
})
})
@@ -361,6 +361,12 @@ export function createBrowserPage(
visible = value
updateVisibility()
},
// Freezes the shown page so the renderer can paint it under DOM overlays while the view hides.
async capture() {
if (closed || !visible || !content) return
const image = await contents.capturePage()
return image.isEmpty() ? undefined : new Uint8Array(image.toJPEG(90))
},
async execute(command: Browser.Command, signal: AbortSignal): Promise<Browser.Result> {
await ready
abortError(signal)
@@ -288,6 +288,9 @@ export function createBrowserPane(storage: StateStore) {
page.layout(bounds, value.background, value.radius)
page.setVisible(true)
},
async capture(win: BrowserWindow, bindingID: string, tabID: Browser.TabID) {
return (await owned(win, bindingID).pages.get(tabID)?.capture()) ?? null
},
async command(win: BrowserWindow, bindingID: string, command: BrowserPaneCommand) {
const entry = owned(win, bindingID)
await execute(entry, { action: command, files: [] }, new AbortController().signal)
@@ -6,7 +6,7 @@ import { IpcPortHandoff } from "../ipc-transport"
import { Shutdown } from "../lifecycle/shutdown"
import { isRendererUrl } from "../windows/scheme"
import { DesktopStorage } from "../storage"
import { sender } from "./context"
import { sender, type RpcContext } from "./context"
export const eventHandlers = EventRpcs.toLayer(
Effect.gen(function* () {
@@ -25,21 +25,29 @@ export const eventHandlers = EventRpcs.toLayer(
})
const remove = yield* shutdown.add(stop)
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
const owner = async (context: RpcContext) => {
const contents = sender(handoff, context)
const win = BrowserWindow.fromWebContents(contents)
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
throw new Error("browser.pane.owner.invalid")
}
browser ??= load()
return { win, pane: await browser }
}
return EventRpcs.of({
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
BrowserPane: ({ request }, context) =>
Effect.tryPromise(async () => {
const contents = sender(handoff, context)
const win = BrowserWindow.fromWebContents(contents)
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
throw new Error("browser.pane.owner.invalid")
}
browser ??= load()
const pane = await browser
if (request.type === "register") return pane.register(win, request.bindingID, request.target)
if (request.type === "layout") return pane.layout(win, request.bindingID, request.layout)
if (request.type === "command") return pane.command(win, request.bindingID, request.command)
return pane.close(win, request.bindingID)
const target = await owner(context)
if (request.type === "register") return target.pane.register(target.win, request.bindingID, request.target)
if (request.type === "layout") return target.pane.layout(target.win, request.bindingID, request.layout)
if (request.type === "command") return target.pane.command(target.win, request.bindingID, request.command)
return target.pane.close(target.win, request.bindingID)
}).pipe(Effect.orDie),
BrowserPaneCapture: (request, context) =>
Effect.tryPromise(async () => {
const target = await owner(context)
return target.pane.capture(target.win, request.bindingID, request.tabID)
}).pipe(Effect.orDie),
})
}),
@@ -4,6 +4,7 @@ import type { DesktopNativeBundle } from "@opencode/app/i18n/desktop-native"
import type { UpdaterState } from "@opencode/app/updater"
import type { WslServersPlatform } from "@opencode/app/wsl/types"
import type { SshPlatform } from "@opencode/app/ssh"
import type { Browser } from "@opencode/plugin-browser/rpc"
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
import type { WindowBootstrap } from "../shared/window-bootstrap"
import type {
@@ -31,6 +32,7 @@ export type ElectronAPI = {
browserPane: {
request(request: BrowserPaneRequest): Promise<void>
send(request: BrowserPaneRequest): void
capture(bindingID: string, tabID: Browser.TabID): Promise<ArrayBuffer | null>
onEvent(callback: (value: { readonly bindingID: string; readonly event: BrowserPaneEvent }) => void): () => void
}
wslServers: WslServersAPI
+2
View File
@@ -50,6 +50,8 @@ export const api: ElectronAPI = {
browserPane: {
request: (request) => invoke("BrowserPane", { request }),
send: (request) => send("BrowserPane", { request }),
capture: (bindingID, tabID) =>
invoke("BrowserPaneCapture", { bindingID, tabID }).then((data) => (data ? toArrayBuffer(data) : null)),
onEvent: (callback) => listen("BrowserPaneEvent", (value) => callback(value)),
},
wslServers: {
@@ -46,6 +46,10 @@ export function createDesktopPlatform(
.catch(() => undefined)
},
command: (command) => ready.then(() => api.browserPane.request({ type: "command", bindingID, command })),
capture: (tabID) =>
ready
.then(() => api.browserPane.capture(bindingID, tabID))
.then((data) => data && new Blob([data], { type: "image/jpeg" })),
close() {
if (closed) return
closed = true
@@ -1,6 +1,7 @@
import { Browser } from "@opencode/plugin-browser/rpc"
import { Schema } from "effect"
import { Rpc } from "effect/unstable/rpc"
import { Transferable } from "effect/unstable/workers"
const text = (maximum: number) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maximum))
const bindingID = text(128)
@@ -42,3 +43,7 @@ export const BrowserPaneEventSchema = Schema.Union([
}),
])
export const BrowserPaneRpc = Rpc.make("BrowserPane", { payload: { request: BrowserPaneRequestSchema } })
export const BrowserPaneCaptureRpc = Rpc.make("BrowserPaneCapture", {
payload: { bindingID, tabID: Browser.TabID },
success: Schema.NullOr(Transferable.Uint8Array),
})
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
import { BrowserPaneCaptureRpc, BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
import { UpdaterStateSchema } from "./updater"
import { WslServersEventSchema } from "./wsl"
import { SshState } from "@opencode/app/ssh"
@@ -63,4 +63,4 @@ export const DesktopEvent = Schema.Union([
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc)
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc, BrowserPaneCaptureRpc)
+4
View File
@@ -71,6 +71,10 @@ async function main() {
void ready.then(() => pane.layout(win, bindingID, layout))
},
command: (command) => ready.then(() => pane.command(win, bindingID, command)),
capture: (tabID) =>
ready
.then(() => pane.capture(win, bindingID, tabID))
.then((data) => data && new Blob([data], { type: "image/jpeg" })),
close: () => {
listeners.delete(bindingID)
void ready.then(() => pane.close(win, bindingID)).catch(() => {})
+5
View File
@@ -333,6 +333,11 @@ async function main() {
await call("tabs.focus", { tabID: second.id })
pane.layout(win, "suite", { tabID: second.id, visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } })
const snap = await call("snapshot", { tabID, boxes: true })
const still = await until(() => pane.capture(win, "suite", second.id))
assert(still)
assert.deepEqual(Array.from(still.subarray(0, 2)), [0xff, 0xd8], "The shown page captures as a JPEG still")
assert.equal(await pane.capture(win, "suite", tabID), null, "A hidden page has no still to show")
console.log("PASS browser pane still capture")
const ref = (text: string) => {
const match = snap.content
.split("\n")
+24 -10
View File
@@ -44,7 +44,7 @@ function segment(value: string) {
return value
}
function createStorage(root: string, channel: string) {
export function createStorage(root: string, channel: string) {
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
const memories = new Map<string, MemoryEntry<object>>()
const pending = new Set<Promise<void>>()
@@ -110,18 +110,32 @@ function createStorage(root: string, channel: string) {
},
}
let reload: ReturnType<typeof setTimeout> | undefined
const watcher = watch(directory, () => {
clearTimeout(reload)
// Atomic writes notify for the temporary file before its final rename, and some
// platforms coalesce the rename event. Reload after the event burst has settled.
reload = setTimeout(() => entries.forEach((entry) => entry.reload()), 50)
})
let reloadTimer: ReturnType<typeof setTimeout> | undefined
let watcher: ReturnType<typeof watch> | undefined
try {
watcher = watch(directory, () => {
clearTimeout(reloadTimer)
// Atomic writes notify for the temporary file before its final rename, and some
// platforms coalesce the rename event. Reload after the event burst has settled.
reloadTimer = setTimeout(() => entries.forEach((entry) => entry.reload()), 50)
})
watcher.on("error", (error) => {
clearTimeout(reloadTimer)
watcher?.close()
watcher = undefined
console.error("Storage directory watcher failed, live-reload disabled", { directory, error })
})
} catch (error) {
// fs.watch throws synchronously (e.g. ENOSPC when the inotify watch limit is
// exhausted). Losing cross-process live-reload is recoverable; crashing the
// whole TUI over it is not, so degrade instead of propagating.
console.error("Failed to watch storage directory, live-reload disabled", { directory, error })
}
return {
storage,
close: () => {
clearTimeout(reload)
watcher.close()
clearTimeout(reloadTimer)
watcher?.close()
},
}
}
+37
View File
@@ -0,0 +1,37 @@
import { afterEach, expect, spyOn, test } from "bun:test"
import * as fs from "fs"
import { mkdtempSync, rmSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { createStorage } from "../../src/context/storage"
afterEach(() => {
spyOn(fs, "watch").mockRestore()
})
test("createStorage degrades gracefully when fs.watch throws (e.g. ENOSPC)", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "storage-test-"))
// fs.watch throws synchronously when inotify_add_watch fails (e.g. the watch
// limit is exhausted). Simulate that scoped to this test only.
spyOn(fs, "watch").mockImplementation(() => {
throw Object.assign(new Error("ENOSPC: no space left on device, watch '/some/dir'"), { code: "ENOSPC" })
})
try {
let result: ReturnType<typeof createStorage> | undefined
expect(() => {
result = createStorage(dir, "next")
}).not.toThrow()
// storage should still be usable even though the live-reload watcher failed to attach
const [store, update] = result!.storage.store("kv", { initial: { count: 0 } })
expect(store.count).toBe(0)
await update((draft) => {
draft.count = 1
})
expect(store.count).toBe(1)
expect(() => result!.close()).not.toThrow()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})