mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-25 01:57:38 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85b98e7da4 | ||
|
|
8061220b08 | ||
|
|
b02cc35f13 | ||
|
|
e23d89c9a9 | ||
|
|
e8b3e19e85 | ||
|
|
5256f30957 |
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,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
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user