From cedf3656746edb125d8a63aadac9648dbdde91ae Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 8 Jul 2026 15:59:49 -0400 Subject: [PATCH 01/18] feat(tui): add structured logging --- packages/cli/src/tui.ts | 12 + packages/tui/package.json | 1 + packages/tui/src/app.tsx | 211 +++++++++--------- packages/tui/src/context/log.tsx | 24 ++ packages/tui/src/context/sdk.tsx | 11 +- packages/tui/src/index.tsx | 1 + packages/tui/test/app-lifecycle.test.tsx | 2 + packages/tui/test/cli/tui/use-event.test.tsx | 35 ++- packages/tui/test/fixture/tui-environment.tsx | 30 +-- 9 files changed, 208 insertions(+), 119 deletions(-) create mode 100644 packages/tui/src/context/log.tsx diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 96d92c50b26..e9d25911acf 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -18,6 +18,7 @@ export function runTui( const config = TuiConfig.resolve({}, { terminalSuspend: false }) let disposeSlots: (() => void) | undefined return Effect.gen(function* () { + const runFork = Effect.runForkWith(yield* Effect.context()) const options = { baseUrl: transport.url, headers: transport.headers } const api = OpenCode.make(options) const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe( @@ -41,6 +42,17 @@ export function runTui( reload, args, config, + log: (level, message, tags) => { + const effect = + level === "debug" + ? Effect.logDebug(message, tags) + : level === "warn" + ? Effect.logWarning(message, tags) + : level === "error" + ? Effect.logError(message, tags) + : Effect.logInfo(message, tags) + runFork(effect) + }, pluginHost: { async start(input) { disposeSlots = await loadBuiltinPlugins(input.api, input.runtime) diff --git a/packages/tui/package.json b/packages/tui/package.json index 4264ac40e79..da2af805046 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -17,6 +17,7 @@ "./context/epilogue": "./src/context/epilogue.tsx", "./context/exit": "./src/context/exit.tsx", "./context/kv": "./src/context/kv.tsx", + "./context/log": "./src/context/log.tsx", "./context/project": "./src/context/project.tsx", "./context/runtime": "./src/context/runtime.tsx", "./context/sdk": "./src/context/sdk.tsx", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index cbb398a55d3..24f32e93074 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -6,6 +6,7 @@ import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { ClipboardProvider, useClipboard } from "./context/clipboard" +import { LogProvider, useLog, type LogSink } from "./context/log" import { ExitProvider, useExit } from "./context/exit" import { EpilogueProvider } from "./context/epilogue" import * as Selection from "./util/selection" @@ -149,6 +150,7 @@ export type TuiInput = { config: TuiConfig.Resolved onSnapshot?: () => Promise pluginHost: TuiPluginHost + log: LogSink } function errorMessage(error: unknown) { @@ -230,7 +232,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { try { await input.pluginHost.dispose() } catch (error) { - console.error("Failed to dispose TUI plugins", error) + input.log("error", "Failed to dispose TUI plugins", { error }) } }), ) @@ -252,112 +254,116 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { await render(() => { return ( - { - if (renderer.isDestroyed) return - exit.reason = reason - destroyRenderer(renderer) - }} - > - (exit.epilogue = value)}> - }> - + { + if (renderer.isDestroyed) return + exit.reason = reason + destroyRenderer(renderer) + }} + > + (exit.epilogue = value)}> + } > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) }, renderer) }) @@ -374,6 +380,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }) function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPluginHost }) { + const log = useLog({ component: "app" }) const startup = useTuiStartup() const tuiConfig = useTuiConfig() const route = useRoute() @@ -454,7 +461,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi dispose: () => attention.dispose(), }) .catch((error) => { - console.error("Failed to load TUI plugins", error) + log.error("Failed to load TUI plugins", { error }) }) .finally(() => { setReady(true) @@ -816,8 +823,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi toast.show({ variant: "info", message: "Reloading server...", duration: 30000 }) // reload resolves once the replacement service is healthy; the // event stream reattaches through the reconnect loop. - await sdk - .reload!() + await sdk.reload!() .then(() => toast.show({ variant: "success", message: "Server reloaded" })) .catch(toast.error) }, @@ -1091,7 +1097,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }) event.on("installation.update-available", async (evt) => { - console.log("installation.update-available", evt) const version = evt.data.version const skipped = kv.get("skipped_version") diff --git a/packages/tui/src/context/log.tsx b/packages/tui/src/context/log.tsx new file mode 100644 index 00000000000..75a5f0cf659 --- /dev/null +++ b/packages/tui/src/context/log.tsx @@ -0,0 +1,24 @@ +import { createContext, useContext, type ParentProps } from "solid-js" + +export type LogLevel = "debug" | "info" | "warn" | "error" +export type LogTags = Readonly> +export type LogSink = (level: LogLevel, message: string, tags: LogTags) => void + +const LogContext = createContext() + +export function LogProvider(props: ParentProps<{ log: LogSink }>) { + return {props.children} +} + +export function useLog(tags: LogTags = {}) { + const sink = useContext(LogContext) + if (!sink) throw new Error("Log context must be used within a LogProvider") + + const write = (level: LogLevel, message: string, extra: LogTags = {}) => sink(level, message, { ...tags, ...extra }) + return { + debug: (message: string, extra?: LogTags) => write("debug", message, extra), + info: (message: string, extra?: LogTags) => write("info", message, extra), + warn: (message: string, extra?: LogTags) => write("warn", message, extra), + error: (message: string, extra?: LogTags) => write("error", message, extra), + } +} diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 728ced3ed42..d481ab3f850 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -4,6 +4,7 @@ import { createGlobalEmitter } from "@solid-primitives/event-bus" import { onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" +import { useLog } from "./log" export type SDKConnectionStatus = "connected" | "connecting" | "reconnecting" @@ -19,6 +20,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ // Stops and starts the managed service; present only in service mode. reload?: () => Promise }) => { + const log = useLog() const abort = new AbortController() let client = props.client let api = props.api @@ -64,7 +66,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ return connection.signal.reason instanceof Error ? connection.signal.reason : new Error("Event stream disconnected") - if (first.value.type !== "server.connected") return new Error("Event stream did not start with server.connected") + if (first.value.type !== "server.connected") + return new Error("Event stream did not start with server.connected") clearTimeout(timeout) attempt = 0 events.emit(first.value.type, first.value) @@ -74,6 +77,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ const event = await iterator.next() if (abort.signal.aborted || controller.signal.aborted) return if (event.done) return new Error("Event stream disconnected") + if ("durable" in event.value) + log.info("event", { + type: event.value.type, + aggregateID: event.value.durable.aggregateID, + seq: event.value.durable.seq, + }) events.emit(event.value.type, event.value) } })() diff --git a/packages/tui/src/index.tsx b/packages/tui/src/index.tsx index 722c2baf51b..e1350bcc2bb 100644 --- a/packages/tui/src/index.tsx +++ b/packages/tui/src/index.tsx @@ -1 +1,2 @@ export { run, type TuiInput } from "./app" +export { LogProvider, useLog, type LogLevel, type LogSink, type LogTags } from "./context/log" diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index c4302e2790e..626c8fd03ea 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -34,6 +34,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { api: createApi(calls.fetch), config: createTuiResolvedConfig({ plugin_enabled: {} }), args: {}, + log: () => {}, pluginHost: { async start() { started() @@ -115,6 +116,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after api: createApi(calls.fetch), config: createTuiResolvedConfig({ plugin_enabled: {} }), args: { sessionID: "dummy" }, + log: () => {}, pluginHost: { async start(input) { api = input.api diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index 7a4465c9279..6c9aa750b9d 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -9,6 +9,7 @@ import { SDKProvider, useSDK } from "../../../src/context/sdk" import { useEvent } from "../../../src/context/event" import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk" import { TestTuiContexts } from "../../fixture/tui-environment" +import type { LogSink } from "../../../src/context/log" const projectID = "proj_test" @@ -49,7 +50,7 @@ function update(version: string): V2Event { } } -async function mount(discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>) { +async function mount(discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>, log?: LogSink) { const events = createEventStream() const calls = createFetch(undefined, events) const seen: V2Event[] = [] @@ -62,7 +63,7 @@ async function mount(discover?: () => Promise<{ client: OpencodeClient; api: Ope }) const app = await testRender(() => ( - + { + test("logs only durable events", async () => { + const logs: Array<{ message: string; tags: Readonly> }> = [] + const { app, emit, seen } = await mount(undefined, (_level, message, tags) => logs.push({ message, tags })) + const durable = event( + { + id: "evt_renamed", + created: 1, + type: "session.renamed", + durable: { aggregateID: "ses_test", seq: 1, version: 1 }, + data: { sessionID: "ses_test", title: "Renamed" }, + }, + { directory: "/tmp/project" }, + ) + + try { + emit(vcs("main")) + emit(durable) + await wait(() => seen.length === 2 && logs.length === 1) + + expect(logs).toEqual([ + { + message: "event", + tags: { type: "session.renamed", aggregateID: "ses_test", seq: 1 }, + }, + ]) + } finally { + app.renderer.destroy() + } + }) + test("delivers events for the current project", async () => { const { app, emit, seen, workspaces } = await mount() diff --git a/packages/tui/test/fixture/tui-environment.tsx b/packages/tui/test/fixture/tui-environment.tsx index 543332ba182..e2ef9ba7745 100644 --- a/packages/tui/test/fixture/tui-environment.tsx +++ b/packages/tui/test/fixture/tui-environment.tsx @@ -6,27 +6,31 @@ import { type TuiPaths, } from "../../src/context/runtime" import type { ParentProps } from "solid-js" +import { LogProvider, type LogSink } from "../../src/context/log" export function TestTuiContexts( props: ParentProps<{ cwd?: string directory?: string paths?: Partial + log?: LogSink }>, ) { return ( - - - {props.children} - - + {})}> + + + {props.children} + + + ) } From 88925ec6e6dd03229d1c6413fd99335977fd2882 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 8 Jul 2026 16:00:52 -0400 Subject: [PATCH 02/18] fix(tui): allow callers without log sink --- packages/tui/src/app.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 24f32e93074..c5535ad32d2 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -150,7 +150,7 @@ export type TuiInput = { config: TuiConfig.Resolved onSnapshot?: () => Promise pluginHost: TuiPluginHost - log: LogSink + log?: LogSink } function errorMessage(error: unknown) { @@ -186,6 +186,7 @@ function isVersionGreater(left: string, right: string) { } export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { + const log = input.log ?? (() => {}) const global = yield* Global.Service const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } const result = yield* Effect.scoped( @@ -232,7 +233,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { try { await input.pluginHost.dispose() } catch (error) { - input.log("error", "Failed to dispose TUI plugins", { error }) + log("error", "Failed to dispose TUI plugins", { error }) } }), ) @@ -254,7 +255,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { await render(() => { return ( - + { if (renderer.isDestroyed) return From 99e52303e6af87b5754285ca5db52a82a852c28b Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 8 Jul 2026 16:34:41 -0400 Subject: [PATCH 03/18] feat(core): discover ecosystem skill directories (#35956) --- packages/core/src/config.ts | 46 +++++++++++++++++++--- packages/core/src/config/plugin/agent.ts | 1 + packages/core/src/config/plugin/command.ts | 1 + packages/core/src/config/plugin/skill.ts | 10 +++++ packages/core/src/plugin/skill.ts | 1 + packages/core/test/config/config.test.ts | 27 ++++++++++++- packages/core/test/config/skill.test.ts | 10 +++++ 7 files changed, 89 insertions(+), 7 deletions(-) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index ae05ddef7d8..4533f9b3780 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -119,7 +119,17 @@ export class Directory extends Schema.Class("Config.Directory")({ path: AbsolutePath, }) {} -export type Entry = Document | Directory +export class AgentsDirectory extends Schema.Class("Config.AgentsDirectory")({ + type: Schema.Literal("agents"), + path: AbsolutePath, +}) {} + +export class ClaudeDirectory extends Schema.Class("Config.ClaudeDirectory")({ + type: Schema.Literal("claude"), + path: AbsolutePath, +}) {} + +export type Entry = Document | Directory | AgentsDirectory | ClaudeDirectory export function latest(entries: readonly Entry[], key: K): Info[K] | undefined { return entries @@ -176,16 +186,37 @@ const layer = Layer.effect( const discover = Effect.fn("Config.discover")(function* () { const globalDirectory = AbsolutePath.make(global.config) + const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents")) + const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude")) const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) const discovered = locationIsGlobal ? [] : yield* fs .up({ - targets: [".opencode", ...names.toReversed()], + targets: [".opencode", ".claude", ".agents", ...names.toReversed()], start: location.directory, stop: location.project.directory, }) - .pipe(Effect.orDie) + .pipe(Effect.orDie) + + // We load certain files from a few other folders in the ecosystem + const claude = [ + ...((yield* fs.isDir(globalClaudeDirectory)) + ? [new ClaudeDirectory({ type: "claude", path: globalClaudeDirectory })] + : []), + ...discovered + .filter((item) => path.basename(item) === ".claude") + .map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) })), + ] + const agents = [ + ...((yield* fs.isDir(globalAgentsDirectory)) + ? [new AgentsDirectory({ type: "agents", path: globalAgentsDirectory })] + : []), + ...discovered + .filter((item) => path.basename(item) === ".agents") + .map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) })), + ] + const directories = [ globalDirectory, ...discovered @@ -193,15 +224,18 @@ const layer = Layer.effect( .toReversed() .map((directory) => AbsolutePath.make(directory)), ] - const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() + const directPaths = discovered + .filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item))) + .toReversed() const direct = yield* Effect.forEach(directPaths, loadFile).pipe( Effect.orDie, Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), ) + const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) return { - entries: [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()], - directories, + entries: [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()], + directories: [...directories, ...claude.map((entry) => entry.path), ...agents.map((entry) => entry.path)], files: directPaths, } }) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index e6ed0b0ed8b..fd494a76310 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -40,6 +40,7 @@ export const Plugin = define({ const load = Effect.fn("ConfigAgentPlugin.load")(function* () { return yield* Effect.forEach(yield* config.entries(), (entry) => { if (entry.type === "document") return Effect.succeed([entry]) + if (entry.type !== "directory") return Effect.succeed([]) return Effect.gen(function* () { const files = yield* discover(fs, entry.path) return yield* Effect.forEach(files, (file) => diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index b294ab15b94..744f58f7a74 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -19,6 +19,7 @@ export const Plugin = define({ const load = Effect.fn("ConfigCommandPlugin.load")(function* () { return yield* Effect.forEach(yield* config.entries(), (entry) => { if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) + if (entry.type !== "directory") return Effect.succeed([]) return loadDirectory(fs, entry.path).pipe( Effect.map((commands) => [ { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 84546b901cf..de2f28ac9cd 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -17,8 +17,18 @@ export const Plugin = define({ const location = yield* Location.Service const loaded = { entries: yield* config.entries() } yield* ctx.skill.transform((draft) => { + const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : [])) + const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : [])) const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) + for (const directory of [...claude, ...agents]) { + draft.source( + SkillV2.DirectorySource.make({ + type: "directory", + path: AbsolutePath.make(path.join(directory, "skills")), + }), + ) + } for (const directory of directories) { draft.source( SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 439b77fd365..9e8726c4e42 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -92,6 +92,7 @@ const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* ( }), ) } + if (entry.type !== "directory") return Effect.succeed([]) return fs .glob("{plugin,plugins}/*.{ts,js}", { cwd: entry.path, diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 85db0a6bf3b..4df256c86b8 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -44,7 +44,7 @@ function testLayer( ) return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [ [Location.node, locationLayer], - [Global.node, Global.layerWith({ config: globalDirectory })], + [Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })], ...(watcher ? ([[Watcher.node, watcher]] as const) : []), ]) } @@ -112,6 +112,7 @@ describe("Config", () => { info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }), }), new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }), + new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }), new Config.Document({ type: "document", info: new Config.Info({}) }), new Config.Document({ type: "document", @@ -848,11 +849,19 @@ describe("Config", () => { const root = path.join(tmp.path, "repo") const parent = path.join(root, "packages") const directory = path.join(parent, "app") + const globalAgents = path.join(global, "home", ".agents") + const globalClaude = path.join(global, "home", ".claude") return Effect.gen(function* () { yield* Effect.promise(async () => { await fs.mkdir(global, { recursive: true }) + await fs.mkdir(globalAgents, { recursive: true }) + await fs.mkdir(globalClaude, { recursive: true }) await fs.mkdir(directory, { recursive: true }) + await fs.mkdir(path.join(root, ".agents"), { recursive: true }) + await fs.mkdir(path.join(root, ".claude"), { recursive: true }) await fs.mkdir(path.join(root, ".opencode"), { recursive: true }) + await fs.mkdir(path.join(directory, ".agents"), { recursive: true }) + await fs.mkdir(path.join(directory, ".claude"), { recursive: true }) await fs.mkdir(path.join(directory, ".opencode"), { recursive: true }) await Promise.all([ fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })), @@ -878,6 +887,16 @@ describe("Config", () => { AbsolutePath.make(path.join(root, ".opencode")), AbsolutePath.make(path.join(directory, ".opencode")), ]) + expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([ + AbsolutePath.make(globalAgents), + AbsolutePath.make(path.join(directory, ".agents")), + AbsolutePath.make(path.join(root, ".agents")), + ]) + expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([ + AbsolutePath.make(globalClaude), + AbsolutePath.make(path.join(directory, ".claude")), + AbsolutePath.make(path.join(root, ".claude")), + ]) expect(documents.map((document) => document.info.$schema)).toEqual([ "global", "root", @@ -887,6 +906,12 @@ describe("Config", () => { "directory-dot", ]) expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([ + AbsolutePath.make(globalClaude), + AbsolutePath.make(path.join(directory, ".claude")), + AbsolutePath.make(path.join(root, ".claude")), + AbsolutePath.make(globalAgents), + AbsolutePath.make(path.join(directory, ".agents")), + AbsolutePath.make(path.join(root, ".agents")), "global", AbsolutePath.make(global), "root", diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 28ae7956dc7..8b5b0bdf627 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -46,6 +46,8 @@ describe("ConfigSkillPlugin.Plugin", () => { Config.Service.of({ entries: () => Effect.succeed([ + new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }), + new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }), new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }), new Config.Document({ type: "document", @@ -59,6 +61,14 @@ describe("ConfigSkillPlugin.Plugin", () => { ) expect(sources).toEqual([ + SkillV2.DirectorySource.make({ + type: "directory", + path: AbsolutePath.make(path.join("/repo/.claude", "skills")), + }), + SkillV2.DirectorySource.make({ + type: "directory", + path: AbsolutePath.make(path.join("/repo/.agents", "skills")), + }), SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skill")), From 212c9f99ee2d082082a00f7074df950974747a2a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:51:54 -0500 Subject: [PATCH 04/18] test(codemode): add Test262 string coverage (#35940) --- packages/codemode/test/LICENSE.test262 | 28 + .../codemode/test/string-core-test262.test.ts | 580 +++++++++++++ .../test/string-regexp-test262.test.ts | 625 ++++++++++++++ .../test/string-search-test262.test.ts | 794 ++++++++++++++++++ packages/codemode/test/test262-string.md | 69 ++ 5 files changed, 2096 insertions(+) create mode 100644 packages/codemode/test/LICENSE.test262 create mode 100644 packages/codemode/test/string-core-test262.test.ts create mode 100644 packages/codemode/test/string-regexp-test262.test.ts create mode 100644 packages/codemode/test/string-search-test262.test.ts create mode 100644 packages/codemode/test/test262-string.md diff --git a/packages/codemode/test/LICENSE.test262 b/packages/codemode/test/LICENSE.test262 new file mode 100644 index 00000000000..fb7434013ba --- /dev/null +++ b/packages/codemode/test/LICENSE.test262 @@ -0,0 +1,28 @@ +Test262: ECMAScript Test Suite ("Software") is protected by copyright and is being +made available under the "BSD License", included below. This Software may be subject to third party rights (rights +from parties other than Ecma International), including patent rights, and no licenses under such third party rights +are granted under this license even if the third party concerned is a member of Ecma International. SEE THE ECMA +CODE OF CONDUCT IN PATENT MATTERS AVAILABLE AT https://www.ecma-international.org/ipr FOR +INFORMATION REGARDING THE LICENSING OF PATENT CLAIMS THAT ARE REQUIRED TO IMPLEMENT ECMA INTERNATIONAL STANDARDS*. + +Copyright (C) 2012 Ecma International +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +following conditions are met: +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following + disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the + following disclaimer in the documentation and/or other materials provided with the distribution. +3. Neither the name of the authors nor Ecma International may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE ECMA INTERNATIONAL "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT +SHALL ECMA INTERNATIONAL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +* Ecma International Standards hereafter means Ecma International Standards as well as Ecma Technical Reports diff --git a/packages/codemode/test/string-core-test262.test.ts b/packages/codemode/test/string-core-test262.test.ts new file mode 100644 index 00000000000..dc804af8584 --- /dev/null +++ b/packages/codemode/test/string-core-test262.test.ts @@ -0,0 +1,580 @@ +/* + * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75: + * - test/built-ins/String/prototype/toLowerCase/S15.5.4.16_A2_T1.js + * - test/built-ins/String/prototype/toLowerCase/special_casing.js + * - test/built-ins/String/prototype/toLowerCase/special_casing_conditional.js + * - test/built-ins/String/prototype/toLowerCase/Final_Sigma_U180E.js + * - test/built-ins/String/prototype/toLowerCase/supplementary_plane.js + * - test/built-ins/String/prototype/toUpperCase/S15.5.4.18_A2_T1.js + * - test/built-ins/String/prototype/toUpperCase/special_casing.js + * - test/built-ins/String/prototype/toUpperCase/supplementary_plane.js + * - test/built-ins/String/prototype/trim/15.5.4.20-3-1.js + * - test/built-ins/String/prototype/trim/15.5.4.20-3-2.js + * - test/built-ins/String/prototype/trim/15.5.4.20-3-3.js + * - test/built-ins/String/prototype/trim/15.5.4.20-3-4.js + * - test/built-ins/String/prototype/trim/15.5.4.20-3-5.js + * - test/built-ins/String/prototype/trim/15.5.4.20-3-6.js + * - test/built-ins/String/prototype/trim/15.5.4.20-3-7.js + * - test/built-ins/String/prototype/trim/15.5.4.20-3-8.js + * - test/built-ins/String/prototype/trim/15.5.4.20-3-9.js + * - test/built-ins/String/prototype/trim/15.5.4.20-3-10.js + * - test/built-ins/String/prototype/trim/15.5.4.20-3-11.js + * - test/built-ins/String/prototype/trim/15.5.4.20-3-12.js + * - test/built-ins/String/prototype/trim/15.5.4.20-3-13.js + * - test/built-ins/String/prototype/trim/15.5.4.20-3-14.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-1.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-2.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-3.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-4.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-5.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-6.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-8.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-10.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-11.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-12.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-13.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-14.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-16.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-18.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-19.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-20.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-21.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-22.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-24.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-27.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-28.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-29.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-30.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-32.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-34.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-35.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-36.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-37.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-38.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-39.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-40.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-41.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-42.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-43.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-44.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-45.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-46.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-47.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-48.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-49.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-50.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-51.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-52.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-53.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-54.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-55.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-56.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-57.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-58.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-59.js + * - test/built-ins/String/prototype/trim/15.5.4.20-4-60.js + * - test/built-ins/String/prototype/trim/u180e.js + * - test/built-ins/String/prototype/trimStart/this-value-whitespace.js + * - test/built-ins/String/prototype/trimStart/this-value-line-terminator.js + * - test/built-ins/String/prototype/trimEnd/this-value-whitespace.js + * - test/built-ins/String/prototype/trimEnd/this-value-line-terminator.js + * - test/built-ins/String/prototype/repeat/repeat-string-n-times.js + * - test/built-ins/String/prototype/repeat/empty-string-returns-empty.js + * - test/built-ins/String/prototype/repeat/count-is-zero-returns-empty-string.js + * - test/built-ins/String/prototype/repeat/count-coerced-to-zero-returns-empty-string.js + * - test/built-ins/String/prototype/padStart/fill-string-empty.js + * - test/built-ins/String/prototype/padStart/normal-operation.js + * - test/built-ins/String/prototype/padStart/fill-string-omitted.js + * - test/built-ins/String/prototype/padStart/max-length-not-greater-than-string.js + * - test/built-ins/String/prototype/padEnd/fill-string-empty.js + * - test/built-ins/String/prototype/padEnd/normal-operation.js + * - test/built-ins/String/prototype/padEnd/fill-string-omitted.js + * - test/built-ins/String/prototype/padEnd/max-length-not-greater-than-string.js + * - test/built-ins/String/prototype/charAt/S15.5.4.4_A1_T4.js + * - test/built-ins/String/prototype/charAt/S15.5.4.4_A1_T7.js + * - test/built-ins/String/prototype/charAt/S15.5.4.4_A1_T8.js + * - test/built-ins/String/prototype/charAt/S15.5.4.4_A4_T1.js + * - test/built-ins/String/prototype/charAt/S15.5.4.4_A4_T2.js + * - test/built-ins/String/prototype/charAt/S15.5.4.4_A4_T3.js + * - test/built-ins/String/prototype/charAt/S9.4_A1.js + * - test/built-ins/String/prototype/charAt/S9.4_A2.js + * - test/built-ins/String/prototype/charAt/pos-rounding.js + * - test/built-ins/String/prototype/charCodeAt/S15.5.4.5_A1_T4.js + * - test/built-ins/String/prototype/charCodeAt/S15.5.4.5_A1_T7.js + * - test/built-ins/String/prototype/charCodeAt/S15.5.4.5_A1_T8.js + * - test/built-ins/String/prototype/charCodeAt/pos-rounding.js + * - test/built-ins/String/prototype/codePointAt/return-single-code-unit.js + * - test/built-ins/String/prototype/codePointAt/return-first-code-unit.js + * - test/built-ins/String/prototype/codePointAt/return-utf16-decode.js + * - test/built-ins/String/prototype/codePointAt/return-code-unit-coerced-position.js + * - test/built-ins/String/prototype/codePointAt/returns-undefined-on-position-less-than-zero.js + * - test/built-ins/String/prototype/codePointAt/returns-undefined-on-position-equal-or-more-than-size.js + * - test/built-ins/String/prototype/at/returns-code-unit.js + * - test/built-ins/String/prototype/at/returns-item.js + * - test/built-ins/String/prototype/at/returns-item-relative-index.js + * - test/built-ins/String/prototype/at/returns-undefined-for-out-of-range-index.js + * - test/built-ins/String/prototype/at/index-non-numeric-argument-tointeger.js + * - test/built-ins/String/prototype/concat/S15.5.4.6_A1_T4.js + * - test/built-ins/String/prototype/toString/string-primitive.js + * - test/built-ins/String/prototype/normalize/return-normalized-string.js + * - test/built-ins/String/prototype/normalize/return-normalized-string-using-default-parameter.js + * - test/built-ins/String/prototype/normalize/form-is-not-valid-throws.js + * - test/built-ins/String/prototype/localeCompare/15.5.4.9_CE.js + * - test/built-ins/String/fromCharCode/S15.5.3.2_A2.js + * - test/built-ins/String/fromCharCode/S15.5.3.2_A3_T1.js + * - test/built-ins/String/fromCharCode/S9.7_A1.js + * - test/built-ins/String/fromCharCode/S9.7_A2.1.js + * - test/built-ins/String/fromCharCode/S9.7_A2.2.js + * - test/built-ins/String/fromCharCode/S9.7_A3.2_T1.js + * - test/built-ins/String/fromCodePoint/arguments-is-empty.js + * - test/built-ins/String/fromCodePoint/return-string-value.js + * - test/built-ins/String/fromCodePoint/argument-is-not-integer.js + * - test/built-ins/String/fromCodePoint/number-is-out-of-range.js + * + * Copyright 2009 the Sputnik authors. All rights reserved. + * Copyright (C) 2009 the Sputnik authors. All rights reserved. + * Copyright (c) 2012 Ecma International. All rights reserved. + * Copyright 2012 Norbert Lindenberg. All rights reserved. + * Copyright 2012 Mozilla Corporation. All rights reserved. + * Copyright 2013 Microsoft Corporation. All rights reserved. + * Copyright (C) 2015 the V8 project authors. All rights reserved. + * Copyright (C) 2015 André Bargull. All rights reserved. + * Copyright (C) 2016 the V8 project authors. All rights reserved. + * Copyright (C) 2016 André Bargull. All rights reserved. + * Copyright (C) 2016 Jordan Harband. All rights reserved. + * Copyright (C) 2016 Mathias Bynens. All rights reserved. + * Copyright (c) 2017 Valerie Young. All rights reserved. + * Copyright (C) 2017 Valerie Young. All rights reserved. + * Copyright (C) 2020 Rick Waldron. All rights reserved. + * Copyright (C) 2022 Richard Gibson. All rights reserved. + * Test262 portions are governed by the BSD license in LICENSE.test262. + */ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode } from "../src/index.js" + +type Argument = string | number | undefined +type Outcome = "undefined" | "length" | "RangeError" +type Assertion = { + label: string + input?: string + args?: ReadonlyArray + expected?: string | number + outcome?: Outcome +} +type Vector = { + path: string + method: string + static?: boolean + assertions: ReadonlyArray +} + +const value = async (code: string) => { + const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} })) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} + +const literal = (input: Argument) => { + if (input === undefined) return "undefined" + if (typeof input === "string") return JSON.stringify(input) + if (Number.isNaN(input)) return "NaN" + if (input === Infinity) return "Infinity" + if (input === -Infinity) return "-Infinity" + if (Object.is(input, -0)) return "-0" + return JSON.stringify(input) +} + +const vectors: Array = [] +const add = (path: string, method: string, assertions: ReadonlyArray, staticMethod = false) => { + vectors.push({ path, method, assertions, static: staticMethod }) +} +const assertion = (label: string, input: string, expected: string | number, args: ReadonlyArray = []) => ({ + label, + input, + args, + expected, +}) + +add("test/built-ins/String/prototype/toLowerCase/S15.5.4.16_A2_T1.js", "toLowerCase", [ + assertion("#1 direct value", "Hello, WoRlD!", "hello, world!"), + assertion("#2 String value", "Hello, WoRlD!", "hello, world!"), +]) +add("test/built-ins/String/prototype/toLowerCase/special_casing.js", "toLowerCase", [ + assertion( + "103 SpecialCasing mappings", + "\u00DF\u0130\uFB00\uFB01\uFB02\uFB03\uFB04\uFB05\uFB06\u0587\uFB13\uFB14\uFB15\uFB16\uFB17\u0149\u0390\u03B0\u01F0\u1E96\u1E97\u1E98\u1E99\u1E9A\u1F50\u1F52\u1F54\u1F56\u1FB6\u1FC6\u1FD2\u1FD3\u1FD6\u1FD7\u1FE2\u1FE3\u1FE4\u1FE6\u1FE7\u1FF6\u1F80\u1F81\u1F82\u1F83\u1F84\u1F85\u1F86\u1F87\u1F88\u1F89\u1F8A\u1F8B\u1F8C\u1F8D\u1F8E\u1F8F\u1F90\u1F91\u1F92\u1F93\u1F94\u1F95\u1F96\u1F97\u1F98\u1F99\u1F9A\u1F9B\u1F9C\u1F9D\u1F9E\u1F9F\u1FA0\u1FA1\u1FA2\u1FA3\u1FA4\u1FA5\u1FA6\u1FA7\u1FA8\u1FA9\u1FAA\u1FAB\u1FAC\u1FAD\u1FAE\u1FAF\u1FB3\u1FBC\u1FC3\u1FCC\u1FF3\u1FFC\u1FB2\u1FB4\u1FC2\u1FC4\u1FF2\u1FF4\u1FB7\u1FC7\u1FF7", + "\u00DF\u0069\u0307\uFB00\uFB01\uFB02\uFB03\uFB04\uFB05\uFB06\u0587\uFB13\uFB14\uFB15\uFB16\uFB17\u0149\u0390\u03B0\u01F0\u1E96\u1E97\u1E98\u1E99\u1E9A\u1F50\u1F52\u1F54\u1F56\u1FB6\u1FC6\u1FD2\u1FD3\u1FD6\u1FD7\u1FE2\u1FE3\u1FE4\u1FE6\u1FE7\u1FF6\u1F80\u1F81\u1F82\u1F83\u1F84\u1F85\u1F86\u1F87\u1F80\u1F81\u1F82\u1F83\u1F84\u1F85\u1F86\u1F87\u1F90\u1F91\u1F92\u1F93\u1F94\u1F95\u1F96\u1F97\u1F90\u1F91\u1F92\u1F93\u1F94\u1F95\u1F96\u1F97\u1FA0\u1FA1\u1FA2\u1FA3\u1FA4\u1FA5\u1FA6\u1FA7\u1FA0\u1FA1\u1FA2\u1FA3\u1FA4\u1FA5\u1FA6\u1FA7\u1FB3\u1FB3\u1FC3\u1FC3\u1FF3\u1FF3\u1FB2\u1FB4\u1FC2\u1FC4\u1FF2\u1FF4\u1FB7\u1FC7\u1FF7", + ), +]) +add("test/built-ins/String/prototype/toLowerCase/special_casing_conditional.js", "toLowerCase", [ + assertion("single sigma", "\u03A3", "\u03C3"), + assertion("preceded by cased", "A\u03A3", "a\u03C2"), + assertion("preceded by supplementary cased", "\uD835\uDCA2\u03A3", "\uD835\uDCA2\u03C2"), + assertion("preceded by full stop", "A.\u03A3", "a.\u03C2"), + assertion("preceded by soft hyphen", "A\u00AD\u03A3", "a\u00AD\u03C2"), + assertion("preceded by combining mark", "A\uD834\uDE42\u03A3", "a\uD834\uDE42\u03C2"), + assertion("preceded by uncased combining mark", "\u0345\u03A3", "\u0345\u03C3"), + assertion("preceded by cased and combining mark", "\u0391\u0345\u03A3", "\u03B1\u0345\u03C2"), + assertion("followed by cased", "A\u03A3B", "a\u03C3b"), + assertion("followed by supplementary cased", "A\u03A3\uD835\uDCA2", "a\u03C3\uD835\uDCA2"), + assertion("followed by full stop and cased", "A\u03A3.b", "a\u03C3.b"), + assertion("followed by soft hyphen and cased", "A\u03A3\u00ADB", "a\u03C3\u00ADb"), + assertion("followed by combining mark and cased", "A\u03A3\uD834\uDE42B", "a\u03C3\uD834\uDE42b"), + assertion("followed by uncased combining mark", "A\u03A3\u0345", "a\u03C2\u0345"), + assertion("followed by combining mark and cased Greek", "A\u03A3\u0345\u0391", "a\u03C3\u0345\u03B1"), +]) +add("test/built-ins/String/prototype/toLowerCase/Final_Sigma_U180E.js", "toLowerCase", [ + assertion("preceded by U+180E", "A\u180E\u03A3", "a\u180E\u03C2"), + assertion("preceded by U+180E and followed by cased", "A\u180E\u03A3B", "a\u180E\u03C3b"), + assertion("followed by U+180E", "A\u03A3\u180E", "a\u03C2\u180E"), + assertion("followed by U+180E and cased", "A\u03A3\u180EB", "a\u03C3\u180Eb"), + assertion("surrounded by U+180E", "A\u180E\u03A3\u180E", "a\u180E\u03C2\u180E"), + assertion("surrounded by U+180E and followed by cased", "A\u180E\u03A3\u180EB", "a\u180E\u03C3\u180Eb"), +]) +add("test/built-ins/String/prototype/toLowerCase/supplementary_plane.js", "toLowerCase", [ + assertion( + "40 Deseret mappings", + "\uD801\uDC00\uD801\uDC01\uD801\uDC02\uD801\uDC03\uD801\uDC04\uD801\uDC05\uD801\uDC06\uD801\uDC07\uD801\uDC08\uD801\uDC09\uD801\uDC0A\uD801\uDC0B\uD801\uDC0C\uD801\uDC0D\uD801\uDC0E\uD801\uDC0F\uD801\uDC10\uD801\uDC11\uD801\uDC12\uD801\uDC13\uD801\uDC14\uD801\uDC15\uD801\uDC16\uD801\uDC17\uD801\uDC18\uD801\uDC19\uD801\uDC1A\uD801\uDC1B\uD801\uDC1C\uD801\uDC1D\uD801\uDC1E\uD801\uDC1F\uD801\uDC20\uD801\uDC21\uD801\uDC22\uD801\uDC23\uD801\uDC24\uD801\uDC25\uD801\uDC26\uD801\uDC27", + "\uD801\uDC28\uD801\uDC29\uD801\uDC2A\uD801\uDC2B\uD801\uDC2C\uD801\uDC2D\uD801\uDC2E\uD801\uDC2F\uD801\uDC30\uD801\uDC31\uD801\uDC32\uD801\uDC33\uD801\uDC34\uD801\uDC35\uD801\uDC36\uD801\uDC37\uD801\uDC38\uD801\uDC39\uD801\uDC3A\uD801\uDC3B\uD801\uDC3C\uD801\uDC3D\uD801\uDC3E\uD801\uDC3F\uD801\uDC40\uD801\uDC41\uD801\uDC42\uD801\uDC43\uD801\uDC44\uD801\uDC45\uD801\uDC46\uD801\uDC47\uD801\uDC48\uD801\uDC49\uD801\uDC4A\uD801\uDC4B\uD801\uDC4C\uD801\uDC4D\uD801\uDC4E\uD801\uDC4F", + ), +]) +add("test/built-ins/String/prototype/toUpperCase/S15.5.4.18_A2_T1.js", "toUpperCase", [ + assertion("#1 direct value", "Hello, WoRlD!", "HELLO, WORLD!"), + assertion("#2 String value", "Hello, WoRlD!", "HELLO, WORLD!"), +]) +add("test/built-ins/String/prototype/toUpperCase/special_casing.js", "toUpperCase", [ + assertion( + "103 SpecialCasing mappings", + "\u00DF\u0130\uFB00\uFB01\uFB02\uFB03\uFB04\uFB05\uFB06\u0587\uFB13\uFB14\uFB15\uFB16\uFB17\u0149\u0390\u03B0\u01F0\u1E96\u1E97\u1E98\u1E99\u1E9A\u1F50\u1F52\u1F54\u1F56\u1FB6\u1FC6\u1FD2\u1FD3\u1FD6\u1FD7\u1FE2\u1FE3\u1FE4\u1FE6\u1FE7\u1FF6\u1F80\u1F81\u1F82\u1F83\u1F84\u1F85\u1F86\u1F87\u1F88\u1F89\u1F8A\u1F8B\u1F8C\u1F8D\u1F8E\u1F8F\u1F90\u1F91\u1F92\u1F93\u1F94\u1F95\u1F96\u1F97\u1F98\u1F99\u1F9A\u1F9B\u1F9C\u1F9D\u1F9E\u1F9F\u1FA0\u1FA1\u1FA2\u1FA3\u1FA4\u1FA5\u1FA6\u1FA7\u1FA8\u1FA9\u1FAA\u1FAB\u1FAC\u1FAD\u1FAE\u1FAF\u1FB3\u1FBC\u1FC3\u1FCC\u1FF3\u1FFC\u1FB2\u1FB4\u1FC2\u1FC4\u1FF2\u1FF4\u1FB7\u1FC7\u1FF7", + "\u0053\u0053\u0130\u0046\u0046\u0046\u0049\u0046\u004C\u0046\u0046\u0049\u0046\u0046\u004C\u0053\u0054\u0053\u0054\u0535\u0552\u0544\u0546\u0544\u0535\u0544\u053B\u054E\u0546\u0544\u053D\u02BC\u004E\u0399\u0308\u0301\u03A5\u0308\u0301\u004A\u030C\u0048\u0331\u0054\u0308\u0057\u030A\u0059\u030A\u0041\u02BE\u03A5\u0313\u03A5\u0313\u0300\u03A5\u0313\u0301\u03A5\u0313\u0342\u0391\u0342\u0397\u0342\u0399\u0308\u0300\u0399\u0308\u0301\u0399\u0342\u0399\u0308\u0342\u03A5\u0308\u0300\u03A5\u0308\u0301\u03A1\u0313\u03A5\u0342\u03A5\u0308\u0342\u03A9\u0342\u1F08\u0399\u1F09\u0399\u1F0A\u0399\u1F0B\u0399\u1F0C\u0399\u1F0D\u0399\u1F0E\u0399\u1F0F\u0399\u1F08\u0399\u1F09\u0399\u1F0A\u0399\u1F0B\u0399\u1F0C\u0399\u1F0D\u0399\u1F0E\u0399\u1F0F\u0399\u1F28\u0399\u1F29\u0399\u1F2A\u0399\u1F2B\u0399\u1F2C\u0399\u1F2D\u0399\u1F2E\u0399\u1F2F\u0399\u1F28\u0399\u1F29\u0399\u1F2A\u0399\u1F2B\u0399\u1F2C\u0399\u1F2D\u0399\u1F2E\u0399\u1F2F\u0399\u1F68\u0399\u1F69\u0399\u1F6A\u0399\u1F6B\u0399\u1F6C\u0399\u1F6D\u0399\u1F6E\u0399\u1F6F\u0399\u1F68\u0399\u1F69\u0399\u1F6A\u0399\u1F6B\u0399\u1F6C\u0399\u1F6D\u0399\u1F6E\u0399\u1F6F\u0399\u0391\u0399\u0391\u0399\u0397\u0399\u0397\u0399\u03A9\u0399\u03A9\u0399\u1FBA\u0399\u0386\u0399\u1FCA\u0399\u0389\u0399\u1FFA\u0399\u038F\u0399\u0391\u0342\u0399\u0397\u0342\u0399\u03A9\u0342\u0399", + ), +]) +add("test/built-ins/String/prototype/toUpperCase/supplementary_plane.js", "toUpperCase", [ + assertion( + "40 Deseret mappings", + "\uD801\uDC28\uD801\uDC29\uD801\uDC2A\uD801\uDC2B\uD801\uDC2C\uD801\uDC2D\uD801\uDC2E\uD801\uDC2F\uD801\uDC30\uD801\uDC31\uD801\uDC32\uD801\uDC33\uD801\uDC34\uD801\uDC35\uD801\uDC36\uD801\uDC37\uD801\uDC38\uD801\uDC39\uD801\uDC3A\uD801\uDC3B\uD801\uDC3C\uD801\uDC3D\uD801\uDC3E\uD801\uDC3F\uD801\uDC40\uD801\uDC41\uD801\uDC42\uD801\uDC43\uD801\uDC44\uD801\uDC45\uD801\uDC46\uD801\uDC47\uD801\uDC48\uD801\uDC49\uD801\uDC4A\uD801\uDC4B\uD801\uDC4C\uD801\uDC4D\uD801\uDC4E\uD801\uDC4F", + "\uD801\uDC00\uD801\uDC01\uD801\uDC02\uD801\uDC03\uD801\uDC04\uD801\uDC05\uD801\uDC06\uD801\uDC07\uD801\uDC08\uD801\uDC09\uD801\uDC0A\uD801\uDC0B\uD801\uDC0C\uD801\uDC0D\uD801\uDC0E\uD801\uDC0F\uD801\uDC10\uD801\uDC11\uD801\uDC12\uD801\uDC13\uD801\uDC14\uD801\uDC15\uD801\uDC16\uD801\uDC17\uD801\uDC18\uD801\uDC19\uD801\uDC1A\uD801\uDC1B\uD801\uDC1C\uD801\uDC1D\uD801\uDC1E\uD801\uDC1F\uD801\uDC20\uD801\uDC21\uD801\uDC22\uD801\uDC23\uD801\uDC24\uD801\uDC25\uD801\uDC26\uD801\uDC27", + ), +]) + +const whitespace = "\u0009\u000A\u000B\u000C\u000D\u0020\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF" +const lineTerminators = "\u000A\u000D\u2028\u2029" +const trim = (file: string, input: string, expected: string) => + add(`test/built-ins/String/prototype/trim/${file}`, "trim", [assertion("upstream assertion", input, expected)]) + +trim("15.5.4.20-3-1.js", lineTerminators, "") +trim("15.5.4.20-3-2.js", whitespace, "") +trim("15.5.4.20-3-3.js", whitespace + lineTerminators, "") +trim("15.5.4.20-3-4.js", whitespace + lineTerminators + "abc", "abc") +trim("15.5.4.20-3-5.js", "abc" + whitespace + lineTerminators, "abc") +trim("15.5.4.20-3-6.js", whitespace + lineTerminators + "abc" + whitespace + lineTerminators, "abc") +trim("15.5.4.20-3-7.js", "ab" + whitespace + lineTerminators + "cd", "ab" + whitespace + lineTerminators + "cd") +trim("15.5.4.20-3-8.js", "\0\u0000", "\0\u0000") +trim("15.5.4.20-3-9.js", "\0", "\0") +trim("15.5.4.20-3-10.js", "\u0000", "\u0000") +trim("15.5.4.20-3-11.js", "\0\u0000abc", "\0\u0000abc") +trim("15.5.4.20-3-12.js", "abc\0\u0000", "abc\0\u0000") +trim("15.5.4.20-3-13.js", "\0\u0000abc\0\u0000", "\0\u0000abc\0\u0000") +trim("15.5.4.20-3-14.js", "a\0\u0000bc", "a\0\u0000bc") +trim("15.5.4.20-4-1.js", "\u0009a bc \u0009", "a bc") +trim("15.5.4.20-4-2.js", " \u0009abc \u0009", "abc") +trim("15.5.4.20-4-3.js", "\u0009abc", "abc") +trim("15.5.4.20-4-4.js", "\u000Babc", "abc") +trim("15.5.4.20-4-5.js", "\u000Cabc", "abc") +trim("15.5.4.20-4-6.js", "\u0020abc", "abc") +trim("15.5.4.20-4-8.js", "\u00A0abc", "abc") +trim("15.5.4.20-4-10.js", "\uFEFFabc", "abc") +trim("15.5.4.20-4-11.js", "abc\u0009", "abc") +trim("15.5.4.20-4-12.js", "abc\u000B", "abc") +trim("15.5.4.20-4-13.js", "abc\u000C", "abc") +trim("15.5.4.20-4-14.js", "abc\u0020", "abc") +trim("15.5.4.20-4-16.js", "abc\u00A0", "abc") +trim("15.5.4.20-4-18.js", "abc\uFEFF", "abc") +trim("15.5.4.20-4-19.js", "\u0009abc\u0009", "abc") +trim("15.5.4.20-4-20.js", "\u000Babc\u000B", "abc") +trim("15.5.4.20-4-21.js", "\u000Cabc\u000C", "abc") +trim("15.5.4.20-4-22.js", "\u0020abc\u0020", "abc") +trim("15.5.4.20-4-24.js", "\u00A0abc\u00A0", "abc") +trim("15.5.4.20-4-27.js", "\u0009\u0009", "") +trim("15.5.4.20-4-28.js", "\u000B\u000B", "") +trim("15.5.4.20-4-29.js", "\u000C\u000C", "") +trim("15.5.4.20-4-30.js", "\u0020\u0020", "") +trim("15.5.4.20-4-32.js", "\u00A0\u00A0", "") +trim("15.5.4.20-4-34.js", "\uFEFF\uFEFF", "") +trim("15.5.4.20-4-35.js", "ab\u0009c", "ab\u0009c") +trim("15.5.4.20-4-36.js", "ab\u000Bc", "ab\u000Bc") +trim("15.5.4.20-4-37.js", "ab\u000Cc", "ab\u000Cc") +trim("15.5.4.20-4-38.js", "ab\u0020c", "ab\u0020c") +trim("15.5.4.20-4-39.js", "ab\u0085c", "ab\u0085c") +trim("15.5.4.20-4-40.js", "ab\u00A0c", "ab\u00A0c") +trim("15.5.4.20-4-41.js", "ab\u200Bc", "ab\u200Bc") +trim("15.5.4.20-4-42.js", "ab\uFEFFc", "ab\uFEFFc") +trim("15.5.4.20-4-43.js", "\u000Aabc", "abc") +trim("15.5.4.20-4-44.js", "\u000Dabc", "abc") +trim("15.5.4.20-4-45.js", "\u2028abc", "abc") +trim("15.5.4.20-4-46.js", "\u2029abc", "abc") +trim("15.5.4.20-4-47.js", "abc\u000A", "abc") +trim("15.5.4.20-4-48.js", "abc\u000D", "abc") +trim("15.5.4.20-4-49.js", "abc\u2028", "abc") +trim("15.5.4.20-4-50.js", "abc\u2029", "abc") +trim("15.5.4.20-4-51.js", "\u000Aabc\u000A", "abc") +trim("15.5.4.20-4-52.js", "\u000Dabc\u000D", "abc") +trim("15.5.4.20-4-53.js", "\u2028abc\u2028", "abc") +trim("15.5.4.20-4-54.js", "\u2029abc\u2029", "abc") +trim("15.5.4.20-4-55.js", "\u000A\u000A", "") +trim("15.5.4.20-4-56.js", "\u000D\u000D", "") +trim("15.5.4.20-4-57.js", "\u2028\u2028", "") +trim("15.5.4.20-4-58.js", "\u2029\u2029", "") +trim("15.5.4.20-4-59.js", "\u2029 abc", "abc") +trim("15.5.4.20-4-60.js", " ", "") +add("test/built-ins/String/prototype/trim/u180e.js", "trim", [ + assertion("trailing U+180E", "_\u180E", "_\u180E"), + assertion("only U+180E", "\u180E", "\u180E"), + assertion("leading U+180E", "\u180E_", "\u180E_"), +]) + +const directionalWhitespace = "\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF" +add("test/built-ins/String/prototype/trimStart/this-value-whitespace.js", "trimStart", [ + assertion("all whitespace", directionalWhitespace + "a" + directionalWhitespace + "b" + directionalWhitespace, "a" + directionalWhitespace + "b" + directionalWhitespace), +]) +add("test/built-ins/String/prototype/trimStart/this-value-line-terminator.js", "trimStart", [ + assertion("all line terminators", lineTerminators + "a" + lineTerminators + "b" + lineTerminators, "a" + lineTerminators + "b" + lineTerminators), +]) +add("test/built-ins/String/prototype/trimEnd/this-value-whitespace.js", "trimEnd", [ + assertion("all whitespace", directionalWhitespace + "a" + directionalWhitespace + "b" + directionalWhitespace, directionalWhitespace + "a" + directionalWhitespace + "b"), +]) +add("test/built-ins/String/prototype/trimEnd/this-value-line-terminator.js", "trimEnd", [ + assertion("all line terminators", lineTerminators + "a" + lineTerminators + "b" + lineTerminators, lineTerminators + "a" + lineTerminators + "b"), +]) +add("test/built-ins/String/prototype/repeat/repeat-string-n-times.js", "repeat", [ + assertion("repeat once", "abc", "abc", [1]), + assertion("repeat three times", "abc", "abcabcabc", [3]), + { label: "repeat 10000 times length", input: ".", args: [10000], expected: 10000, outcome: "length" }, +]) +add("test/built-ins/String/prototype/repeat/empty-string-returns-empty.js", "repeat", [ + assertion("count 1", "", "", [1]), + assertion("count 3", "", "", [3]), + assertion("maximum 32-bit count", "", "", [0xffffffff]), +]) +add("test/built-ins/String/prototype/repeat/count-is-zero-returns-empty-string.js", "repeat", [ + assertion("zero", "foo", "", [0]), +]) +add("test/built-ins/String/prototype/repeat/count-coerced-to-zero-returns-empty-string.js", "repeat", [ + assertion("fraction truncates to zero", "abc", "", [0.9]), +]) +add("test/built-ins/String/prototype/padStart/fill-string-empty.js", "padStart", [assertion("empty fill", "abc", "abc", [5, ""])]) +add("test/built-ins/String/prototype/padStart/normal-operation.js", "padStart", [ + assertion("truncated multi-character fill", "abc", "defdabc", [7, "def"]), + assertion("single-character fill", "abc", "**abc", [5, "*"]), + assertion("truncated surrogate pair", "abc", "\uD83D\uDCA9\uD83Dabc", [6, "\uD83D\uDCA9"]), +]) +add("test/built-ins/String/prototype/padStart/fill-string-omitted.js", "padStart", [ + assertion("omitted fill", "abc", " abc", [5]), + assertion("undefined fill", "abc", " abc", [5, undefined]), +]) +add("test/built-ins/String/prototype/padStart/max-length-not-greater-than-string.js", "padStart", [ + assertion("NaN", "abc", "abc", [NaN, "def"]), + assertion("negative infinity", "abc", "abc", [-Infinity, "def"]), + assertion("zero", "abc", "abc", [0, "def"]), + assertion("negative one", "abc", "abc", [-1, "def"]), + assertion("equal length", "abc", "abc", [3, "def"]), + assertion("fraction truncates", "abc", "abc", [3.9999, "def"]), +]) +add("test/built-ins/String/prototype/padEnd/fill-string-empty.js", "padEnd", [assertion("empty fill", "abc", "abc", [5, ""])]) +add("test/built-ins/String/prototype/padEnd/normal-operation.js", "padEnd", [ + assertion("truncated multi-character fill", "abc", "abcdefd", [7, "def"]), + assertion("single-character fill", "abc", "abc**", [5, "*"]), + assertion("truncated surrogate pair", "abc", "abc\uD83D\uDCA9\uD83D", [6, "\uD83D\uDCA9"]), +]) +add("test/built-ins/String/prototype/padEnd/fill-string-omitted.js", "padEnd", [ + assertion("omitted fill", "abc", "abc ", [5]), + assertion("undefined fill", "abc", "abc ", [5, undefined]), +]) +add("test/built-ins/String/prototype/padEnd/max-length-not-greater-than-string.js", "padEnd", [ + assertion("NaN", "abc", "abc", [NaN, "def"]), + assertion("negative infinity", "abc", "abc", [-Infinity, "def"]), + assertion("zero", "abc", "abc", [0, "def"]), + assertion("negative one", "abc", "abc", [-1, "def"]), + assertion("equal length", "abc", "abc", [3, "def"]), + assertion("fraction truncates", "abc", "abc", [3.9999, "def"]), +]) + +add("test/built-ins/String/prototype/charAt/S15.5.4.4_A1_T4.js", "charAt", [assertion("omitted position", "lego", "l")]) +add("test/built-ins/String/prototype/charAt/S15.5.4.4_A1_T7.js", "charAt", [assertion("undefined position", "lego", "l", [undefined])]) +add("test/built-ins/String/prototype/charAt/S15.5.4.4_A1_T8.js", "charAt", [assertion("undefined position", "42", "4", [undefined])]) +add("test/built-ins/String/prototype/charAt/S15.5.4.4_A4_T1.js", "charAt", ["A", "B", "C", "A", "B", "C"].map((expected, position) => assertion(`position ${position}`, "ABCABC", expected, [position]))) +add("test/built-ins/String/prototype/charAt/S15.5.4.4_A4_T2.js", "charAt", [-2, -1].map((position) => assertion(`position ${position}`, "ABCABC", "", [position]))) +add("test/built-ins/String/prototype/charAt/S15.5.4.4_A4_T3.js", "charAt", [6, 7].map((position) => assertion(`position ${position}`, "ABCABC", "", [position]))) +add("test/built-ins/String/prototype/charAt/S9.4_A1.js", "charAt", [assertion("NaN position", "abc", "a", [NaN])]) +add("test/built-ins/String/prototype/charAt/S9.4_A2.js", "charAt", [ + assertion("positive zero", "abc", "a", [0]), + assertion("negative zero", "abc", "a", [-0]), +]) +add("test/built-ins/String/prototype/charAt/pos-rounding.js", "charAt", [ + assertion("-0.99999", "abc", "a", [-0.99999]), + assertion("-0.00001", "abc", "a", [-0.00001]), + assertion("0.00001", "abc", "a", [0.00001]), + assertion("0.99999", "abc", "a", [0.99999]), + assertion("1.00001", "abc", "b", [1.00001]), + assertion("1.99999", "abc", "b", [1.99999]), +]) + +add("test/built-ins/String/prototype/charCodeAt/S15.5.4.5_A1_T4.js", "charCodeAt", [assertion("omitted position", "smart", 0x73)]) +add("test/built-ins/String/prototype/charCodeAt/S15.5.4.5_A1_T7.js", "charCodeAt", [assertion("undefined position", "lego", 0x6c, [undefined])]) +add("test/built-ins/String/prototype/charCodeAt/S15.5.4.5_A1_T8.js", "charCodeAt", [assertion("undefined position", "42", 0x34, [undefined])]) +add("test/built-ins/String/prototype/charCodeAt/pos-rounding.js", "charCodeAt", [ + assertion("-0.99999", "abc", 0x61, [-0.99999]), + assertion("-0.00001", "abc", 0x61, [-0.00001]), + assertion("0.00001", "abc", 0x61, [0.00001]), + assertion("0.99999", "abc", 0x61, [0.99999]), + assertion("1.00001", "abc", 0x62, [1.00001]), + assertion("1.99999", "abc", 0x62, [1.99999]), +]) + +add("test/built-ins/String/prototype/codePointAt/return-single-code-unit.js", "codePointAt", [ + assertion("a", "abc", 97, [0]), assertion("b", "abc", 98, [1]), assertion("c", "abc", 99, [2]), + assertion("ordinary BMP", "\uAAAA\uBBBB", 0xaaaa, [0]), assertion("before high-surrogate range", "\uD7FF\uAAAA", 0xd7ff, [0]), + assertion("low surrogate", "\uDC00\uAAAA", 0xdc00, [0]), assertion("trailing D800", "123\uD800", 0xd800, [3]), + assertion("trailing DAAA", "123\uDAAA", 0xdaaa, [3]), assertion("trailing DBFF", "123\uDBFF", 0xdbff, [3]), +]) +add("test/built-ins/String/prototype/codePointAt/return-first-code-unit.js", "codePointAt", [ + assertion("D800 before DBFF", "\uD800\uDBFF", 0xd800, [0]), assertion("D800 before E000", "\uD800\uE000", 0xd800, [0]), + assertion("DAAA before DBFF", "\uDAAA\uDBFF", 0xdaaa, [0]), assertion("DAAA before E000", "\uDAAA\uE000", 0xdaaa, [0]), + assertion("DBFF before DBFF", "\uDBFF\uDBFF", 0xdbff, [0]), assertion("DBFF before E000", "\uDBFF\uE000", 0xdbff, [0]), + assertion("D800 before NUL", "\uD800\u0000", 0xd800, [0]), assertion("D800 before FFFF", "\uD800\uFFFF", 0xd800, [0]), + assertion("DAAA before NUL", "\uDAAA\u0000", 0xdaaa, [0]), assertion("DAAA before FFFF", "\uDAAA\uFFFF", 0xdaaa, [0]), + assertion("DBFF before FFFF", "\uDBFF\uFFFF", 0xdbff, [0]), +]) +add("test/built-ins/String/prototype/codePointAt/return-utf16-decode.js", "codePointAt", [ + assertion("U+10000", "\uD800\uDC00", 65536, [0]), assertion("U+101D0", "\uD800\uDDD0", 66000, [0]), + assertion("U+103FF", "\uD800\uDFFF", 66559, [0]), assertion("U+BA800", "\uDAAA\uDC00", 763904, [0]), + assertion("U+BA9D0", "\uDAAA\uDDD0", 764368, [0]), assertion("U+BABFF", "\uDAAA\uDFFF", 764927, [0]), + assertion("U+10FC00", "\uDBFF\uDC00", 1113088, [0]), assertion("U+10FDD0", "\uDBFF\uDDD0", 1113552, [0]), + assertion("U+10FFFF", "\uDBFF\uDFFF", 1114111, [0]), +]) +add("test/built-ins/String/prototype/codePointAt/return-code-unit-coerced-position.js", "codePointAt", [ + assertion("NaN", "\uD800\uDC00", 65536, [NaN]), assertion("undefined", "\uD800\uDC00", 65536, [undefined]), +]) +add("test/built-ins/String/prototype/codePointAt/returns-undefined-on-position-less-than-zero.js", "codePointAt", [ + { label: "negative one", input: "abc", args: [-1], outcome: "undefined" }, + { label: "negative infinity", input: "abc", args: [-Infinity], outcome: "undefined" }, +]) +add("test/built-ins/String/prototype/codePointAt/returns-undefined-on-position-equal-or-more-than-size.js", "codePointAt", [ + { label: "equal to size", input: "abc", args: [3], outcome: "undefined" }, + { label: "greater than size", input: "abc", args: [4], outcome: "undefined" }, + { label: "positive infinity", input: "abc", args: [Infinity], outcome: "undefined" }, +]) + +add("test/built-ins/String/prototype/at/returns-code-unit.js", "at", [ + assertion("position 0", "12\uD80034", "1", [0]), assertion("position 1", "12\uD80034", "2", [1]), + assertion("unpaired surrogate", "12\uD80034", "\uD800", [2]), assertion("position 3", "12\uD80034", "3", [3]), + assertion("position 4", "12\uD80034", "4", [4]), +]) +add("test/built-ins/String/prototype/at/returns-item.js", "at", ["1", "2", "3", "4", "5"].map((expected, position) => assertion(`position ${position}`, "12345", expected, [position]))) +add("test/built-ins/String/prototype/at/returns-item-relative-index.js", "at", [ + assertion("zero", "12345", "1", [0]), assertion("negative one", "12345", "5", [-1]), + assertion("negative three", "12345", "3", [-3]), assertion("negative four", "12345", "2", [-4]), +]) +add("test/built-ins/String/prototype/at/returns-undefined-for-out-of-range-index.js", "at", [-2, 0, 1].map((position) => ({ label: `position ${position}`, input: "", args: [position], outcome: "undefined" }))) +add("test/built-ins/String/prototype/at/index-non-numeric-argument-tointeger.js", "at", [assertion("undefined", "01", "0", [undefined])]) + +add("test/built-ins/String/prototype/concat/S15.5.4.6_A1_T4.js", "concat", [assertion("no arguments", "lego", "lego")]) +add("test/built-ins/String/prototype/toString/string-primitive.js", "toString", [ + assertion("empty string", "", ""), assertion("non-empty string", "str", "str"), +]) + +add("test/built-ins/String/prototype/normalize/return-normalized-string.js", "normalize", [ + assertion("NFC short", "\u1E9B\u0323", "\u1E9B\u0323", ["NFC"]), + assertion("NFD short", "\u1E9B\u0323", "\u017F\u0323\u0307", ["NFD"]), + assertion("NFKC short", "\u1E9B\u0323", "\u1E69", ["NFKC"]), + assertion("NFKD short", "\u1E9B\u0323", "\u0073\u0323\u0307", ["NFKD"]), + assertion("NFC long", "\u00C5\u2ADC\u0958\u2126\u0344", "\xC5\u2ADD\u0338\u0915\u093C\u03A9\u0308\u0301", ["NFC"]), + assertion("NFD long", "\u00C5\u2ADC\u0958\u2126\u0344", "A\u030A\u2ADD\u0338\u0915\u093C\u03A9\u0308\u0301", ["NFD"]), + assertion("NFKC long", "\u00C5\u2ADC\u0958\u2126\u0344", "\xC5\u2ADD\u0338\u0915\u093C\u03A9\u0308\u0301", ["NFKC"]), + assertion("NFKD long", "\u00C5\u2ADC\u0958\u2126\u0344", "A\u030A\u2ADD\u0338\u0915\u093C\u03A9\u0308\u0301", ["NFKD"]), +]) +add("test/built-ins/String/prototype/normalize/return-normalized-string-using-default-parameter.js", "normalize", [ + assertion("omitted", "\u00C5\u2ADC\u0958\u2126\u0344", "\xC5\u2ADD\u0338\u0915\u093C\u03A9\u0308\u0301"), + assertion("undefined", "\u00C5\u2ADC\u0958\u2126\u0344", "\xC5\u2ADD\u0338\u0915\u093C\u03A9\u0308\u0301", [undefined]), +]) +add("test/built-ins/String/prototype/normalize/form-is-not-valid-throws.js", "normalize", [ + { label: "bar", input: "foo", args: ["bar"], outcome: "RangeError" }, + { label: "NFC1", input: "foo", args: ["NFC1"], outcome: "RangeError" }, +]) + +add("test/built-ins/String/prototype/localeCompare/15.5.4.9_CE.js", "localeCompare", [ + assertion("D70", "o\u0308", 0, ["ö"]), assertion("reordered diaeresis", "ä\u0323", 0, ["a\u0323\u0308"]), + assertion("reordered marks", "a\u0308\u0323", 0, ["a\u0323\u0308"]), assertion("precomposed dot below", "ạ\u0308", 0, ["a\u0323\u0308"]), + assertion("breve after diaeresis", "ä\u0306", 0, ["a\u0308\u0306"]), assertion("diaeresis after breve", "ă\u0308", 0, ["a\u0306\u0308"]), + assertion("Hangul", "\u1111\u1171\u11B6", 0, ["퓛"]), assertion("angstrom compatibility", "Å", 0, ["Å"]), + assertion("angstrom decomposed", "Å", 0, ["A\u030A"]), assertion("reordered horn and dot", "x\u031B\u0323", 0, ["x\u0323\u031B"]), + assertion("Vietnamese precomposed 1", "ự", 0, ["ụ\u031B"]), assertion("Vietnamese decomposed", "ự", 0, ["u\u031B\u0323"]), + assertion("Vietnamese precomposed 2", "ự", 0, ["ư\u0323"]), assertion("Vietnamese reordered", "ự", 0, ["u\u0323\u031B"]), + assertion("cedilla", "Ç", 0, ["C\u0327"]), assertion("q reordered", "q\u0307\u0323", 0, ["q\u0323\u0307"]), + assertion("Hangul syllable", "가", 0, ["\u1100\u1161"]), assertion("ohm", "Ω", 0, ["Ω"]), + assertion("angstrom", "Å", 0, ["A\u030A"]), assertion("circumflex", "ô", 0, ["o\u0302"]), + assertion("s with marks", "ṩ", 0, ["s\u0323\u0307"]), assertion("d composed plus dot", "ḋ\u0323", 0, ["d\u0323\u0307"]), + assertion("d two precompositions", "ḋ\u0323", 0, ["ḍ\u0307"]), +]) + +add("test/built-ins/String/fromCharCode/S15.5.3.2_A2.js", "fromCharCode", [{ label: "no arguments", expected: "" }], true) +add("test/built-ins/String/fromCharCode/S15.5.3.2_A3_T1.js", "fromCharCode", [{ label: "ABBA", args: [65, 66, 66, 65], expected: "ABBA" }], true) +add("test/built-ins/String/fromCharCode/S9.7_A1.js", "fromCharCode", [ + { label: "NaN", args: [NaN], expected: 0 }, { label: "zero", args: [0], expected: 0 }, { label: "negative zero", args: [-0], expected: 0 }, + { label: "positive infinity", args: [Infinity], expected: 0 }, { label: "negative infinity", args: [-Infinity], expected: 0 }, +], true) +add("test/built-ins/String/fromCharCode/S9.7_A2.1.js", "fromCharCode", [ + [0, 0], [1, 1], [-1, 65535], [65535, 65535], [65534, 65534], [65536, 0], [4294967295, 65535], [4294967294, 65534], [4294967296, 0], +].map(([input, expected]) => ({ label: String(input), args: [input!], expected })), true) +add("test/built-ins/String/fromCharCode/S9.7_A2.2.js", "fromCharCode", [ + [-32767, 32769], [-32768, 32768], [-32769, 32767], [-65535, 1], [-65536, 0], [-65537, 65535], [65535, 65535], [65536, 0], [65537, 1], [131071, 65535], [131072, 0], [131073, 1], +].map(([input, expected]) => ({ label: String(input), args: [input!], expected })), true) +add("test/built-ins/String/fromCharCode/S9.7_A3.2_T1.js", "fromCharCode", [ + { label: "positive fraction", args: [1.2345], expected: 1 }, { label: "negative fraction", args: [-5.4321], expected: 65531 }, +], true) + +add("test/built-ins/String/fromCodePoint/arguments-is-empty.js", "fromCodePoint", [{ label: "no arguments", expected: "" }], true) +add("test/built-ins/String/fromCodePoint/return-string-value.js", "fromCodePoint", [ + { label: "NUL", args: [0], expected: "\x00" }, { label: "asterisk", args: [42], expected: "*" }, + { label: "AZ", args: [65, 90], expected: "AZ" }, { label: "Cyrillic", args: [0x404], expected: "\u0404" }, + { label: "hex supplementary", args: [0x2f804], expected: "\uD87E\uDC04" }, { label: "decimal supplementary", args: [194564], expected: "\uD87E\uDC04" }, + { label: "mixed supplementary", args: [0x1d306, 0x61, 0x1d307], expected: "\uD834\uDF06a\uD834\uDF07" }, + { label: "maximum code point", args: [1114111], expected: "\uDBFF\uDFFF" }, +], true) +add("test/built-ins/String/fromCodePoint/argument-is-not-integer.js", "fromCodePoint", [ + { label: "fraction", args: [3.14], outcome: "RangeError" }, { label: "fraction after valid", args: [42, 3.14], outcome: "RangeError" }, +], true) +add("test/built-ins/String/fromCodePoint/number-is-out-of-range.js", "fromCodePoint", [ + { label: "negative one", args: [-1], outcome: "RangeError" }, { label: "negative after valid", args: [1, -1], outcome: "RangeError" }, + { label: "above maximum", args: [1114112], outcome: "RangeError" }, { label: "infinity", args: [Infinity], outcome: "RangeError" }, +], true) + +describe("Test262-adapted core String behavior", () => { + for (const vector of vectors) { + test(vector.path, async () => { + const results = vector.assertions.map((item) => { + const args = (item.args ?? []).map(literal).join(", ") + const expression = vector.static + ? `String.${vector.method}(${args})` + : `${JSON.stringify(item.input)}.${vector.method}(${args})` + const observed = vector.static && vector.method === "fromCharCode" && typeof item.expected === "number" + ? `${expression}.charCodeAt(0)` + : expression + const checked = item.outcome === "undefined" + ? `${observed} === undefined` + : item.outcome === "length" + ? `${observed}.length` + : item.outcome === "RangeError" + ? `(() => { try { ${observed}; return false } catch (error) { return error instanceof RangeError } })()` + : observed + return `{ label: ${JSON.stringify(item.label)}, value: ${checked} }` + }) + const expected = vector.assertions.map((item) => ({ + label: item.label, + value: item.outcome === undefined || item.outcome === "length" ? item.expected! : true, + })) + expect(await value(`return [${results.join(",")}]`)).toEqual(expected) + }) + } +}) diff --git a/packages/codemode/test/string-regexp-test262.test.ts b/packages/codemode/test/string-regexp-test262.test.ts new file mode 100644 index 00000000000..80c89082299 --- /dev/null +++ b/packages/codemode/test/string-regexp-test262.test.ts @@ -0,0 +1,625 @@ +/* + * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75: + * - test/built-ins/String/prototype/split/separator-regexp.js + * - test/built-ins/String/prototype/split/arguments-are-regexp-s-and-3-and-instance-is-string-a-b-c-de-f.js + * - test/built-ins/String/prototype/split/argument-is-regexp-s-and-instance-is-string-a-b-c-de-f.js + * - test/built-ins/String/prototype/split/argument-is-regexp-d-and-instance-is-string-dfe23iu-34-65.js + * - test/built-ins/String/prototype/split/argument-is-regexp-reg-exp-d-and-instance-is-string-dfe23iu-34-65.js + * - test/built-ins/String/prototype/split/argument-is-regexp-a-z-and-instance-is-string-abc.js + * - test/built-ins/String/prototype/split/argument-is-reg-exp-a-z-and-instance-is-string-abc.js + * - test/built-ins/String/prototype/split/arguments-are-regexp-l-and-undefined-and-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/arguments-are-regexp-l-and-0-and-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/arguments-are-regexp-l-and-1-and-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/arguments-are-regexp-l-and-2-and-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/arguments-are-regexp-l-and-3-and-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/arguments-are-regexp-l-and-4-and-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/argument-is-regexp-l-and-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/argument-is-new-reg-exp-and-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/arguments-are-new-reg-exp-and-0-and-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/arguments-are-new-reg-exp-and-1-and-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/arguments-are-new-reg-exp-and-2-and-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/arguments-are-new-reg-exp-and-3-and-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/arguments-are-new-reg-exp-and-4-and-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/arguments-are-new-reg-exp-and-undefined-and-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/call-split-2-instance-is-string-one-two-three-four-five.js + * - test/built-ins/String/prototype/split/separator-regexp-comma-instance-is-string-one-1-two-2-four-4.js + * - test/built-ins/String/prototype/split/argument-is-regexp-x-and-instance-is-string-a-b-c-de-f.js + * - test/built-ins/String/prototype/replace/regexp-capture-by-index.js + * - test/built-ins/String/prototype/replace/S15.5.4.11_A1_T17.js + * - test/built-ins/String/prototype/replace/S15.5.4.11_A2_T1.js + * - test/built-ins/String/prototype/replace/S15.5.4.11_A2_T2.js + * - test/built-ins/String/prototype/replace/S15.5.4.11_A2_T3.js + * - test/built-ins/String/prototype/replace/S15.5.4.11_A2_T4.js + * - test/built-ins/String/prototype/replace/S15.5.4.11_A2_T5.js + * - test/built-ins/String/prototype/replace/S15.5.4.11_A2_T6.js + * - test/built-ins/String/prototype/replace/S15.5.4.11_A2_T7.js + * - test/built-ins/String/prototype/replace/S15.5.4.11_A2_T8.js + * - test/built-ins/String/prototype/replace/S15.5.4.11_A2_T9.js + * - test/built-ins/String/prototype/replace/S15.5.4.11_A2_T10.js + * - test/built-ins/String/prototype/replace/S15.5.4.11_A3_T1.js + * - test/built-ins/String/prototype/replace/S15.5.4.11_A3_T2.js + * - test/built-ins/String/prototype/replace/S15.5.4.11_A3_T3.js + * - test/built-ins/String/prototype/replace/S15.5.4.11_A5_T1.js + * - test/built-ins/String/prototype/replaceAll/searchValue-replacer-RegExp-call.js + * - test/built-ins/String/prototype/replaceAll/searchValue-empty-string.js + * - test/built-ins/String/prototype/replaceAll/searchValue-empty-string-this-empty-string.js + * - test/built-ins/String/prototype/replaceAll/replaceValue-value-replaces-string.js + * - test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024.js + * - test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024-0x0024.js + * - test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024-0x0026.js + * - test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024-0x0060.js + * - test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024-0x0027.js + * - test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024N.js + * - test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024NN.js + * - test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024-0x003C.js + * - test/built-ins/String/prototype/match/S15.5.4.10_A1_T14.js + * - test/built-ins/String/prototype/match/S15.5.4.10_A2_T2.js + * - test/built-ins/String/prototype/match/S15.5.4.10_A2_T3.js + * - test/built-ins/String/prototype/match/S15.5.4.10_A2_T4.js + * - test/built-ins/String/prototype/match/S15.5.4.10_A2_T5.js + * - test/built-ins/String/prototype/match/S15.5.4.10_A2_T6.js + * - test/built-ins/String/prototype/match/S15.5.4.10_A2_T7.js + * - test/built-ins/String/prototype/match/S15.5.4.10_A2_T8.js + * - test/built-ins/String/prototype/match/S15.5.4.10_A2_T12.js + * - test/built-ins/String/prototype/matchAll/regexp-prototype-matchAll-v-u-flag.js + * - test/built-ins/String/prototype/search/S15.5.4.12_A1_T14.js + * - test/built-ins/String/prototype/search/S15.5.4.12_A2_T1.js + * - test/built-ins/String/prototype/search/S15.5.4.12_A2_T2.js + * - test/built-ins/String/prototype/search/S15.5.4.12_A2_T3.js + * - test/built-ins/String/prototype/search/S15.5.4.12_A2_T4.js + * - test/built-ins/String/prototype/search/S15.5.4.12_A2_T5.js + * - test/built-ins/String/prototype/search/S15.5.4.12_A2_T6.js + * - test/built-ins/String/prototype/search/S15.5.4.12_A2_T7.js + * - test/built-ins/String/prototype/search/S15.5.4.12_A3_T1.js + * - test/built-ins/String/prototype/search/S15.5.4.12_A3_T2.js + * + * Copyright 2009 the Sputnik authors. All rights reserved. + * Copyright (C) 2019 Leo Balter. All rights reserved. + * Copyright (C) 2020 Rick Waldron. All rights reserved. + * Copyright (C) 2023 Richard Gibson. All rights reserved. + * Copyright (C) 2024 Tan Meng. All rights reserved. + * Test262 portions are governed by the BSD license in LICENSE.test262. + */ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode } from "../src/index.js" + +type Vector = { + readonly path: string + readonly code: string + readonly expected: CodeMode.DataValue +} + +const value = async (code: string) => { + const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} })) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} + +const run = (name: string, vectors: ReadonlyArray) => { + describe(name, () => { + for (const vector of vectors) { + test(vector.path, async () => { + expect(await value(vector.code)).toEqual(vector.expected) + }) + } + }) +} + +run("Test262-adapted regexp split behavior", [ + { + path: "test/built-ins/String/prototype/split/separator-regexp.js", + code: ` + return [ + "x".split(/^/), "x".split(/$/), "x".split(/.?/), "x".split(/.*/), "x".split(/.+/), + "x".split(/.*?/), "x".split(/.{1}/), "x".split(/.{1,}/), "x".split(/.{1,2}/), + "x".split(/()/), "x".split(/./), "x".split(/(?:)/), "x".split(/(...)/), + "x".split(/(|)/), "x".split(/[]/), "x".split(/[^]/), "x".split(/[.-.]/), + "x".split(/\\0/), "x".split(/\\b/), "x".split(/\\B/), "x".split(/\\d/), + "x".split(/\\D/), "x".split(/\\n/), "x".split(/\\r/), "x".split(/\\s/), + "x".split(/\\S/), "x".split(/\\v/), "x".split(/\\w/), "x".split(/\\W/), + ] + `, + expected: [ + ["x"], ["x"], ["", ""], ["", ""], ["", ""], ["x"], ["", ""], ["", ""], ["", ""], + ["x"], ["", ""], ["x"], ["x"], ["x"], ["x"], ["", ""], ["x"], ["x"], ["x"], + ["x"], ["x"], ["", ""], ["x"], ["x"], ["x"], ["", ""], ["x"], ["", ""], ["x"], + ], + }, + { + path: "test/built-ins/String/prototype/split/arguments-are-regexp-s-and-3-and-instance-is-string-a-b-c-de-f.js", + code: `return "a b c de f".split(/\\s/, 3)`, + expected: ["a", "b", "c"], + }, + { + path: "test/built-ins/String/prototype/split/argument-is-regexp-s-and-instance-is-string-a-b-c-de-f.js", + code: `return "a b c de f".split(/\\s/)`, + expected: ["a", "b", "c", "de", "f"], + }, + { + path: "test/built-ins/String/prototype/split/argument-is-regexp-d-and-instance-is-string-dfe23iu-34-65.js", + code: `return "dfe23iu 34 =+65--".split(/\\d+/)`, + expected: ["dfe", "iu ", " =+", "--"], + }, + { + path: "test/built-ins/String/prototype/split/argument-is-regexp-reg-exp-d-and-instance-is-string-dfe23iu-34-65.js", + code: `return "dfe23iu 34 =+65--".split(new RegExp("\\\\d+"))`, + expected: ["dfe", "iu ", " =+", "--"], + }, + { + path: "test/built-ins/String/prototype/split/argument-is-regexp-a-z-and-instance-is-string-abc.js", + code: `return "abc".split(/[a-z]/)`, + expected: ["", "", "", ""], + }, + { + path: "test/built-ins/String/prototype/split/argument-is-reg-exp-a-z-and-instance-is-string-abc.js", + code: `return "abc".split(new RegExp("[a-z]"))`, + expected: ["", "", "", ""], + }, + { + path: "test/built-ins/String/prototype/split/arguments-are-regexp-l-and-undefined-and-instance-is-string-hello.js", + code: `return "hello".split(/l/, undefined)`, + expected: ["he", "", "o"], + }, + { + path: "test/built-ins/String/prototype/split/arguments-are-regexp-l-and-0-and-instance-is-string-hello.js", + code: `return "hello".split(/l/, 0)`, + expected: [], + }, + { + path: "test/built-ins/String/prototype/split/arguments-are-regexp-l-and-1-and-instance-is-string-hello.js", + code: `return "hello".split(/l/, 1)`, + expected: ["he"], + }, + { + path: "test/built-ins/String/prototype/split/arguments-are-regexp-l-and-2-and-instance-is-string-hello.js", + code: `return "hello".split(/l/, 2)`, + expected: ["he", ""], + }, + { + path: "test/built-ins/String/prototype/split/arguments-are-regexp-l-and-3-and-instance-is-string-hello.js", + code: `return "hello".split(/l/, 3)`, + expected: ["he", "", "o"], + }, + { + path: "test/built-ins/String/prototype/split/arguments-are-regexp-l-and-4-and-instance-is-string-hello.js", + code: `return "hello".split(/l/, 4)`, + expected: ["he", "", "o"], + }, + { + path: "test/built-ins/String/prototype/split/argument-is-regexp-l-and-instance-is-string-hello.js", + code: `return "hello".split(/l/)`, + expected: ["he", "", "o"], + }, + { + path: "test/built-ins/String/prototype/split/argument-is-new-reg-exp-and-instance-is-string-hello.js", + code: `return "hello".split(new RegExp())`, + expected: ["h", "e", "l", "l", "o"], + }, + { + path: "test/built-ins/String/prototype/split/arguments-are-new-reg-exp-and-0-and-instance-is-string-hello.js", + code: `return "hello".split(new RegExp(), 0)`, + expected: [], + }, + { + path: "test/built-ins/String/prototype/split/arguments-are-new-reg-exp-and-1-and-instance-is-string-hello.js", + code: `return "hello".split(new RegExp(), 1)`, + expected: ["h"], + }, + { + path: "test/built-ins/String/prototype/split/arguments-are-new-reg-exp-and-2-and-instance-is-string-hello.js", + code: `return "hello".split(new RegExp(), 2)`, + expected: ["h", "e"], + }, + { + path: "test/built-ins/String/prototype/split/arguments-are-new-reg-exp-and-3-and-instance-is-string-hello.js", + code: `return "hello".split(new RegExp(), 3)`, + expected: ["h", "e", "l"], + }, + { + path: "test/built-ins/String/prototype/split/arguments-are-new-reg-exp-and-4-and-instance-is-string-hello.js", + code: `return "hello".split(new RegExp(), 4)`, + expected: ["h", "e", "l", "l"], + }, + { + path: "test/built-ins/String/prototype/split/arguments-are-new-reg-exp-and-undefined-and-instance-is-string-hello.js", + code: `return "hello".split(new RegExp(), undefined)`, + expected: ["h", "e", "l", "l", "o"], + }, + { + path: "test/built-ins/String/prototype/split/call-split-2-instance-is-string-one-two-three-four-five.js", + code: `return "one two three four five".split(/ /, 2)`, + expected: ["one", "two"], + }, + { + path: "test/built-ins/String/prototype/split/separator-regexp-comma-instance-is-string-one-1-two-2-four-4.js", + code: `return "one-1,two-2,four-4".split(/,/)`, + expected: ["one-1", "two-2", "four-4"], + }, + { + path: "test/built-ins/String/prototype/split/argument-is-regexp-x-and-instance-is-string-a-b-c-de-f.js", + code: `return "a b c de f".split(/X/)`, + expected: ["a b c de f"], + }, +]) + +run("Test262-adapted replace behavior", [ + { + path: "test/built-ins/String/prototype/replace/regexp-capture-by-index.js", + code: ` + const str = "foo-x-bar" + const patterns = ["x", /x/, /(x)/, /(x)($^)?/, /((((((((((x))))))))))/] + const replacements = ["|$0|", "|$00|", "|$000|", "|$1|", "|$01|", "|$010|", "|$2|", "|$02|", "|$020|", "|$10|", "|$100|", "|$20|", "|$200|"] + return replacements.flatMap((replacement) => patterns.map((pattern) => str.replace(pattern, replacement))) + `, + expected: [ + "foo-|$0|-bar", "foo-|$0|-bar", "foo-|$0|-bar", "foo-|$0|-bar", "foo-|$0|-bar", + "foo-|$00|-bar", "foo-|$00|-bar", "foo-|$00|-bar", "foo-|$00|-bar", "foo-|$00|-bar", + "foo-|$000|-bar", "foo-|$000|-bar", "foo-|$000|-bar", "foo-|$000|-bar", "foo-|$000|-bar", + "foo-|$1|-bar", "foo-|$1|-bar", "foo-|x|-bar", "foo-|x|-bar", "foo-|x|-bar", + "foo-|$01|-bar", "foo-|$01|-bar", "foo-|x|-bar", "foo-|x|-bar", "foo-|x|-bar", + "foo-|$010|-bar", "foo-|$010|-bar", "foo-|x0|-bar", "foo-|x0|-bar", "foo-|x0|-bar", + "foo-|$2|-bar", "foo-|$2|-bar", "foo-|$2|-bar", "foo-||-bar", "foo-|x|-bar", + "foo-|$02|-bar", "foo-|$02|-bar", "foo-|$02|-bar", "foo-||-bar", "foo-|x|-bar", + "foo-|$020|-bar", "foo-|$020|-bar", "foo-|$020|-bar", "foo-|0|-bar", "foo-|x0|-bar", + "foo-|$10|-bar", "foo-|$10|-bar", "foo-|x0|-bar", "foo-|x0|-bar", "foo-|x|-bar", + "foo-|$100|-bar", "foo-|$100|-bar", "foo-|x00|-bar", "foo-|x00|-bar", "foo-|x0|-bar", + "foo-|$20|-bar", "foo-|$20|-bar", "foo-|$20|-bar", "foo-|0|-bar", "foo-|x0|-bar", + "foo-|$200|-bar", "foo-|$200|-bar", "foo-|$200|-bar", "foo-|00|-bar", "foo-|x00|-bar", + ], + }, + { + path: "test/built-ins/String/prototype/replace/S15.5.4.11_A1_T17.js", + code: `return "asdf".replace(new RegExp(undefined, "g"), "1")`, + expected: "1a1s1d1f1", + }, + { + path: "test/built-ins/String/prototype/replace/S15.5.4.11_A2_T1.js", + code: `return "She sells seashells by the seashore.".replace(/sh/g, "sch")`, + expected: "She sells seaschells by the seaschore.", + }, + { + path: "test/built-ins/String/prototype/replace/S15.5.4.11_A2_T2.js", + code: `return "She sells seashells by the seashore.".replace(/sh/g, "$$sch")`, + expected: "She sells sea$schells by the sea$schore.", + }, + { + path: "test/built-ins/String/prototype/replace/S15.5.4.11_A2_T3.js", + code: `return "She sells seashells by the seashore.".replace(/sh/g, "$&sch")`, + expected: "She sells seashschells by the seashschore.", + }, + { + path: "test/built-ins/String/prototype/replace/S15.5.4.11_A2_T4.js", + code: `return "She sells seashells by the seashore.".replace(/sh/g, "$\`sch")`, + expected: "She sells seaShe sells seaschells by the seaShe sells seashells by the seaschore.", + }, + { + path: "test/built-ins/String/prototype/replace/S15.5.4.11_A2_T5.js", + code: `return "She sells seashells by the seashore.".replace(/sh/g, "$'sch")`, + expected: "She sells seaells by the seashore.schells by the seaore.schore.", + }, + { + path: "test/built-ins/String/prototype/replace/S15.5.4.11_A2_T6.js", + code: `return "She sells seashells by the seashore.".replace(/sh/, "sch")`, + expected: "She sells seaschells by the seashore.", + }, + { + path: "test/built-ins/String/prototype/replace/S15.5.4.11_A2_T7.js", + code: `return "She sells seashells by the seashore.".replace(/sh/, "$$sch")`, + expected: "She sells sea$schells by the seashore.", + }, + { + path: "test/built-ins/String/prototype/replace/S15.5.4.11_A2_T8.js", + code: `return "She sells seashells by the seashore.".replace(/sh/, "$&sch")`, + expected: "She sells seashschells by the seashore.", + }, + { + path: "test/built-ins/String/prototype/replace/S15.5.4.11_A2_T9.js", + code: `return "She sells seashells by the seashore.".replace(/sh/, "$\`sch")`, + expected: "She sells seaShe sells seaschells by the seashore.", + }, + { + path: "test/built-ins/String/prototype/replace/S15.5.4.11_A2_T10.js", + code: `return "She sells seashells by the seashore.".replace(/sh/, "$'sch")`, + expected: "She sells seaells by the seashore.schells by the seashore.", + }, + { + path: "test/built-ins/String/prototype/replace/S15.5.4.11_A3_T1.js", + code: `return "uid=31".replace(/(uid=)(\\d+)/, "$1115")`, + expected: "uid=115", + }, + { + path: "test/built-ins/String/prototype/replace/S15.5.4.11_A3_T2.js", + code: `return "uid=31".replace(/(uid=)(\\d+)/, "$1115")`, + expected: "uid=115", + }, + { + path: "test/built-ins/String/prototype/replace/S15.5.4.11_A3_T3.js", + code: `return "uid=31".replace(/(uid=)(\\d+)/, "$11A15")`, + expected: "uid=1A15", + }, + { + path: "test/built-ins/String/prototype/replace/S15.5.4.11_A5_T1.js", + code: `return "aaaaaaaaaa,aaaaaaaaaaaaaaa".replace(/^(a+)\\1*,\\1+$/, "$1")`, + expected: "aaaaa", + }, +]) + +run("Test262-adapted replaceAll behavior", [ + { + path: "test/built-ins/String/prototype/replaceAll/searchValue-replacer-RegExp-call.js", + code: ` + return [ + "abc abc abc".replaceAll(new RegExp("b", "g"), "z"), + "abc abc abc".replaceAll(new RegExp("b", "gy"), "z"), + "abc abc abc".replaceAll(new RegExp("b", "giy"), "z"), + "No Uppercase!".replaceAll(new RegExp("[A-Z]", "g"), ""), + "No Uppercase?".replaceAll(new RegExp("[A-Z]", "gy"), ""), + "NO UPPERCASE!".replaceAll(new RegExp("[A-Z]", "gy"), ""), + "abcabcabcabc".replaceAll(new RegExp("a(b)(ca)", "g"), "$2-$1"), + "abcabcabcabc".replaceAll(new RegExp("(a(.))", "g"), "$1$2$3"), + "aabacadaeafagahaiajakalamano a azaya".replaceAll(new RegExp("(((((((((((((a(.).).).).).).).).))))))", "g"), "($10)-($12)-($1)"), + "abcba".replaceAll(new RegExp("b", "g"), "$'"), + "abcba".replaceAll(new RegExp("b", "g"), "$\`"), + "abcba".replaceAll(new RegExp("(?b)", "g"), "($)"), + "abcba".replaceAll(new RegExp("(?b)", "g"), "($b)", "g"), "($)"), + "abcabcabcabc".replaceAll(new RegExp("a(b)(ca)", "g"), "($$)"), + "abcabcabcabc".replaceAll(new RegExp("a(b)(ca)", "g"), "($)"), + "abcabcabcabc".replaceAll(new RegExp("a(b)(ca)", "g"), "($$$$)"), + "abcabcabcabc".replaceAll(new RegExp("a(b)(ca)", "g"), "($$$)"), + "abcabcabcabc".replaceAll(new RegExp("a(b)(ca)", "g"), "($$&)"), + "abcabcabcabc".replaceAll(new RegExp("a(b)(ca)", "g"), "($$1)"), + "abcabcabcabc".replaceAll(new RegExp("a(b)(ca)", "g"), "($$\`)"), + "abcabcabcabc".replaceAll(new RegExp("a(b)(ca)", "g"), "($$')"), + "abcabcabcabc".replaceAll(new RegExp("a(?b)(ca)", "g"), "($$)"), + "abcabcabcabc".replaceAll(new RegExp("a(b)(ca)", "g"), "($&)"), + ] + `, + expected: [ + "azc azc azc", "abc abc abc", "abc abc abc", "o ppercase!", "o Uppercase?", " UPPERCASE!", + "ca-bbcca-bbc", "abb$3cabb$3cabb$3cabb$3c", + "(aabaca)-(aaba)-(aabacadaea)f(agahai)-(agah)-(agahaiajak)(alaman)-(alam)-(alamano a )azaya", + "acbacaa", "aacabca", "a(b)c(b)a", "a($)bc($)bc", "(abca)bc(abca)bc", + ], + }, + { + path: "test/built-ins/String/prototype/replaceAll/searchValue-empty-string.js", + code: `return ["aab c \\nx".replaceAll("", "_"), "a".replaceAll("", "_")]`, + expected: ["_a_a_b_ _c_ _ _\n_x_", "_a_"], + }, + { + path: "test/built-ins/String/prototype/replaceAll/searchValue-empty-string-this-empty-string.js", + code: `return "".replaceAll("", "abc")`, + expected: "abc", + }, + { + path: "test/built-ins/String/prototype/replaceAll/replaceValue-value-replaces-string.js", + code: `return ["aaab a a aac".replaceAll("aa", "z"), "aaab a a aac".replaceAll("aa", "a"), "aaab a a aac".replaceAll("a", "a"), "aaab a a aac".replaceAll("a", "z")]`, + expected: ["zab a a zc", "aab a a ac", "aaab a a aac", "zzzb z z zzc"], + }, + { + path: "test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024.js", + code: ` + const str = "Ninguém é igual a ninguém. Todo o ser humano é um estranho ímpar." + return [str.replaceAll("ninguém", "$"), str.replaceAll("é", "$"), str.replaceAll("é", "$ -"), str.replaceAll("é", "$$$")] + `, + expected: [ + "Ninguém é igual a $. Todo o ser humano é um estranho ímpar.", + "Ningu$m $ igual a ningu$m. Todo o ser humano $ um estranho ímpar.", + "Ningu$ -m $ - igual a ningu$ -m. Todo o ser humano $ - um estranho ímpar.", + "Ningu$$m $$ igual a ningu$$m. Todo o ser humano $$ um estranho ímpar.", + ], + }, + { + path: "test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024-0x0024.js", + code: ` + const str = "Ninguém é igual a ninguém. Todo o ser humano é um estranho ímpar." + return [str.replaceAll("ninguém", "$$"), str.replaceAll("é", "$$"), str.replaceAll("é", "$$ -"), str.replaceAll("é", "$$&"), str.replaceAll("é", "$$$"), str.replaceAll("é", "$$$$")] + `, + expected: [ + "Ninguém é igual a $. Todo o ser humano é um estranho ímpar.", + "Ningu$m $ igual a ningu$m. Todo o ser humano $ um estranho ímpar.", + "Ningu$ -m $ - igual a ningu$ -m. Todo o ser humano $ - um estranho ímpar.", + "Ningu$&m $& igual a ningu$&m. Todo o ser humano $& um estranho ímpar.", + "Ningu$$m $$ igual a ningu$$m. Todo o ser humano $$ um estranho ímpar.", + "Ningu$$m $$ igual a ningu$$m. Todo o ser humano $$ um estranho ímpar.", + ], + }, + { + path: "test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024-0x0026.js", + code: ` + const str = "Ninguém é igual a ninguém. Todo o ser humano é um estranho ímpar." + return [str.replaceAll("ninguém", "$&"), str.replaceAll("ninguém", "($&)"), str.replaceAll("é", "($&)"), str.replaceAll("é", "($&) $&")] + `, + expected: [ + "Ninguém é igual a ninguém. Todo o ser humano é um estranho ímpar.", + "Ninguém é igual a (ninguém). Todo o ser humano é um estranho ímpar.", + "Ningu(é)m (é) igual a ningu(é)m. Todo o ser humano (é) um estranho ímpar.", + "Ningu(é) ém (é) é igual a ningu(é) ém. Todo o ser humano (é) é um estranho ímpar.", + ], + }, + { + path: "test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024-0x0060.js", + code: ` + const str = "Ninguém é igual a ninguém. Todo o ser humano é um estranho ímpar." + return [str.replaceAll("ninguém", "$\`"), str.replaceAll("Ninguém", "$\`"), str.replaceAll("ninguém", "($\`)"), str.replaceAll("é", "($\`)")] + `, + expected: [ + "Ninguém é igual a Ninguém é igual a . Todo o ser humano é um estranho ímpar.", + " é igual a ninguém. Todo o ser humano é um estranho ímpar.", + "Ninguém é igual a (Ninguém é igual a ). Todo o ser humano é um estranho ímpar.", + "Ningu(Ningu)m (Ninguém ) igual a ningu(Ninguém é igual a ningu)m. Todo o ser humano (Ninguém é igual a ninguém. Todo o ser humano ) um estranho ímpar.", + ], + }, + { + path: "test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024-0x0027.js", + code: ` + const str = "Ninguém é igual a ninguém. Todo o ser humano é um estranho ímpar." + return [str.replaceAll("ninguém", "$'"), str.replaceAll(".", "--- $'"), str.replaceAll("é", "($')")] + `, + expected: [ + "Ninguém é igual a . Todo o ser humano é um estranho ímpar.. Todo o ser humano é um estranho ímpar.", + "Ninguém é igual a ninguém--- Todo o ser humano é um estranho ímpar. Todo o ser humano é um estranho ímpar--- ", + "Ningu(m é igual a ninguém. Todo o ser humano é um estranho ímpar.)m ( igual a ninguém. Todo o ser humano é um estranho ímpar.) igual a ningu(m. Todo o ser humano é um estranho ímpar.)m. Todo o ser humano ( um estranho ímpar.) um estranho ímpar.", + ], + }, + { + path: "test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024N.js", + code: ` + const str = "ABC AAA ABC AAA" + return ["$1", "$2", "$3", "$4", "$5", "$6", "$7", "$8", "$9"].map((replacement) => str.replaceAll("ABC", replacement)) + `, + expected: ["$1 AAA $1 AAA", "$2 AAA $2 AAA", "$3 AAA $3 AAA", "$4 AAA $4 AAA", "$5 AAA $5 AAA", "$6 AAA $6 AAA", "$7 AAA $7 AAA", "$8 AAA $8 AAA", "$9 AAA $9 AAA"], + }, + { + path: "test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024NN.js", + code: ` + const str = "aaaaaaaaaaaaaaaa aaaaaaaa aaaaaaaaaaaaaaaa" + return [str.replaceAll("a", "$11"), str.replaceAll("a", "$29")] + `, + expected: [ + "$11$11$11$11$11$11$11$11$11$11$11$11$11$11$11$11 $11$11$11$11$11$11$11$11 $11$11$11$11$11$11$11$11$11$11$11$11$11$11$11$11", + "$29$29$29$29$29$29$29$29$29$29$29$29$29$29$29$29 $29$29$29$29$29$29$29$29 $29$29$29$29$29$29$29$29$29$29$29$29$29$29$29$29", + ], + }, + { + path: "test/built-ins/String/prototype/replaceAll/getSubstitution-0x0024-0x003C.js", + code: `return "aaaaaaaaaaaaaaaa aaaaaaaa aaaaaaaaaaaaaaaa".replaceAll("a", "$<")`, + expected: "$<$<$<$<$<$<$<$<$<$<$<$<$<$<$<$< $<$<$<$<$<$<$<$< $<$<$<$<$<$<$<$<$<$<$<$<$<$<$<$<", + }, +]) + +run("Test262-adapted match behavior", [ + { + path: "test/built-ins/String/prototype/match/S15.5.4.10_A1_T14.js", + code: `const match = "ABBABABAB77BBAA".match(new RegExp("77")); return [match[0], match.index]`, + expected: ["77", 9], + }, + { + path: "test/built-ins/String/prototype/match/S15.5.4.10_A2_T2.js", + code: `return "343443444".match(/34/g)`, + expected: ["34", "34", "34"], + }, + { + path: "test/built-ins/String/prototype/match/S15.5.4.10_A2_T3.js", + code: `return "123456abcde7890".match(/\\d{1}/g)`, + expected: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"], + }, + { + path: "test/built-ins/String/prototype/match/S15.5.4.10_A2_T4.js", + code: `return "123456abcde7890".match(/\\d{2}/g)`, + expected: ["12", "34", "56", "78", "90"], + }, + { + path: "test/built-ins/String/prototype/match/S15.5.4.10_A2_T5.js", + code: `return "123456abcde7890".match(/\\D{2}/g)`, + expected: ["ab", "cd"], + }, + { + path: "test/built-ins/String/prototype/match/S15.5.4.10_A2_T6.js", + code: `const match = "Boston, Mass. 02134".match(/([\\d]{5})([- ]?[\\d]{4})?$/); return [match[0], match[1], match[2] === undefined, match.length, match.index]`, + expected: ["02134", "02134", true, 3, 14], + }, + { + path: "test/built-ins/String/prototype/match/S15.5.4.10_A2_T7.js", + code: `return "Boston, Mass. 02134".match(/([\\d]{5})([- ]?[\\d]{4})?$/g)`, + expected: ["02134"], + }, + { + path: "test/built-ins/String/prototype/match/S15.5.4.10_A2_T8.js", + code: `const match = "Boston, MA 02134".match(/([\\d]{5})([- ]?[\\d]{4})?$/); return [match[0], match[1], match[2] === undefined, match.length, match.index]`, + expected: ["02134", "02134", true, 3, 11], + }, + { + path: "test/built-ins/String/prototype/match/S15.5.4.10_A2_T12.js", + code: `return "Boston, MA 02134".match(/([\\d]{5})([- ]?[\\d]{4})?$/g)`, + expected: ["02134"], + }, +]) + +run("Test262-adapted matchAll behavior", [ + { + path: "test/built-ins/String/prototype/matchAll/regexp-prototype-matchAll-v-u-flag.js", + code: ` + const text = "𠮷a𠮷b𠮷" + const collect = (regex) => { + const matches = text.matchAll(regex) + return matches.map((match) => match[0]).concat(matches.map((match) => match.index)) + } + const empty = text.matchAll(/(?:)/gu) + const complex = "a𠮷b􏿿c".matchAll(/\\P{ASCII}/gu) + return [ + collect(/𠮷/g), + collect(/𠮷/gu), + collect(/\\p{Script=Han}/gu), + collect(/./gu), + empty.map((match) => match[0]).concat(empty.map((match) => match.index)).length, + complex.map((match) => match[0]), + ] + `, + expected: [ + ["𠮷", "𠮷", "𠮷", 0, 3, 6], + ["𠮷", "𠮷", "𠮷", 0, 3, 6], + ["𠮷", "𠮷", "𠮷", 0, 3, 6], + ["𠮷", "a", "𠮷", "b", "𠮷", 0, 2, 3, 5, 6], + 12, + ["𠮷", "􏿿"], + ], + }, +]) + +run("Test262-adapted search behavior", [ + { + path: "test/built-ins/String/prototype/search/S15.5.4.12_A1_T14.js", + code: `return "ABBABABAB77BBAA".search(new RegExp("77"))`, + expected: 9, + }, + { + path: "test/built-ins/String/prototype/search/S15.5.4.12_A2_T1.js", + code: `return "test string".search("string")`, + expected: 5, + }, + { + path: "test/built-ins/String/prototype/search/S15.5.4.12_A2_T2.js", + code: `return "test string".search("String")`, + expected: -1, + }, + { + path: "test/built-ins/String/prototype/search/S15.5.4.12_A2_T3.js", + code: `return "test string".search(/String/i)`, + expected: 5, + }, + { + path: "test/built-ins/String/prototype/search/S15.5.4.12_A2_T4.js", + code: `return "one two three four five".search(/Four/)`, + expected: -1, + }, + { + path: "test/built-ins/String/prototype/search/S15.5.4.12_A2_T5.js", + code: `return "one two three four five".search(/four/)`, + expected: 14, + }, + { + path: "test/built-ins/String/prototype/search/S15.5.4.12_A2_T6.js", + code: `return "test string".search("notexist")`, + expected: -1, + }, + { + path: "test/built-ins/String/prototype/search/S15.5.4.12_A2_T7.js", + code: `return "test string probe".search("string pro")`, + expected: 5, + }, + { + path: "test/built-ins/String/prototype/search/S15.5.4.12_A3_T1.js", + code: `const text = "power of the power of the power of the great sword"; return [text.search(/the/), text.search(/the/g)]`, + expected: [9, 9], + }, + { + path: "test/built-ins/String/prototype/search/S15.5.4.12_A3_T2.js", + code: `const text = "power of the power of the power of the great sword"; return [text.search(/of/), text.search(/of/g)]`, + expected: [6, 6], + }, +]) diff --git a/packages/codemode/test/string-search-test262.test.ts b/packages/codemode/test/string-search-test262.test.ts new file mode 100644 index 00000000000..f332ea16715 --- /dev/null +++ b/packages/codemode/test/string-search-test262.test.ts @@ -0,0 +1,794 @@ +/* + * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75: + * - test/built-ins/String/prototype/split/call-split-l-0-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/call-split-l-1-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/call-split-l-2-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/call-split-l-3-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/call-split-l-4-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/call-split-l-na-n-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/call-split-l-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/call-split-ll-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/call-split-h-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/call-split-hello-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/call-split-hellothere-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/call-split-o-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/call-split-x-instance-is-string-hello.js + * - test/built-ins/String/prototype/split/call-split-x-instance-is-empty-string.js + * - test/built-ins/String/prototype/split/call-split-4-instance-is-string-one-1-two-2-four-4.js + * - test/built-ins/String/prototype/split/call-split-on-instance-is-string-one-1-two-2-four-4.js + * - test/built-ins/String/prototype/split/call-split-instance-is-string-one-two-three-four-five.js + * - test/built-ins/String/prototype/split/call-split-instance-is-string-one-two-three.js + * - test/built-ins/String/prototype/split/call-split-instance-is-string.js + * - test/built-ins/String/prototype/split/instance-is-string-one-two-three-four-five.js + * - test/built-ins/String/prototype/split/instance-is-string.js + * - test/built-ins/String/prototype/split/separator-colon-instance-is-string-one-1-two-2-four-4.js + * - test/built-ins/String/prototype/split/separator-comma-instance-is-string-one-two-three-four-five.js + * - test/built-ins/String/prototype/split/separator-empty-string-instance-is-string.js + * - test/built-ins/String/prototype/split/call-split-without-arguments-and-instance-is-empty-string.js + * - test/built-ins/String/prototype/split/separator-undef.js + * - test/built-ins/String/prototype/slice/S15.5.4.13_A1_T6.js + * - test/built-ins/String/prototype/slice/S15.5.4.13_A1_T14.js + * - test/built-ins/String/prototype/slice/S15.5.4.13_A2_T1.js + * - test/built-ins/String/prototype/slice/S15.5.4.13_A2_T2.js + * - test/built-ins/String/prototype/slice/S15.5.4.13_A2_T3.js + * - test/built-ins/String/prototype/slice/S15.5.4.13_A2_T4.js + * - test/built-ins/String/prototype/slice/S15.5.4.13_A2_T5.js + * - test/built-ins/String/prototype/slice/S15.5.4.13_A2_T6.js + * - test/built-ins/String/prototype/slice/S15.5.4.13_A2_T7.js + * - test/built-ins/String/prototype/slice/S15.5.4.13_A2_T8.js + * - test/built-ins/String/prototype/slice/S15.5.4.13_A2_T9.js + * - test/built-ins/String/prototype/substring/S15.5.4.15_A1_T6.js + * - test/built-ins/String/prototype/substring/S15.5.4.15_A1_T14.js + * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T1.js + * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T2.js + * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T3.js + * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T4.js + * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T5.js + * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T6.js + * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T7.js + * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T8.js + * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T9.js + * - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T10.js + * - test/annexB/built-ins/String/prototype/substr/start-negative.js + * - test/annexB/built-ins/String/prototype/substr/length-negative.js + * - test/annexB/built-ins/String/prototype/substr/length-positive.js + * - test/annexB/built-ins/String/prototype/substr/length-falsey.js + * - test/annexB/built-ins/String/prototype/substr/length-undef.js + * - test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js + * - test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js + * - test/built-ins/String/prototype/includes/String.prototype.includes_SuccessNoLocation.js + * - test/built-ins/String/prototype/includes/String.prototype.includes_FailBadLocation.js + * - test/built-ins/String/prototype/includes/String.prototype.includes_FailLocation.js + * - test/built-ins/String/prototype/includes/String.prototype.includes_Success.js + * - test/built-ins/String/prototype/includes/searchstring-found-with-position.js + * - test/built-ins/String/prototype/includes/searchstring-found-without-position.js + * - test/built-ins/String/prototype/includes/searchstring-not-found-with-position.js + * - test/built-ins/String/prototype/includes/searchstring-not-found-without-position.js + * - test/built-ins/String/prototype/includes/return-false-with-out-of-bounds-position.js + * - test/built-ins/String/prototype/includes/return-true-if-searchstring-is-empty.js + * - test/built-ins/String/prototype/includes/coerced-values-of-position.js + * - test/built-ins/String/prototype/startsWith/searchstring-found-with-position.js + * - test/built-ins/String/prototype/startsWith/searchstring-found-without-position.js + * - test/built-ins/String/prototype/startsWith/searchstring-not-found-with-position.js + * - test/built-ins/String/prototype/startsWith/searchstring-not-found-without-position.js + * - test/built-ins/String/prototype/startsWith/out-of-bounds-position.js + * - test/built-ins/String/prototype/startsWith/return-true-if-searchstring-is-empty.js + * - test/built-ins/String/prototype/startsWith/coerced-values-of-position.js + * - test/built-ins/String/prototype/endsWith/String.prototype.endsWith_Success.js + * - test/built-ins/String/prototype/endsWith/String.prototype.endsWith_Success_2.js + * - test/built-ins/String/prototype/endsWith/String.prototype.endsWith_Success_3.js + * - test/built-ins/String/prototype/endsWith/String.prototype.endsWith_Success_4.js + * - test/built-ins/String/prototype/endsWith/String.prototype.endsWith_Fail.js + * - test/built-ins/String/prototype/endsWith/String.prototype.endsWith_Fail_2.js + * - test/built-ins/String/prototype/endsWith/searchstring-found-with-position.js + * - test/built-ins/String/prototype/endsWith/searchstring-found-without-position.js + * - test/built-ins/String/prototype/endsWith/searchstring-not-found-with-position.js + * - test/built-ins/String/prototype/endsWith/searchstring-not-found-without-position.js + * - test/built-ins/String/prototype/endsWith/return-false-if-search-start-is-less-than-zero.js + * - test/built-ins/String/prototype/endsWith/return-true-if-searchstring-is-empty.js + * - test/built-ins/String/prototype/endsWith/coerced-values-of-position.js + * - test/built-ins/String/prototype/indexOf/S15.5.4.7_A2_T1.js + * - test/built-ins/String/prototype/indexOf/S15.5.4.7_A2_T2.js + * - test/built-ins/String/prototype/indexOf/S15.5.4.7_A2_T3.js + * - test/built-ins/String/prototype/indexOf/S15.5.4.7_A2_T4.js + * - test/built-ins/String/prototype/indexOf/S15.5.4.7_A3_T1.js + * - test/built-ins/String/prototype/indexOf/S15.5.4.7_A3_T3.js + * - test/built-ins/String/prototype/indexOf/position-tointeger.js + * - test/built-ins/String/prototype/indexOf/searchstring-tostring.js + * - test/built-ins/String/prototype/lastIndexOf/not-a-substring.js + * + * Copyright 2009 the Sputnik authors. All rights reserved. + * Copyright (c) 2014 Ryan Lewis. All rights reserved. + * Copyright (C) 2015 the V8 project authors. All rights reserved. + * Copyright (C) 2016 the V8 project authors. All rights reserved. + * Copyright (C) 2017 Josh Wolfe. All rights reserved. + * Copyright (C) 2020 Leo Balter. All rights reserved. + * Copyright (C) 2026 Garham Lee. All rights reserved. + * Test262 portions are governed by the BSD license in LICENSE.test262. + */ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode } from "../src/index.js" + +const value = async (code: string) => { + const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} })) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} + +const cases = [ + { + path: "test/built-ins/String/prototype/split/call-split-l-0-instance-is-string-hello.js", + code: `const result = "hello".split("l", 0); return [result.length, result[0] === undefined]`, + expected: [0, true], + labels: [ + "The value of __split.length is expected to equal the value of __expected.length", + "The value of __split[0] is expected to equal the value of __expected[0]", + ], + }, + { + path: "test/built-ins/String/prototype/split/call-split-l-1-instance-is-string-hello.js", + code: `const result = "hello".split("l", 1); return [result.length, result[0]]`, + expected: [1, "he"], + labels: [ + "The value of __split.length is expected to equal the value of __expected.length", + "The value of __split[0] is expected to equal the value of __expected[0]", + ], + }, + { + path: "test/built-ins/String/prototype/split/call-split-l-2-instance-is-string-hello.js", + code: `const result = "hello".split("l", 2); return [result.length, result[0], result[1]]`, + expected: [2, "he", ""], + labels: [ + "The value of __split.length is expected to equal the value of __expected.length", + "The value of __split[index] is expected to equal the value of __expected[index]", + "The value of __split[index] is expected to equal the value of __expected[index]", + ], + }, + { + path: "test/built-ins/String/prototype/split/call-split-l-3-instance-is-string-hello.js", + code: `const result = "hello".split("l", 3); return [result.length, result[0], result[1], result[2]]`, + expected: [3, "he", "", "o"], + labels: [ + "The value of __split.length is expected to equal the value of __expected.length", + "The value of __split[index] is expected to equal the value of __expected[index]", + "The value of __split[index] is expected to equal the value of __expected[index]", + "The value of __split[index] is expected to equal the value of __expected[index]", + ], + }, + { + path: "test/built-ins/String/prototype/split/call-split-l-4-instance-is-string-hello.js", + code: `const result = "hello".split("l", 4); return [result.length, result[0], result[1], result[2]]`, + expected: [3, "he", "", "o"], + labels: [ + "The value of __split.length is expected to equal the value of __expected.length", + "The value of __split[index] is expected to equal the value of __expected[index]", + "The value of __split[index] is expected to equal the value of __expected[index]", + "The value of __split[index] is expected to equal the value of __expected[index]", + ], + }, + { + path: "test/built-ins/String/prototype/split/call-split-l-na-n-instance-is-string-hello.js", + code: `const result = "hello".split("l", NaN); return [result.length, result[0] === undefined]`, + expected: [0, true], + labels: [ + "The value of __split.length is expected to equal the value of __expected.length", + "The value of __split[0] is expected to equal the value of __expected[0]", + ], + }, + { + path: "test/built-ins/String/prototype/split/call-split-l-instance-is-string-hello.js", + code: `const result = "hello".split("l"); return [result.length, result[0], result[1], result[2]]`, + expected: [3, "he", "", "o"], + labels: [ + "The value of __split.length is 3", + 'The value of __split[0] is "he"', + 'The value of __split[1] is ""', + 'The value of __split[2] is "o"', + ], + }, + { + path: "test/built-ins/String/prototype/split/call-split-ll-instance-is-string-hello.js", + code: `const result = "hello".split("ll"); return [result.length, result[0], result[1]]`, + expected: [2, "he", "o"], + labels: ["The value of __split.length is 2", 'The value of __split[0] is "he"', 'The value of __split[1] is "o"'], + }, + { + path: "test/built-ins/String/prototype/split/call-split-h-instance-is-string-hello.js", + code: `const result = "hello".split("h"); return [result.length, result[0], result[1]]`, + expected: [2, "", "ello"], + labels: ["The value of __split.length is 2", 'The value of __split[0] is ""', 'The value of __split[1] is "ello"'], + }, + { + path: "test/built-ins/String/prototype/split/call-split-hello-instance-is-string-hello.js", + code: `const result = "hello".split("hello"); return [result.length, result[0], result[1]]`, + expected: [2, "", ""], + labels: ["The value of __split.length is 2", 'The value of __split[0] is ""', 'The value of __split[1] is ""'], + }, + { + path: "test/built-ins/String/prototype/split/call-split-hellothere-instance-is-string-hello.js", + code: `const result = "hello".split("hellothere"); return [result.length, result[0]]`, + expected: [1, "hello"], + labels: ["The value of __split.length is 1", 'The value of __split[0] is "hello"'], + }, + { + path: "test/built-ins/String/prototype/split/call-split-o-instance-is-string-hello.js", + code: `const result = "hello".split("o"); return [result.length, result[0], result[1]]`, + expected: [2, "hell", ""], + labels: ["The value of __split.length is 2", 'The value of __split[0] is "hell"', 'The value of __split[1] is ""'], + }, + { + path: "test/built-ins/String/prototype/split/call-split-x-instance-is-string-hello.js", + code: `const result = "hello".split("x"); return [result.length, result[0]]`, + expected: [1, "hello"], + labels: ["The value of __split.length is 1", 'The value of __split[0] is "hello"'], + }, + { + path: "test/built-ins/String/prototype/split/call-split-x-instance-is-empty-string.js", + code: `const result = "".split("x"); return [result.length, result[0]]`, + expected: [1, ""], + labels: ["The value of __split.length is 1", 'The value of __split[0] is ""'], + }, + { + path: "test/built-ins/String/prototype/split/call-split-4-instance-is-string-one-1-two-2-four-4.js", + code: `const result = "one-1 two-2 four-4".split("-4"); return [result.length, result[0], result[1]]`, + expected: [2, "one-1 two-2 four", ""], + labels: [ + "The value of __split.length is 2", + 'The value of __split[0] is "one-1 two-2 four"', + 'The value of __split[1] is ""', + ], + }, + { + path: "test/built-ins/String/prototype/split/call-split-on-instance-is-string-one-1-two-2-four-4.js", + code: `const result = "one-1 two-2 four-4".split("on"); return [result.length, result[0], result[1]]`, + expected: [2, "", "e-1 two-2 four-4"], + labels: [ + "The value of __split.length is 2", + 'The value of __split[0] is ""', + 'The value of __split[1] is "e-1 two-2 four-4"', + ], + }, + { + path: "test/built-ins/String/prototype/split/call-split-instance-is-string-one-two-three-four-five.js", + code: `const result = "one two three four five".split(" "); return [result.length, ...result]`, + expected: [5, "one", "two", "three", "four", "five"], + labels: [ + "The value of __split.length is 5", 'The value of __split[0] is "one"', 'The value of __split[1] is "two"', + 'The value of __split[2] is "three"', 'The value of __split[3] is "four"', 'The value of __split[4] is "five"', + ], + }, + { + path: "test/built-ins/String/prototype/split/call-split-instance-is-string-one-two-three.js", + code: `const result = "one two three".split(""); return [result[0], result[1], result[11], result[12]]`, + expected: ["o", "n", "e", "e"], + labels: [ + 'The value of __split[0] is "o"', 'The value of __split[1] is "n"', + 'The value of __split[11] is "e"', 'The value of __split[12] is "e"', + ], + }, + { + path: "test/built-ins/String/prototype/split/call-split-instance-is-string.js", + code: `const result = " ".split(" "); return [result.length, result[0], result[1]]`, + expected: [2, "", ""], + labels: ["The value of __split.length is 2", 'The value of __split[0] is ""', 'The value of __split[1] is ""'], + }, + { + path: "test/built-ins/String/prototype/split/instance-is-string-one-two-three-four-five.js", + code: `const result = "one,two,three,four,five".split(); return [result.length, result[0]]`, + expected: [1, "one,two,three,four,five"], + labels: ["The value of __split.length is 1", 'The value of __split[0] is "one,two,three,four,five"'], + }, + { + path: "test/built-ins/String/prototype/split/instance-is-string.js", + code: `const result = " ".split(); return [result.length, result[0]]`, + expected: [1, " "], + labels: ["The value of __split.length is 1", 'The value of __split[0] is " "'], + }, + { + path: "test/built-ins/String/prototype/split/separator-colon-instance-is-string-one-1-two-2-four-4.js", + code: `const result = "one-1,two-2,four-4".split(":"); return [result.length, result[0]]`, + expected: [1, "one-1,two-2,four-4"], + labels: ["The value of __split.length is 1", 'The value of __split[0] is "one-1,two-2,four-4"'], + }, + { + path: "test/built-ins/String/prototype/split/separator-comma-instance-is-string-one-two-three-four-five.js", + code: `const result = "one,two,three,four,five".split(","); return [result.length, ...result]`, + expected: [5, "one", "two", "three", "four", "five"], + labels: [ + "The value of __split.length is 5", + 'The value of __split[0] is "one"', + 'The value of __split[1] is "two"', + 'The value of __split[2] is "three"', + 'The value of __split[3] is "four"', + 'The value of __split[4] is "five"', + ], + }, + { + path: "test/built-ins/String/prototype/split/separator-empty-string-instance-is-string.js", + code: `const result = " ".split(""); return [result.length, result[0]]`, + expected: [1, " "], + labels: ["The value of __split.length is 1", 'The value of __split[0] is " "'], + }, + { + path: "test/built-ins/String/prototype/split/call-split-without-arguments-and-instance-is-empty-string.js", + code: `const result = "".split(); return [result.length, result[0]]`, + expected: [1, ""], + labels: ["The value of __split.length is 1", 'The value of __split[0] is ""'], + }, + { + path: "test/built-ins/String/prototype/split/separator-undef.js", + code: `const result = "undefined is not a function".split(); return [Array.isArray(result), result.length, result[0]]`, + expected: [true, 1, "undefined is not a function"], + labels: ["implicit separator, result is array", "implicit separator, result.length", "implicit separator, [0] is the same string"], + }, + { + path: "test/built-ins/String/prototype/slice/S15.5.4.13_A1_T6.js", + code: `return ["undefined".slice(undefined, 3)]`, + expected: ["und"], + labels: ['#1: new String("undefined").slice(x,3) === "und"'], + }, + { + path: "test/built-ins/String/prototype/slice/S15.5.4.13_A1_T14.js", + code: `return ["report".slice(undefined)]`, + expected: ["report"], + labels: ['#1: "report".slice(function(){}()) === "report"'], + }, + { + path: "test/built-ins/String/prototype/slice/S15.5.4.13_A2_T1.js", + code: `return [typeof "this is a string object".slice()]`, + expected: ["string"], + labels: ['#1: typeof __string.slice() === "string"'], + }, + { + path: "test/built-ins/String/prototype/slice/S15.5.4.13_A2_T2.js", + code: `return ["this is a string object".slice(NaN, Infinity)]`, + expected: ["this is a string object"], + labels: ['#1: __string.slice(NaN, Infinity) === "this is a string object"'], + }, + { + path: "test/built-ins/String/prototype/slice/S15.5.4.13_A2_T3.js", + code: `return ["".slice(1, 0)]`, + expected: [""], + labels: ['#1: __string.slice(1,0) === ""'], + }, + { + path: "test/built-ins/String/prototype/slice/S15.5.4.13_A2_T4.js", + code: `return ["this is a string object".slice(Infinity, NaN)]`, + expected: [""], + labels: ['#1: __string.slice(Infinity, NaN) === ""'], + }, + { + path: "test/built-ins/String/prototype/slice/S15.5.4.13_A2_T5.js", + code: `return ["this is a string object".slice(Infinity, Infinity)]`, + expected: [""], + labels: ['#1: __string.slice(Infinity, Infinity) === ""'], + }, + { + path: "test/built-ins/String/prototype/slice/S15.5.4.13_A2_T6.js", + code: `return ["this is a string object".slice(-0.01, 0)]`, + expected: [""], + labels: ['#1: __string.slice(-0.01,0) === ""'], + }, + { + path: "test/built-ins/String/prototype/slice/S15.5.4.13_A2_T7.js", + code: `const text = "this is a string object"; return [text.slice(text.length, text.length)]`, + expected: [""], + labels: ['#1: __string.slice(__string.length, __string.length) === ""'], + }, + { + path: "test/built-ins/String/prototype/slice/S15.5.4.13_A2_T8.js", + code: `const text = "this is a string object"; return [text.slice(text.length + 1, 0)]`, + expected: [""], + labels: ['#1: __string.slice(__string.length+1, 0) === ""'], + }, + { + path: "test/built-ins/String/prototype/slice/S15.5.4.13_A2_T9.js", + code: `return ["this is a string object".slice(-Infinity, -Infinity)]`, + expected: [""], + labels: ['#1: __string.slice(-Infinity, -Infinity) === ""'], + }, + { + path: "test/built-ins/String/prototype/substring/S15.5.4.15_A1_T6.js", + code: `return ["undefined".substring(undefined, 3)]`, + expected: ["und"], + labels: ['#1: new String("undefined").substring(x,3) === "und"'], + }, + { + path: "test/built-ins/String/prototype/substring/S15.5.4.15_A1_T14.js", + code: `return ["report".substring(undefined)]`, + expected: ["report"], + labels: ['#1: "report".substring(function(){}()) === "report"'], + }, + { + path: "test/built-ins/String/prototype/substring/S15.5.4.15_A2_T1.js", + code: `return [typeof "this is a string object".substring()]`, + expected: ["string"], + labels: ['#1: typeof __string.substring() === "string"'], + }, + { + path: "test/built-ins/String/prototype/substring/S15.5.4.15_A2_T2.js", + code: `return ["this is a string object".substring(NaN, Infinity)]`, + expected: ["this is a string object"], + labels: ['#1: __string.substring(NaN, Infinity) === "this is a string object"'], + }, + { + path: "test/built-ins/String/prototype/substring/S15.5.4.15_A2_T3.js", + code: `return ["".substring(1, 0)]`, + expected: [""], + labels: ['#1: __string.substring(1,0) === ""'], + }, + { + path: "test/built-ins/String/prototype/substring/S15.5.4.15_A2_T4.js", + code: `return ["this is a string object".substring(Infinity, NaN)]`, + expected: ["this is a string object"], + labels: ['#1: __string.substring(Infinity, NaN) === "this is a string object"'], + }, + { + path: "test/built-ins/String/prototype/substring/S15.5.4.15_A2_T5.js", + code: `return ["this is a string object".substring(Infinity, Infinity)]`, + expected: [""], + labels: ['#1: __string.substring(Infinity, Infinity) === ""'], + }, + { + path: "test/built-ins/String/prototype/substring/S15.5.4.15_A2_T6.js", + code: `return ["this is a string object".substring(-0.01, 0)]`, + expected: [""], + labels: ['#1: __string.substring(-0.01,0) === ""'], + }, + { + path: "test/built-ins/String/prototype/substring/S15.5.4.15_A2_T7.js", + code: `const text = "this is a string object"; return [text.substring(text.length, text.length)]`, + expected: [""], + labels: ['#1: __string.substring(__string.length, __string.length) === ""'], + }, + { + path: "test/built-ins/String/prototype/substring/S15.5.4.15_A2_T8.js", + code: `const text = "this is a string object"; return [text.substring(text.length + 1, 0)]`, + expected: ["this is a string object"], + labels: ['#1: __string.substring(__string.length+1, 0) === "this is a string object"'], + }, + { + path: "test/built-ins/String/prototype/substring/S15.5.4.15_A2_T9.js", + code: `return ["this is a string object".substring(-Infinity, -Infinity)]`, + expected: [""], + labels: ['#1: __string.substring(-Infinity, -Infinity) === ""'], + }, + { + path: "test/built-ins/String/prototype/substring/S15.5.4.15_A2_T10.js", + code: `return ["this_is_a_string object".substring(0, 8)]`, + expected: ["this_is_"], + labels: ['#1: __string.substring(0,8) === "this_is_"'], + }, + { + path: "test/annexB/built-ins/String/prototype/substr/start-negative.js", + code: `return ["abc".substr(-1), "abc".substr(-2), "abc".substr(-3), "abc".substr(-4), "abc".substr(-1.1)]`, + expected: ["c", "bc", "abc", "abc", "c"], + labels: ["-1", "-2", "-3", "size + intStart < 0", "floating point rounding semantics"], + }, + { + path: "test/annexB/built-ins/String/prototype/substr/length-negative.js", + code: `return [ + "abc".substr(0, -1), "abc".substr(0, -2), "abc".substr(0, -3), "abc".substr(0, -4), + "abc".substr(1, -1), "abc".substr(1, -2), "abc".substr(1, -3), "abc".substr(1, -4), + "abc".substr(2, -1), "abc".substr(2, -2), "abc".substr(2, -3), "abc".substr(2, -4), + "abc".substr(3, -1), "abc".substr(3, -2), "abc".substr(3, -3), "abc".substr(3, -4), + ]`, + expected: ["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""], + labels: [ + "0, -1", "0, -2", "0, -3", "0, -4", "1, -1", "1, -2", "1, -3", "1, -4", + "2, -1", "2, -2", "2, -3", "2, -4", "3, -1", "3, -2", "3, -3", "3, -4", + ], + }, + { + path: "test/annexB/built-ins/String/prototype/substr/length-positive.js", + code: `return [ + "abc".substr(0, 1), "abc".substr(0, 2), "abc".substr(0, 3), "abc".substr(0, 4), + "abc".substr(1, 1), "abc".substr(1, 2), "abc".substr(1, 3), "abc".substr(1, 4), + "abc".substr(2, 1), "abc".substr(2, 2), "abc".substr(2, 3), "abc".substr(2, 4), + "abc".substr(3, 1), "abc".substr(3, 2), "abc".substr(3, 3), "abc".substr(3, 4), + ]`, + expected: ["a", "ab", "abc", "abc", "b", "bc", "bc", "bc", "c", "c", "c", "c", "", "", "", ""], + labels: [ + "0, 1", "0, 1", "0, 1", "0, 1", "1, 1", "1, 1", "1, 1", "1, 1", + "2, 1", "2, 1", "2, 1", "2, 1", "3, 1", "3, 1", "3, 1", "3, 1", + ], + }, + { + path: "test/annexB/built-ins/String/prototype/substr/length-falsey.js", + code: `return ["abc".substr(0, NaN), "abc".substr(1, NaN), "abc".substr(2, NaN), "abc".substr(3, NaN)]`, + expected: ["", "", "", ""], + labels: ["start: 0, length: NaN", "start: 1, length: NaN", "start: 2, length: NaN", "start: 3, length: NaN"], + }, + { + path: "test/annexB/built-ins/String/prototype/substr/length-undef.js", + code: `return [ + "abc".substr(0), "abc".substr(1), "abc".substr(2), "abc".substr(3), + "abc".substr(0, undefined), "abc".substr(1, undefined), "abc".substr(2, undefined), "abc".substr(3, undefined), + ]`, + expected: ["abc", "bc", "c", "", "abc", "bc", "c", ""], + labels: [ + "start: 0, length: unspecified", "start: 1, length: unspecified", "start: 2, length: unspecified", "start: 3, length: unspecified", + "start: 0, length: undefined", "start: 1, length: undefined", "start: 2, length: undefined", "start: 3, length: undefined", + ], + }, + { + path: "test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js", + code: `return [ + "\uD834\uDF06".substr(0), "\uD834\uDF06".substr(1), "\uD834\uDF06".substr(2), + "\uD834\uDF06".substr(0, 0), "\uD834\uDF06".substr(0, 1), "\uD834\uDF06".substr(0, 2), + ]`, + expected: ["\uD834\uDF06", "\uDF06", "", "", "\uD834", "\uD834\uDF06"], + labels: ["start: 0", "start: 1", "start: 2", "end: 0", "end: 1", "end: 2"], + }, + { + path: "test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js", + code: `return ["word".includes("a", 0)]`, expected: [false], labels: ['"word".includes("a", 0)'], + }, + { + path: "test/built-ins/String/prototype/includes/String.prototype.includes_SuccessNoLocation.js", + code: `return ["word".includes("w")]`, expected: [true], labels: ['"word".includes("w")'], + }, + { + path: "test/built-ins/String/prototype/includes/String.prototype.includes_FailBadLocation.js", + code: `return ["word".includes("w", 5)]`, expected: [false], labels: ['"word".includes("w", 5)'], + }, + { + path: "test/built-ins/String/prototype/includes/String.prototype.includes_FailLocation.js", + code: `return ["word".includes("o", 3)]`, expected: [false], labels: ['"word".includes("o", 3)'], + }, + { + path: "test/built-ins/String/prototype/includes/String.prototype.includes_Success.js", + code: `return ["word".includes("w", 0)]`, expected: [true], labels: ['"word".includes("w", 0)'], + }, + { + path: "test/built-ins/String/prototype/includes/searchstring-found-with-position.js", + code: `const text = "The future is cool!"; return [text.includes("The future", 0), text.includes(" is ", 1), text.includes("cool!", 10)]`, + expected: [true, true, true], + labels: [ + 'Returns true for str.includes("The future", 0)', + 'Returns true for str.includes(" is ", 1)', + 'Returns true for str.includes("cool!", 10)', + ], + }, + { + path: "test/built-ins/String/prototype/includes/searchstring-found-without-position.js", + code: `const text = "The future is cool!"; return [text.includes("The future"), text.includes("is cool!"), text.includes(text)]`, + expected: [true, true, true], + labels: [ + 'Returns true for str.includes("The future")', + 'Returns true for str.includes("is cool!")', + "Returns true for str.includes(str)", + ], + }, + { + path: "test/built-ins/String/prototype/includes/searchstring-not-found-with-position.js", + code: `const text = "The future is cool!"; return [text.includes("The future", 1), text.includes(text, 1)]`, + expected: [false, false], + labels: ['Returns false on str.includes("The future", 1)', "Returns false on str.includes(str, 1)"], + }, + { + path: "test/built-ins/String/prototype/includes/searchstring-not-found-without-position.js", + code: `const text = "The future is cool!"; return [text.includes("Flash"), text.includes("FUTURE")]`, + expected: [false, false], labels: ["Flash if not included", "includes is case sensitive"], + }, + { + path: "test/built-ins/String/prototype/includes/return-false-with-out-of-bounds-position.js", + code: `const text = "The future is cool!"; return [ + text.includes("!", text.length + 1), text.includes("!", 100), text.includes("!", Infinity), text.includes("!", text.length), + ]`, + expected: [false, false, false, false], + labels: [ + 'str.includes("!", str.length + 1) returns false', 'str.includes("!", 100) returns false', + 'str.includes("!", Infinity) returns false', 'str.includes("!", str.length) returns false', + ], + }, + { + path: "test/built-ins/String/prototype/includes/return-true-if-searchstring-is-empty.js", + code: `const text = "The future is cool!"; return [text.includes("", text.length), text.includes(""), text.includes("", Infinity)]`, + expected: [true, true, true], + labels: ['str.includes("", str.length) returns true', 'str.includes("") returns true', 'str.includes("", Infinity) returns true'], + }, + { + path: "test/built-ins/String/prototype/includes/coerced-values-of-position.js", + code: `const text = "The future is cool!"; return [ + text.includes("The future", NaN), text.includes("The future", undefined), text.includes("The future", 0.4), + text.includes("The future", -1), text.includes("The future", 1.4), + ]`, + expected: [true, true, true, true, false], + labels: ["NaN coerced to 0", "undefined coerced to 0", "0.4 coerced to 0", "negative position", "1.4 coerced to 1"], + }, + { + path: "test/built-ins/String/prototype/startsWith/searchstring-found-with-position.js", + code: `const text = "The future is cool!"; return [text.startsWith("The future", 0), text.startsWith("future", 4), text.startsWith(" is cool!", 10)]`, + expected: [true, true, true], + labels: [ + 'str.startsWith("The future", 0) === true', 'str.startsWith("future", 4) === true', + 'str.startsWith(" is cool!", 10) === true', + ], + }, + { + path: "test/built-ins/String/prototype/startsWith/searchstring-found-without-position.js", + code: `const text = "The future is cool!"; return [text.startsWith("The "), text.startsWith("The future"), text.startsWith(text)]`, + expected: [true, true, true], + labels: ['str.startsWith("The ") === true', 'str.startsWith("The future") === true', "str.startsWith(str) === true"], + }, + { + path: "test/built-ins/String/prototype/startsWith/searchstring-not-found-with-position.js", + code: `const text = "The future is cool!"; return [text.startsWith("The future", 1), text.startsWith(text, 1)]`, + expected: [false, false], + labels: ['str.startsWith("The future", 1) === false', "str.startsWith(str, 1) === false"], + }, + { + path: "test/built-ins/String/prototype/startsWith/searchstring-not-found-without-position.js", + code: `const text = "The future is cool!"; return [text.startsWith("Flash"), text.startsWith("THE FUTURE"), text.startsWith("future is cool!")]`, + expected: [false, false, false], + labels: ['str.startsWith("Flash") === false', "startsWith is case sensitive", 'str.startsWith("future is cool!") === false'], + }, + { + path: "test/built-ins/String/prototype/startsWith/out-of-bounds-position.js", + code: `const text = "The future is cool!"; return [ + text.startsWith("!", text.length), text.startsWith("!", 100), text.startsWith("!", Infinity), + text.startsWith("The future", -1), text.startsWith("The future", -Infinity), + ]`, + expected: [false, false, false, true, true], + labels: [ + 'str.startsWith("!", str.length) returns false', 'str.startsWith("!", 100) returns false', + 'str.startsWith("!", Infinity) returns false', "position argument < 0 will search from the start of the string (-1)", + "position argument < 0 will search from the start of the string (-Infinity)", + ], + }, + { + path: "test/built-ins/String/prototype/startsWith/return-true-if-searchstring-is-empty.js", + code: `const text = "The future is cool!"; return [text.startsWith(""), text.startsWith("", text.length), text.startsWith("", Infinity)]`, + expected: [true, true, true], + labels: ['str.startsWith("") returns true', 'str.startsWith("", str.length) returns true', 'str.startsWith("", Infinity) returns true'], + }, + { + path: "test/built-ins/String/prototype/startsWith/coerced-values-of-position.js", + code: `const text = "The future is cool!"; return [ + text.startsWith("The future", NaN), text.startsWith("The future", undefined), + text.startsWith("The future", 0.4), text.startsWith("The future", 1.4), + ]`, + expected: [true, true, true, false], + labels: ["NaN coerced to 0", "undefined coerced to 0", "0.4 coerced to 0", "1.4 coerced to 1"], + }, + { + path: "test/built-ins/String/prototype/endsWith/String.prototype.endsWith_Success.js", + code: `return ["word".endsWith("d")]`, expected: [true], labels: ['"word".endsWith("d")'], + }, + { + path: "test/built-ins/String/prototype/endsWith/String.prototype.endsWith_Success_2.js", + code: `return ["word".endsWith("d", 4)]`, expected: [true], labels: ['"word".endsWith("d", 4)'], + }, + { + path: "test/built-ins/String/prototype/endsWith/String.prototype.endsWith_Success_3.js", + code: `return ["word".endsWith("d", 25)]`, expected: [true], labels: ['"word".endsWith("d", 25)'], + }, + { + path: "test/built-ins/String/prototype/endsWith/String.prototype.endsWith_Success_4.js", + code: `return ["word".endsWith("r", 3)]`, expected: [true], labels: ['"word".endsWith("r", 3)'], + }, + { + path: "test/built-ins/String/prototype/endsWith/String.prototype.endsWith_Fail.js", + code: `return ["word".endsWith("r")]`, expected: [false], labels: ['"word".endsWith("r")'], + }, + { + path: "test/built-ins/String/prototype/endsWith/String.prototype.endsWith_Fail_2.js", + code: `return ["word".endsWith("d", 3)]`, expected: [false], labels: ['"word".endsWith("d", 3)'], + }, + { + path: "test/built-ins/String/prototype/endsWith/searchstring-found-with-position.js", + code: `const text = "The future is cool!"; return [text.endsWith("The future", 10), text.endsWith("future", 10), text.endsWith(" is cool!", text.length)]`, + expected: [true, true, true], + labels: [ + 'str.endsWith("The future", 10) === true', 'str.endsWith("future", 10) === true', + 'str.endsWith(" is cool!", str.length) === true', + ], + }, + { + path: "test/built-ins/String/prototype/endsWith/searchstring-found-without-position.js", + code: `const text = "The future is cool!"; return [text.endsWith("cool!"), text.endsWith("!"), text.endsWith(text)]`, + expected: [true, true, true], + labels: ['str.endsWith("cool!") === true', 'str.endsWith("!") === true', "str.endsWith(str) === true"], + }, + { + path: "test/built-ins/String/prototype/endsWith/searchstring-not-found-with-position.js", + code: `const text = "The future is cool!"; return [text.endsWith("is cool!", text.length - 1), text.endsWith("!", 1)]`, + expected: [false, false], + labels: ['str.endsWith("is cool!", str.length - 1) === false', 'str.endsWith("!", 1) === false'], + }, + { + path: "test/built-ins/String/prototype/endsWith/searchstring-not-found-without-position.js", + code: `const text = "The future is cool!"; return [text.endsWith("is Flash!"), text.endsWith("IS COOL!"), text.endsWith("The future")]`, + expected: [false, false, false], + labels: ['str.endsWith("is Flash!") === false', "endsWith is case sensitive", 'str.endsWith("The future") === false'], + }, + { + path: "test/built-ins/String/prototype/endsWith/return-false-if-search-start-is-less-than-zero.js", + code: `return ["web".endsWith("w", 0), "Bob".endsWith(" Bob")]`, + expected: [false, false], + labels: ['"web".endsWith("w", 0) returns false', '"Bob".endsWith(" Bob") returns false'], + }, + { + path: "test/built-ins/String/prototype/endsWith/return-true-if-searchstring-is-empty.js", + code: `const text = "The future is cool!"; return [ + text.endsWith(""), text.endsWith("", text.length), text.endsWith("", Infinity), + text.endsWith("", -1), text.endsWith("", -Infinity), + ]`, + expected: [true, true, true, true, true], + labels: [ + 'str.endsWith("") returns true', 'str.endsWith("", str.length) returns true', 'str.endsWith("", Infinity) returns true', + 'str.endsWith("", -1) returns true', 'str.endsWith("", -Infinity) returns true', + ], + }, + { + path: "test/built-ins/String/prototype/endsWith/coerced-values-of-position.js", + code: `const text = "The future is cool!"; return [ + text.endsWith("", NaN), text.endsWith("", undefined), text.endsWith("The future", 10.4), + ]`, + expected: [true, true, true], + labels: ["NaN coerced to 0", "undefined coerced to 0", "10.4 coerced to 10"], + }, + { + path: "test/built-ins/String/prototype/indexOf/S15.5.4.7_A2_T1.js", + code: `return ["abcd".indexOf("abcdab")]`, expected: [-1], labels: ['#1: "abcd".indexOf("abcdab")===-1'], + }, + { + path: "test/built-ins/String/prototype/indexOf/S15.5.4.7_A2_T2.js", + code: `return ["abcd".indexOf("abcdab", 0)]`, expected: [-1], labels: ['#1: "abcd".indexOf("abcdab",0)===-1'], + }, + { + path: "test/built-ins/String/prototype/indexOf/S15.5.4.7_A2_T3.js", + code: `return ["abcd".indexOf("abcdab", 99)]`, expected: [-1], labels: ['#1: "abcd".indexOf("abcdab",99)===-1'], + }, + { + path: "test/built-ins/String/prototype/indexOf/S15.5.4.7_A2_T4.js", + code: `return ["abcd".indexOf("abcdab", NaN)]`, expected: [-1], labels: ['#1: "abcd".indexOf("abcdab",NaN)===-1'], + }, + { + path: "test/built-ins/String/prototype/indexOf/S15.5.4.7_A3_T1.js", + code: `return ["$$abcdabcd".indexOf("ab", NaN)]`, expected: [2], labels: ['#1: "$$abcdabcd".indexOf("ab",NaN)===2'], + }, + { + path: "test/built-ins/String/prototype/indexOf/S15.5.4.7_A3_T3.js", + code: `return ["$$abcdabcd".indexOf("ab", -Infinity)]`, expected: [2], labels: ['#1: "$$abcdabcd".indexOf("ab", function(){return -Infinity;}())===2'], + }, + { + path: "test/built-ins/String/prototype/indexOf/position-tointeger.js", + code: `return [ + "aaaa".indexOf("aa", 0), "aaaa".indexOf("aa", 1), "aaaa".indexOf("aa", -0.9), + "aaaa".indexOf("aa", 0.9), "aaaa".indexOf("aa", 1.9), "aaaa".indexOf("aa", NaN), + "aaaa".indexOf("aa", Infinity), "aaaa".indexOf("aa", undefined), + "aaaa".indexOf("aa", 2), "aaaa".indexOf("aa", 2.9), + ]`, + expected: [0, 1, 0, 0, 1, 0, -1, 0, 2, 2], + labels: [ + "position 0", "position 1", "ToInteger: truncate towards 0 (-0.9)", "ToInteger: truncate towards 0 (0.9)", + "ToInteger: truncate towards 0 (1.9)", "ToInteger: NaN => 0", "position Infinity", + "ToInteger: undefined => NaN => 0", "position 2", "ToInteger: truncate towards 0 (2.9)", + ], + }, + { + path: "test/built-ins/String/prototype/indexOf/searchstring-tostring.js", + code: `return ["foo".indexOf(""), "__foo__".indexOf("foo")]`, + expected: [0, 2], labels: ['"foo".indexOf("")', '"__foo__".indexOf("foo")'], + }, + { + path: "test/built-ins/String/prototype/lastIndexOf/not-a-substring.js", + code: `return ["abc".lastIndexOf("d")]`, + expected: [-1], + labels: ["String.prototype.lastIndexOf returns -1 when searchString is shorter than this and searchString is not a substring of this."], + }, +] as const + +describe("Test262-adapted String search and extraction behavior", () => { + for (const item of cases) { + test(item.path, async () => { + const actual = await value(item.code) + if (!Array.isArray(actual)) throw new Error(`expected assertion values for ${item.path}`) + expect(actual.length, "adapted assertion count").toBe(item.expected.length) + item.expected.forEach((expected, index) => expect(actual[index], item.labels[index]!).toEqual(expected)) + }) + } +}) diff --git a/packages/codemode/test/test262-string.md b/packages/codemode/test/test262-string.md new file mode 100644 index 00000000000..94d01f83d9d --- /dev/null +++ b/packages/codemode/test/test262-string.md @@ -0,0 +1,69 @@ +# Test262 String Coverage + +The String tests adapt Test262 at revision `250f204f23a9249ff204be2baec29600faae7b75`. They cover CodeMode's 32 +exposed instance methods and two static methods using primitive receivers, accepted argument types, and deterministic +behavior. Each executable case names its exact upstream source path. `LICENSE.test262` contains the upstream BSD terms. + +This is coverage of CodeMode's bounded String surface, not a claim of ECMAScript or Test262 conformance. One upstream +file may contain both adapted and inapplicable assertions, so a cited source means only that the represented assertions +were adapted. + +## Inventory + +The relevant upstream directories contain 1,048 files: 1,009 core built-in files, 29 Annex B files for exposed methods, +and 10 Intl `localeCompare` files. The executable suite adapts assertions from 298 distinct sources. + +| API | Upstream files | Adapted sources | +| --- | ---: | ---: | +| `String.fromCharCode` | 17 | 6 | +| `String.fromCodePoint` | 11 | 4 | +| `String.prototype.at` | 11 | 5 | +| `String.prototype.charAt` | 30 | 9 | +| `String.prototype.charCodeAt` | 25 | 4 | +| `String.prototype.codePointAt` | 16 | 6 | +| `String.prototype.concat` | 22 | 1 | +| `String.prototype.endsWith` | 27 | 13 | +| `String.prototype.includes` | 27 | 12 | +| `String.prototype.indexOf` | 47 | 8 | +| `String.prototype.lastIndexOf` | 25 | 1 | +| `String.prototype.localeCompare` | 23 | 1 | +| `String.prototype.match` | 52 | 9 | +| `String.prototype.matchAll` | 26 | 1 | +| `String.prototype.normalize` | 14 | 3 | +| `String.prototype.padEnd` | 13 | 4 | +| `String.prototype.padStart` | 13 | 4 | +| `String.prototype.repeat` | 16 | 4 | +| `String.prototype.replace` | 56 | 16 | +| `String.prototype.replaceAll` | 46 | 12 | +| `String.prototype.search` | 44 | 10 | +| `String.prototype.slice` | 38 | 11 | +| `String.prototype.split` | 121 | 50 | +| `String.prototype.startsWith` | 21 | 7 | +| `String.prototype.substr` | 15 | 6 | +| `String.prototype.substring` | 46 | 12 | +| `String.prototype.toLowerCase` | 30 | 5 | +| `String.prototype.toString` | 7 | 1 | +| `String.prototype.toUpperCase` | 26 | 3 | +| `String.prototype.trim` | 129 | 66 | +| `String.prototype.trimEnd` | 23 | 2 | +| `String.prototype.trimLeft` | 4 | 0 | +| `String.prototype.trimRight` | 4 | 0 | +| `String.prototype.trimStart` | 23 | 2 | + +## Exclusions + +Assertions are not adapted when they test behavior outside CodeMode's documented String surface: + +- Function metadata, property descriptors, constructibility, prototype mutation, or cross-realm identity. +- The `trimLeft`/`trimRight` Test262 files assert prototype function identity, which CodeMode does not expose. Their + supported call behavior remains covered by CodeMode-specific tests. +- Boxed strings, generic receivers, custom coercion objects, Symbols, BigInts, or argument types CodeMode rejects. +- Symbol-based RegExp dispatch, custom matchers, species constructors, or iterator protocol details. CodeMode materializes + `matchAll` results instead of exposing iterators. +- Locale selection and options. CodeMode deliberately uses the host default locale and ignores those arguments. +- Test262 harness behavior or setup syntax unavailable in the confined interpreter. +- Function-replacer behavior that is covered by CodeMode-specific tests for sequential callbacks, async tool calls, + result coercion, diagnostics, and sandbox boundaries. +- Assertions requiring an exact native error type when CodeMode deliberately exposes only its safe runtime error. + +Handwritten tests remain where they specify CodeMode behavior rather than ordinary ECMAScript String semantics. From 19f42f7102ec7fd6ec33d593584ced3dd36ef55d Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 9 Jul 2026 00:00:09 +0200 Subject: [PATCH 05/18] feat(v2/cli): add console login (#35969) --- bun.lock | 1 + packages/cli/package.json | 1 + packages/cli/src/commands/commands.ts | 11 + .../src/commands/handlers/console/login.ts | 116 +++++++++ packages/cli/src/index.ts | 3 + packages/cli/src/ui/timeline.tsx | 242 ++++++++++++++++++ packages/core/src/integration.ts | 82 ++++-- packages/core/src/plugin/provider/opencode.ts | 27 +- packages/core/test/integration.test.ts | 62 ++++- .../test/plugin/provider-opencode.test.ts | 67 +++++ 10 files changed, 584 insertions(+), 28 deletions(-) create mode 100644 packages/cli/src/commands/handlers/console/login.ts create mode 100644 packages/cli/src/ui/timeline.tsx diff --git a/bun.lock b/bun.lock index df33000ba53..6e8f024ea2d 100644 --- a/bun.lock +++ b/bun.lock @@ -114,6 +114,7 @@ "effect": "catalog:", "fuzzysort": "catalog:", "jsonc-parser": "3.3.1", + "open": "10.1.2", "opentui-spinner": "catalog:", "semver": "catalog:", "solid-js": "catalog:", diff --git a/packages/cli/package.json b/packages/cli/package.json index e11986e5b2e..d924dd6a3a2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,6 +46,7 @@ "effect": "catalog:", "fuzzysort": "catalog:", "jsonc-parser": "3.3.1", + "open": "10.1.2", "opentui-spinner": "catalog:", "semver": "catalog:", "solid-js": "catalog:", diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index a3b49b3d06f..d3a5e0b8136 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -75,6 +75,17 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO description: "Debugging and troubleshooting tools", commands: [Spec.make("agents", { description: "List all agents" })], }), + Spec.make("console", { + description: "Manage OpenCode Console access", + commands: [ + Spec.make("login", { + description: "Log in to OpenCode Console", + params: { + url: Argument.string("url").pipe(Argument.withDescription("Console server URL"), Argument.optional), + }, + }), + ], + }), Spec.make("mcp", { description: "Manage MCP (Model Context Protocol) servers", commands: [ diff --git a/packages/cli/src/commands/handlers/console/login.ts b/packages/cli/src/commands/handlers/console/login.ts new file mode 100644 index 00000000000..959263e89db --- /dev/null +++ b/packages/cli/src/commands/handlers/console/login.ts @@ -0,0 +1,116 @@ +import { Cause, Effect, Exit, Option } from "effect" +import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" +import { AppProcess } from "@opencode-ai/core/process" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../daemon" +import { createTimelineHost, type TimelineHost } from "../../../ui/timeline" + +const integrationID = "opencode" +const location = { directory: process.cwd() } + +export default Runtime.handler( + Commands.commands.console.commands.login, + Effect.fn("cli.console.login")(function* (input) { + const timeline = yield* Effect.acquireRelease( + Effect.promise(() => createTimelineHost()), + (value) => request(() => value.close()).pipe(Effect.ignore), + ) + const exit = yield* login(timeline, Option.getOrUndefined(input.url)).pipe( + Effect.raceFirst(AppProcess.waitForAbort(timeline.signal)), + Effect.exit, + ) + if (Exit.isSuccess(exit)) return + + const cancelled = timeline.signal.aborted + yield* request(() => timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(exit.cause))).pipe( + Effect.ignore, + ) + process.exitCode = cancelled ? 130 : 1 + }), +) + +const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHost, server?: string) { + yield* request(() => timeline.intro("Log in")) + yield* request(() => timeline.pending("Connecting to OpenCode...")) + + const transport = yield* Daemon.transport({ mode: "shared" }) + const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers }) + const found = yield* request((signal) => client.integration.get({ integrationID, location }, { signal })) + const integration = yield* required(found.data, "OpenCode Console integration is unavailable") + const method = yield* required( + integration.methods.find((candidate) => candidate.type === "oauth"), + "OpenCode Console login is unavailable", + ) + + yield* request(() => timeline.pending("Starting authorization...")) + const started = yield* request((signal) => + client.integration.connect.oauth( + { + integrationID, + methodID: method.id, + inputs: server ? { server } : {}, + location, + }, + { signal }, + ), + ) + const attempt = started.data + yield* Effect.addFinalizer(() => + request(() => + client.integration.attempt.cancel( + { attemptID: attempt.attemptID, location }, + { signal: AbortSignal.timeout(5_000) }, + ), + ).pipe(Effect.ignore), + ) + if (attempt.mode !== "auto") yield* Effect.fail(new Error("OpenCode Console requires a device login")) + + yield* request(() => timeline.item(`Go to: ${attempt.url}`)) + yield* request(() => timeline.item(attempt.instructions)) + yield* request(async () => { + const { default: open } = await import("open") + await open(attempt.url) + }).pipe(Effect.ignore) + yield* request(() => timeline.pending("Waiting for authorization...")) + + const status = yield* waitForConsoleLogin(client, attempt.attemptID) + if (status.status === "failed") yield* Effect.fail(new Error(status.message)) + if (status.status === "expired") yield* Effect.fail(new Error("Device code expired")) + + yield* request(() => timeline.success("Connected to OpenCode Console")) + yield* request(() => timeline.outro("Done")) +}) + +const waitForConsoleLogin = Effect.fn("cli.console.login.wait")(function* ( + client: OpenCodeClient, + attemptID: string, +) { + while (true) { + const response = yield* request((signal) => + client.integration.attempt.status({ attemptID, location }, { signal }), + ) + if (response.data.status !== "pending") return response.data + yield* Effect.sleep(500) + } +}) + +function request(task: (signal: AbortSignal) => Promise) { + return Effect.tryPromise({ + try: task, + catch: (cause) => cause, + }) +} + +function required(value: A | null | undefined, message: string) { + return value === null || value === undefined ? Effect.fail(new Error(message)) : Effect.succeed(value) +} + +function errorMessage(cause: Cause.Cause) { + const error = Cause.squash(cause) + if (error instanceof Error) return error.message + if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") { + return error.message + } + return String(error) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5b851d7f497..d6d758c95de 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,6 +24,9 @@ const Handlers = Runtime.handlers(Commands, { debug: { agents: () => import("./commands/handlers/debug/agents"), }, + console: { + login: () => import("./commands/handlers/console/login"), + }, mcp: { list: () => import("./commands/handlers/mcp/list"), add: () => import("./commands/handlers/mcp/add"), diff --git a/packages/cli/src/ui/timeline.tsx b/packages/cli/src/ui/timeline.tsx new file mode 100644 index 00000000000..8c7e6612c67 --- /dev/null +++ b/packages/cli/src/ui/timeline.tsx @@ -0,0 +1,242 @@ +/** @jsxImportSource @opentui/solid */ +import { createCliRenderer, RGBA, type CliRenderer, type ColorInput, type ScrollbackWriter } from "@opentui/core" +import { createScrollbackWriter, render, useKeyboard } from "@opentui/solid" +import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner" +import { Show, createSignal } from "solid-js" + +registerOpencodeSpinner() + +export type TimelineHost = { + readonly signal: AbortSignal + intro(text: string): Promise + item(text: string): Promise + pending(text: string): Promise + success(text: string): Promise + failure(text: string): Promise + outro(text: string): Promise + close(): Promise +} + +type RowKind = "intro" | "item" | "success" | "failure" | "outro" + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] +const IDLE_TIMEOUT = 1_000 +const COLORS = { + accent: RGBA.fromIndex(6), + error: RGBA.fromIndex(1), + foreground: RGBA.defaultForeground(), + muted: RGBA.fromIndex(8), + success: RGBA.fromIndex(2), +} +const ROWS: Record = { + intro: { marker: "┌", color: COLORS.muted, connector: true }, + item: { marker: "●", color: COLORS.accent, connector: true }, + success: { marker: "◇", color: COLORS.success, connector: true }, + failure: { marker: "■", color: COLORS.error, connector: false }, + outro: { marker: "└", color: COLORS.muted, connector: false }, +} + +function row(kind: RowKind, value: string): ScrollbackWriter { + const style = ROWS[kind] + return createScrollbackWriter( + () => ( + + + + {style.marker} + + + {value} + + + + + + + ), + { startOnNewLine: true, trailingNewline: !style.connector }, + ) +} + +function TimelineFooter(props: { pending: () => string | undefined; cancel: () => void }) { + useKeyboard((event) => { + if (event.name !== "escape" && !(event.ctrl && event.name === "c")) return + event.preventDefault() + props.cancel() + }) + + return ( + + + {(text) => ( + <> + + + {text()} + + + )} + + + ) +} + +function bounded(task: Promise) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, IDLE_TIMEOUT) + timer.unref() + const finish = () => { + clearTimeout(timer) + resolve() + } + void task.then(finish, finish) + }) +} + +async function shutdown(renderer: CliRenderer): Promise { + await bounded(renderer.idle()) + try { + renderer.externalOutputMode = "passthrough" + } finally { + try { + renderer.screenMode = "main-screen" + } finally { + if (!renderer.isDestroyed) renderer.destroy() + } + } +} + +export async function createTimelineHost(): Promise { + const stdout = process.stdout + const controller = new AbortController() + const signals: NodeJS.Signals[] = ["SIGINT", "SIGHUP", "SIGQUIT"] + const cancel = () => { + if (!controller.signal.aborted) controller.abort() + } + signals.forEach((signal) => process.on(signal, cancel)) + + if (!stdout.isTTY || !process.stdin.isTTY) { + let closed = false + let writing = false + let active: Promise | undefined + let closeTask: Promise | undefined + const write = async (kind: RowKind | "pending", text: string) => { + if (closed) throw new Error("timeline closed") + if (writing) throw new Error("timeline write already in progress") + writing = true + try { + const style = kind === "pending" ? undefined : ROWS[kind] + const marker = kind === "pending" ? "." : ROWS[kind].marker + const connector = style?.connector ? "│\n" : "" + active = new Promise((resolve, reject) => { + stdout.write(`${marker} ${text}\n${connector}`, (error) => (error ? reject(error) : resolve())) + }) + await active + } finally { + writing = false + active = undefined + } + } + const close = () => { + if (closeTask) return closeTask + closed = true + closeTask = (async () => { + await active?.catch(() => { }) + signals.forEach((signal) => process.off(signal, cancel)) + })() + return closeTask + } + return { + signal: controller.signal, + intro: (text) => write("intro", text), + item: (text) => write("item", text), + pending: (text) => write("pending", text), + success: (text) => write("success", text), + failure: (text) => write("failure", text), + outro: (text) => write("outro", text), + close, + } + } + + let renderer: CliRenderer | undefined + + try { + // Start on a fresh row so delayed SSH cursor reports cannot make + // split-footer overwrite the shell command. + process.stdout.write("\n") + renderer = await createCliRenderer({ + stdin: process.stdin, + useMouse: false, + autoFocus: false, + openConsoleOnError: false, + exitOnCtrlC: false, + exitSignals: [], + screenMode: "split-footer", + footerHeight: 1, + externalOutputMode: "capture-stdout", + consoleMode: "disabled", + clearOnShutdown: false, + }) + const activeRenderer = renderer + const [pending, setPending] = createSignal() + const renderTask = render(() => , activeRenderer) + void renderTask.catch(cancel) + await bounded(activeRenderer.idle()) + + let closed = false + let writing = false + let active: Promise | undefined + let closeTask: Promise | undefined + const write = (kind: RowKind | "pending", text: string) => { + if (closed) return Promise.reject(new Error("timeline closed")) + if (writing) return Promise.reject(new Error("timeline write already in progress")) + writing = true + active = (async () => { + if (kind === "pending") { + setPending(text) + activeRenderer.requestRender() + } else { + if (kind === "success" || kind === "failure" || kind === "outro") setPending(undefined) + activeRenderer.writeToScrollback(row(kind, text)) + activeRenderer.requestRender() + } + await bounded(activeRenderer.idle()) + })().finally(() => { + writing = false + active = undefined + }) + return active + } + const close = () => { + if (closeTask) return closeTask + closed = true + closeTask = (async () => { + await active?.catch(() => { }) + try { + await shutdown(activeRenderer) + await bounded(renderTask) + } finally { + signals.forEach((signal) => process.off(signal, cancel)) + } + })() + return closeTask + } + return { + signal: controller.signal, + intro: (text) => write("intro", text), + item: (text) => write("item", text), + pending: (text) => write("pending", text), + success: (text) => write("success", text), + failure: (text) => write("failure", text), + outro: (text) => write("outro", text), + close, + } + } catch (error) { + try { + if (renderer) await shutdown(renderer) + } finally { + signals.forEach((signal) => process.off(signal, cancel)) + } + throw error + } +} diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 12dad07dd34..c4f7c3b8f2d 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -203,6 +203,7 @@ type AttemptTime = { created: number; expires: number } type PendingAttempt = { status: "pending" completing: boolean + persisting: boolean authorization: OAuthAuthorization integrationID: ID methodID: MethodID @@ -320,27 +321,61 @@ const layer = Layer.effect( } const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) { - const now = yield* Clock.currentTimeMillis - const result = yield* SynchronizedRef.modify(attempts, (current) => { - const attempt = current.get(attemptID) - if (!attempt || attempt.status !== "pending") return [undefined, current] - const terminal: TerminalAttempt = Exit.isSuccess(exit) - ? { status: "complete", time: attempt.time, removeAt: now + terminalRetention } - : { status: "failed", message: message(exit.cause), time: attempt.time, removeAt: now + terminalRetention } - return [attempt, new Map(current).set(attemptID, terminal)] - }) - if (!result) return - if (Exit.isSuccess(exit)) { - const implementation = state.get().integrations.get(result.integrationID)?.implementations.get(result.methodID) - yield* credentials.create({ - integrationID: result.integrationID, - label: result.label ?? implementation?.label?.(exit.value), - value: exit.value, - }) - yield* events.publish(Event.ConnectionUpdated, { integrationID: result.integrationID }) - yield* events.publish(Event.Updated, {}) - } - yield* close(result.scope) + return yield* Effect.uninterruptible( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + const attempt = yield* SynchronizedRef.modify(attempts, (current) => { + const match = current.get(attemptID) + if (!match || match.status !== "pending" || match.persisting) return [undefined, current] + const next = Exit.isSuccess(exit) + ? { ...match, persisting: true } + : { + status: "failed" as const, + message: message(exit.cause), + time: match.time, + removeAt: now + terminalRetention, + } + return [match, new Map(current).set(attemptID, next)] + }) + if (!attempt) return + if (Exit.isFailure(exit)) { + yield* close(attempt.scope) + return + } + + yield* Effect.gen(function* () { + const implementation = state + .get() + .integrations.get(attempt.integrationID) + ?.implementations.get(attempt.methodID) + const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe( + Effect.flatMap((label) => + credentials.create({ + integrationID: attempt.integrationID, + label, + value: exit.value, + }), + ), + Effect.asVoid, + Effect.exit, + ) + const settledAt = yield* Clock.currentTimeMillis + const terminal: TerminalAttempt = Exit.isSuccess(persistence) + ? { status: "complete", time: attempt.time, removeAt: settledAt + terminalRetention } + : { + status: "failed", + message: message(persistence.cause), + time: attempt.time, + removeAt: settledAt + terminalRetention, + } + // Persisting attempts cannot be cancelled, expired, or claimed again. + yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal)) + if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause) + yield* events.publish(Event.ConnectionUpdated, { integrationID: attempt.integrationID }) + yield* events.publish(Event.Updated, {}) + }).pipe(Effect.ensuring(close(attempt.scope))) + }), + ) }) const scrub = Effect.fnUntraced(function* () { @@ -349,7 +384,7 @@ const layer = Layer.effect( const next = new Map(current) const scopes: Scope.Closeable[] = [] for (const [id, attempt] of current) { - if (attempt.status === "pending" && attempt.time.expires <= now) { + if (attempt.status === "pending" && !attempt.persisting && attempt.time.expires <= now) { scopes.push(attempt.scope) next.set(id, { status: "expired", time: attempt.time, removeAt: now + terminalRetention }) continue @@ -432,6 +467,7 @@ const layer = Layer.effect( new Map(current).set(id, { status: "pending", completing: authorization.mode === "auto", + persisting: false, authorization, integrationID: input.integrationID, methodID: input.methodID, @@ -506,7 +542,7 @@ const layer = Layer.effect( cancel: Effect.fn("Integration.attempt.cancel")(function* (attemptID) { const attempt = yield* SynchronizedRef.modify(attempts, (current) => { const match = current.get(attemptID) - if (!match || match.status !== "pending") return [undefined, current] + if (!match || match.status !== "pending" || match.persisting) return [undefined, current] const next = new Map(current) next.delete(attemptID) return [match, next] diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index cca6a2dfa73..6092903b41a 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -43,14 +43,21 @@ function oauth(http: HttpClient.HttpClient) { type: "oauth", label: "OpenCode Console account", }, - authorize: () => + authorize: (inputs) => Effect.gen(function* () { - const device = yield* post(http, `${defaultServer}/auth/device/code`, { client_id: clientID }, Device) + const server = yield* normalizeServer(inputs.server ?? defaultServer) + const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device) + const verification = URL.canParse(device.verification_uri_complete) + ? new URL(device.verification_uri_complete) + : undefined + if (verification && verification.protocol !== "http:" && verification.protocol !== "https:") { + return yield* Effect.fail(new Error("Invalid device verification URL: expected HTTP(S)")) + } return { mode: "auto" as const, - url: `${defaultServer}${device.verification_uri_complete}`, + url: verification?.href ?? `${server}/${device.verification_uri_complete.replace(/^\/+/, "")}`, instructions: `Enter code: ${device.user_code}`, - callback: poll(http, defaultServer, device.device_code, Duration.seconds(device.interval)), + callback: poll(http, server, device.device_code, Duration.seconds(device.interval)), } }), refresh: (credential) => @@ -219,6 +226,18 @@ function withoutCredentials(body: Readonly> | undefined) return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers")) } +function normalizeServer(input: string) { + return Effect.try({ + try: () => { + const url = new URL(input) + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("expected HTTP(S)") + return `${url.origin}${url.pathname.replace(/\/+$/, "")}` + }, + catch: (cause) => + new Error(`Invalid OpenCode server URL: ${cause instanceof Error ? cause.message : String(cause)}`), + }) +} + function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) { const base = { input: Money.USDPerMillionTokens.make(input.input), diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index 376d2f9523b..dc20303f001 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -1,14 +1,33 @@ import { describe, expect } from "bun:test" -import { Duration, Effect, Exit, Fiber, Scope, Stream } from "effect" +import { Cause, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect" import * as TestClock from "effect/testing/TestClock" import { Credential } from "@opencode-ai/core/credential" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Integration } from "@opencode-ai/core/integration" import { testEffect } from "./lib/effect" const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node]))) +const failingCredentialNode = makeGlobalNode({ + service: Credential.Service, + layer: Layer.succeed( + Credential.Service, + Credential.Service.of({ + all: () => Effect.succeed([]), + list: () => Effect.succeed([]), + get: () => Effect.succeed(undefined), + create: () => Effect.die(new Error("credential persistence failed")), + update: () => Effect.void, + remove: () => Effect.void, + }), + ), + deps: [], +}) +const failingIt = testEffect( + AppNodeBuilder.build(LayerNode.group([Integration.node, EventV2.node]), [[Credential.node, failingCredentialNode]]), +) describe("Integration", () => { it.effect("registers integrations through the editor", () => @@ -254,6 +273,47 @@ describe("Integration", () => { }), ) + failingIt.effect("fails the attempt when credential persistence fails", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const integrationID = Integration.ID.make("openai") + const methodID = Integration.MethodID.make("chatgpt") + yield* integrations.transform((editor) => + editor.method.update({ + integrationID, + method: { id: methodID, type: "oauth", label: "ChatGPT" }, + authorize: () => + Effect.succeed({ + mode: "code" as const, + url: "https://example.com/authorize", + instructions: "Paste the code", + callback: () => + Effect.succeed( + Credential.OAuth.make({ + type: "oauth", + methodID, + access: "access", + refresh: "refresh", + expires: 1, + }), + ), + }), + }), + ) + + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) + const exit = yield* integrations.attempt + .complete({ attemptID: attempt.attemptID, code: "1234" }) + .pipe(Effect.exit) + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true) + expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({ + status: "failed", + message: "credential persistence failed", + time: attempt.time, + }) + }), + ) + it.effect("expires abandoned OAuth attempts", () => Effect.gen(function* () { const integrations = yield* Integration.Service diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index f8334026f90..0b3e30cd319 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -92,6 +92,73 @@ describe("OpencodePlugin", () => { }), ) + it.live("uses a canonical custom server throughout device authorization", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const requests: string[] = [] + const server = Bun.serve({ + port: 0, + fetch: (request) => { + const url = new URL(request.url) + requests.push(`${request.method} ${url.pathname}`) + if (url.pathname.endsWith("/auth/device/code")) { + return Response.json({ + device_code: "device", + user_code: "user", + verification_uri_complete: `${url.origin}/verify`, + expires_in: 60, + interval: 0, + }) + } + if (url.pathname.endsWith("/auth/device/token")) { + return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 600 }) + } + if (url.pathname.endsWith("/api/user")) return Response.json({ id: "user", email: "user@example.com" }) + if (url.pathname.endsWith("/api/orgs")) return Response.json([{ id: "org", name: "Org" }]) + return new Response("Not found", { status: 404 }) + }, + }) + return { requests, server } + }), + ({ requests, server }) => + Effect.gen(function* () { + yield* addPlugin() + const integrations = yield* Integration.Service + const attempt = yield* integrations.connection.oauth({ + integrationID: Integration.ID.make("opencode"), + methodID: Integration.MethodID.make("device"), + inputs: { server: `${server.url.origin}/console///?ignored=true#ignored` }, + }) + expect(attempt.url).toBe(`${server.url.origin}/verify`) + yield* eventually(integrations.attempt.status(attempt.attemptID), (status) => status.status === "complete") + + expect(requests).toContain("POST /console/auth/device/code") + expect(requests).toContain("POST /console/auth/device/token") + expect(requests).toContain("GET /console/api/user") + expect(requests).toContain("GET /console/api/orgs") + expect((yield* (yield* Credential.Service).list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({ + metadata: { server: `${server.url.origin}/console` }, + }) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) + + it.effect("rejects non-HTTP OpenCode servers", () => + Effect.gen(function* () { + yield* addPlugin() + const error = yield* (yield* Integration.Service).connection + .oauth({ + integrationID: Integration.ID.make("opencode"), + methodID: Integration.MethodID.make("device"), + inputs: { server: "ftp://console.example.com" }, + }) + .pipe(Effect.flip) + expect(error).toBeInstanceOf(Integration.AuthorizationError) + expect(String(error.cause)).toContain("Invalid OpenCode server URL: expected HTTP(S)") + }), + ) + it.live("loads providers and models from the connected OpenCode server", () => Effect.acquireUseRelease( Effect.sync(() => { From 79415f625a24b043aa28f2ada660609570c82734 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 8 Jul 2026 18:14:43 -0400 Subject: [PATCH 06/18] feat(tui): revamp session export (#35971) --- packages/tui/src/routes/session/index.tsx | 95 ++++--- packages/tui/src/ui/dialog-export-options.tsx | 262 +++++++++--------- packages/tui/src/ui/dialog-export-result.tsx | 56 ++++ 3 files changed, 244 insertions(+), 169 deletions(-) create mode 100644 packages/tui/src/ui/dialog-export-result.tsx diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 1dc2baa2ba1..dc619fc6693 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -14,6 +14,7 @@ import { useContext, } from "solid-js" import path from "node:path" +import { EOL, tmpdir } from "node:os" import { mkdir, writeFile } from "node:fs/promises" import { useRoute, useRouteData } from "../../context/route" import { createStore } from "solid-js/store" @@ -61,6 +62,7 @@ import { normalizePath } from "../../util/path" import { PermissionPrompt } from "./permission" import { FormPrompt } from "./form" import { DialogExportOptions } from "../../ui/dialog-export-options" +import { DialogExportResult } from "../../ui/dialog-export-result" import { sessionEpilogue } from "../../util/presentation" import { useTuiConfig } from "../../config" import { useClipboard } from "../../context/clipboard" @@ -711,7 +713,13 @@ export function Session() { try { const sessionData = session() if (!sessionData) return - const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), showDetails()) + const transcript = formatSessionTranscript( + sessionData, + messages(), + showThinking(), + showDetails(), + showAssistantMetadata(), + ) await clipboard.write?.(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) } catch { @@ -731,53 +739,61 @@ export function Session() { try { const sessionData = session() if (!sessionData) return - const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md` const options = await DialogExportOptions.show( dialog, - defaultFilename, showThinking(), showDetails(), showAssistantMetadata(), - false, ) if (options === null) return - const transcript = formatSessionTranscript(sessionData, messages(), options.thinking, options.toolDetails) + const content = + options.format === "markdown" + ? formatSessionTranscript( + sessionData, + messages(), + options.thinking, + options.toolDetails, + options.assistantMetadata, + ) + : await (async () => { + if (options.debug) { + const events: unknown[] = [] + for await (const event of sdk.api.session.log({ sessionID: sessionData.id, follow: false })) { + if (event.type !== "log.synced") events.push(event) + } + return JSON.stringify({ info: sessionData, events }, null, 2) + EOL + } - if (options.openWithoutSaving) { - // Just open in editor without saving - await openEditor({ - renderer, - value: transcript, - cwd: - (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || - project.instance.directory() || - paths.cwd, - }) - } else { - const exportDir = paths.cwd - const filename = options.filename.trim() - const filepath = path.join(exportDir, filename) + const messages: unknown[] = [] + let cursor: string | undefined + do { + const page = await sdk.api.message.list( + cursor + ? { sessionID: sessionData.id, limit: 200, cursor } + : { sessionID: sessionData.id, limit: 200, order: "asc" }, + ) + messages.push(...page.data) + cursor = page.data.length ? (page.cursor.next ?? undefined) : undefined + } while (cursor) + return JSON.stringify({ info: sessionData, messages }, null, 2) + EOL + })() - await writeExport(filepath, transcript) - - // Open with EDITOR if available - const result = await openEditor({ - renderer, - value: transcript, - cwd: - (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || - project.instance.directory() || - paths.cwd, - }) - if (result !== undefined) { - await writeExport(filepath, result) - } - - toast.show({ message: `Session exported to ${filename}`, variant: "success" }) + if (options.action === "copy") { + await clipboard.write?.(content) + dialog.clear() + toast.show({ message: "Copied to clipboard", variant: "success" }) + return } + + const filepath = path.join( + tmpdir(), + `session-${crypto.randomUUID()}.${options.format === "markdown" ? "md" : "json"}`, + ) + await writeExport(filepath, content) + await DialogExportResult.show(dialog, filepath) } catch { toast.show({ message: "Failed to export session", variant: "error" }) } @@ -2746,6 +2762,7 @@ function formatSessionTranscript( messages: SessionMessageInfo[], thinking: boolean, toolDetails: boolean, + assistantMetadata: boolean, ) { const body = messages.flatMap((message) => { if (message.type === "user") return [`## User\n\n${message.text}`] @@ -2767,7 +2784,13 @@ function formatSessionTranscript( .join("\n") return [`**Tool: ${item.name}**\n\n**Input:**\n\`\`\`json\n${input}\n\`\`\`\n\n${output}`] }) - return [`## Assistant\n\n${content.join("\n\n")}`] + const duration = message.time.completed + ? ` · ${((message.time.completed - message.time.created) / 1000).toFixed(1)}s` + : "" + const heading = assistantMetadata + ? `## Assistant (${message.agent} · ${message.model.providerID}/${message.model.id}${duration})` + : "## Assistant" + return [`${heading}\n\n${content.join("\n\n")}`] }) return `# ${session.title}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n` } diff --git a/packages/tui/src/ui/dialog-export-options.tsx b/packages/tui/src/ui/dialog-export-options.tsx index cab853ff2ac..914d0960c8a 100644 --- a/packages/tui/src/ui/dialog-export-options.tsx +++ b/packages/tui/src/ui/dialog-export-options.tsx @@ -1,38 +1,63 @@ -import { TextareaRenderable, TextAttributes } from "@opentui/core" +import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" -import { onMount, Show } from "solid-js" +import { For, Show } from "solid-js" import { useBindings } from "../keymap" +export type ExportFormat = "markdown" | "json" + export type DialogExportOptionsProps = { - defaultFilename: string defaultThinking: boolean defaultToolDetails: boolean defaultAssistantMetadata: boolean - defaultOpenWithoutSaving: boolean onConfirm?: (options: { - filename: string + action: "copy" | "export" + format: ExportFormat + debug: boolean thinking: boolean toolDetails: boolean assistantMetadata: boolean - openWithoutSaving: boolean }) => void onCancel?: () => void } +type Active = ExportFormat | "debug" | "thinking" | "toolDetails" | "assistantMetadata" | "copy" | "export" + export function DialogExportOptions(props: DialogExportOptionsProps) { const dialog = useDialog() const { theme } = useTheme() - let textarea: TextareaRenderable const [store, setStore] = createStore({ + format: "markdown" as ExportFormat, + debug: false, thinking: props.defaultThinking, toolDetails: props.defaultToolDetails, assistantMetadata: props.defaultAssistantMetadata, - openWithoutSaving: props.defaultOpenWithoutSaving, - active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving", + active: "markdown" as Active, }) + const confirm = (action: "copy" | "export") => + props.onConfirm?.({ + action, + format: store.format, + debug: store.debug, + thinking: store.thinking, + toolDetails: store.toolDetails, + assistantMetadata: store.assistantMetadata, + }) + + const activate = () => { + if (store.active === "markdown" || store.active === "json") { + setStore("format", store.active) + return + } + if (store.active === "debug") setStore("debug", !store.debug) + if (store.active === "thinking") setStore("thinking", !store.thinking) + if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) + if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata) + if (store.active === "copy" || store.active === "export") confirm(store.active) + } + useBindings(() => ({ bindings: [ { @@ -40,173 +65,144 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { desc: "Next export option", group: "Dialog", cmd: () => { - const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [ - "filename", - "thinking", - "toolDetails", - "assistantMetadata", - "openWithoutSaving", - ] - const currentIndex = order.indexOf(store.active) - const nextIndex = (currentIndex + 1) % order.length - setStore("active", order[nextIndex]) + const order: Active[] = + store.format === "markdown" + ? ["markdown", "json", "thinking", "toolDetails", "assistantMetadata", "copy", "export"] + : ["markdown", "json", "debug", "copy", "export"] + setStore("active", order[(order.indexOf(store.active) + 1) % order.length]) }, }, - ], - })) - - useBindings(() => ({ - enabled: store.active !== "filename", - bindings: [ { - key: "space", - desc: "Toggle export option", + key: "return", + desc: "Select export option", group: "Dialog", - cmd: () => { - if (store.active === "thinking") setStore("thinking", !store.thinking) - if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) - if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata) - if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving) - }, + cmd: activate, }, ], })) - onMount(() => { - dialog.setSize("medium") - setTimeout(() => { - if (!textarea || textarea.isDestroyed) return - textarea.focus() - }, 1) - textarea.gotoLineEnd() - }) + const selectFormat = (format: ExportFormat) => { + setStore("format", format) + setStore("active", format) + } + + const toggle = (option: "thinking" | "toolDetails" | "assistantMetadata") => { + setStore("active", option) + setStore(option, !store[option]) + } return ( - Export Options + Export session dialog.clear()}> esc - - - Filename: - -