From 3cbcd8d727e3f271d4cbea6d61d79d52a4fda70e Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 8 Jul 2026 18:41:46 +0530 Subject: [PATCH 01/32] feat(llm): complete provider package entrypoints (#35907) --- packages/llm/AGENTS.md | 17 +++++++ packages/llm/README.md | 26 ++++++++++ packages/llm/STATUS.md | 33 ++++++------ packages/llm/example/call-sites.md | 27 +++++++--- packages/llm/package.json | 2 + packages/llm/src/providers/azure.ts | 30 +++++++++++ packages/llm/src/providers/azure/chat.ts | 2 + packages/llm/src/providers/azure/responses.ts | 2 + packages/llm/src/providers/google.ts | 19 ++++++- packages/llm/test/provider-package.test.ts | 50 +++++++++++++++++++ 10 files changed, 182 insertions(+), 26 deletions(-) create mode 100644 packages/llm/src/providers/azure/chat.ts create mode 100644 packages/llm/src/providers/azure/responses.ts diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md index c4edaba2b49..5ccd955f524 100644 --- a/packages/llm/AGENTS.md +++ b/packages/llm/AGENTS.md @@ -113,6 +113,23 @@ Keep provider facades small and explicit: `Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior. +### Provider Package Entrypoints + +Catalog-selected native providers use package-like export paths from `@opencode-ai/llm`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays. + +```ts +import { model } from "@opencode-ai/llm/providers/openai/responses" + +const selected = model("gpt-5", { + apiKey, + transport: "websocket", +}) +``` + +Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `Model`. + +Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`. + ### Folder layout ``` diff --git a/packages/llm/README.md b/packages/llm/README.md index 330222de93a..477d01701ab 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -106,6 +106,32 @@ const gateway = CloudflareAIGateway.configure({ Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc. +### Package-like entrypoints + +Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/llm` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays. + +```ts +import { model } from "@opencode-ai/llm/providers/openai/responses" + +const selected = model("gpt-5", { + apiKey: process.env.OPENAI_API_KEY, + transport: "websocket", + headers: { "x-application": "opencode" }, + limits: { context: 200_000, output: 64_000 }, +}) +``` + +OpenAI Chat and OpenAI Responses are separate semantic entrypoints: + +- `@opencode-ai/llm/providers/openai/chat` +- `@opencode-ai/llm/providers/openai/responses` + +Responses HTTP versus WebSocket is a scoped `transport` setting on the Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Anthropic, OpenAI-compatible Chat, Google Gemini, and Amazon Bedrock expose their single native API through their existing provider paths. + +Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path. + +Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package. + ## Provider options & HTTP overlays Three escape hatches in order of stability: diff --git a/packages/llm/STATUS.md b/packages/llm/STATUS.md index 874862d9af0..872023e665b 100644 --- a/packages/llm/STATUS.md +++ b/packages/llm/STATUS.md @@ -1,6 +1,6 @@ # LLM Provider Parity Status -Last reviewed: 2026-07-02 +Last reviewed: 2026-07-08 This file tracks the gap between the native `@opencode-ai/llm` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths. @@ -64,26 +64,27 @@ Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` wh 5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review. 6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage. 7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed. -8. Package/namespace boundaries need to be made explicit in docs and exports. Protocol namespaces exist, but planned public groupings should call out OpenAI Chat, OpenAI Responses, OpenAI-compatible Chat, OpenAI-compatible Responses, Anthropic Messages, Gemini, Vertex Gemini, Vertex Anthropic Messages, Bedrock Converse, and Bedrock Mantle as separate API slices. +8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Missing native API boundaries remain for OpenAI-compatible Responses, Vertex Gemini, Vertex Anthropic Messages, and Bedrock Mantle. 9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults. -## Proposed Native Namespace Shape +## Native Namespace Shape These are implementation/API slices, not separate npm packages. -| Namespace | Purpose | -| --- | --- | -| `OpenAI.Chat` or `OpenAIChat` | OpenAI `/chat/completions` semantics. | -| `OpenAI.Responses` or `OpenAIResponses` | OpenAI `/responses` HTTP and WebSocket semantics. | -| `OpenAICompatible.Chat` or `OpenAICompatibleChat` | Generic OpenAI-compatible `/chat/completions`. | -| `OpenAICompatible.Responses` or `OpenAICompatibleResponses` | Generic OpenAI-compatible `/responses`. Missing today. | -| `Anthropic.Messages` or `AnthropicMessages` | Anthropic Messages API. | -| `Google.Gemini` or `Gemini` | Gemini Developer API. | -| `GoogleVertex.Gemini` | Vertex Gemini API. Missing today. | -| `GoogleVertex.AnthropicMessages` | Vertex-hosted Anthropic Messages API. Missing today. | -| `Bedrock.Converse` or `BedrockConverse` | AWS Bedrock Converse API. | -| `Bedrock.Mantle` | AWS Bedrock Mantle OpenAI-compatible APIs. Missing today. | -| `Azure.OpenAIChat` / `Azure.OpenAIResponses` | Azure deployment specializations over OpenAI protocols. | +| API slice | Package-like entrypoint | Purpose | +| --- | --- | --- | +| OpenAI Chat | `@opencode-ai/llm/providers/openai/chat` | OpenAI `/chat/completions` semantics. | +| OpenAI Responses | `@opencode-ai/llm/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | +| OpenAI-compatible Chat | `@opencode-ai/llm/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | +| OpenAI-compatible Responses | Missing | Generic OpenAI-compatible `/responses`. | +| Anthropic Messages | `@opencode-ai/llm/providers/anthropic` | Anthropic Messages API. | +| Gemini Developer API | `@opencode-ai/llm/providers/google` | Google AI Studio Gemini API. | +| Vertex Gemini | Missing | Vertex Gemini API. | +| Vertex Anthropic Messages | Missing | Vertex-hosted Anthropic Messages API. | +| Bedrock Converse | `@opencode-ai/llm/providers/amazon-bedrock` | AWS Bedrock Converse API. | +| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. | +| Azure OpenAI Chat | `@opencode-ai/llm/providers/azure/chat` | Azure specialization of OpenAI Chat. | +| Azure OpenAI Responses | `@opencode-ai/llm/providers/azure/responses` | Azure specialization of OpenAI Responses. | ## Suggested Next Work Slices diff --git a/packages/llm/example/call-sites.md b/packages/llm/example/call-sites.md index 093f74e51de..7c5a411cbac 100644 --- a/packages/llm/example/call-sites.md +++ b/packages/llm/example/call-sites.md @@ -342,14 +342,24 @@ const response = ) ``` -HTTP versus WebSocket is represented as named route selectors, not as model or -request overrides. Same protocol, different transport, different route: +For direct provider-facade calls, HTTP versus WebSocket is represented as named +route selectors, not as model or request overrides. Same protocol, different +transport, different route: ```ts OpenAI.responses("gpt-4o") OpenAI.responsesWebSocket("gpt-4o") ``` +The package-like OpenAI Responses entrypoint instead keeps transport scoped to +Responses settings while preserving the same `model(...)` contract: + +```ts +import { model } from "@opencode-ai/llm/providers/openai/responses" + +model("gpt-4o", { apiKey, transport: "websocket" }) +``` + The client should not require a different public layer just because a selected route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime capabilities available; routes that do not need WebSocket simply never touch it. @@ -468,10 +478,10 @@ const model = ``` That boundary can branch on durable config/catalog metadata and call typed -provider APIs directly. Transport selection belongs there too: map metadata like -`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`; otherwise use -the normal `OpenAI.responses(apiModelID)` route. The client runtime only executes -the route carried by the model. +provider APIs directly. A direct provider-facade boundary maps metadata like +`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`. A package-loading +boundary passes `transport: "websocket"` to the OpenAI Responses entrypoint. +The client runtime only executes the route carried by the resulting model. ## Competitive Shape @@ -507,8 +517,9 @@ App boundary = explicit durable-config -> typed-provider call id. - No `model(id, overrides)` escape hatch. Model selection takes the model id; endpoint/auth/deployment customization happens by configuring the route first. -- No transport override on model/request. HTTP SSE versus WebSocket is a named - route selector such as `responses` versus `responsesWebSocket`. +- No transport override on an executable model or request. Direct provider + facades use `responses` versus `responsesWebSocket`; the package-like Responses + entrypoint maps its scoped `transport` setting before constructing the model. - No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one client layer with the available transport capabilities. - No executable `ModelRef`. The executable handle is `Model`; durable model diff --git a/packages/llm/package.json b/packages/llm/package.json index 783ca64b958..1947cbb8d7b 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -19,6 +19,8 @@ "./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts", "./providers/anthropic": "./src/providers/anthropic.ts", "./providers/azure": "./src/providers/azure.ts", + "./providers/azure/responses": "./src/providers/azure/responses.ts", + "./providers/azure/chat": "./src/providers/azure/chat.ts", "./providers/cloudflare": "./src/providers/cloudflare.ts", "./providers/github-copilot": "./src/providers/github-copilot.ts", "./providers/google": "./src/providers/google.ts", diff --git a/packages/llm/src/providers/azure.ts b/packages/llm/src/providers/azure.ts index bfac2d1cad3..dd0691a5391 100644 --- a/packages/llm/src/providers/azure.ts +++ b/packages/llm/src/providers/azure.ts @@ -1,6 +1,7 @@ import { Auth } from "../route/auth" import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options" import type { Route as RouteDef, RouteDefaultsInput } from "../route/client" +import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" @@ -23,6 +24,14 @@ export type ModelOptions = AzureURL & } export type Config = ModelOptions +export type Settings = ProviderPackage.Settings & + AzureURL & { + readonly apiKey?: string + readonly apiVersion?: string + readonly queryParams?: Readonly> + readonly providerOptions?: OpenAIProviderOptionsInput + } + const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1` const responsesRoute = OpenAIResponses.route.with({ @@ -108,3 +117,24 @@ export const provider = { id, configure, } + +const config = (settings: Settings): Config => { + const common = { + apiKey: settings.apiKey, + apiVersion: settings.apiVersion, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + providerOptions: settings.providerOptions, + queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, + } + if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL } + if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName } + throw new Error("Azure requires resourceName or baseURL") +} + +export const responsesModel: ProviderPackage.Definition["model"] = (modelID, settings) => + configure(config(settings)).responses(modelID) +export const chatModel: ProviderPackage.Definition["model"] = (modelID, settings) => + configure(config(settings)).chat(modelID) +export const model = responsesModel diff --git a/packages/llm/src/providers/azure/chat.ts b/packages/llm/src/providers/azure/chat.ts new file mode 100644 index 00000000000..ff3e474332e --- /dev/null +++ b/packages/llm/src/providers/azure/chat.ts @@ -0,0 +1,2 @@ +export { chatModel as model } from "../azure" +export type { Settings } from "../azure" diff --git a/packages/llm/src/providers/azure/responses.ts b/packages/llm/src/providers/azure/responses.ts new file mode 100644 index 00000000000..e7b8ab15ae4 --- /dev/null +++ b/packages/llm/src/providers/azure/responses.ts @@ -0,0 +1,2 @@ +export { responsesModel as model } from "../azure" +export type { Settings } from "../azure" diff --git a/packages/llm/src/providers/google.ts b/packages/llm/src/providers/google.ts index c8a72c31f67..6cf9ac21ec3 100644 --- a/packages/llm/src/providers/google.ts +++ b/packages/llm/src/providers/google.ts @@ -1,7 +1,8 @@ import type { RouteDefaultsInput } from "../route/client" import { Auth } from "../route/auth" import type { ProviderAuthOption } from "../route/auth-options" -import { ProviderID, type ModelID } from "../schema" +import type { ProviderPackage } from "../provider-package" +import { ProviderID, type ModelID, type ProviderOptions } from "../schema" import * as Gemini from "../protocols/gemini" export const id = ProviderID.make("google") @@ -10,6 +11,12 @@ export const routes = [Gemini.route] export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly providerOptions?: ProviderOptions +} + const auth = (options: ProviderAuthOption<"optional">) => { if ("auth" in options && options.auth) return options.auth return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey") @@ -32,4 +39,12 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model = provider.model +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + providerOptions: settings.providerOptions, + }).model(modelID) diff --git a/packages/llm/test/provider-package.test.ts b/packages/llm/test/provider-package.test.ts index 0d3ddc811c4..346b6b86ee3 100644 --- a/packages/llm/test/provider-package.test.ts +++ b/packages/llm/test/provider-package.test.ts @@ -10,10 +10,15 @@ describe("provider package entrypoints", () => { import("@opencode-ai/llm/providers/anthropic"), import("@opencode-ai/llm/providers/openai-compatible"), import("@opencode-ai/llm/providers/amazon-bedrock"), + import("@opencode-ai/llm/providers/azure"), + import("@opencode-ai/llm/providers/azure/responses"), + import("@opencode-ai/llm/providers/azure/chat"), + import("@opencode-ai/llm/providers/google"), ]) for (const module of modules) expect(module.model).toBeFunction() expect(modules[0].model).toBe(modules[1].model) + expect(modules[6].model).toBe(modules[7].model) }) test("maps package settings onto the executable model", () => { @@ -49,4 +54,49 @@ describe("provider package entrypoints", () => { "OpenAI-Project": "proj_123", }) }) + + test("selects Azure API entrypoints with the same model contract", async () => { + const Azure = await import("@opencode-ai/llm/providers/azure") + const AzureChat = await import("@opencode-ai/llm/providers/azure/chat") + const AzureResponses = await import("@opencode-ai/llm/providers/azure/responses") + const settings = { + apiKey: "fixture", + resourceName: "opencode-test", + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + limits: { context: 200_000, output: 64_000 }, + } + + const responses = AzureResponses.model("deployment", settings) + const chat = AzureChat.model("deployment", settings) + + expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses") + expect(responses.route.id).toBe("azure-openai-responses") + expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1") + expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" }) + expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) + expect(chat.route.id).toBe("azure-openai-chat") + }) + + test("maps Google package settings onto the Gemini model", async () => { + const Google = await import("@opencode-ai/llm/providers/google") + const selected = Google.model("gemini-2.5-flash", { + apiKey: "fixture", + baseURL: "https://generativelanguage.test/v1beta", + headers: { "x-application": "opencode" }, + body: { safetySettings: [] }, + limits: { context: 1_000_000, output: 65_536 }, + providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } }, + }) + + expect(selected.route.id).toBe("gemini") + expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta") + expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] }) + expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 }) + expect(selected.route.defaults.providerOptions).toEqual({ + gemini: { thinkingConfig: { thinkingBudget: 1_024 } }, + }) + }) }) From bf15c97e4b5723f54d5434469cbec2d3cc95b7fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=9B=F0=9D=93=B2=F0=9D=93=BD=F0=9D=93=BD?= =?UTF-8?q?=F0=9D=93=B5=F0=9D=93=AE=20=F0=9D=93=95=F0=9D=93=BB=F0=9D=93=AA?= =?UTF-8?q?=F0=9D=93=B7=F0=9D=93=B4?= Date: Wed, 8 Jul 2026 13:45:20 +0000 Subject: [PATCH 02/32] Revert "fix(tui): paginate session history" This reverts commit cc29f86cdc1371ec8255d68f750a732eb1f6cfd5. --- packages/tui/src/context/data.tsx | 306 +++++++++--------- packages/tui/src/routes/session/index.tsx | 111 ++----- packages/tui/src/routes/session/rows.ts | 126 +++++--- packages/tui/test/cli/tui/data.test.tsx | 205 ++++++------ .../tui/test/cli/tui/session-rows.test.ts | 22 +- 5 files changed, 385 insertions(+), 385 deletions(-) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index adefd3487f2..da44a0f8e95 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,77 +1,60 @@ -// Client data layer: apply server events and cache API reads into a Solid store. -// Prefer straightforward projection. Do not add generation counters, stale-response -// merges, live/history overlays, or other race machinery here—last write wins. -// Reconnect may re-bootstrap; that is enough. UI and the server own ordering concerns. - import type { - AgentV2Info, - CommandV2Info, + AgentInfo, + CommandInfo, FormFormInfo, FormUrlInfo, IntegrationInfo, LocationRef, McpServer, - ModelV2Info, + ModelInfo, PermissionSavedInfo, PermissionV2Request, ProviderV2Info, ReferenceInfo, - SessionMessage, + SessionMessageInfo, SessionMessageAssistant, SessionMessageAssistantReasoning, SessionMessageAssistantText, SessionMessageAssistantTool, - SessionV2Info, + SessionInfo, Shell, - SkillV2Info, + SkillInfo, V2Event, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" -import { batch, createSignal, onCleanup } from "solid-js" +import { createSignal, onCleanup } from "solid-js" export type DataSessionStatus = "idle" | "running" const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") -const MESSAGE_PAGE_SIZE = 25 export type FormInfo = FormFormInfo | FormUrlInfo -// Per-session message timeline plus older-history paging. `items` is ascending; -// `cursor` is the opaque server cursor for the next older page after a desc first load. -type SessionMessages = { - items: SessionMessage[] - cursor?: string - complete: boolean - loading: boolean -} - type LocationData = { - agent?: AgentV2Info[] - command?: CommandV2Info[] + agent?: AgentInfo[] + command?: CommandInfo[] integration?: IntegrationInfo[] mcp?: McpServer[] - model?: ModelV2Info[] + model?: ModelInfo[] provider?: ProviderV2Info[] reference?: ReferenceInfo[] // Currently running shell commands for this location, keyed by shell id. Entries are removed // once the command exits or is deleted, so this only ever holds in-flight shells. shell?: Record - skill?: SkillV2Info[] + skill?: SkillInfo[] } type Data = { session: { - info: Record + info: Record // Family index keyed by a family's root (or furthest-known-ancestor when the // true root is not yet loaded). The value is a flat deduplicated list of every // session ID in that family, including the key itself once its info arrives. family: Record status: Record - compaction: Partial> - compactionReason: Partial> - message: Record + message: Record input: Record permission: Record // Pending forms keyed by session ID. @@ -83,10 +66,6 @@ type Data = { location: Record } -function emptyMessages(): SessionMessages { - return { items: [], complete: false, loading: false } -} - function locationKey(location: LocationRef) { return JSON.stringify([location.directory, location.workspaceID]) } @@ -111,8 +90,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ info: {}, family: {}, status: {}, - compaction: {}, - compactionReason: {}, message: {}, input: {}, permission: {}, @@ -129,44 +106,66 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: process.cwd(), }) const messageIndex = new Map>() + const sessionRefreshGeneration = new Map() + const sessionRefreshApplied = new Map() + const sessionUsage = new Map() + let connectionGeneration = 0 + let statusChanges: Set | undefined let bootstrapping: Promise | undefined function setSessionStatus(sessionID: string, status: DataSessionStatus) { + statusChanges?.add(sessionID) setStore("session", "status", sessionID, status) } + function nextSessionRefresh(sessionID: string) { + const generation = (sessionRefreshGeneration.get(sessionID) ?? 0) + 1 + sessionRefreshGeneration.set(sessionID, generation) + return generation + } + + function applySessionRefresh(sessionID: string, generation: number) { + if ((sessionRefreshApplied.get(sessionID) ?? 0) > generation) return false + sessionRefreshApplied.set(sessionID, generation) + return true + } + + function updateSessionUsage(sessionID: string, cost: number, tokens: SessionInfo["tokens"]) { + sessionUsage.set(sessionID, { generation: (sessionUsage.get(sessionID)?.generation ?? 0) + 1, cost, tokens }) + if (!store.session.info[sessionID]) return + setStore("session", "info", sessionID, { cost, tokens }) + } + const message = { - update(sessionID: string, fn: (messages: SessionMessage[], index: Map) => void) { + update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map) => void) { setStore( "session", "message", produce((draft) => { - fn((draft[sessionID] ??= emptyMessages()).items, index(sessionID)) + fn((draft[sessionID] ??= []), index(sessionID)) }), ) }, - append(messages: SessionMessage[], index: Map, item: SessionMessage) { + append(messages: SessionMessageInfo[], index: Map, item: SessionMessageInfo) { if (index.has(item.id)) return index.set(item.id, messages.length) messages.push(item) }, - activeAssistant(messages: SessionMessage[]) { + activeAssistant(messages: SessionMessageInfo[]) { const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed) return item?.type === "assistant" ? item : undefined }, - assistant(messages: SessionMessage[], index: Map, messageID: string) { + assistant(messages: SessionMessageInfo[], index: Map, messageID: string) { const position = index.get(messageID) const item = position === undefined ? undefined : messages[position] return item?.type === "assistant" ? item : undefined }, - shell(messages: SessionMessage[], shellID: string) { - const item = messages.findLast((item) => item.type === "shell" && item.shell.id === shellID) + shell(messages: SessionMessageInfo[], shellID: string) { + const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID) return item?.type === "shell" ? item : undefined }, - compaction(messages: SessionMessage[]) { - const item = messages.findLast( - (item) => item.type === "compaction" && (item.status === "queued" || item.status === "running"), - ) + compaction(messages: SessionMessageInfo[]) { + const item = messages.findLast((item) => item.type === "compaction" && item.status === "running") return item?.type === "compaction" ? item : undefined }, latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { @@ -238,13 +237,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } function removeSession(sessionID: string) { + sessionRefreshApplied.set(sessionID, nextSessionRefresh(sessionID)) + sessionUsage.delete(sessionID) messageIndex.delete(sessionID) setStore( "session", produce((draft) => { delete draft.info[sessionID] delete draft.status[sessionID] - delete draft.compaction[sessionID] delete draft.message[sessionID] delete draft.input[sessionID] delete draft.permission[sessionID] @@ -267,11 +267,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ removeSession(event.data.sessionID) break case "session.usage.updated": - if (store.session.info[event.data.sessionID]) - setStore("session", "info", event.data.sessionID, { - cost: event.data.cost, - tokens: event.data.tokens, - }) + updateSessionUsage(event.data.sessionID, event.data.cost, event.data.tokens) break case "catalog.updated": void Promise.all([ @@ -378,6 +374,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ id: messageIDFromEvent(event.id), type: "system", text: event.data.text, + metadata: event.metadata, time: { created: event.created }, }) }) @@ -387,9 +384,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.append(draft, index, { id: messageIDFromEvent(event.id), type: "synthetic", - sessionID: event.data.sessionID, text: event.data.text, description: event.data.description, + metadata: event.data.metadata, time: { created: event.created }, }) }) @@ -399,7 +396,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.append(draft, index, { id: messageIDFromEvent(event.id), type: "shell", - shell: event.data.shell, + shellID: event.data.shell.id, + command: event.data.shell.command, + status: event.data.shell.status, + exit: event.data.shell.exit, + metadata: event.metadata, time: { created: event.created }, }) }) @@ -408,7 +409,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.update(event.data.sessionID, (draft) => { const match = message.shell(draft, event.data.shell.id) if (!match) return - match.shell = event.data.shell + match.status = event.data.shell.status + match.exit = event.data.shell.exit match.output = event.data.output match.time.completed = event.created }) @@ -437,6 +439,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ type: "assistant", agent: event.data.agent, model: event.data.model, + metadata: event.metadata, content: [], snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, time: { created: event.created }, @@ -497,7 +500,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ id: event.data.callID, name: event.data.name, time: { created: event.created }, - state: { status: "pending", input: "" }, + state: { status: "streaming", input: "" }, }) }) break @@ -507,7 +510,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) - if (match?.state.status === "pending") match.state.input += event.data.delta + if (match?.state.status === "streaming") match.state.input += event.data.delta }) break case "session.tool.input.ended": @@ -516,7 +519,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) - if (match?.state.status === "pending") match.state.input = event.data.text + if (match?.state.status === "streaming") match.state.input = event.data.text }) break case "session.tool.called": @@ -568,7 +571,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) - if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return + if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return match.state = { status: "error", error: event.data.error, @@ -623,29 +626,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setSessionStatus(event.data.sessionID, "running") break case "session.compaction.admitted": - message.update(event.data.sessionID, (draft, index) => { - if (message.compaction(draft)) return - message.append(draft, index, { - id: event.data.inputID, - type: "compaction", - status: "queued", - reason: "manual", - summary: "", - recent: "", - time: { created: event.created }, - }) - }) break case "session.compaction.started": - setStore("session", "compaction", event.data.sessionID, "") - setStore("session", "compactionReason", event.data.sessionID, event.data.reason) message.update(event.data.sessionID, (draft, index) => { - const current = message.compaction(draft) - if (current) { - current.status = "running" - current.reason = event.data.reason - return - } message.append(draft, index, { id: event.data.inputID ?? messageIDFromEvent(event.id), type: "compaction", @@ -661,10 +644,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.execution.failed": case "session.execution.interrupted": setSessionStatus(event.data.sessionID, "idle") - if (store.session.compaction[event.data.sessionID] !== undefined) - setStore("session", "compaction", event.data.sessionID, undefined) - if (store.session.compactionReason[event.data.sessionID] !== undefined) - setStore("session", "compactionReason", event.data.sessionID, undefined) message.update(event.data.sessionID, (draft) => { const currentAssistant = message.activeAssistant(draft) if (currentAssistant) currentAssistant.retry = undefined @@ -695,22 +674,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.compaction.delta": - setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text) message.update(event.data.sessionID, (draft) => { const current = message.compaction(draft) - if (current) current.summary += event.data.text + if (current?.status === "running") current.summary += event.data.text }) break case "session.compaction.ended": - setStore("session", "compaction", event.data.sessionID, undefined) - setStore("session", "compactionReason", event.data.sessionID, undefined) message.update(event.data.sessionID, (draft, index) => { - const current = message.compaction(draft) - if (current) { - current.status = "completed" - current.reason = event.data.reason - current.summary = event.data.text - current.recent = event.data.recent + const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") + const current = draft[position] + if (current?.type === "compaction") { + draft[position] = { + id: current.id, + type: "compaction", + status: "completed", + reason: event.data.reason, + summary: event.data.text, + recent: event.data.recent, + metadata: current.metadata, + time: current.time, + } return } message.append(draft, index, { @@ -725,11 +708,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.compaction.failed": - setStore("session", "compaction", event.data.sessionID, undefined) - setStore("session", "compactionReason", event.data.sessionID, undefined) - message.update(event.data.sessionID, (draft) => { - const current = message.compaction(draft) - if (current) current.status = "failed" + message.update(event.data.sessionID, (draft, index) => { + const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") + const current = draft[position] + const failed: Extract = { + id: current?.id ?? event.data.inputID ?? messageIDFromEvent(event.id), + type: "compaction", + status: "failed", + reason: event.data.reason ?? "manual", + error: event.data.error ?? { + type: "compaction.failed", + message: "Compaction failed before recording an error", + }, + metadata: current?.type === "compaction" ? current.metadata : event.metadata, + time: current?.type === "compaction" ? current.time : { created: event.created }, + } + if (current?.type === "compaction") { + draft[position] = failed + return + } + message.append(draft, index, failed) }) break case "permission.v2.asked": @@ -847,73 +845,50 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.input[sessionID]?.includes(inputID) ?? false }, }, - compaction(sessionID: string) { - return store.session.compaction[sessionID] - }, async refresh(sessionID: string) { - setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID }))) + const generation = nextSessionRefresh(sessionID) + const usageGeneration = sessionUsage.get(sessionID)?.generation ?? 0 + const info = mutable(await sdk.api.session.get({ sessionID })) + if (!applySessionRefresh(sessionID, generation)) return + const usage = sessionUsage.get(sessionID) + setStore( + "session", + "info", + sessionID, + usage && usage.generation !== usageGeneration ? { ...info, cost: usage.cost, tokens: usage.tokens } : info, + ) registerSession(sessionID) }, message: { ids(sessionID: string) { - return (store.session.message[sessionID]?.items ?? []).map((message) => message.id) + return (store.session.message[sessionID] ?? []).map((message) => message.id) }, list(sessionID: string) { - return store.session.message[sessionID]?.items ?? [] + return store.session.message[sessionID] ?? [] }, get(sessionID: string, messageID: string) { - const messages = store.session.message[sessionID]?.items + const messages = store.session.message[sessionID] const position = messageIndex.get(sessionID)?.get(messageID) return position === undefined ? undefined : messages?.[position] }, - cursor(sessionID: string) { - return store.session.message[sessionID]?.cursor - }, - complete(sessionID: string) { - return store.session.message[sessionID]?.complete ?? false - }, - loading(sessionID: string) { - return store.session.message[sessionID]?.loading ?? false - }, async refresh(sessionID: string) { - setStore("session", "message", sessionID, { ...emptyMessages(), loading: true }) + const live = [...(store.session.message[sessionID] ?? [])] + setStore("session", "message", sessionID, []) messageIndex.set(sessionID, new Map()) - const response = await sdk.api.message.list({ sessionID, limit: MESSAGE_PAGE_SIZE, order: "desc" }) - const items = mutable(response.data).toReversed() - messageIndex.set(sessionID, new Map(items.map((message, index) => [message.id, index]))) - setStore("session", "message", sessionID, { - items, - cursor: response.cursor.next ?? undefined, - complete: response.data.length < MESSAGE_PAGE_SIZE, - loading: false, - }) - const running = items.find((message) => message.type === "compaction" && message.status === "running") - setStore("session", "compaction", sessionID, running?.type === "compaction" ? running.summary : undefined) - setStore( - "session", - "compactionReason", - sessionID, - running?.type === "compaction" ? running.reason : undefined, - ) - }, - async more(sessionID: string) { - const current = store.session.message[sessionID] - if (!current || current.loading || current.complete || !current.cursor) return - const cursor = current.cursor - setStore("session", "message", sessionID, "loading", true) - const response = await sdk.api.message.list({ sessionID, limit: MESSAGE_PAGE_SIZE, cursor }) - const older = mutable(response.data).toReversed() - const prepend = older.filter((item) => !messageIndex.get(sessionID)?.has(item.id)) - const items = [...prepend, ...current.items] - messageIndex.set(sessionID, new Map(items.map((item, position) => [item.id, position]))) - batch(() => { - setStore("session", "message", sessionID, "items", items) - setStore("session", "message", sessionID, { - cursor: response.cursor.next ?? undefined, - complete: response.data.length < MESSAGE_PAGE_SIZE, - loading: false, - }) - }) + const loaded = mutable( + (await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data, + ).toReversed() + const loadedIDs = new Set(loaded.map((message) => message.id)) + const liveByID = new Map(live.map((message) => [message.id, message])) + const messages = [ + ...loaded.map((message) => { + if (message.type === "user") return message + return liveByID.get(message.id) ?? message + }), + ...live.filter((message) => !loadedIDs.has(message.id)), + ].toSorted((a, b) => a.time.created - b.time.created) + messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) + setStore("session", "message", sessionID, messages) }, }, permission: { @@ -1056,6 +1031,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async function bootstrap() { if (bootstrapping) return bootstrapping + const generation = new Map(sessionRefreshApplied) + const usageGeneration = new Map(Array.from(sessionUsage, ([id, usage]) => [id, usage.generation])) bootstrapping = Promise.allSettled([ sdk.api.session .list({ @@ -1069,7 +1046,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ "session", "info", produce((draft) => { - for (const session of response.data) draft[session.id] = mutable(session) + for (const session of response.data) { + if ((sessionRefreshApplied.get(session.id) ?? 0) !== (generation.get(session.id) ?? 0)) continue + const usage = sessionUsage.get(session.id) + draft[session.id] = mutable( + usage && usage.generation !== (usageGeneration.get(session.id) ?? 0) + ? { ...session, cost: usage.cost, tokens: usage.tokens } + : session, + ) + } }), ) for (const session of response.data) registerSession(session.id) @@ -1116,16 +1101,23 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } function refreshActive() { + const generation = ++connectionGeneration + const changed = new Set() + statusChanges = changed void sdk.api.session .active() .then((active) => { - setStore( - "session", - "status", - reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))), + if (generation !== connectionGeneration) return + const status: Record = Object.fromEntries( + Object.keys(active).map((sessionID) => [sessionID, "running" as const]), ) + for (const sessionID of changed) status[sessionID] = store.session.status[sessionID] + setStore("session", "status", reconcile(status)) }) .catch(() => undefined) + .finally(() => { + if (statusChanges === changed) statusChanges = undefined + }) } onCleanup( diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 1c02329d62c..e37fae92494 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -198,16 +198,6 @@ export function Session() { ?.id }) - // Admitted inputs and in-flight manual compaction sit after history, not in rows. - const pendingMessages = createMemo(() => { - const boundary = session()?.revert?.messageID - return messages().filter((message) => { - if (boundary && message.id >= boundary) return false - if (data.session.input.has(route.sessionID, message.id)) return true - return message.type === "compaction" && (message.status === "queued" || message.status === "running") - }) - }) - const lastAssistant = createMemo(() => { return messages().findLast((x) => x.type === "assistant") }) @@ -251,44 +241,37 @@ export function Session() { }), ) - createEffect( - on( - () => route.sessionID, - (sessionID) => { - void (async () => { - if (data.session.message.list(sessionID).length === 0) { - await Promise.all([ - data.session.refresh(sessionID), - data.session.message.refresh(sessionID), - data.session.permission.refresh(sessionID), - data.session.form.refresh(sessionID), - ]) - } - const info = data.session.get(sessionID) - if (!info) { - toast.show({ - message: `Session not found: ${sessionID}`, - variant: "error", - duration: 5000, - }) - navigate({ type: "home" }) - return - } - project.workspace.set(info.location.workspaceID) - editor.reconnect(info.location.directory) - if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) - })().catch((error) => { - if (route.sessionID !== sessionID) return - toast.show({ - message: errorMessage(error), - variant: "error", - duration: 5000, - }) - navigate({ type: "home" }) + createEffect(() => { + const sessionID = route.sessionID + void (async () => { + await Promise.all([ + data.session.refresh(sessionID), + data.session.permission.refresh(sessionID), + data.session.form.refresh(sessionID), + ]) + const info = data.session.get(sessionID) + if (!info) { + toast.show({ + message: `Session not found: ${sessionID}`, + variant: "error", + duration: 5000, }) - }, - ), - ) + navigate({ type: "home" }) + return + } + project.workspace.set(info.location.workspaceID) + editor.reconnect(info.location.directory) + if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) + })().catch((error) => { + if (route.sessionID !== sessionID) return + toast.show({ + message: errorMessage(error), + variant: "error", + duration: 5000, + }) + navigate({ type: "home" }) + }) + }) let seeded = false let scroll: ScrollBoxRenderable @@ -344,14 +327,12 @@ export function Session() { if (!targetID) { scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height) - if (direction === "prev") loadOlder() dialog.clear() return } const child = scroll.getChildren().find((c) => c.id === targetID) if (child) scroll.scrollBy(child.y - scroll.y - 1) - if (direction === "prev") loadOlder() dialog.clear() } @@ -362,24 +343,6 @@ export function Session() { }, 50) } - let loadingOlder = false - function loadOlder() { - if (loadingOlder || scroll.scrollTop > 2) return - loadingOlder = true - const before = scroll.scrollHeight - void data.session.message.more(route.sessionID).then( - () => { - setTimeout(() => { - if (!scroll.isDestroyed) scroll.scrollBy(scroll.scrollHeight - before) - loadingOlder = false - }, 50) - }, - () => { - loadingOlder = false - }, - ) - } - const sessionCommandList = createMemo(() => [ { title: "Share session", @@ -585,7 +548,6 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-scroll.height / 2) - loadOlder() dialog.clear() }, }, @@ -606,7 +568,6 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-1) - loadOlder() dialog.clear() }, }, @@ -627,7 +588,6 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-scroll.height / 4) - loadOlder() dialog.clear() }, }, @@ -648,7 +608,6 @@ export function Session() { hidden: true, run: () => { scroll.scrollTo(0) - loadOlder() dialog.clear() }, }, @@ -958,9 +917,6 @@ export function Session() { stickyStart="bottom" flexGrow={1} scrollAcceleration={scrollAcceleration()} - onMouseScroll={(event) => { - if (event.scroll?.direction === "up") void loadOlder() - }} > {(row) => ( @@ -970,13 +926,6 @@ export function Session() { /> )} - - {(message) => ( - - - - )} - ) { const [rows, setRows] = createStore([]) const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID - function pendingIDs() { - const inputs = data.session.input.list(sessionID()) - const pending = new Set(inputs) - for (const message of data.session.message.list(sessionID())) { - if (message.type === "compaction" && (message.status === "queued" || message.status === "running")) - pending.add(message.id) - } - return pending - } - function reduce() { const messages = data.session.message.list(sessionID()) + const inputs = new Set(data.session.input.list(sessionID())) const boundary = revertBoundary() - const visible = boundary ? messages.filter((message) => message.id < boundary) : messages - const pending = pendingIDs() - const rows = reduceSessionRows(visible.filter((message) => !pending.has(message.id))) + const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs) partitionPending(rows, pendingPermissions()) return rows } @@ -63,30 +52,48 @@ export function createSessionRows(sessionID: Accessor) { }) createEffect( - on(sessionID, () => { - setRows(reduce()) + on(sessionID, (id) => { + setRows(reconcile(reduce())) + void data.session.message.refresh(id).then( + () => { + if (sessionID() !== id) return + setRows(reconcile(reduce())) + }, + () => undefined, + ) }), ) + // Re-reduce when the revert boundary changes (stage/clear/commit). createEffect( on(revertBoundary, () => { - setRows(reduce()) + setRows(reconcile(reduce())) }), ) - // Pending inputs and compaction leaving the pending set change history membership. createEffect( on( - () => { - const messages = data.session.message.list(sessionID()) - const pending = data.session.input.list(sessionID()).join("\0") - const compaction = messages - .filter((message) => message.type === "compaction") - .map((message) => `${message.id}:${message.status}`) - .join("\0") - return `${pending}\u0001${compaction}` - }, - () => setRows(reduce()), + () => + data.session.message.list(sessionID()).flatMap((message) => + message.type === "user" + ? [ + { + id: message.id, + created: message.time.created, + input: data.session.input.has(sessionID(), message.id), + }, + ] + : message.type === "compaction" + ? [ + { + id: message.id, + created: message.time.created, + input: message.status === "running", + }, + ] + : [], + ), + () => setRows(reconcile(reduce())), ), ) @@ -94,9 +101,12 @@ export function createSessionRows(sessionID: Accessor) { setRows( produce((draft) => { if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return - if (pendingIDs().has(messageID)) return - completePrevious(draft) - draft.push({ type: "message", messageID }) + const pending = isPending(messageID) + const message = data.session.message.get(sessionID(), messageID) + const index = + message?.type === "compaction" && pending ? queuedStart(draft) : pending ? draft.length : queuedStart(draft) + if (!pending) completePrevious(draft, index) + draft.splice(index, 0, { type: "message", messageID }) }), ) @@ -104,14 +114,15 @@ export function createSessionRows(sessionID: Accessor) { setRows( produce((draft) => { if (hasPart(draft, ref)) return + const index = queuedStart(draft) if (name && exploration(name)) { - const previous = draft.at(-1) + const previous = draft[index - 1] if (previous?.type === "group" && previous.kind === "exploration") { previous.refs.push(ref) return } - completePrevious(draft) - draft.push({ + completePrevious(draft, index) + draft.splice(index, 0, { type: "group", kind: "exploration", refs: [ref], @@ -120,8 +131,8 @@ export function createSessionRows(sessionID: Accessor) { }) return } - completePrevious(draft) - draft.push({ type: "part", ref }) + completePrevious(draft, index) + draft.splice(index, 0, { type: "part", ref }) }), ) @@ -129,8 +140,9 @@ export function createSessionRows(sessionID: Accessor) { setRows( produce((draft) => { if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return - completePrevious(draft) - draft.push({ type: "assistant-footer", messageID }) + const index = queuedStart(draft) + completePrevious(draft, index) + draft.splice(index, 0, { type: "assistant-footer", messageID }) }), ) @@ -142,13 +154,26 @@ export function createSessionRows(sessionID: Accessor) { }), ) + const isPending = (messageID: string) => { + const message = data.session.message.get(sessionID(), messageID) + if (message?.type === "user") return data.session.input.has(sessionID(), messageID) + return message?.type === "compaction" && message.status === "running" + } + + const queuedStart = (rows: SessionRow[]) => { + const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID)) + return index === -1 ? rows.length : index + } + const message = (event: { id: string; data: { sessionID: string } }) => { if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_")) } + const input = (event: { data: { sessionID: string; inputID: string } }) => { + if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID) + } const subscriptions = [ - data.on("session.prompt.promoted", (event) => { - if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID) - }), + data.on("session.prompt.admitted", input), + data.on("session.compaction.started", message), data.on("session.instructions.updated", message), data.on("session.synthetic", (event) => { if (event.data.sessionID === sessionID() && event.data.description?.trim()) @@ -157,7 +182,9 @@ export function createSessionRows(sessionID: Accessor) { data.on("session.shell.started", message), data.on("session.agent.selected", message), data.on("session.model.selected", message), - + data.on("session.compaction.ended", (event) => { + if (event.data.reason !== "manual") message(event) + }), data.on("session.text.delta", (event) => { if (event.data.sessionID === sessionID()) appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }) @@ -197,11 +224,18 @@ export function createSessionRows(sessionID: Accessor) { return rows } -export function reduceSessionRows(messages: SessionMessage[]) { - return messages.reduce((rows, message) => { +export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set()) { + const isInput = (message: SessionMessageInfo) => inputs.has(message.id) + const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running") + const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs]) + return [ + ...messages.filter((message) => !pending.has(message.id)), + ...pendingCompactions, + ...messages.filter(isInput), + ].reduce((rows, message) => { if (message.type !== "assistant") { if (message.type === "synthetic" && !message.description?.trim()) return rows - completePrevious(rows) + if (!pending.has(message.id)) completePrevious(rows) rows.push({ type: "message", messageID: message.id }) return rows } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index b17e6eea576..c116f431787 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -114,92 +114,24 @@ test("refreshes resources into reactive getters", async () => { } }) -test("pages older messages through nested message state", async () => { - const events = createEventStream() - const sessionID = "ses_message_page" - const pages: Array<{ limit?: string | null; order?: string | null; cursor?: string | null }> = [] - // Full first page (desc) so complete stays false until a short older page arrives. - const first = Array.from({ length: 50 }, (_, index) => { - const n = 51 - index - return { id: `msg_${n}`, type: "user" as const, text: String(n), time: { created: n } } - }) - const calls = createFetch((url) => { - if (url.pathname !== `/api/session/${sessionID}/message`) return - pages.push({ - limit: url.searchParams.get("limit"), - order: url.searchParams.get("order"), - cursor: url.searchParams.get("cursor"), - }) - if (!url.searchParams.get("cursor")) - return json({ - data: first, - cursor: { next: "cursor-older" }, - }) - return json({ - data: [{ id: "msg_1", type: "user", text: "one", time: { created: 1 } }], - cursor: {}, - }) - }, events) - let data!: ReturnType - - function Probe() { - data = useData() - return - } - - const app = await testRender(() => ( - - - - - - - - - - )) - - try { - await data.session.message.refresh(sessionID) - expect(pages).toEqual([{ limit: "50", order: "desc", cursor: null }]) - expect(data.session.message.ids(sessionID)).toEqual(first.toReversed().map((message) => message.id)) - expect(data.session.message.cursor(sessionID)).toBe("cursor-older") - expect(data.session.message.complete(sessionID)).toBe(false) - expect(data.session.message.loading(sessionID)).toBe(false) - - await data.session.message.more(sessionID) - expect(pages).toEqual([ - { limit: "50", order: "desc", cursor: null }, - { limit: "50", order: null, cursor: "cursor-older" }, - ]) - expect(data.session.message.ids(sessionID)[0]).toBe("msg_1") - expect(data.session.message.ids(sessionID)).toHaveLength(51) - expect(data.session.message.cursor(sessionID)).toBeUndefined() - expect(data.session.message.complete(sessionID)).toBe(true) - - await data.session.message.more(sessionID) - expect(pages).toHaveLength(2) - } finally { - app.renderer.destroy() - } -}) - -test("applies absolute usage events to session info", async () => { +test("applies absolute usage events without losing full session updates", async () => { const events = createEventStream() const sessionID = "ses_usage_refresh" + let resolveSessions!: (response: Response) => void + const resolveSession: Array<(response: Response) => void> = [] + let sessionsRequested = false const calls = createFetch((url) => { - if (url.pathname === `/api/session/${sessionID}`) - return json({ - data: { - id: sessionID, - projectID: "proj_test", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 0, updated: 0 }, - title: "Usage", - location: { directory }, - }, + if (url.pathname === "/api/session") { + sessionsRequested = true + return new Promise((resolve) => { + resolveSessions = resolve }) + } + if (url.pathname === `/api/session/${sessionID}`) { + return new Promise((resolve) => { + resolveSession.push(resolve) + }) + } }, events) let data!: ReturnType @@ -221,7 +153,7 @@ test("applies absolute usage events to session info", async () => { )) try { - await data.session.refresh(sessionID) + await wait(() => sessionsRequested) emitEvent(events, { id: "evt_usage_2", created: 2, @@ -232,6 +164,38 @@ test("applies absolute usage events to session info", async () => { tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }, }, }) + const initialRefresh = data.session.refresh(sessionID) + await wait(() => resolveSession.length === 1) + resolveSessions( + json({ + data: [ + { + id: sessionID, + projectID: "proj_test", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Stale usage", + location: { directory }, + }, + ], + cursor: {}, + }), + ) + resolveSession[0]( + json({ + data: { + id: sessionID, + projectID: "proj_test", + cost: 0.5, + tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }, + time: { created: 0, updated: 0 }, + title: "Current usage", + location: { directory }, + }, + }), + ) + await initialRefresh await wait(() => data.session.get(sessionID)?.cost === 0.5) expect(data.session.get(sessionID)?.tokens).toEqual({ input: 5, @@ -240,6 +204,7 @@ test("applies absolute usage events to session info", async () => { cache: { read: 1, write: 1 }, }) + const fullRefresh = data.session.refresh(sessionID) emitEvent(events, { id: "evt_usage_3", created: 3, @@ -251,8 +216,57 @@ test("applies absolute usage events to session info", async () => { }, }) await wait(() => data.session.get(sessionID)?.cost === 1) - expect(data.session.get(sessionID)?.title).toBe("Usage") + resolveSession[1]( + json({ + data: { + id: sessionID, + projectID: "proj_test", + cost: 0.75, + tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } }, + time: { created: 0, updated: 0 }, + title: "Older usage", + location: { directory }, + }, + }), + ) + await fullRefresh + await Bun.sleep(20) + expect(data.session.get(sessionID)?.cost).toBe(1) + expect(data.session.get(sessionID)?.title).toBe("Older usage") + emitEvent(events, { + id: "evt_usage_6", + created: 6, + type: "session.usage.updated", + data: { + sessionID, + cost: 1.25, + tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } }, + }, + }) + emitEvent(events, { + id: "evt_usage_7", + created: 7, + type: "session.usage.updated", + data: { + sessionID, + cost: 1.25, + tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } }, + }, + }) + await wait(() => data.session.get(sessionID)?.cost === 1.25) + expect(data.session.get(sessionID)?.title).toBe("Older usage") + + emitEvent(events, { + id: "evt_usage_8", + created: 8, + type: "session.usage.updated", + data: { + sessionID, + cost: 1.5, + tokens: { input: 14, output: 6, reasoning: 1, cache: { read: 1, write: 1 } }, + }, + }) emitEvent(events, { id: "evt_usage_deleted", created: 9, @@ -260,7 +274,8 @@ test("applies absolute usage events to session info", async () => { durable: durable(sessionID, 9, 2), data: { sessionID }, }) - await wait(() => data.session.get(sessionID) === undefined) + await Bun.sleep(20) + expect(data.session.get(sessionID)).toBeUndefined() } finally { app.renderer.destroy() } @@ -594,7 +609,15 @@ test("reconnects the event stream and bootstraps fresh data", async () => { expect(data.connection.error()).toBe("Event stream disconnected") await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000) - resolveActive(json({ data: { "session-new": { type: "running" } } })) + emitEvent(events, { + id: "evt_execution_started_after_reconnect", + created: 1, + type: "session.execution.started", + durable: durable("session-new"), + data: { sessionID: "session-new" }, + }) + await wait(() => data.session.status("session-new") === "running") + resolveActive(json({ data: {} })) await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000) await wait(() => data.session.status("session-stale") === "idle") @@ -608,18 +631,16 @@ test("reconnects the event stream and bootstraps fresh data", async () => { } }) -test("keeps pending prompts out of history rows until promoted", async () => { +test("completes exploration when a queued prompt is promoted", async () => { const events = createEventStream() const sessionID = "session-promotion" const calls = createFetch((url) => { if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) }, events) let rows!: ReturnType - let data!: ReturnType function Probe() { rows = createSessionRows(() => sessionID) - data = useData() return } @@ -674,8 +695,7 @@ test("keeps pending prompts out of history rows until promoted", async () => { delivery: "steer", }, }) - await wait(() => data.session.input.has(sessionID, "message-user")) - expect(rows.some((row) => row.type === "message" && row.messageID === "message-user")).toBe(false) + await wait(() => rows.at(-1)?.type === "message") expect(rows.find((row) => row.type === "group")?.completed).toBe(false) emitEvent(events, { @@ -685,8 +705,7 @@ test("keeps pending prompts out of history rows until promoted", async () => { durable: durable(sessionID, 3), data: { sessionID, inputID: "message-user" }, }) - await wait(() => rows.some((row) => row.type === "message" && row.messageID === "message-user")) - expect(data.session.input.has(sessionID, "message-user")).toBe(false) + await wait(() => rows.find((row) => row.type === "group")?.completed === true) expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" }) } finally { app.renderer.destroy() diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index 1def9caa555..c43a6faf9df 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -207,25 +207,31 @@ test("renders a footer for a pre-output retry assistant after replay", () => { expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }]) }) -test("history reduce keeps chronological order without pending reordering", () => { +test("places a running compaction barrier before every queued user message", () => { + const queued = (id: string, text: string, created: number): SessionMessageInfo => ({ + type: "user", + id, + text, + time: { created }, + }) const messages: SessionMessageInfo[] = [ - { type: "user", id: "user-1", text: "Before", time: { created: 1 } }, + queued("user-before", "Before", 1), { type: "compaction", id: "compaction", - status: "completed", + status: "running", reason: "manual", - summary: "done", + summary: "", recent: "", time: { created: 2 }, }, - { type: "user", id: "user-2", text: "After", time: { created: 3 } }, + queued("user-after", "After", 3), ] - expect(reduceSessionRows(messages)).toEqual([ - { type: "message", messageID: "user-1" }, + expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([ { type: "message", messageID: "compaction" }, - { type: "message", messageID: "user-2" }, + { type: "message", messageID: "user-before" }, + { type: "message", messageID: "user-after" }, ]) }) From 4d2b06f8cf97618369e7c3a93559d40a57c13f7e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 8 Jul 2026 10:11:46 -0400 Subject: [PATCH 03/32] docs: prohibit bypassing git hooks --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c89653ba95a..d6bd72d4b00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,8 @@ Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes a Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`. +Never bypass Git hooks. Do not use `--no-verify` or otherwise disable, skip, or circumvent commit or push hooks. If a hook fails, fix the failure or stop and report it to the user. + ## Style Guide ### General Principles From c6156f171c1bb4ca77a1fe1fc5f28b1366502ac6 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 8 Jul 2026 10:33:11 -0400 Subject: [PATCH 04/32] feat(simulation): stream UI recordings (#35909) --- packages/simulation/package.json | 3 +- packages/simulation/src/frontend/actions.ts | 68 ++--------- packages/simulation/src/frontend/renderer.ts | 38 +++++- packages/simulation/src/frontend/server.ts | 51 ++------ .../simulation/src/frontend/simulation.ts | 9 +- packages/simulation/src/manifest.ts | 19 +-- packages/simulation/src/protocol/index.ts | 23 ++-- packages/simulation/src/recording.ts | 115 ++++++++++++++++++ packages/simulation/test/recording.test.ts | 55 +++++++++ 9 files changed, 249 insertions(+), 132 deletions(-) create mode 100644 packages/simulation/src/recording.ts create mode 100644 packages/simulation/test/recording.test.ts diff --git a/packages/simulation/package.json b/packages/simulation/package.json index a98b6f3289c..f9be3bfc33f 100644 --- a/packages/simulation/package.json +++ b/packages/simulation/package.json @@ -10,7 +10,8 @@ "./backend/*": "./src/backend/*.ts", "./frontend": "./src/frontend/simulation.ts", "./frontend/*": "./src/frontend/*.ts", - "./protocol": "./src/protocol/index.ts" + "./protocol": "./src/protocol/index.ts", + "./recording": "./src/recording.ts" }, "scripts": { "typecheck": "tsgo --noEmit" diff --git a/packages/simulation/src/frontend/actions.ts b/packages/simulation/src/frontend/actions.ts index 4b03cb69395..068949459b7 100644 --- a/packages/simulation/src/frontend/actions.ts +++ b/packages/simulation/src/frontend/actions.ts @@ -1,7 +1,7 @@ -import { mkdtemp } from "node:fs/promises" +import { mkdir, mkdtemp } from "node:fs/promises" import { tmpdir } from "node:os" -import { join } from "node:path" -import type { CapturedFrame, CliRenderer, Renderable } from "@opentui/core" +import { dirname, join } from "node:path" +import type { CliRenderer, Renderable } from "@opentui/core" import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing" import type { SimulationProtocol } from "../protocol" import { SimulationRenderer } from "./renderer" @@ -104,67 +104,15 @@ export function state(harness: Harness) { } } -export async function screenshot(harness: Harness) { +export async function screenshot(harness: Harness, output?: string) { await harness.renderOnce() const image = SimulationPng.screenshot(harness.renderer) - const path = join(await mkdtemp(join(tmpdir(), "opencode-drive-")), "screenshot.png") + const path = output ?? join(await mkdtemp(join(tmpdir(), "opencode-drive-")), "screenshot.png") + if (output) await mkdir(dirname(output), { recursive: true }) await Bun.write(path, image.data) return path } -export function frame(harness: Harness): CapturedFrame { - const buffer = harness.renderer.currentRenderBuffer - return { - cols: buffer.width, - rows: buffer.height, - cursor: [0, 0], - lines: buffer.getSpanLines().map((line) => ({ - spans: line.spans.map((span) => ({ - text: span.text, - fg: span.fg, - bg: span.bg, - attributes: span.attributes, - width: span.width, - })), - })), - } -} - -export async function video(frames: CapturedFrame[]) { - const directory = await mkdtemp(join(tmpdir(), "opencode-drive-recording-")) - await Promise.all( - frames.map((frame, index) => - Bun.write( - join(directory, `frame-${index.toString().padStart(6, "0")}.png`), - SimulationPng.screenshotFrame(frame).data, - ), - ), - ) - const path = join(directory, "recording.mp4") - const process = Bun.spawn( - [ - "ffmpeg", - "-loglevel", - "error", - "-framerate", - "10", - "-i", - join(directory, "frame-%06d.png"), - "-c:v", - "libx264", - "-pix_fmt", - "yuv420p", - "-movflags", - "+faststart", - "-y", - path, - ], - { stderr: "pipe" }, - ) - if ((await process.exited) !== 0) throw new Error(`ffmpeg failed: ${await new Response(process.stderr).text()}`) - return path -} - export async function execute(harness: Harness, action: Action) { switch (action.type) { case "ui.type": @@ -180,7 +128,9 @@ export async function execute(harness: Harness, action: Action) { harness.mockInput.pressArrow(action.direction) break case "ui.focus": - all(harness.renderer.root).find((item) => item.num === action.target)?.focus() + all(harness.renderer.root) + .find((item) => item.num === action.target) + ?.focus() break case "ui.click": await harness.mockMouse.click(action.x, action.y) diff --git a/packages/simulation/src/frontend/renderer.ts b/packages/simulation/src/frontend/renderer.ts index e973d57a98d..43947d9ea6b 100644 --- a/packages/simulation/src/frontend/renderer.ts +++ b/packages/simulation/src/frontend/renderer.ts @@ -1,21 +1,43 @@ import type { CliRenderer, CliRendererConfig } from "@opentui/core" import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing" +import { Timeline } from "../recording" const setups = new WeakMap() +const recordings = new WeakMap() /** - * Creates the headless simulation renderer: a real CliRenderer backed by an - * in-memory screen buffer instead of a terminal. The TestRendererSetup is - * kept module-side (keyed by renderer) so the harness can use the supported - * testing APIs without app code carrying it around. + * Creates a headless renderer with optional recording: a real CliRenderer + * backed by an in-memory screen buffer. The TestRendererSetup is kept + * module-side so the harness can use supported testing APIs without app + * code carrying it around. */ -export async function create(options: CliRendererConfig): Promise { +export async function create(options: CliRendererConfig, path?: string): Promise { + if (!path) { + const setup = await createTestRenderer({ + ...options, + width: 100, + height: 40, + }) + setups.set(setup.renderer, setup) + return setup.renderer + } + const recording = await Timeline.create(path, 100, 40) const setup = await createTestRenderer({ ...options, width: 100, height: 40, + stdout: recording as unknown as NodeJS.WriteStream, + bufferedOutput: "stdout", + onDestroy: () => { + void recording.finish().catch((error) => process.stderr.write(`Failed to finish UI recording: ${error}\n`)) + options.onDestroy?.() + }, + }).catch(async (error) => { + await recording.finish().catch(() => undefined) + throw error }) setups.set(setup.renderer, setup) + recordings.set(setup.renderer, recording) return setup.renderer } @@ -23,4 +45,10 @@ export function setupFor(renderer: CliRenderer): TestRendererSetup | undefined { return setups.get(renderer) } +export function finish(renderer: CliRenderer) { + const recording = recordings.get(renderer) + if (!recording) throw new Error("UI recording is not available") + return recording.finish() +} + export * as SimulationRenderer from "./renderer" diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index 4edd6878873..e3149c39f83 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -1,4 +1,3 @@ -import type { CapturedFrame } from "@opentui/core" import { SimulationProtocol } from "../protocol" import { SimulationActions, type Harness } from "./actions" @@ -11,50 +10,20 @@ function parseRequest(input: string | Buffer) { return SimulationProtocol.Frontend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) } -interface Recording { - readonly frames: CapturedFrame[] - readonly timer: ReturnType - pending: Promise -} - async function handle( harness: Harness, request: SimulationProtocol.Frontend.Request, - recording: { current?: Recording }, - headless: boolean, + finishRecording?: () => Promise, ) { switch (request.method) { case "ui.screenshot": - return SimulationActions.screenshot(harness) + return SimulationActions.screenshot(harness, request.params?.path) case "ui.state": { return SimulationActions.state(harness) } - case "ui.start-record": { - if (recording.current) throw new Error("UI recording is already active") - const frames = [SimulationActions.frame(harness)] - const current: Recording = { - frames, - timer: setInterval(() => { - current.pending = current.pending.then(async () => { - if (headless) await harness.renderOnce() - frames.push(SimulationActions.frame(harness)) - }) - }, 100), - pending: Promise.resolve(), - } - recording.current = current - return { recording: true } - } - case "ui.end-record": { - if (!recording.current) throw new Error("UI recording is not active") - const current = recording.current - clearInterval(current.timer) - await current.pending - if (headless) await harness.renderOnce() - current.frames.push(SimulationActions.frame(harness)) - recording.current = undefined - return SimulationActions.video(current.frames) - } + case "ui.recording.finish": + if (!finishRecording) throw new Error("UI recording is not available") + return finishRecording() case "ui.type": return SimulationActions.execute(harness, { type: "ui.type", text: request.params.text }) case "ui.enter": @@ -79,14 +48,13 @@ async function handle( } } -export function start(harness: Harness, endpoint: string, headless: boolean): Server { +export function start(harness: Harness, endpoint: string, finishRecording?: () => Promise): Server { const url = new URL(endpoint) - const recording: { current?: Recording } = {} - const server = Bun.serve<{ readonly drive: true; readonly headless: boolean }>({ + const server = Bun.serve<{ readonly drive: true }>({ hostname: url.hostname, port: Number(url.port), fetch(request, server) { - if (server.upgrade(request, { data: { drive: true, headless } })) return undefined + if (server.upgrade(request, { data: { drive: true } })) return undefined return new Response("opencode drive ui websocket", { status: 426 }) }, websocket: { @@ -94,7 +62,7 @@ export function start(harness: Harness, endpoint: string, headless: boolean): Se let request: SimulationProtocol.Frontend.Request | undefined try { request = parseRequest(message) - const result = await handle(harness, request, recording, headless) + const result = await handle(harness, request, finishRecording) const next = SimulationProtocol.JsonRpc.success(request.id, result) if (next) socket.send(JSON.stringify(next)) } catch (error) { @@ -106,7 +74,6 @@ export function start(harness: Harness, endpoint: string, headless: boolean): Se return { url: endpoint, stop: () => { - if (recording.current) clearInterval(recording.current.timer) server.stop(true) }, } diff --git a/packages/simulation/src/frontend/simulation.ts b/packages/simulation/src/frontend/simulation.ts index c2a56ad8c1c..2a08d2dbef7 100644 --- a/packages/simulation/src/frontend/simulation.ts +++ b/packages/simulation/src/frontend/simulation.ts @@ -14,11 +14,14 @@ import { SimulationServer } from "./server" */ export async function create(options: CliRendererConfig): Promise { const headless = process.env.OPENCODE_DRIVE_RENDERER === "headless" - const renderer = headless ? await SimulationRenderer.create(options) : await createCliRenderer(options) + const manifest = DriveManifest.resolve() + const renderer = headless + ? await SimulationRenderer.create(options, manifest.recording?.timeline) + : await createCliRenderer(options) const server = SimulationServer.start( SimulationActions.createHarness(renderer), - DriveManifest.resolve().endpoints.ui, - headless, + manifest.endpoints.ui, + headless && manifest.recording ? () => SimulationRenderer.finish(renderer) : undefined, ) process.stderr.write(`opencode drive ui websocket: ${server.url}\n`) renderer.once("destroy", () => server.stop()) diff --git a/packages/simulation/src/manifest.ts b/packages/simulation/src/manifest.ts index 0ef4530e486..5544d23b70e 100644 --- a/packages/simulation/src/manifest.ts +++ b/packages/simulation/src/manifest.ts @@ -1,12 +1,15 @@ import { existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" -import { join } from "node:path" +import { isAbsolute, join } from "node:path" export interface Manifest { readonly endpoints: { readonly ui: string readonly backend: string } + readonly recording?: { + readonly timeline: string + } } export const defaults: Manifest = { @@ -32,18 +35,16 @@ export function resolve() { if (!isManifest(manifest)) throw new Error(`Invalid drive manifest: ${file}`) validateEndpoint(manifest.endpoints.ui, "ui") validateEndpoint(manifest.endpoints.backend, "backend") + if (manifest.recording && !isAbsolute(manifest.recording.timeline)) { + throw new Error(`Invalid drive recording timeline path: ${manifest.recording.timeline}`) + } return manifest } function isManifest(value: unknown): value is Manifest { - if (typeof value !== "object" || value === null) return false - if (!("endpoints" in value) || typeof value.endpoints !== "object" || value.endpoints === null) return false - return ( - "ui" in value.endpoints && - typeof value.endpoints.ui === "string" && - "backend" in value.endpoints && - typeof value.endpoints.backend === "string" - ) + if (typeof value !== "object" || value === null || !("endpoints" in value)) return false + if (typeof value.endpoints !== "object" || value.endpoints === null) return false + return "ui" in value.endpoints && "backend" in value.endpoints } function validateEndpoint(value: string, name: string) { diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index 73c43f41190..eda31c30bfd 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -94,11 +94,11 @@ export namespace Frontend { export const Screenshot = Schema.String export type Screenshot = Schema.Schema.Type - export const StartRecord = Schema.Struct({ recording: Schema.Literal(true) }) - export interface StartRecord extends Schema.Schema.Type {} + export const RecordingFinish = Schema.String + export type RecordingFinish = Schema.Schema.Type - export const EndRecord = Schema.String - export type EndRecord = Schema.Schema.Type + export const ArtifactParams = Schema.Struct({ path: Schema.optional(Schema.String) }) + export interface ArtifactParams extends Schema.Schema.Type {} export const TypeParams = Schema.Struct({ text: Schema.String }) export interface TypeParams extends Schema.Schema.Type {} @@ -123,18 +123,16 @@ export namespace Frontend { Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.click"), params: ClickParams }), Schema.Struct({ ...JsonRpc.RequestFields, - method: Schema.Literals([ - "ui.enter", - "ui.screenshot", - "ui.state", - "ui.start-record", - "ui.end-record", - ]), + method: Schema.Literal("ui.screenshot"), + params: Schema.optional(ArtifactParams), + }), + Schema.Struct({ + ...JsonRpc.RequestFields, + method: Schema.Literals(["ui.enter", "ui.state", "ui.recording.finish"]), }), ]) export type Request = Schema.Schema.Type export const decodeRequest = Schema.decodeUnknownSync(Request) - } export namespace Backend { @@ -189,7 +187,6 @@ export namespace Backend { matched: Schema.Boolean, }) export interface NetworkLogEntry extends Schema.Schema.Type {} - } export * as SimulationProtocol from "./index" diff --git a/packages/simulation/src/recording.ts b/packages/simulation/src/recording.ts new file mode 100644 index 00000000000..6d0b3a6bd59 --- /dev/null +++ b/packages/simulation/src/recording.ts @@ -0,0 +1,115 @@ +import { createWriteStream, type WriteStream } from "node:fs" +import { mkdir } from "node:fs/promises" +import { dirname } from "node:path" +import { Writable } from "node:stream" +import { finished } from "node:stream/promises" +import { Schema } from "effect" + +export const Header = Schema.Struct({ + type: Schema.Literal("header"), + version: Schema.Literal(1), + cols: Schema.Number, + rows: Schema.Number, + encoding: Schema.Literal("base64"), +}) +export interface Header extends Schema.Schema.Type {} + +export const Output = Schema.Struct({ + type: Schema.Literal("output"), + at_ms: Schema.Number, + data: Schema.String, +}) +export interface Output extends Schema.Schema.Type {} + +export const Event = Schema.Union([Header, Output]) +export type Event = Schema.Schema.Type + +export class Timeline extends Writable { + readonly isTTY = true + readonly path: string + readonly columns: number + readonly rows: number + private readonly output: WriteStream + private readonly started = performance.now() + private readonly timestamps: number[] = [] + private done?: Promise + + private constructor(path: string, cols: number, rows: number, output: WriteStream) { + super() + this.path = path + this.columns = cols + this.rows = rows + this.output = output + // finish() reports stream failures; keep Writable from also throwing them process-wide. + this.on("error", () => {}) + output.on("error", (error) => this.destroy(error)) + } + + static async create(path: string, cols: number, rows: number) { + await mkdir(dirname(path), { recursive: true }) + const output = createWriteStream(path) + const timeline = new Timeline(path, cols, rows, output) + await new Promise((resolve, reject) => { + output.write( + `${JSON.stringify({ type: "header", version: 1, cols, rows, encoding: "base64" } satisfies Header)}\n`, + (error) => (error ? reject(error) : resolve()), + ) + }) + return timeline + } + + getColorDepth() { + return 24 + } + + override write(chunk: unknown, callback?: (error?: Error | null) => void): boolean + override write(chunk: unknown, encoding: BufferEncoding, callback?: (error?: Error | null) => void): boolean + override write( + chunk: unknown, + encoding?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, + ) { + if (!this.writableEnded) { + this.timestamps.push(this.elapsed()) + if (typeof encoding === "function") return super.write(chunk, encoding) + if (encoding === undefined) return super.write(chunk, callback) + return super.write(chunk, encoding, callback) + } + const done = typeof encoding === "function" ? encoding : callback + queueMicrotask(() => done?.(null)) + return true + } + + override _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void) { + this.writeOutput(chunk, this.timestamps.shift() ?? this.elapsed(), callback) + } + + override _final(callback: (error?: Error | null) => void) { + this.writeOutput(Buffer.alloc(0), this.elapsed(), (error) => { + if (error) return callback(error) + this.output.end(callback) + }) + } + + finish() { + if (this.done) return this.done + this.end() + this.done = finished(this).then(() => this.path) + return this.done + } + + private elapsed() { + return Math.max(0, Math.round(performance.now() - this.started)) + } + + private writeOutput(data: Buffer, at_ms: number, callback: (error?: Error | null) => void) { + const event = { + type: "output", + at_ms, + data: data.toString("base64"), + } satisfies Output + this.output.write(`${JSON.stringify(event)}\n`, callback) + } +} + +export * as SimulationRecording from "./recording" diff --git a/packages/simulation/test/recording.test.ts b/packages/simulation/test/recording.test.ts new file mode 100644 index 00000000000..d87453bb79a --- /dev/null +++ b/packages/simulation/test/recording.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { SimulationRenderer } from "../src/frontend/renderer" +import { Timeline, type Event } from "../src/recording" + +test("streams ANSI chunks into a versioned JSONL timeline", async () => { + const directory = await mkdtemp(join(tmpdir(), "simulation-recording-")) + const path = join(directory, "nested", "timeline.jsonl") + + try { + const timeline = await Timeline.create(path, 80, 24) + await new Promise((resolve, reject) => { + timeline.write(Buffer.from("\u001b[2Jhello"), (error) => (error ? reject(error) : resolve())) + }) + expect(await timeline.finish()).toBe(path) + await new Promise((resolve) => timeline.write(Buffer.from("ignored"), () => resolve())) + + const events = (await Bun.file(path).text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Event) + expect(events[0]).toEqual({ type: "header", version: 1, cols: 80, rows: 24, encoding: "base64" }) + expect(events[1]?.type).toBe("output") + if (events[1]?.type !== "output") throw new Error("Missing output event") + expect(Buffer.from(events[1].data, "base64").toString()).toBe("\u001b[2Jhello") + expect(events[1].at_ms).toBeGreaterThanOrEqual(0) + expect(events.at(-1)).toMatchObject({ type: "output", data: "" }) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test("captures native renderer output and finishes on destroy", async () => { + const directory = await mkdtemp(join(tmpdir(), "simulation-renderer-recording-")) + const path = join(directory, "timeline.jsonl") + const renderer = await SimulationRenderer.create({}, path) + + try { + await SimulationRenderer.setupFor(renderer)?.renderOnce() + renderer.destroy() + expect(await SimulationRenderer.finish(renderer)).toBe(path) + + const events = (await Bun.file(path).text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Event) + expect(events.some((event) => event.type === "output")).toBe(true) + } finally { + if (!renderer.isDestroyed) renderer.destroy() + await SimulationRenderer.finish(renderer) + await rm(directory, { recursive: true, force: true }) + } +}) From 8b634e4a589eef93f06071741aaf64a65bb4ab0c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 8 Jul 2026 10:37:29 -0400 Subject: [PATCH 05/32] refactor(tui): simplify client data state --- packages/tui/src/app.tsx | 12 +- packages/tui/src/context/data.tsx | 102 +++----------- packages/tui/src/context/sdk.tsx | 11 +- packages/tui/test/cli/tui/data.test.tsx | 170 ++++++------------------ 4 files changed, 65 insertions(+), 230 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 8d924c9e9b5..cbb398a55d3 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1146,11 +1146,8 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi return render({ params: route.data.data }) }) - // Suppress the full-screen reconnecting overlay for transient disconnects (initial startup, host - // reload, sub-second event-stream blips). After the first successful connect, show it only once the - // connection has been lost for a full second; before the first connect give a longer grace period so - // startup never flashes it, but a server that dies before ever connecting still surfaces instead of - // leaving a silent empty app. Hide it immediately the moment status leaves "connecting". + // Suppress the full-screen overlay for transient startup and event-stream retry states. + // Initial connection gets a longer grace period; retries surface more quickly. const [showReconnecting, setShowReconnecting] = createSignal(false) let reconnectTimer: ReturnType | undefined createEffect(() => { @@ -1158,7 +1155,8 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi clearTimeout(reconnectTimer) reconnectTimer = undefined } - if (sdk.connection.status() !== "connecting") { + const status = sdk.connection.status() + if (status === "connected") { setShowReconnecting(false) return } @@ -1167,7 +1165,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi reconnectTimer = undefined setShowReconnecting(true) }, - sdk.connection.connectedOnce() ? 1000 : 5000, + status === "reconnecting" ? 1000 : 5000, ).unref() }) onCleanup(() => { diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index da44a0f8e95..8ac669492a0 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,3 +1,8 @@ +// Client data layer: apply server events and cache API reads into a Solid store. +// Prefer straightforward projection. Do not add generation counters, stale-response +// merges, live/history overlays, or other race machinery here—last write wins. +// Reconnect may re-bootstrap; that is enough. UI and the server own ordering concerns. + import type { AgentInfo, CommandInfo, @@ -106,36 +111,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: process.cwd(), }) const messageIndex = new Map>() - const sessionRefreshGeneration = new Map() - const sessionRefreshApplied = new Map() - const sessionUsage = new Map() - let connectionGeneration = 0 - let statusChanges: Set | undefined let bootstrapping: Promise | undefined function setSessionStatus(sessionID: string, status: DataSessionStatus) { - statusChanges?.add(sessionID) setStore("session", "status", sessionID, status) } - function nextSessionRefresh(sessionID: string) { - const generation = (sessionRefreshGeneration.get(sessionID) ?? 0) + 1 - sessionRefreshGeneration.set(sessionID, generation) - return generation - } - - function applySessionRefresh(sessionID: string, generation: number) { - if ((sessionRefreshApplied.get(sessionID) ?? 0) > generation) return false - sessionRefreshApplied.set(sessionID, generation) - return true - } - - function updateSessionUsage(sessionID: string, cost: number, tokens: SessionInfo["tokens"]) { - sessionUsage.set(sessionID, { generation: (sessionUsage.get(sessionID)?.generation ?? 0) + 1, cost, tokens }) - if (!store.session.info[sessionID]) return - setStore("session", "info", sessionID, { cost, tokens }) - } - const message = { update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map) => void) { setStore( @@ -237,8 +218,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } function removeSession(sessionID: string) { - sessionRefreshApplied.set(sessionID, nextSessionRefresh(sessionID)) - sessionUsage.delete(sessionID) messageIndex.delete(sessionID) setStore( "session", @@ -267,7 +246,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ removeSession(event.data.sessionID) break case "session.usage.updated": - updateSessionUsage(event.data.sessionID, event.data.cost, event.data.tokens) + if (store.session.info[event.data.sessionID]) + setStore("session", "info", event.data.sessionID, { + cost: event.data.cost, + tokens: event.data.tokens, + }) break case "catalog.updated": void Promise.all([ @@ -807,20 +790,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const result = { on: sdk.event.on, listen: sdk.event.listen, - connection: { - status() { - return sdk.connection.status() - }, - attempt() { - return sdk.connection.attempt() - }, - error() { - return sdk.connection.error() - }, - connectedOnce() { - return sdk.connection.connectedOnce() - }, - }, session: { list() { return Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated) @@ -846,17 +815,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, }, async refresh(sessionID: string) { - const generation = nextSessionRefresh(sessionID) - const usageGeneration = sessionUsage.get(sessionID)?.generation ?? 0 - const info = mutable(await sdk.api.session.get({ sessionID })) - if (!applySessionRefresh(sessionID, generation)) return - const usage = sessionUsage.get(sessionID) - setStore( - "session", - "info", - sessionID, - usage && usage.generation !== usageGeneration ? { ...info, cost: usage.cost, tokens: usage.tokens } : info, - ) + setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID }))) registerSession(sessionID) }, message: { @@ -872,21 +831,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return position === undefined ? undefined : messages?.[position] }, async refresh(sessionID: string) { - const live = [...(store.session.message[sessionID] ?? [])] setStore("session", "message", sessionID, []) messageIndex.set(sessionID, new Map()) - const loaded = mutable( + const messages = mutable( (await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data, ).toReversed() - const loadedIDs = new Set(loaded.map((message) => message.id)) - const liveByID = new Map(live.map((message) => [message.id, message])) - const messages = [ - ...loaded.map((message) => { - if (message.type === "user") return message - return liveByID.get(message.id) ?? message - }), - ...live.filter((message) => !loadedIDs.has(message.id)), - ].toSorted((a, b) => a.time.created - b.time.created) messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) setStore("session", "message", sessionID, messages) }, @@ -1031,8 +980,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async function bootstrap() { if (bootstrapping) return bootstrapping - const generation = new Map(sessionRefreshApplied) - const usageGeneration = new Map(Array.from(sessionUsage, ([id, usage]) => [id, usage.generation])) bootstrapping = Promise.allSettled([ sdk.api.session .list({ @@ -1046,15 +993,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ "session", "info", produce((draft) => { - for (const session of response.data) { - if ((sessionRefreshApplied.get(session.id) ?? 0) !== (generation.get(session.id) ?? 0)) continue - const usage = sessionUsage.get(session.id) - draft[session.id] = mutable( - usage && usage.generation !== (usageGeneration.get(session.id) ?? 0) - ? { ...session, cost: usage.cost, tokens: usage.tokens } - : session, - ) - } + for (const session of response.data) draft[session.id] = mutable(session) }), ) for (const session of response.data) registerSession(session.id) @@ -1101,23 +1040,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } function refreshActive() { - const generation = ++connectionGeneration - const changed = new Set() - statusChanges = changed void sdk.api.session .active() .then((active) => { - if (generation !== connectionGeneration) return - const status: Record = Object.fromEntries( - Object.keys(active).map((sessionID) => [sessionID, "running" as const]), + setStore( + "session", + "status", + reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))), ) - for (const sessionID of changed) status[sessionID] = store.session.status[sessionID] - setStore("session", "status", reconcile(status)) }) .catch(() => undefined) - .finally(() => { - if (statusChanges === changed) statusChanges = undefined - }) } onCleanup( diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 0ad516ceacc..728ced3ed42 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -5,7 +5,7 @@ import { onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" -export type SDKConnectionStatus = "connected" | "connecting" +export type SDKConnectionStatus = "connected" | "connecting" | "reconnecting" type SDKEventMap = { [Type in V2Event["type"]]: Extract } const connectTimeout = 2_000 @@ -27,11 +27,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ status: SDKConnectionStatus attempt: number error?: string - connectedOnce: boolean }>({ status: "connecting", attempt: 0, - connectedOnce: false, }) let stream: AbortController | undefined @@ -70,7 +68,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ clearTimeout(timeout) attempt = 0 events.emit(first.value.type, first.value) - setConnection({ status: "connected", attempt: 0, error: undefined, connectedOnce: true }) + setConnection({ status: "connected", attempt: 0, error: undefined }) connected() while (!abort.signal.aborted && !controller.signal.aborted) { const event = await iterator.next() @@ -98,7 +96,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ } } setConnection({ - status: "connecting", + status: "reconnecting", attempt, error: error instanceof Error ? error.message : String(error), }) @@ -136,9 +134,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ error() { return connection.error }, - connectedOnce() { - return connection.connectedOnce - }, }, reload: props.reload, } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index c116f431787..e0612bb059c 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -6,7 +6,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { EventV2 } from "@opencode-ai/core/event" import { onMount } from "solid-js" import { ProjectProvider } from "../../../src/context/project" -import { SDKProvider } from "../../../src/context/sdk" +import { SDKProvider, useSDK } from "../../../src/context/sdk" import { DataProvider, useData } from "../../../src/context/data" import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk" @@ -114,24 +114,22 @@ test("refreshes resources into reactive getters", async () => { } }) -test("applies absolute usage events without losing full session updates", async () => { +test("applies absolute usage events to session info", async () => { const events = createEventStream() const sessionID = "ses_usage_refresh" - let resolveSessions!: (response: Response) => void - const resolveSession: Array<(response: Response) => void> = [] - let sessionsRequested = false const calls = createFetch((url) => { - if (url.pathname === "/api/session") { - sessionsRequested = true - return new Promise((resolve) => { - resolveSessions = resolve + if (url.pathname === `/api/session/${sessionID}`) + return json({ + data: { + id: sessionID, + projectID: "proj_test", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Usage", + location: { directory }, + }, }) - } - if (url.pathname === `/api/session/${sessionID}`) { - return new Promise((resolve) => { - resolveSession.push(resolve) - }) - } }, events) let data!: ReturnType @@ -153,7 +151,7 @@ test("applies absolute usage events without losing full session updates", async )) try { - await wait(() => sessionsRequested) + await data.session.refresh(sessionID) emitEvent(events, { id: "evt_usage_2", created: 2, @@ -164,38 +162,6 @@ test("applies absolute usage events without losing full session updates", async tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }, }, }) - const initialRefresh = data.session.refresh(sessionID) - await wait(() => resolveSession.length === 1) - resolveSessions( - json({ - data: [ - { - id: sessionID, - projectID: "proj_test", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 0, updated: 0 }, - title: "Stale usage", - location: { directory }, - }, - ], - cursor: {}, - }), - ) - resolveSession[0]( - json({ - data: { - id: sessionID, - projectID: "proj_test", - cost: 0.5, - tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }, - time: { created: 0, updated: 0 }, - title: "Current usage", - location: { directory }, - }, - }), - ) - await initialRefresh await wait(() => data.session.get(sessionID)?.cost === 0.5) expect(data.session.get(sessionID)?.tokens).toEqual({ input: 5, @@ -204,7 +170,6 @@ test("applies absolute usage events without losing full session updates", async cache: { read: 1, write: 1 }, }) - const fullRefresh = data.session.refresh(sessionID) emitEvent(events, { id: "evt_usage_3", created: 3, @@ -216,57 +181,8 @@ test("applies absolute usage events without losing full session updates", async }, }) await wait(() => data.session.get(sessionID)?.cost === 1) - resolveSession[1]( - json({ - data: { - id: sessionID, - projectID: "proj_test", - cost: 0.75, - tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } }, - time: { created: 0, updated: 0 }, - title: "Older usage", - location: { directory }, - }, - }), - ) - await fullRefresh - await Bun.sleep(20) - expect(data.session.get(sessionID)?.cost).toBe(1) - expect(data.session.get(sessionID)?.title).toBe("Older usage") + expect(data.session.get(sessionID)?.title).toBe("Usage") - emitEvent(events, { - id: "evt_usage_6", - created: 6, - type: "session.usage.updated", - data: { - sessionID, - cost: 1.25, - tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } }, - }, - }) - emitEvent(events, { - id: "evt_usage_7", - created: 7, - type: "session.usage.updated", - data: { - sessionID, - cost: 1.25, - tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } }, - }, - }) - await wait(() => data.session.get(sessionID)?.cost === 1.25) - expect(data.session.get(sessionID)?.title).toBe("Older usage") - - emitEvent(events, { - id: "evt_usage_8", - created: 8, - type: "session.usage.updated", - data: { - sessionID, - cost: 1.5, - tokens: { input: 14, output: 6, reasoning: 1, cache: { read: 1, write: 1 } }, - }, - }) emitEvent(events, { id: "evt_usage_deleted", created: 9, @@ -274,8 +190,7 @@ test("applies absolute usage events without losing full session updates", async durable: durable(sessionID, 9, 2), data: { sessionID }, }) - await Bun.sleep(20) - expect(data.session.get(sessionID)).toBeUndefined() + await wait(() => data.session.get(sessionID) === undefined) } finally { app.renderer.destroy() } @@ -579,9 +494,11 @@ test("reconnects the event stream and bootstraps fresh data", async () => { }) }, events) let data!: ReturnType + let sdk!: ReturnType function Probe() { data = useData() + sdk = useSDK() return } @@ -600,32 +517,24 @@ test("reconnects the event stream and bootstraps fresh data", async () => { try { await wait(() => data.location.model.list()?.[0]?.id === "model-1") await wait(() => data.session.status("session-stale") === "running") - expect(data.connection.status()).toBe("connected") - expect(data.connection.attempt()).toBe(0) + expect(sdk.connection.status()).toBe("connected") + expect(sdk.connection.attempt()).toBe(0) events.disconnect() - await wait(() => data.connection.status() === "connecting") - expect(data.connection.attempt()).toBe(1) - expect(data.connection.error()).toBe("Event stream disconnected") + await wait(() => sdk.connection.status() === "reconnecting") + expect(sdk.connection.attempt()).toBe(1) + expect(sdk.connection.error()).toBe("Event stream disconnected") - await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000) - emitEvent(events, { - id: "evt_execution_started_after_reconnect", - created: 1, - type: "session.execution.started", - durable: durable("session-new"), - data: { sessionID: "session-new" }, - }) - await wait(() => data.session.status("session-new") === "running") - resolveActive(json({ data: {} })) + await wait(() => requests.active === 2 && sdk.connection.status() === "connected", 4000) + resolveActive(json({ data: { "session-new": { type: "running" } } })) await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000) await wait(() => data.session.status("session-stale") === "idle") expect(data.session.status("session-new")).toBe("running") expect(requests.event).toBe(2) - expect(data.connection.status()).toBe("connected") - expect(data.connection.attempt()).toBe(0) - expect(data.connection.error()).toBeUndefined() + expect(sdk.connection.status()).toBe("connected") + expect(sdk.connection.attempt()).toBe(0) + expect(sdk.connection.error()).toBeUndefined() } finally { app.renderer.destroy() } @@ -766,7 +675,7 @@ test("removes committed revert messages from local state", async () => { } }) -test("connectedOnce is false until first connect and persists across disconnect", async () => { +test("distinguishes initial connection from reconnection", async () => { const encoder = new TextEncoder() let stream: ReadableStreamDefaultController | undefined const eventResponse = () => @@ -792,10 +701,10 @@ test("connectedOnce is false until first connect and persists across disconnect" const calls = createFetch((url) => { if (url.pathname === "/api/event") return eventResponse() }) - let data!: ReturnType + let sdk!: ReturnType function Probe() { - data = useData() + sdk = useSDK() return } @@ -813,16 +722,13 @@ test("connectedOnce is false until first connect and persists across disconnect" try { await wait(() => stream !== undefined) - expect(data.connection.status()).toBe("connecting") - expect(data.connection.connectedOnce()).toBe(false) + expect(sdk.connection.status()).toBe("connecting") connect() - await wait(() => data.connection.status() === "connected") - expect(data.connection.connectedOnce()).toBe(true) + await wait(() => sdk.connection.status() === "connected") disconnect() - await wait(() => data.connection.status() === "connecting") - expect(data.connection.connectedOnce()).toBe(true) + await wait(() => sdk.connection.status() === "reconnecting") } finally { app.renderer.destroy() } @@ -1462,9 +1368,11 @@ test("adds and dismisses permission requests from live events", async () => { const events = createEventStream() const calls = createFetch(undefined, events) let data!: ReturnType + let sdk!: ReturnType function Probe() { data = useData() + sdk = useSDK() return } @@ -1481,7 +1389,7 @@ test("adds and dismisses permission requests from live events", async () => { )) try { - await wait(() => data.connection.status() === "connected") + await wait(() => sdk.connection.status() === "connected") emitEvent(events, { id: "evt_permission_asked_1", created: 0, @@ -1580,9 +1488,11 @@ test("adds, dismisses, and refreshes form requests", async () => { return json({ data: [{ id: "frm_remote", sessionID: "ses_1", mode: "form", fields: [] }] }) }, events) let data!: ReturnType + let sdk!: ReturnType function Probe() { data = useData() + sdk = useSDK() return } @@ -1599,7 +1509,7 @@ test("adds, dismisses, and refreshes form requests", async () => { )) try { - await wait(() => data.connection.status() === "connected") + await wait(() => sdk.connection.status() === "connected") emitEvent(events, { id: "evt_form_created_1", created: 0, From 05e8d5be7330290eba65d53a9d371d31a8002f4a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:06:58 -0500 Subject: [PATCH 06/32] fix(core): preserve prompt attachment order (#35921) --- .../core/src/session/runner/to-llm-message.ts | 90 ++++++++-------- .../core/test/session-runner-message.test.ts | 102 ++++++++++++++---- 2 files changed, 130 insertions(+), 62 deletions(-) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index bd750948780..7aa379ab278 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -21,45 +21,50 @@ const media = (file: FileAttachment): ContentPart => ({ metadata: file.description === undefined ? undefined : { description: file.description }, }) -const textAttachment = (file: FileAttachment) => - Message.make({ - role: "user", - content: [ - `Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`, - file.description === undefined ? undefined : `Description: ${file.description}`, - "", - Buffer.from(file.data, "base64").toString("utf8"), - ] - .filter((line): line is string => line !== undefined) - .join("\n"), - metadata: { - attachment: { - source: file.source, - name: file.name, - description: file.description, - }, +const textAttachment = (file: FileAttachment): ContentPart => ({ + type: "text", + text: `\n\n${[ + `Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`, + file.description === undefined ? undefined : `Description: ${file.description}`, + "", + Buffer.from(file.data, "base64").toString("utf8"), + ] + .filter((line): line is string => line !== undefined) + .join("\n")}`, + metadata: { + attachment: { + source: file.source, + name: file.name, + description: file.description, }, - }) + }, +}) -const directoryAttachment = (file: FileAttachment) => - Message.make({ - role: "user", - content: [ - `Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`, - file.description === undefined ? undefined : `Description: ${file.description}`, - file.data.length === 0 ? undefined : "", - file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"), - ] - .filter((line): line is string => line !== undefined) - .join("\n"), - metadata: { - attachment: { - source: file.source, - name: file.name, - description: file.description, - }, +const directoryAttachment = (file: FileAttachment): ContentPart => ({ + type: "text", + text: `\n\n${[ + `Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`, + file.description === undefined ? undefined : `Description: ${file.description}`, + file.data.length === 0 ? undefined : "", + file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"), + ] + .filter((line): line is string => line !== undefined) + .join("\n")}`, + metadata: { + attachment: { + source: file.source, + name: file.name, + description: file.description, }, - }) + }, +}) + +const attachmentContent = (file: FileAttachment): ContentPart[] => { + if (file.mime === "text/plain") return [textAttachment(file)] + if (file.mime === "application/x-directory") return [directoryAttachment(file)] + if (imageMimes.has(file.mime)) return [media(file)] + return [] +} const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) @@ -174,17 +179,16 @@ function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, provider case "model-switched": return [] case "user": - const files = message.files ?? [] + const content = [ + ...(message.text === "" ? [] : [Message.text(message.text)]), + ...(message.files ?? []).flatMap(attachmentContent), + ] + if (content.length === 0) return [] return [ - ...files.filter((file) => file.mime === "text/plain").map(textAttachment), - ...files.filter((file) => file.mime === "application/x-directory").map(directoryAttachment), Message.make({ id: message.id, role: "user", - content: [ - { type: "text", text: message.text }, - ...files.filter((file) => imageMimes.has(file.mime)).map(media), - ], + content, metadata: { ...message.metadata, ...(message.agents?.length ? { agents: message.agents } : {}), diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index c593749f374..396b833a6d4 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -144,7 +144,7 @@ Recent work ]) }) - test("lowers text attachments as separate user messages", () => { + test("lowers text attachments after the prompt in one user message", () => { const file = FileAttachment.make({ data: Base64.make(Buffer.from("export const value = 1").toString("base64")), mime: "text/plain", @@ -164,21 +164,18 @@ Recent work model, ) - expect(messages).toHaveLength(2) + expect(messages).toHaveLength(1) expect(messages[0]).toMatchObject({ - role: "user", - content: [ - { - type: "text", - text: "Attached file: main.ts\n\nexport const value = 1", - }, - ], - metadata: { attachment: { source: file.source, name: "main.ts" } }, - }) - expect(messages[1]).toMatchObject({ id: id("user-text-file"), role: "user", - content: [{ type: "text", text: "Review this file" }], + content: [ + { type: "text", text: "Review this file" }, + { + type: "text", + text: "\n\nAttached file: main.ts\n\nexport const value = 1", + metadata: { attachment: { source: file.source, name: "main.ts" } }, + }, + ], }) }) @@ -203,10 +200,11 @@ Recent work model, ) - expect(messages[0]?.content).toEqual([ + expect(messages[0]?.content).toMatchObject([ + { type: "text", text: "Review this file" }, { type: "text", - text: "Attached file: inline.txt\n\ninline content", + text: "\n\nAttached file: inline.txt\n\ninline content", }, ]) }) @@ -231,13 +229,79 @@ Recent work model, ) - expect(messages).toHaveLength(2) + expect(messages).toHaveLength(1) expect(messages[0]).toMatchObject({ + id: id("user-directory"), role: "user", - content: [{ type: "text", text: "Attached directory: src/\n\nlib/\nindex.ts" }], - metadata: { attachment: { source: directory.source, name: "src/" } }, + content: [ + { type: "text", text: "Review this directory" }, + { + type: "text", + text: "\n\nAttached directory: src/\n\nlib/\nindex.ts", + metadata: { attachment: { source: directory.source, name: "src/" } }, + }, + ], }) - expect(messages[1]?.content).toEqual([{ type: "text", text: "Review this directory" }]) + }) + + test("preserves attachment order after the prompt", () => { + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-mixed-files"), + type: "user", + text: "Review these attachments", + files: [ + FileAttachment.make({ + data: Base64.make(Buffer.from("index.ts").toString("base64")), + mime: "application/x-directory", + source: { type: "uri", uri: "file:///project/src" }, + name: "src/", + }), + FileAttachment.make({ + data: Base64.make(Buffer.from("export const value = 1").toString("base64")), + mime: "text/plain", + source: { type: "uri", uri: "file:///project/main.ts" }, + name: "main.ts", + }), + ], + time: { created }, + }), + ], + model, + ) + + expect(messages).toHaveLength(1) + expect(messages[0]?.content.map((part) => (part.type === "text" ? part.text : part.type))).toEqual([ + "Review these attachments", + "\n\nAttached directory: src/\n\nindex.ts", + "\n\nAttached file: main.ts\n\nexport const value = 1", + ]) + }) + + test("omits empty prompt text before an attachment", () => { + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-attachment-only"), + type: "user", + text: "", + files: [ + FileAttachment.make({ + data: Base64.make(Buffer.from("index.ts").toString("base64")), + mime: "application/x-directory", + source: { type: "uri", uri: "file:///project/src" }, + name: "src/", + }), + ], + time: { created }, + }), + ], + model, + ) + + expect(messages).toHaveLength(1) + expect(messages[0]?.content).toMatchObject([{ type: "text", text: "\n\nAttached directory: src/\n\nindex.ts" }]) }) test("uses materialized image data as provider media and drops unsupported attachments", () => { From 3dd29094f4bce7396e16b764ac2a0242e14fd1cd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 8 Jul 2026 11:41:59 -0400 Subject: [PATCH 07/32] feat(http-recorder): sync recorder v0.3 (#35619) --- bun.lock | 4 +- .../core/test/session-runner-recorded.test.ts | 17 +- packages/http-recorder/README.md | 105 ++- packages/http-recorder/package.json | 12 +- packages/http-recorder/script/build.ts | 10 +- packages/http-recorder/script/pack.ts | 14 +- .../http-recorder/script/verify-package.ts | 71 +- packages/http-recorder/src/api.ts | 46 + packages/http-recorder/src/cassette/model.ts | 43 + .../src/{cassette.ts => cassette/store.ts} | 54 +- packages/http-recorder/src/effect.ts | 25 - .../http-recorder/src/{ => http}/matching.ts | 79 +- packages/http-recorder/src/http/model.ts | 30 + .../{internal-effect.ts => http/recorder.ts} | 95 +- packages/http-recorder/src/index.ts | 41 +- packages/http-recorder/src/internal.ts | 15 - packages/http-recorder/src/options.ts | 1 + packages/http-recorder/src/recorder.ts | 62 -- packages/http-recorder/src/redaction.ts | 117 --- .../http-recorder/src/redaction/redactor.ts | 173 ++++ .../http-recorder/src/redaction/secrets.ts | 47 + packages/http-recorder/src/redactor.ts | 135 --- .../http-recorder/src/replay/comparison.ts | 29 + packages/http-recorder/src/replay/state.ts | 96 ++ packages/http-recorder/src/schema.ts | 87 -- packages/http-recorder/src/socket.ts | 326 ------- packages/http-recorder/src/types.ts | 108 --- packages/http-recorder/src/websocket.ts | 173 ---- packages/http-recorder/src/websocket/model.ts | 35 + .../http-recorder/src/websocket/recorder.ts | 584 ++++++++++++ packages/http-recorder/sst-env.d.ts | 2 +- packages/http-recorder/test/cassette.test.ts | 195 ++++ .../{record-replay => http}/multi-step.json | 0 .../{record-replay => http}/retry.json | 0 packages/http-recorder/test/http.test.ts | 328 +++++++ .../http-recorder/test/record-replay.test.ts | 879 ------------------ packages/http-recorder/test/redaction.test.ts | 167 ++++ packages/http-recorder/test/support.ts | 61 ++ packages/http-recorder/test/tsconfig.json | 11 + packages/http-recorder/test/websocket.test.ts | 529 +++++++++++ packages/llm/AGENTS.md | 4 +- .../llm/test/provider/golden.recorded.test.ts | 9 - packages/llm/test/recorded-golden.ts | 4 +- packages/llm/test/recorded-runner.ts | 7 +- packages/llm/test/recorded-test.ts | 32 +- packages/llm/test/recorded-websocket.ts | 26 - .../test/session/llm-native-recorded.test.ts | 16 +- 47 files changed, 2697 insertions(+), 2207 deletions(-) create mode 100644 packages/http-recorder/src/api.ts create mode 100644 packages/http-recorder/src/cassette/model.ts rename packages/http-recorder/src/{cassette.ts => cassette/store.ts} (81%) delete mode 100644 packages/http-recorder/src/effect.ts rename packages/http-recorder/src/{ => http}/matching.ts (54%) create mode 100644 packages/http-recorder/src/http/model.ts rename packages/http-recorder/src/{internal-effect.ts => http/recorder.ts} (67%) delete mode 100644 packages/http-recorder/src/internal.ts create mode 100644 packages/http-recorder/src/options.ts delete mode 100644 packages/http-recorder/src/recorder.ts delete mode 100644 packages/http-recorder/src/redaction.ts create mode 100644 packages/http-recorder/src/redaction/redactor.ts create mode 100644 packages/http-recorder/src/redaction/secrets.ts delete mode 100644 packages/http-recorder/src/redactor.ts create mode 100644 packages/http-recorder/src/replay/comparison.ts create mode 100644 packages/http-recorder/src/replay/state.ts delete mode 100644 packages/http-recorder/src/schema.ts delete mode 100644 packages/http-recorder/src/socket.ts delete mode 100644 packages/http-recorder/src/types.ts delete mode 100644 packages/http-recorder/src/websocket.ts create mode 100644 packages/http-recorder/src/websocket/model.ts create mode 100644 packages/http-recorder/src/websocket/recorder.ts create mode 100644 packages/http-recorder/test/cassette.test.ts rename packages/http-recorder/test/fixtures/recordings/{record-replay => http}/multi-step.json (100%) rename packages/http-recorder/test/fixtures/recordings/{record-replay => http}/retry.json (100%) create mode 100644 packages/http-recorder/test/http.test.ts delete mode 100644 packages/http-recorder/test/record-replay.test.ts create mode 100644 packages/http-recorder/test/redaction.test.ts create mode 100644 packages/http-recorder/test/support.ts create mode 100644 packages/http-recorder/test/tsconfig.json create mode 100644 packages/http-recorder/test/websocket.test.ts delete mode 100644 packages/llm/test/recorded-websocket.ts diff --git a/bun.lock b/bun.lock index 491dbd16794..bd1981f6e5d 100644 --- a/bun.lock +++ b/bun.lock @@ -526,10 +526,10 @@ "name": "@opencode-ai/http-recorder", "version": "1.17.14", "dependencies": { - "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", }, "devDependencies": { + "@effect/platform-node": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", "@types/node": "catalog:", @@ -538,7 +538,7 @@ "typescript": "catalog:", }, "peerDependencies": { - "effect": "4.0.0-beta.83", + "effect": "catalog:", }, }, "packages/httpapi-codegen": { diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index aa2484d34f8..38c017af2b2 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -1,5 +1,4 @@ import { HttpRecorder } from "@opencode-ai/http-recorder" -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route" import { Database } from "@opencode-ai/core/database/database" @@ -43,15 +42,13 @@ import { Effect, Layer } from "effect" import path from "node:path" import { testEffect } from "./lib/effect" -const cassette = - process.env.RECORD === "true" - ? HttpRecorderInternal.cassetteLayer("session-runner/openai-chat-streams-text", { - directory: path.resolve(import.meta.dir, "fixtures/recordings"), - mode: "record", - }) - : HttpRecorder.http("session-runner/openai-chat-streams-text", { - directory: path.resolve(import.meta.dir, "fixtures/recordings"), - }) +const cassetteName = "session-runner/openai-chat-streams-text" +const cassetteDirectory = path.resolve(import.meta.dir, "fixtures/recordings") +if (process.env.RECORD === "true") { + if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") + HttpRecorder.removeCassetteSync(cassetteName, { directory: cassetteDirectory }) +} +const cassette = HttpRecorder.layerFetch(cassetteName, { directory: cassetteDirectory }) const executor = RequestExecutor.layer.pipe(Layer.provide(cassette)) const client = LLMClient.layer.pipe(Layer.provide(executor)) const permission = Layer.succeed( diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md index f388e8e2539..84c539081ec 100644 --- a/packages/http-recorder/README.md +++ b/packages/http-recorder/README.md @@ -9,13 +9,13 @@ Use it for provider integrations, retries, polling, multi-step flows, and any te ## Install ```sh -bun add effect@4.0.0-beta.74 -bun add -d @opencode-ai/http-recorder@beta @effect/vitest vitest +bun add effect@4.0.0-beta.83 +bun add -d @opencode-ai/http-recorder @effect/vitest@4.0.0-beta.83 vitest@^4 ``` The package supports Node.js 22+ and Bun. It is not intended for browsers, workers, or Deno. -Effect `4.0.0-beta.74` has a known declaration error (`SchemaErrorTypeId` is missing). Until that upstream declaration is fixed, TypeScript consumers need: +Effect `4.0.0-beta.83` currently contains unresolved symbols in its published declarations. Until those upstream declarations are fixed, TypeScript consumers need: ```json { @@ -51,7 +51,7 @@ describe("getUser", () => { assert.strictEqual(user.id, 1) assert.strictEqual(user.name, "Leanne Graham") - }).pipe(Effect.provide(HttpRecorder.http("users/get-one"))), + }).pipe(Effect.provide(HttpRecorder.layerFetch("users/get-one"))), ) }) ``` @@ -81,49 +81,83 @@ Application code does not need to know whether a response is live or replayed. ## API ```ts -HttpRecorder.http(name, options?) -HttpRecorder.socket(name, options?) +HttpRecorder.layer(name, options?) +HttpRecorder.layerFetch(name, options?) +HttpRecorder.layerSocket(name, options?) +HttpRecorder.layerWebSocketConstructor(name, options?) +HttpRecorder.hasCassetteSync(name, options?) +HttpRecorder.removeCassetteSync(name, options?) ``` -That is the complete public API. `http` provides a fetch-backed recorded `HttpClient`. `socket` decorates a standard Effect `Socket.Socket` supplied beneath it. +That is the complete runtime API. `layer` decorates an application-provided `HttpClient`; `layerFetch` is the convenience layer that supplies Effect's fetch client. `layerWebSocketConstructor` decorates Effect's `Socket.WebSocketConstructor`, recording every dynamically selected URL and protocol. `layerSocket` is the lower-level transport-neutral decorator for an application-provided `Socket.Socket`. + +Use `hasCassetteSync` when registering fixture-gated tests. `removeCassetteSync` explicitly removes one cassette before a focused refresh; removing a missing cassette is a no-op. Both helpers use the same cassette-name validation and default directory as the recorder layers. + +Use `layer` to record through another Effect HTTP transport: + +```ts +import { NodeHttpClient } from "@effect/platform-node" +import { Layer } from "effect" + +const recorder = HttpRecorder.layer("users/get-one").pipe(Layer.provide(NodeHttpClient.layerUndici)) +``` + +The `HttpRecorder` namespace also exposes the configuration types `RecorderOptions`, `SocketRecorderOptions`, `RedactOptions`, `RequestMatcher`, `RequestSnapshot`, and `CassetteMetadata`. ## WebSockets -WebSocket cassettes preserve one ordered transcript of client and server text or binary frames. Replay follows that chronology: server frames are released until the next recorded client frame, then replay waits for the application to send the matching frame before continuing. +Real applications often select WebSocket URLs inside domain services. Effect represents that capability with `Socket.WebSocketConstructor`; production supplies the platform implementation, while tests can decorate it without changing application code. ```ts -import { assert, it } from "@effect/vitest" import { NodeSocket } from "@effect/platform-node" -import { Effect, Layer } from "effect" +import { it } from "@effect/vitest" +import { Deferred, Effect, Layer } from "effect" import { Socket } from "effect/unstable/socket" import { HttpRecorder } from "@opencode-ai/http-recorder" -const echo = Effect.gen(function* () { - const socket = yield* Socket.Socket +const roundTrip = Effect.fn("Echo.roundTrip")(function* (url: string, message: string) { + const socket = yield* Socket.makeWebSocket(url, { closeCodeIsError: () => false }) const write = yield* socket.writer + const echoed = yield* Deferred.make() yield* socket.runString( - (message) => - Effect.gen(function* () { - assert.strictEqual(message, "hello") - yield* write(new Socket.CloseEvent(1000)) - }), - { onOpen: write("hello") }, + (response) => { + return Deferred.succeed(echoed, response).pipe( + Effect.andThen(write(new Socket.CloseEvent(1000, "done"))), + Effect.orDie, + ) + }, + { onOpen: write(message).pipe(Effect.orDie) }, ) + + return yield* Deferred.await(echoed) }) -const recordedSocket = HttpRecorder.socket("echo/hello").pipe( - Layer.provide( - NodeSocket.layerWebSocket("wss://ws.postman-echo.com/raw", { - closeCodeIsError: (code) => code !== 1000, - }), +it.effect("round trips a message", () => + roundTrip("wss://ws.postman-echo.com/raw", "hello").pipe( + Effect.scoped, + Effect.provide( + HttpRecorder.layerWebSocketConstructor("echo/round-trip").pipe( + Layer.provide(NodeSocket.layerWebSocketConstructor), + ), + ), ), ) - -it.effect("exchanges WebSocket frames", () => echo.pipe(Effect.provide(recordedSocket))) ``` -The application owns the WebSocket URL and protocols through normal Effect layer wiring. The recorder wraps that socket without duplicating its URL in recorder configuration. Provide separate socket layers for separate endpoints or concurrent connections. +The production application supplies only `NodeSocket.layerWebSocketConstructor`. The recorder appears in test wiring and observes each call to `Socket.makeWebSocket`, including URLs selected at runtime. + +`socket.runString` owns the receive loop and finishes when the connection closes or fails. Its optional `onOpen` effect is the safe place to send protocols whose client speaks first. The writer is scoped because sending is valid only while a connection run is active. + +WebSocket cassettes preserve one ordered transcript of client and server text or binary frames. Replay releases recorded server frames until it reaches a client frame, waits for the application to write the matching frame, then continues. This preserves causal ordering without reproducing network timing. + +Client text frames containing JSON compare canonically, so object-key order does not matter. Changed fields, extra fields, non-JSON text, and binary frames must match exactly after redaction. There is intentionally no custom WebSocket matcher in this beta. + +Incoming frame handlers start in recorded order and may run concurrently, matching Effect's socket abstraction. Replay waits for all handlers before the socket run completes, but handler completion order is not guaranteed. Use Effect synchronization such as `Queue`, `Ref`, or `Deferred` instead of unsynchronized mutable state. + +A constructor cassette records the URL, requested protocols, frames, and terminal close for each connection. Replay validates the URL and protocols before opening the simulated socket. Closing before every recorded frame is consumed fails the test. + +Use `layerSocket` when a protocol layer already consumes one application-provided `Socket.Socket`, including non-WebSocket transports. Because that lower-level abstraction has no URL or protocols, its cassettes use the cassette name and connection order as identity. Text frames use the same JSON-field and body redaction as HTTP bodies. Binary frames are stored losslessly as base64. Client and server frame kinds must match during replay. @@ -143,7 +177,7 @@ There is intentionally no public overwrite mode. Deletion makes the set of recor Secure defaults remove most headers and redact common credentials in headers, URLs, and JSON bodies. Extend those defaults at layer construction: ```ts -HttpRecorder.http("anthropic/messages", { +HttpRecorder.layerFetch("anthropic/messages", { redact: { headers: ["x-project-token"], allowRequestHeaders: ["anthropic-version"], @@ -171,16 +205,16 @@ Redaction is defense in depth, not a substitute for review. Inspect cassette dif ## Matching And Ordering -A cassette contains an ordered sequence of interactions. The first runtime request is checked against the first recorded request, the second against the second, and so on. +A runtime request atomically claims the first unused recorded interaction that matches it. Distinct requests may replay in any order or concurrently. -This strict ordering correctly models repeated identical requests whose responses change, including retries, polling, and cache tests. JSON object keys are canonicalized before matching. +Repeated identical requests consume their matching responses in cassette order, which models retries, polling, and cache tests deterministically. A mismatch consumes nothing, and JSON object keys are canonicalized before matching. -Concurrent requests are recorded in request-start order even when their responses complete out of order. +Concurrent requests are recorded in request-start order even when their responses complete out of order. Each recorded interaction can be claimed only once, and leaving interactions unused fails when the recorder layer closes. Supply a custom equivalence rule when a request contains intentionally volatile data: ```ts -HttpRecorder.http("events/create", { +HttpRecorder.layerFetch("events/create", { match: (incoming, recorded) => incoming.method === recorded.method && new URL(incoming.url).pathname === new URL(recorded.url).pathname, }) @@ -191,14 +225,18 @@ HttpRecorder.http("events/create", { ```ts interface RecorderOptions { readonly directory?: string - readonly metadata?: Record + readonly metadata?: Readonly> readonly redact?: RedactOptions readonly match?: RequestMatcher } + +type SocketRecorderOptions = Omit ``` `directory` defaults to `/test/fixtures/recordings`. +See [`examples/`](./examples) for complete HTTP and WebSocket examples. + ## Cassettes Cassettes are readable JSON files intended to be committed with your tests. HTTP interactions are stored in request order. WebSocket cassettes preserve the observed order of client and server frames. Text stays readable; binary bodies and frames are stored losslessly as base64. @@ -207,7 +245,8 @@ Cassettes are readable JSON files intended to be committed with your tests. HTTP - Responses are buffered while recording and replaying, so this beta is not suitable for tests that assert streaming timing, cancellation, or backpressure. - WebSocket replay preserves frame chronology and content, not real network timing or backpressure. -- WebSocket V1 cassettes do not reproduce terminal close codes, close reasons, or transport failures. Failed and interrupted live runs are not recorded. +- Constructor-level WebSocket cassettes reproduce terminal close codes and reasons, but not selected subprotocols, handshake headers, transport timing, or transport failures. Lower-level `layerSocket` cassettes contain frames only. +- Failed and interrupted live WebSocket connections are not recorded. - WebSocket transcripts are retained in memory until the connection finishes; avoid using this beta for unbounded sessions. - The package currently requires the exact Effect beta listed above. - Cassette format version `1` has no migration tooling yet. diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 0d4f1175ad0..b3166190f68 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/package.json", "version": "1.17.14", "name": "@opencode-ai/http-recorder", - "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", + "description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes", "type": "module", "license": "MIT", "repository": { @@ -28,14 +28,14 @@ }, "scripts": { "test": "bun test --timeout 30000 --only-failures", - "typecheck": "tsgo --noEmit", + "typecheck": "tsgo --noEmit && tsgo -p test/tsconfig.json --noEmit", "build": "bun ./script/build.ts", "verify:package": "bun ./script/verify-package.ts" }, "exports": { - ".": "./src/index.ts", - "./internal": "./src/internal.ts" + ".": "./src/index.ts" }, + "sideEffects": false, "files": [ "dist", "README.md", @@ -47,14 +47,14 @@ "@types/bun": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "@effect/platform-node": "catalog:", "effect": "catalog:", "typescript": "catalog:" }, "dependencies": { - "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83" }, "peerDependencies": { - "effect": "4.0.0-beta.83" + "effect": "catalog:" } } diff --git a/packages/http-recorder/script/build.ts b/packages/http-recorder/script/build.ts index f7d839236eb..9d9de65e625 100644 --- a/packages/http-recorder/script/build.ts +++ b/packages/http-recorder/script/build.ts @@ -14,7 +14,13 @@ const build = await Bun.build({ }) if (!build.success) throw new AggregateError(build.logs, "Failed to build @opencode-ai/http-recorder") -const publicFiles = new Set(["index.js", "index.d.ts", "effect.d.ts", "socket.d.ts", "types.d.ts"]) await Promise.all( - (await readdir("dist")).filter((file) => !publicFiles.has(file)).map((file) => rm(`dist/${file}`, { force: true })), + (await readdir("dist", { recursive: true })) + .filter((file) => file.endsWith(".d.ts") && file !== "index.d.ts" && file !== "api.d.ts") + .map((file) => rm(`dist/${file}`)), ) + +for (const file of ["dist/index.d.ts", "dist/api.d.ts"]) { + if ((await Bun.file(file).text()).includes(["import", "("].join(""))) + throw new Error(`${file} contains dynamic import syntax`) +} diff --git a/packages/http-recorder/script/pack.ts b/packages/http-recorder/script/pack.ts index 4dad7a44167..c40d83c8497 100644 --- a/packages/http-recorder/script/pack.ts +++ b/packages/http-recorder/script/pack.ts @@ -15,10 +15,6 @@ export const pack = async () => { } for (const [key, value] of Object.entries(pkg.exports)) { - if (key === "./internal") { - delete pkg.exports[key] - continue - } if (typeof value !== "string") continue const file = value.replace("./src/", "./dist/").replace(/\.ts$/, "") pkg.exports[key] = { import: `${file}.js`, types: `${file}.d.ts` } @@ -32,4 +28,14 @@ export const pack = async () => { } } +export const withPackedArchive = async (use: (archive: string) => Promise) => { + const archive = await pack() + try { + return await use(archive) + } finally { + const file = Bun.file(archive) + if (await file.exists()) await file.delete() + } +} + if (import.meta.main) await pack() diff --git a/packages/http-recorder/script/verify-package.ts b/packages/http-recorder/script/verify-package.ts index 272394c9446..04f1f2bca5c 100644 --- a/packages/http-recorder/script/verify-package.ts +++ b/packages/http-recorder/script/verify-package.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" -import { pack } from "./pack.js" +import { withPackedArchive } from "./pack.js" const run = async (command: ReadonlyArray, cwd: string) => { const process = Bun.spawn(command, { cwd, env: globalThis.process.env, stdout: "inherit", stderr: "inherit" }) @@ -10,6 +10,11 @@ const run = async (command: ReadonlyArray, cwd: string) => { if (exitCode !== 0) throw new Error(`${command.join(" ")} exited with code ${exitCode}`) } +const reject = async (command: ReadonlyArray, cwd: string) => { + const process = Bun.spawn([...command], { cwd, env: globalThis.process.env, stdout: "ignore", stderr: "ignore" }) + if ((await process.exited) === 0) throw new Error(`${command.join(" ")} unexpectedly succeeded`) +} + export const verifyPackage = async (archive: string) => { const directory = await mkdtemp(path.join(tmpdir(), "http-recorder-consumer-")) try { @@ -25,11 +30,40 @@ import { Layer } from "effect" import { HttpClient } from "effect/unstable/http" import { Socket } from "effect/unstable/socket" -const options: HttpRecorder.RecorderOptions = { redact: { jsonFields: ["access_token"] } } -HttpRecorder.http("consumer", options) satisfies Layer.Layer -HttpRecorder.socket("consumer/socket", options).pipe( +const options: HttpRecorder.RecorderOptions = { match: () => true, redact: { jsonFields: ["access_token"] } } +const socketOptions: HttpRecorder.SocketRecorderOptions = { redact: { jsonFields: ["access_token"] } } +HttpRecorder.layer("consumer", options) satisfies Layer.Layer +HttpRecorder.layerFetch("consumer", options) satisfies Layer.Layer +HttpRecorder.hasCassetteSync("consumer", { directory: "recordings" }) satisfies boolean +HttpRecorder.removeCassetteSync("consumer", { directory: "recordings" }) +HttpRecorder.layerSocket("consumer/socket", socketOptions).pipe( Layer.provide(NodeSocket.layerWebSocket("wss://example.test")), ) satisfies Layer.Layer +HttpRecorder.layerWebSocketConstructor("consumer/websocket", socketOptions).pipe( + Layer.provide(NodeSocket.layerWebSocketConstructor), +) satisfies Layer.Layer +// @ts-expect-error HTTP request matching does not apply to WebSocket frames. +HttpRecorder.layerSocket("consumer/socket", { match: () => true }) +`, + ) + await writeFile( + path.join(directory, "exports.mjs"), + `import { HttpRecorder } from "@opencode-ai/http-recorder" + +const root = Object.keys(await import("@opencode-ai/http-recorder")).sort() +if (JSON.stringify(root) !== JSON.stringify(["HttpRecorder"])) { + throw new Error(\`Unexpected root exports: \${root}\`) +} + +const namespace = Object.keys(HttpRecorder).sort() +if (JSON.stringify(namespace) !== JSON.stringify(["hasCassetteSync", "layer", "layerFetch", "layerSocket", "layerWebSocketConstructor", "removeCassetteSync"])) { + throw new Error(\`Unexpected HttpRecorder exports: \${namespace}\`) +} +`, + ) + await writeFile( + path.join(directory, "deep-import.mjs"), + `import "@opencode-ai/http-recorder/internal" `, ) await writeFile( @@ -41,7 +75,7 @@ HttpRecorder.socket("consumer/socket", options).pipe( moduleResolution: "NodeNext", strict: true, noEmit: true, - // Required by effect@4.0.0-beta.74: its schema.d.ts references an undeclared SchemaErrorTypeId. + // Required by effect@4.0.0-beta.83: its declarations currently contain unresolved internal symbols. skipLibCheck: true, lib: ["ES2022", "DOM", "ESNext.Disposable"], }, @@ -49,27 +83,28 @@ HttpRecorder.socket("consumer/socket", options).pipe( }), ) - await run(["npm", "install", archive, "typescript@5.8.2"], directory) await run( [ - "node", - "--input-type=module", - "-e", - 'import("@opencode-ai/http-recorder").then((module) => { const root = Object.keys(module).sort(); const namespace = Object.keys(module.HttpRecorder).sort(); if (JSON.stringify(root) !== JSON.stringify(["HttpRecorder"])) throw new Error(`Unexpected root exports: ${root}`); if (JSON.stringify(namespace) !== JSON.stringify(["http", "socket"])) throw new Error(`Unexpected namespace exports: ${namespace}`) })', + "npm", + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--package-lock=false", + archive, + "typescript@5.8.2", + "effect@4.0.0-beta.83", + "@effect/platform-node@4.0.0-beta.83", ], directory, ) + await run(["node", path.join(directory, "exports.mjs")], directory) + await run(["bun", path.join(directory, "exports.mjs")], directory) + await reject(["node", path.join(directory, "deep-import.mjs")], directory) await run([path.join(directory, "node_modules", ".bin", "tsc"), "--noEmit"], directory) } finally { await rm(directory, { recursive: true, force: true }) } } -if (import.meta.main) { - const archive = await pack() - try { - await verifyPackage(archive) - } finally { - await Bun.file(archive).delete() - } -} +if (import.meta.main) await withPackedArchive(verifyPackage) diff --git a/packages/http-recorder/src/api.ts b/packages/http-recorder/src/api.ts new file mode 100644 index 00000000000..47d652029b8 --- /dev/null +++ b/packages/http-recorder/src/api.ts @@ -0,0 +1,46 @@ +/** JSON-compatible cassette metadata value. */ +export type JsonValue = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: JsonValue } + +/** Additional JSON metadata stored with a cassette. */ +export type CassetteMetadata = Readonly> + +/** The normalized HTTP request representation used for matching. */ +export interface RequestSnapshot { + readonly method: string + readonly url: string + readonly headers: Record + readonly body: string +} + +/** Returns whether an incoming HTTP request matches a recorded request. */ +export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean + +/** Additive redaction and header-preservation policy. */ +export interface RedactOptions { + readonly headers?: ReadonlyArray + readonly allowRequestHeaders?: ReadonlyArray + readonly allowResponseHeaders?: ReadonlyArray + readonly queryParameters?: ReadonlyArray + readonly jsonFields?: ReadonlyArray + readonly url?: (url: string) => string + readonly body?: (body: string) => string +} + +/** Options shared by HTTP recorder layers. */ +export interface RecorderOptions { + readonly directory?: string + readonly metadata?: CassetteMetadata + readonly redact?: RedactOptions + readonly match?: RequestMatcher +} + +/** Recorder configuration for Effect socket and WebSocket layers. */ +export type SocketRecorderOptions = Omit + +export * as Api from "./api.js" diff --git a/packages/http-recorder/src/cassette/model.ts b/packages/http-recorder/src/cassette/model.ts new file mode 100644 index 00000000000..8667505da8a --- /dev/null +++ b/packages/http-recorder/src/cassette/model.ts @@ -0,0 +1,43 @@ +import { Schema } from "effect" +import type { CassetteMetadata, JsonValue } from "../api.js" +import { HttpInteractionSchema } from "../http/model.js" +import { WebSocketInteractionSchema } from "../websocket/model.js" + +export type { CassetteMetadata, JsonValue } from "../api.js" + +const JsonValueSchema = Schema.suspend( + (): Schema.Codec => + Schema.Union([ + Schema.Null, + Schema.Boolean, + Schema.Number, + Schema.String, + Schema.Array(JsonValueSchema), + Schema.Record(Schema.String, JsonValueSchema), + ]), +) + +export const CassetteMetadataSchema = Schema.Record(Schema.String, JsonValueSchema) + +export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe( + Schema.toTaggedUnion("transport"), +) +export type Interaction = Schema.Schema.Type + +export const isHttpInteraction = InteractionSchema.guards.http +export const isWebSocketInteraction = InteractionSchema.guards.websocket +export const httpInteractions = (interactions: ReadonlyArray) => interactions.filter(isHttpInteraction) +export const webSocketInteractions = (interactions: ReadonlyArray) => + interactions.filter(isWebSocketInteraction) + +export const CassetteSchema = Schema.Struct({ + version: Schema.Literal(1), + metadata: Schema.optional(CassetteMetadataSchema), + interactions: Schema.Array(InteractionSchema), +}) +export type Cassette = Schema.Schema.Type + +export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema) +export const encodeCassette = Schema.encodeSync(CassetteSchema) + +export * as CassetteModel from "./model.js" diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette/store.ts similarity index 81% rename from packages/http-recorder/src/cassette.ts rename to packages/http-recorder/src/cassette/store.ts index 5138bb12dff..2079632bea9 100644 --- a/packages/http-recorder/src/cassette.ts +++ b/packages/http-recorder/src/cassette/store.ts @@ -1,8 +1,8 @@ import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect" -import * as fs from "node:fs" -import * as path from "node:path" -import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction.js" -import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema.js" +import { existsSync, rmSync } from "node:fs" +import path from "node:path" +import { secretFindings, SecretFindingSchema, type SecretFinding } from "../redaction/secrets.js" +import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./model.js" const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") @@ -14,6 +14,15 @@ export class CassetteNotFoundError extends Schema.TaggedErrorClass()("InvalidCassetteError", { + cassetteName: Schema.String, + description: Schema.String, +}) { + override get message() { + return `Cassette "${this.cassetteName}" is invalid: ${this.description}` + } +} + export class UnsafeCassetteError extends Schema.TaggedErrorClass()("UnsafeCassetteError", { cassetteName: Schema.String, findings: Schema.Array(SecretFindingSchema), @@ -26,7 +35,9 @@ export class UnsafeCassetteError extends Schema.TaggedErrorClass Effect.Effect, CassetteNotFoundError> + readonly read: ( + name: string, + ) => Effect.Effect, CassetteNotFoundError | InvalidCassetteError> readonly append: ( name: string, interaction: Interaction, @@ -50,7 +61,10 @@ const cassettePath = (directory: string, name: string) => { } export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => - fs.existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name)) + existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name)) + +export const removeCassetteSync = (name: string, options: { readonly directory?: string } = {}) => + rmSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name), { force: true }) const buildCassette = ( name: string, @@ -58,14 +72,16 @@ const buildCassette = ( metadata: CassetteMetadata | undefined, ): Cassette => ({ version: 1, - metadata: { name, recordedAt: new Date().toISOString(), ...metadata }, + metadata: { ...metadata, name, recordedAt: new Date().toISOString() }, interactions, }) - const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n` - const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema)) - +const invalidCassette = (name: string, error: unknown) => + new InvalidCassetteError({ + cassetteName: name, + description: error instanceof Error ? error.message : String(error), + }) const failIfUnsafe = (name: string, findings: ReadonlyArray) => findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })) @@ -79,9 +95,7 @@ export const fileSystem = ( const directory = options.directory ?? DEFAULT_RECORDINGS_DIR const recorded = new Map() const appendLock = yield* Semaphore.make(1) - const pathFor = (name: string) => cassettePath(directory, name) - const walk = (current: string): Effect.Effect> => Effect.gen(function* () { const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[]))) @@ -98,8 +112,17 @@ export const fileSystem = ( return Service.of({ read: (name) => fs.readFileString(pathFor(name)).pipe( - Effect.map((raw) => parseCassette(raw).interactions), - Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))), + Effect.mapError((error) => + error.reason._tag === "NotFound" + ? new CassetteNotFoundError({ cassetteName: name }) + : invalidCassette(name, error), + ), + Effect.flatMap((raw) => + Effect.try({ + try: () => parseCassette(raw).interactions, + catch: (error) => invalidCassette(name, error), + }), + ), ), append: (name, interaction, metadata) => appendLock.withPermit( @@ -151,7 +174,6 @@ export const memory = (initial: Record> = {}) ) const accumulatedFindings = new Map() const appendLock = Semaphore.makeUnsafe(1) - return Service.of({ read: (name) => stored.has(name) @@ -162,7 +184,7 @@ export const memory = (initial: Record> = {}) Effect.suspend(() => { const interactions = [...(stored.get(name) ?? []), interaction] const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)] - const allFindings = metadata ? [...findings, ...secretFindings({ name, ...metadata })] : findings + const allFindings = metadata ? [...findings, ...secretFindings({ ...metadata, name })] : findings return failIfUnsafe(name, allFindings).pipe( Effect.tap(() => Effect.sync(() => { diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts deleted file mode 100644 index 583dee8ca12..00000000000 --- a/packages/http-recorder/src/effect.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node" -import * as Layer from "effect/Layer" -import { FetchHttpClient } from "effect/unstable/http" -import type * as HttpClient from "effect/unstable/http/HttpClient" -import * as CassetteService from "./cassette.js" -import { recordingLayer } from "./internal-effect.js" -import { make } from "./redactor.js" -import type { RecorderOptions } from "./types.js" - -/** - * Provides a fetch-backed `HttpClient` with cassette recording and replay. - * - * Locally, a missing cassette is recorded from the real service. Existing - * cassettes are replayed, and `CI=true` makes a missing cassette fail. - */ -export const http = (name: string, options: RecorderOptions = {}): Layer.Layer => - recordingLayer(name, { - metadata: options.metadata, - redactor: make(options.redact), - match: options.match, - }).pipe( - Layer.provide(CassetteService.fileSystem({ directory: options.directory })), - Layer.provide(FetchHttpClient.layer), - Layer.provide(NodeFileSystem.layer), - ) diff --git a/packages/http-recorder/src/matching.ts b/packages/http-recorder/src/http/matching.ts similarity index 54% rename from packages/http-recorder/src/matching.ts rename to packages/http-recorder/src/http/matching.ts index 731aa8b57ac..fcd446eddad 100644 --- a/packages/http-recorder/src/matching.ts +++ b/packages/http-recorder/src/http/matching.ts @@ -1,64 +1,31 @@ -import { Option, Schema } from "effect" -import { REDACTED, secretFindings } from "./redaction.js" -import type { HttpInteraction, RequestMatcher, RequestSnapshot } from "./types.js" +import { HashSet, Option } from "effect" +import type { RequestMatcher, RequestSnapshot } from "../api.js" +import { canonicalizeJson, decodeJson, isJsonRecord, jsonBody, safeText } from "../replay/comparison.js" +import type { HttpInteraction } from "./model.js" -const JsonValue = Schema.fromJsonString(Schema.Unknown) -export const decodeJson = Schema.decodeUnknownOption(JsonValue) - -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === "object" && !Array.isArray(value) - -export const canonicalizeJson = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(canonicalizeJson) - if (isRecord(value)) { - return Object.fromEntries( - Object.keys(value) - .toSorted() - .map((key) => [key, canonicalizeJson(value[key])]), - ) - } - return value -} - -export type { RequestMatcher } from "./types.js" +export type { RequestMatcher } from "../api.js" export const canonicalSnapshot = (snapshot: RequestSnapshot): string => JSON.stringify({ method: snapshot.method, url: snapshot.url, headers: canonicalizeJson(snapshot.headers), - body: Option.match(decodeJson(snapshot.body), { - onNone: () => snapshot.body, - onSome: canonicalizeJson, - }), + body: Option.match(decodeJson(snapshot.body), { onNone: () => snapshot.body, onSome: canonicalizeJson }), }) - export const defaultMatcher: RequestMatcher = (incoming, recorded) => canonicalSnapshot(incoming) === canonicalSnapshot(recorded) -export const safeText = (value: unknown) => { - if (value === undefined) return "undefined" - if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) - const text = JSON.stringify(value) - if (!text) return typeof value - return text.length > 300 ? `${text.slice(0, 300)}...` : text -} - -const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body)) - const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray => { if (Object.is(expected, received)) return [] - if (isRecord(expected) && isRecord(received)) { + if (isJsonRecord(expected) && isJsonRecord(received)) return [...new Set([...Object.keys(expected), ...Object.keys(received)])] .toSorted() .flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit)) .slice(0, limit) - } - if (Array.isArray(expected) && Array.isArray(received)) { + if (Array.isArray(expected) && Array.isArray(received)) return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index) .flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit)) .slice(0, limit) - } return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`] } @@ -72,12 +39,9 @@ const headerDiffs = (expected: Record, received: Record => { const lines: string[] = [] - if (expected.method !== received.method) { + if (expected.method !== received.method) lines.push("method:", ` expected ${expected.method}, received ${received.method}`) - } - if (expected.url !== received.url) { - lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`) - } + if (expected.url !== received.url) lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`) const headers = headerDiffs(expected.headers, received.headers) if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8)) const expectedBody = jsonBody(expected.body) @@ -92,15 +56,22 @@ export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot return lines } -export const selectSequential = ( +export const selectFirstMatching = ( interactions: ReadonlyArray, incoming: RequestSnapshot, match: RequestMatcher, - index: number, -): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => { - const interaction = interactions[index] - if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` } - if (!match(incoming, interaction.request)) - return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") } - return { interaction, detail: "" } + used: HashSet.HashSet, +): { readonly _tag: "Matched"; readonly index: number } | { readonly _tag: "Unmatched"; readonly detail: string } => { + let firstUnused: HttpInteraction | undefined + for (let index = 0; index < interactions.length; index++) { + if (HashSet.has(used, index)) continue + const interaction = interactions[index] + firstUnused ??= interaction + if (match(incoming, interaction.request)) return { _tag: "Matched", index } + } + if (firstUnused === undefined) + return { _tag: "Unmatched", detail: `all ${interactions.length} recorded interactions have already been consumed` } + return { _tag: "Unmatched", detail: requestDiff(firstUnused.request, incoming).join("\n") } } + +export * as HttpMatching from "./matching.js" diff --git a/packages/http-recorder/src/http/model.ts b/packages/http-recorder/src/http/model.ts new file mode 100644 index 00000000000..861d502f882 --- /dev/null +++ b/packages/http-recorder/src/http/model.ts @@ -0,0 +1,30 @@ +import { Schema } from "effect" +import type { RequestSnapshot } from "../api.js" + +export const RequestSnapshotSchema = Schema.Struct({ + method: Schema.String, + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.String, +}) + +export type { RequestSnapshot } from "../api.js" + +export const ResponseSnapshotSchema = Schema.Struct({ + status: Schema.Number, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.String, + bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])), +}) + +export interface ResponseSnapshot extends Schema.Schema.Type {} + +export const HttpInteractionSchema = Schema.Struct({ + transport: Schema.tag("http"), + request: RequestSnapshotSchema, + response: ResponseSnapshotSchema, +}) + +export interface HttpInteraction extends Schema.Schema.Type {} + +export * as HttpModel from "./model.js" diff --git a/packages/http-recorder/src/internal-effect.ts b/packages/http-recorder/src/http/recorder.ts similarity index 67% rename from packages/http-recorder/src/internal-effect.ts rename to packages/http-recorder/src/http/recorder.ts index 6b886311a99..568b101743a 100644 --- a/packages/http-recorder/src/internal-effect.ts +++ b/packages/http-recorder/src/http/recorder.ts @@ -1,27 +1,22 @@ -import { NodeFileSystem } from "@effect/platform-node" -import { Deferred, Effect, Layer, Option, Ref } from "effect" +import { NodeFileSystem } from "@effect/platform-node-shared" +import { Deferred, Effect, Layer, Ref } from "effect" import { FetchHttpClient, - Headers, - HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse, - UrlParams, } from "effect/unstable/http" -import * as CassetteService from "./cassette.js" -import { defaultMatcher, selectSequential } from "./matching.js" -import { makeReplayState, resolveAutoMode } from "./recorder.js" -import { make, type Redactor } from "./redactor.js" -import { redactUrl } from "./redaction.js" -import { httpInteractions } from "./schema.js" -import type { CassetteMetadata, HttpInteraction, RequestMatcher, ResponseSnapshot } from "./types.js" +import { fileSystem, Service } from "../cassette/store.js" +import type { RecorderOptions } from "../options.js" +import { make, redactUrl, type Redactor } from "../redaction/redactor.js" +import { makeReplayPoolState, resolveAutoMode } from "../replay/state.js" +import { httpInteractions, type CassetteMetadata } from "../cassette/model.js" +import { defaultMatcher, selectFirstMatching, type RequestMatcher } from "./matching.js" +import type { HttpInteraction, ResponseSnapshot } from "./model.js" export { defaultMatcher } - export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough" - export interface RecordReplayOptions { readonly mode?: RecordReplayMode readonly directory?: string @@ -40,7 +35,6 @@ const TEXT_CONTENT_TYPES = new Set([ "application/yaml", "image/svg+xml", ]) - const isTextContentType = (contentType: string | undefined) => { const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase() if (!mediaType) return false @@ -51,7 +45,6 @@ const isTextContentType = (contentType: string | undefined) => { TEXT_CONTENT_TYPES.has(mediaType) ) } - const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) => response.arrayBuffer.pipe( Effect.map((bytes) => @@ -60,10 +53,8 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co : { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const }, ), ) - const decodeResponseBody = (snapshot: ResponseSnapshot) => snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body - const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snapshot: ResponseSnapshot) => HttpClientResponse.fromWeb( request, @@ -75,35 +66,28 @@ const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snap ), ) -export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => - HttpClientRequest.makeWith( - request.method, - redactUrl(request.url), - UrlParams.empty, - Option.none(), - Headers.empty, - HttpBody.empty, - ) - -const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) => +export const redactedErrorRequest = ( + request: HttpClientRequest.HttpClientRequest, + redactedUrl = redactUrl(request.url), +) => HttpClientRequest.make(request.method)(redactedUrl) +const transportError = (request: HttpClientRequest.HttpClientRequest, description: string, redactedUrl?: string) => new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }), + reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request, redactedUrl), description }), }) export const recordingLayer = ( name: string, options: Omit = {}, -): Layer.Layer => +): Layer.Layer => Layer.effect( HttpClient.HttpClient, Effect.gen(function* () { const upstream = yield* HttpClient.HttpClient - const cassetteService = yield* CassetteService.Service + const cassette = yield* Service const redactor = options.redactor ?? make() const match = options.match ?? defaultMatcher const requested = options.mode ?? "auto" - const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested - + const mode = requested === "auto" ? yield* resolveAutoMode(cassette, name) : requested const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) @@ -114,9 +98,7 @@ export const recordingLayer = ( body: yield* Effect.promise(() => web.text()), }) }) - if (mode === "passthrough") return upstream - if (mode === "record") { const initial = yield* Deferred.make() yield* Deferred.succeed(initial, undefined) @@ -127,6 +109,7 @@ export const recordingLayer = ( const previous = yield* Ref.modify(tail, (current) => [current, completed]) return yield* Effect.gen(function* () { const incoming = yield* snapshotRequest(request) + const requestError = (description: string) => transportError(request, description, incoming.url) const response = yield* upstream.execute(request) const captured = yield* captureResponseBody(response, response.headers["content-type"]) const responseSnapshot: ResponseSnapshot = { @@ -140,39 +123,32 @@ export const recordingLayer = ( response: redactor.response(responseSnapshot), } yield* Deferred.await(previous) - yield* cassetteService + yield* cassette .append(name, interaction, options.metadata) - .pipe( - Effect.catchTag("UnsafeCassetteError", (error) => - Effect.fail(transportError(request, error.message)), - ), - ) + .pipe(Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(requestError(error.message)))) return responseFromSnapshot(request, responseSnapshot) }).pipe(Effect.ensuring(Deferred.succeed(completed, undefined))) }), ) } - - const replay = yield* makeReplayState(cassetteService, name, httpInteractions) + const replay = yield* makeReplayPoolState(cassette, name, httpInteractions) return HttpClient.make((request) => Effect.gen(function* () { const incoming = yield* snapshotRequest(request) + const requestError = (description: string) => transportError(request, description, incoming.url) const claimed = yield* replay - .claim((interaction, index, interactions) => { - const result = selectSequential(interactions, incoming, match, index) - if (result.interaction) return Effect.void + .claim((interactions, used) => { + const result = selectFirstMatching(interactions, incoming, match, used) + if (result._tag === "Matched") return Effect.succeed(result.index) return Effect.fail( - transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`), + requestError(`Fixture "${name}" does not match the current request: ${result.detail}.`), ) }) .pipe( Effect.mapError((error) => error._tag === "CassetteNotFoundError" - ? transportError( - request, - `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`, - ) - : error, + ? requestError(`Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`) + : requestError(error.message), ), ) return responseFromSnapshot(request, claimed.interaction.response) @@ -183,7 +159,18 @@ export const recordingLayer = ( export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer => recordingLayer(name, options).pipe( - Layer.provide(CassetteService.fileSystem({ directory: options.directory })), + Layer.provide(fileSystem({ directory: options.directory })), Layer.provide(FetchHttpClient.layer), Layer.provide(NodeFileSystem.layer), ) + +export const layer = ( + name: string, + options: RecorderOptions = {}, +): Layer.Layer => + recordingLayer(name, { metadata: options.metadata, redactor: make(options.redact), match: options.match }).pipe( + Layer.provide(fileSystem({ directory: options.directory })), + Layer.provide(NodeFileSystem.layer), + ) +export const layerFetch = (name: string, options: RecorderOptions = {}): Layer.Layer => + layer(name, options).pipe(Layer.provide(FetchHttpClient.layer)) diff --git a/packages/http-recorder/src/index.ts b/packages/http-recorder/src/index.ts index eac4b75b4bb..2dcba03e6fc 100644 --- a/packages/http-recorder/src/index.ts +++ b/packages/http-recorder/src/index.ts @@ -1,18 +1,43 @@ -import { http } from "./effect.js" -import { socket } from "./socket.js" +import { Layer } from "effect" +import { HttpClient } from "effect/unstable/http" +import { Socket } from "effect/unstable/socket" +import { Api } from "./api.js" +import { hasCassetteSync, removeCassetteSync } from "./cassette/store.js" +import { layer, layerFetch } from "./http/recorder.js" +import { layerSocket, layerWebSocketConstructor } from "./websocket/recorder.js" /** HTTP and WebSocket cassette recording. */ -export const HttpRecorder = { http, socket } as const +export const HttpRecorder: { + readonly layer: ( + name: string, + options?: Api.RecorderOptions, + ) => Layer.Layer + readonly layerFetch: (name: string, options?: Api.RecorderOptions) => Layer.Layer + readonly layerSocket: ( + name: string, + options?: Api.SocketRecorderOptions, + ) => Layer.Layer + readonly layerWebSocketConstructor: ( + name: string, + options?: Api.SocketRecorderOptions, + ) => Layer.Layer + readonly hasCassetteSync: (name: string, options?: { readonly directory?: string }) => boolean + readonly removeCassetteSync: (name: string, options?: { readonly directory?: string }) => void +} = { hasCassetteSync, layer, layerFetch, layerSocket, layerWebSocketConstructor, removeCassetteSync } export namespace HttpRecorder { /** Additional JSON metadata stored with a cassette. */ - export type CassetteMetadata = import("./types.js").CassetteMetadata + export type JsonValue = Api.JsonValue + /** Additional JSON metadata stored with a cassette. */ + export type CassetteMetadata = Api.CassetteMetadata /** Recorder configuration. */ - export type RecorderOptions = import("./types.js").RecorderOptions + export type RecorderOptions = Api.RecorderOptions /** Additive redaction and header-preservation policy. */ - export type RedactOptions = import("./types.js").RedactOptions + export type RedactOptions = Api.RedactOptions /** Returns whether an incoming HTTP request matches a recorded request. */ - export type RequestMatcher = import("./types.js").RequestMatcher + export type RequestMatcher = Api.RequestMatcher /** The normalized HTTP request representation used for matching. */ - export type RequestSnapshot = import("./types.js").RequestSnapshot + export type RequestSnapshot = Api.RequestSnapshot + /** Recorder configuration for Effect socket and WebSocket layers. */ + export type SocketRecorderOptions = Api.SocketRecorderOptions } diff --git a/packages/http-recorder/src/internal.ts b/packages/http-recorder/src/internal.ts deleted file mode 100644 index 7faecf0db06..00000000000 --- a/packages/http-recorder/src/internal.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette.js" -export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./internal-effect.js" -export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction.js" -export { socketLayer } from "./socket.js" -export { - makeWebSocketExecutor, - type WebSocketConnection, - type WebSocketExecutor, - type WebSocketRecordReplayOptions, - type WebSocketRequest, -} from "./websocket.js" -export * as Cassette from "./cassette.js" -export * as Redactor from "./redactor.js" - -export * as HttpRecorderInternal from "./internal.js" diff --git a/packages/http-recorder/src/options.ts b/packages/http-recorder/src/options.ts new file mode 100644 index 00000000000..df62d6a2470 --- /dev/null +++ b/packages/http-recorder/src/options.ts @@ -0,0 +1 @@ +export type { RecorderOptions, RedactOptions, SocketRecorderOptions } from "./api.js" diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts deleted file mode 100644 index c58afde4633..00000000000 --- a/packages/http-recorder/src/recorder.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Effect, Scope, SynchronizedRef } from "effect" -import type * as CassetteService from "./cassette.js" -import type { CassetteNotFoundError } from "./cassette.js" -import type { Interaction } from "./schema.js" - -const isCI = () => { - const value = process.env.CI - return value !== undefined && value !== "" && value !== "false" && value !== "0" -} - -export const resolveAutoMode = ( - cassette: CassetteService.Interface, - name: string, -): Effect.Effect<"record" | "replay" | "passthrough"> => - Effect.gen(function* () { - if (isCI()) return "replay" - return (yield* cassette.exists(name)) ? "replay" : "record" - }) - -export interface ReplayState { - readonly claim: ( - validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray) => Effect.Effect, - ) => Effect.Effect<{ readonly interaction: T; readonly index: number }, CassetteNotFoundError | E> -} - -export const makeReplayState = ( - cassette: CassetteService.Interface, - name: string, - project: (interactions: ReadonlyArray) => ReadonlyArray, -): Effect.Effect, never, Scope.Scope> => - Effect.gen(function* () { - const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project))) - const position = yield* SynchronizedRef.make(0) - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const used = yield* SynchronizedRef.get(position) - if (used === 0) return yield* Effect.void - const interactions = yield* load.pipe(Effect.orDie) - if (used < interactions.length) - return yield* Effect.die( - new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`), - ) - return yield* Effect.void - }), - ) - - return { - claim: (validate) => - Effect.flatMap(load, (interactions) => - SynchronizedRef.modifyEffect(position, (index) => - Effect.gen(function* () { - const interaction = interactions[index] - yield* validate(interaction, index, interactions) - if (interaction === undefined) - return yield* Effect.die("Replay validation accepted a missing interaction") - return [{ interaction, index }, index + 1] as const - }), - ), - ), - } - }) diff --git a/packages/http-recorder/src/redaction.ts b/packages/http-recorder/src/redaction.ts deleted file mode 100644 index b84996adecb..00000000000 --- a/packages/http-recorder/src/redaction.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Schema } from "effect" - -export const REDACTED = "[REDACTED]" - -const DEFAULT_REDACT_HEADERS = [ - "authorization", - "cookie", - "proxy-authorization", - "set-cookie", - "x-api-key", - "x-amz-security-token", - "x-goog-api-key", -] - -const DEFAULT_REDACT_QUERY = [ - "access_token", - "api-key", - "api_key", - "apikey", - "code", - "key", - "signature", - "sig", - "token", - "x-amz-credential", - "x-amz-security-token", - "x-amz-signature", -] - -const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [ - { label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i }, - { label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ }, - { label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ }, - { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ }, - { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ }, - { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ }, - { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ }, -] - -const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i -const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"]) - -const envSecrets = () => - Object.entries(process.env).flatMap(([name, value]) => { - if (!value) return [] - if (!ENV_SECRET_NAMES.test(name)) return [] - if (value.length < 12) return [] - if (SAFE_ENV_VALUES.has(value.toLowerCase())) return [] - return [{ name, value }] - }) - -const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key) - -const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => { - if (typeof value === "string") return [{ path: base, value }] - if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`)) - if (value && typeof value === "object") { - return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key))) - } - return [] -} - -const redactionSet = (values: ReadonlyArray | undefined, defaults: ReadonlyArray) => - new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase())) - -export type UrlRedactor = (url: string) => string - -export const redactUrl = ( - raw: string, - query: ReadonlyArray = DEFAULT_REDACT_QUERY, - urlRedactor?: UrlRedactor, -) => { - if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw - const url = new URL(raw) - if (url.username) url.username = REDACTED - if (url.password) url.password = REDACTED - const redacted = redactionSet(query, DEFAULT_REDACT_QUERY) - for (const key of url.searchParams.keys()) { - if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED) - } - return urlRedactor?.(url.toString()) ?? url.toString() -} - -export const redactHeaders = ( - headers: Record, - allow: ReadonlyArray, - redact: ReadonlyArray = DEFAULT_REDACT_HEADERS, -) => { - const allowed = new Set(allow.map((name) => name.toLowerCase())) - const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS) - return Object.fromEntries( - Object.entries(headers) - .map(([name, value]) => [name.toLowerCase(), value] as const) - .filter(([name]) => allowed.has(name)) - .map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const) - .toSorted(([a], [b]) => a.localeCompare(b)), - ) -} - -export const SecretFindingSchema = Schema.Struct({ - path: Schema.String, - reason: Schema.String, -}) -export type SecretFinding = Schema.Schema.Type - -export const secretFindings = (value: unknown): ReadonlyArray => { - const environment = envSecrets() - return stringEntries(value).flatMap((entry) => [ - ...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({ - path: entry.path, - reason: item.label, - })), - ...environment - .filter((item) => entry.value.includes(item.value)) - .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), - ]) -} diff --git a/packages/http-recorder/src/redaction/redactor.ts b/packages/http-recorder/src/redaction/redactor.ts new file mode 100644 index 00000000000..d921f55d73f --- /dev/null +++ b/packages/http-recorder/src/redaction/redactor.ts @@ -0,0 +1,173 @@ +import { Option, Schema } from "effect" +import type { RequestSnapshot, ResponseSnapshot } from "../http/model.js" +import type { RedactOptions } from "../options.js" + +export type { RedactOptions } from "../options.js" +export const REDACTED = "[REDACTED]" + +const DEFAULT_REDACT_HEADERS = [ + "authorization", + "cookie", + "proxy-authorization", + "set-cookie", + "x-api-key", + "x-amz-security-token", + "x-goog-api-key", +] +const DEFAULT_REDACT_QUERY = [ + "access_token", + "api-key", + "api_key", + "apikey", + "code", + "key", + "signature", + "sig", + "token", + "x-amz-credential", + "x-amz-security-token", + "x-amz-signature", +] +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const redactionSet = (values: ReadonlyArray | undefined, defaults: ReadonlyArray) => + new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase())) + +export const redactUrl = ( + raw: string, + query: ReadonlyArray = DEFAULT_REDACT_QUERY, + transform?: (url: string) => string, +) => { + if (!URL.canParse(raw)) return transform?.(raw) ?? raw + const url = new URL(raw) + if (url.username) url.username = REDACTED + if (url.password) url.password = REDACTED + const redacted = redactionSet(query, DEFAULT_REDACT_QUERY) + for (const key of url.searchParams.keys()) if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED) + return transform?.(url.toString()) ?? url.toString() +} + +export const redactHeaders = ( + headers: Record, + allow: ReadonlyArray, + redact: ReadonlyArray = DEFAULT_REDACT_HEADERS, +) => { + const allowed = new Set(allow.map((name) => name.toLowerCase())) + const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS) + return Object.fromEntries( + Object.entries(headers) + .map(([name, value]) => [name.toLowerCase(), value] as const) + .filter(([name]) => allowed.has(name)) + .map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const) + .toSorted(([a], [b]) => a.localeCompare(b)), + ) +} + +const DEFAULT_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] +const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["content-type"] +const identity = (value: T) => value + +export interface Redactor { + readonly request: (snapshot: RequestSnapshot) => RequestSnapshot + readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot +} + +export const compose = (...redactors: ReadonlyArray>): Redactor => { + const requests = redactors + .map((redactor) => redactor.request) + .filter((fn): fn is Redactor["request"] => fn !== undefined) + const responses = redactors + .map((redactor) => redactor.response) + .filter((fn): fn is Redactor["response"] => fn !== undefined) + return { + request: requests.length === 0 ? identity : (snapshot) => requests.reduce((value, fn) => fn(value), snapshot), + response: responses.length === 0 ? identity : (snapshot) => responses.reduce((value, fn) => fn(value), snapshot), + } +} + +interface HeaderOptions { + readonly allow?: ReadonlyArray + readonly redact?: ReadonlyArray +} +const requestHeaders = (options: HeaderOptions = {}): Partial => ({ + request: (snapshot) => ({ + ...snapshot, + headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact), + }), +}) +const responseHeaders = (options: HeaderOptions = {}): Partial => ({ + response: (snapshot) => ({ + ...snapshot, + headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact), + }), +}) + +interface UrlOptions { + readonly query?: ReadonlyArray + readonly transform?: (url: string) => string +} +const url = (options: UrlOptions = {}): Partial => ({ + request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }), +}) + +const DEFAULT_REDACT_JSON_FIELDS = [ + "access_token", + "api_key", + "apikey", + "client_secret", + "password", + "refresh_token", + "secret", + "token", +] +const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase() +interface RedactedJson { + readonly value: unknown + readonly changed: boolean +} +const redactJsonFields = (value: unknown, fields: ReadonlySet): RedactedJson => { + if (Array.isArray(value)) { + const items = value.map((item) => redactJsonFields(item, fields)) + return { value: items.map((item) => item.value), changed: items.some((item) => item.changed) } + } + if (!value || typeof value !== "object") return { value, changed: false } + let changed = false + const entries = Object.entries(value).map(([key, child]) => { + if (fields.has(normalizeField(key))) { + if (child !== REDACTED) changed = true + return [key, REDACTED] as const + } + const redacted = redactJsonFields(child, fields) + if (redacted.changed) changed = true + return [key, redacted.value] as const + }) + return { value: Object.fromEntries(entries), changed } +} +const redactBody = (value: string, fields: ReadonlySet, transform: ((body: string) => string) | undefined) => { + const redacted = Option.match(decodeJson(value), { + onNone: () => value, + onSome: (parsed) => { + const result = redactJsonFields(parsed, fields) + return result.changed ? JSON.stringify(result.value) : value + }, + }) + return transform?.(redacted) ?? redacted +} + +export const make = (options: RedactOptions = {}): Redactor => { + const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField)) + return compose( + requestHeaders({ + allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])], + redact: options.headers, + }), + responseHeaders({ + allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])], + redact: options.headers, + }), + url({ query: options.queryParameters, transform: options.url }), + { + request: (snapshot) => ({ ...snapshot, body: redactBody(snapshot.body, fields, options.body) }), + response: (snapshot) => ({ ...snapshot, body: redactBody(snapshot.body, fields, options.body) }), + }, + ) +} diff --git a/packages/http-recorder/src/redaction/secrets.ts b/packages/http-recorder/src/redaction/secrets.ts new file mode 100644 index 00000000000..b3ffd3c3a63 --- /dev/null +++ b/packages/http-recorder/src/redaction/secrets.ts @@ -0,0 +1,47 @@ +import { Schema } from "effect" + +const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [ + { label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i }, + { label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ }, + { label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ }, + { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ }, + { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ }, + { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ }, + { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ }, +] + +const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i +const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"]) + +const envSecrets = () => + Object.entries(process.env).flatMap(([name, value]) => { + if (!value || !ENV_SECRET_NAMES.test(name) || value.length < 12 || SAFE_ENV_VALUES.has(value.toLowerCase())) + return [] + return [{ name, value }] + }) + +const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key) + +const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => { + if (typeof value === "string") return [{ path: base, value }] + if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`)) + if (value && typeof value === "object") + return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key))) + return [] +} + +export const SecretFindingSchema = Schema.Struct({ path: Schema.String, reason: Schema.String }) +export type SecretFinding = Schema.Schema.Type + +export const secretFindings = (value: unknown): ReadonlyArray => { + const environment = envSecrets() + return stringEntries(value).flatMap((entry) => [ + ...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({ + path: entry.path, + reason: item.label, + })), + ...environment + .filter((item) => entry.value.includes(item.value)) + .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), + ]) +} diff --git a/packages/http-recorder/src/redactor.ts b/packages/http-recorder/src/redactor.ts deleted file mode 100644 index 582c647702a..00000000000 --- a/packages/http-recorder/src/redactor.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Option } from "effect" -import { decodeJson } from "./matching.js" -import { REDACTED, redactHeaders, redactUrl } from "./redaction.js" -import type { RedactOptions, RequestSnapshot, ResponseSnapshot } from "./types.js" - -export type { RedactOptions } from "./types.js" - -export const DEFAULT_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] -export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["content-type"] - -const identity = (value: T) => value - -export interface Redactor { - readonly request: (snapshot: RequestSnapshot) => RequestSnapshot - readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot -} - -export const compose = (...redactors: ReadonlyArray>): Redactor => { - const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined) - const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined) - return { - request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot), - response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot), - } -} - -export interface HeaderOptions { - readonly allow?: ReadonlyArray - readonly redact?: ReadonlyArray -} - -export const requestHeaders = (options: HeaderOptions = {}): Partial => ({ - request: (snapshot) => ({ - ...snapshot, - headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact), - }), -}) - -export const responseHeaders = (options: HeaderOptions = {}): Partial => ({ - response: (snapshot) => ({ - ...snapshot, - headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact), - }), -}) - -export interface UrlOptions { - readonly query?: ReadonlyArray - readonly transform?: (url: string) => string -} - -export const url = (options: UrlOptions = {}): Partial => ({ - request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }), -}) - -export const body = (transform: (parsed: unknown) => unknown): Partial => ({ - request: (snapshot) => ({ - ...snapshot, - body: Option.match(decodeJson(snapshot.body), { - onNone: () => snapshot.body, - onSome: (parsed) => JSON.stringify(transform(parsed)), - }), - }), -}) - -export interface DefaultRedactorOverrides { - readonly requestHeaders?: HeaderOptions - readonly responseHeaders?: HeaderOptions - readonly url?: UrlOptions - readonly body?: (parsed: unknown) => unknown -} - -const DEFAULT_REDACT_JSON_FIELDS = [ - "access_token", - "api_key", - "apikey", - "client_secret", - "password", - "refresh_token", - "secret", - "token", -] - -const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase() - -const redactJsonFields = (value: unknown, fields: ReadonlySet): unknown => { - if (Array.isArray(value)) return value.map((item) => redactJsonFields(item, fields)) - if (!value || typeof value !== "object") return value - return Object.fromEntries( - Object.entries(value).map(([key, child]) => [ - key, - fields.has(normalizeField(key)) ? REDACTED : redactJsonFields(child, fields), - ]), - ) -} - -const redactBody = (value: string, fields: ReadonlySet, transform: ((body: string) => string) | undefined) => { - const redacted = Option.match(decodeJson(value), { - onNone: () => value, - onSome: (parsed) => JSON.stringify(redactJsonFields(parsed, fields)), - }) - return transform?.(redacted) ?? redacted -} - -export const make = (options: RedactOptions = {}): Redactor => { - const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField)) - return compose( - requestHeaders({ - allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])], - redact: options.headers, - }), - responseHeaders({ - allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])], - redact: options.headers, - }), - url({ query: options.queryParameters, transform: options.url }), - { - request: (snapshot) => ({ - ...snapshot, - body: redactBody(snapshot.body, fields, options.body), - }), - response: (snapshot) => ({ - ...snapshot, - body: redactBody(snapshot.body, fields, options.body), - }), - }, - ) -} - -export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor => - compose( - requestHeaders(overrides.requestHeaders), - responseHeaders(overrides.responseHeaders), - url(overrides.url), - ...(overrides.body ? [body(overrides.body)] : []), - ) diff --git a/packages/http-recorder/src/replay/comparison.ts b/packages/http-recorder/src/replay/comparison.ts new file mode 100644 index 00000000000..106dbd33a47 --- /dev/null +++ b/packages/http-recorder/src/replay/comparison.ts @@ -0,0 +1,29 @@ +import { Option, Schema } from "effect" +import { REDACTED } from "../redaction/redactor.js" +import { secretFindings } from "../redaction/secrets.js" + +export const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value) + +export const canonicalizeJson = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(canonicalizeJson) + if (isRecord(value)) + return Object.fromEntries( + Object.keys(value) + .toSorted() + .map((key) => [key, canonicalizeJson(value[key])]), + ) + return value +} + +export const safeText = (value: unknown) => { + if (value === undefined) return "undefined" + if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) + const text = JSON.stringify(value) + if (!text) return typeof value + return text.length > 300 ? `${text.slice(0, 300)}...` : text +} + +export const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body)) +export const isJsonRecord = isRecord diff --git a/packages/http-recorder/src/replay/state.ts b/packages/http-recorder/src/replay/state.ts new file mode 100644 index 00000000000..79f6edfb3ae --- /dev/null +++ b/packages/http-recorder/src/replay/state.ts @@ -0,0 +1,96 @@ +import { Effect, Exit, HashSet, Ref, Scope, SynchronizedRef } from "effect" +import type { Interaction } from "../cassette/model.js" +import type { CassetteNotFoundError, Interface, InvalidCassetteError } from "../cassette/store.js" + +const isCI = () => { + const value = process.env.CI + return value !== undefined && value !== "" && value !== "false" && value !== "0" +} + +export const resolveAutoMode = ( + cassette: Interface, + name: string, +): Effect.Effect<"record" | "replay" | "passthrough"> => + Effect.gen(function* () { + if (isCI()) return "replay" + return (yield* cassette.exists(name)) ? "replay" : "record" + }) + +export interface ReplayState { + readonly claim: ( + validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray) => Effect.Effect, + ) => Effect.Effect< + { readonly interaction: T; readonly index: number }, + CassetteNotFoundError | InvalidCassetteError | E + > +} +export interface ReplayPoolState { + readonly claim: ( + select: (interactions: ReadonlyArray, used: HashSet.HashSet) => Effect.Effect, + ) => Effect.Effect< + { readonly interaction: T; readonly index: number }, + CassetteNotFoundError | InvalidCassetteError | E + > +} + +export const makeReplayPoolState = ( + cassette: Interface, + name: string, + project: (interactions: ReadonlyArray) => ReadonlyArray, +): Effect.Effect, never, Scope.Scope> => + Effect.gen(function* () { + const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project))) + const claimed = yield* SynchronizedRef.make(HashSet.empty()) + const attempted = yield* Ref.make(false) + yield* Effect.addFinalizer((exit) => + Exit.isFailure(exit) + ? Effect.void + : Effect.gen(function* () { + const used = yield* SynchronizedRef.get(claimed) + if (HashSet.isEmpty(used) && (yield* Ref.get(attempted))) return yield* Effect.void + const interactions = yield* load.pipe( + Effect.catchTag("CassetteNotFoundError", () => Effect.succeed([] as ReadonlyArray)), + Effect.orDie, + ) + if (HashSet.size(used) < interactions.length) + return yield* Effect.die( + new Error( + `Unused recorded interactions in ${name}: used ${HashSet.size(used)} of ${interactions.length}`, + ), + ) + return yield* Effect.void + }), + ) + return { + claim: (select) => + Ref.set(attempted, true).pipe( + Effect.andThen(load), + Effect.flatMap((interactions) => + SynchronizedRef.modifyEffect(claimed, (used) => + Effect.gen(function* () { + const index = yield* select(interactions, used) + const interaction = interactions[index] + if (interaction === undefined || HashSet.has(used, index)) + return yield* Effect.die("Replay selected an unavailable interaction") + return [{ interaction, index }, HashSet.add(used, index)] as const + }), + ), + ), + ), + } + }) + +export const makeReplayState = ( + cassette: Interface, + name: string, + project: (interactions: ReadonlyArray) => ReadonlyArray, +): Effect.Effect, never, Scope.Scope> => + makeReplayPoolState(cassette, name, project).pipe( + Effect.map((pool) => ({ + claim: (validate) => + pool.claim((interactions, used) => { + const index = HashSet.size(used) + return validate(interactions[index], index, interactions).pipe(Effect.as(index)) + }), + })), + ) diff --git a/packages/http-recorder/src/schema.ts b/packages/http-recorder/src/schema.ts deleted file mode 100644 index a9e67378043..00000000000 --- a/packages/http-recorder/src/schema.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Schema } from "effect" -import type { - CassetteMetadata, - HttpInteraction, - RequestSnapshot, - ResponseSnapshot, - WebSocketEvent, - WebSocketInteraction, -} from "./types.js" - -export type { - CassetteMetadata, - HttpInteraction, - RequestSnapshot, - ResponseSnapshot, - WebSocketEvent, - WebSocketInteraction, -} from "./types.js" - -export const RequestSnapshotSchema = Schema.Struct({ - method: Schema.String, - url: Schema.String, - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.String, -}) - -export const ResponseSnapshotSchema = Schema.Struct({ - status: Schema.Number, - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.String, - bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])), -}) - -export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown) - -export const HttpInteractionSchema = Schema.Struct({ - transport: Schema.tag("http"), - request: RequestSnapshotSchema, - response: ResponseSnapshotSchema, -}) - -export const WebSocketEventSchema = Schema.Union([ - Schema.Struct({ - direction: Schema.Literals(["client", "server"]), - kind: Schema.tag("text"), - body: Schema.String, - }), - Schema.Struct({ - direction: Schema.Literals(["client", "server"]), - kind: Schema.tag("binary"), - body: Schema.String, - bodyEncoding: Schema.Literal("base64"), - }), -]) - -export const WebSocketInteractionSchema = Schema.Struct({ - transport: Schema.tag("websocket"), - open: Schema.Struct({ - url: Schema.String, - headers: Schema.Record(Schema.String, Schema.String), - }), - events: Schema.Array(WebSocketEventSchema), -}) - -export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe( - Schema.toTaggedUnion("transport"), -) -export type Interaction = Schema.Schema.Type - -export const isHttpInteraction = InteractionSchema.guards.http - -export const isWebSocketInteraction = InteractionSchema.guards.websocket - -export const httpInteractions = (interactions: ReadonlyArray) => interactions.filter(isHttpInteraction) - -export const webSocketInteractions = (interactions: ReadonlyArray) => - interactions.filter(isWebSocketInteraction) - -export const CassetteSchema = Schema.Struct({ - version: Schema.Literal(1), - metadata: Schema.optional(CassetteMetadataSchema), - interactions: Schema.Array(InteractionSchema), -}) -export type Cassette = Schema.Schema.Type - -export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema) -export const encodeCassette = Schema.encodeSync(CassetteSchema) diff --git a/packages/http-recorder/src/socket.ts b/packages/http-recorder/src/socket.ts deleted file mode 100644 index c7486db4aa2..00000000000 --- a/packages/http-recorder/src/socket.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node" -import { Deferred, Effect, Exit, FiberSet, Layer, Ref, Scope, Semaphore } from "effect" -import { Socket } from "effect/unstable/socket" -import * as CassetteService from "./cassette.js" -import { canonicalizeJson, decodeJson, safeText } from "./matching.js" -import { makeReplayState, resolveAutoMode } from "./recorder.js" -import { make, type Redactor } from "./redactor.js" -import { webSocketInteractions } from "./schema.js" -import type { - RecorderOptions, - WebSocketEvent, - WebSocketInteraction, - WebSocketRecorderOptions, - WebSocketRequest, -} from "./types.js" - -interface ActiveReplay { - readonly interaction: WebSocketInteraction - readonly progress: Ref.Ref<{ readonly position: number; readonly changed: Deferred.Deferred }> - readonly writeLock: Semaphore.Semaphore - readonly closed: Ref.Ref -} - -interface ActiveRecording { - readonly events: Array - readonly eventLock: Semaphore.Semaphore - readonly accepting: Ref.Ref - opened: boolean - valid: boolean -} - -type Frame = string | Uint8Array - -const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent => - typeof message === "string" - ? { direction, kind: "text", body: message } - : { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" } - -const decodeEvent = (event: WebSocketEvent): Frame => - event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64")) - -const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => { - if (event.kind === "binary") return event - const body = - event.direction === "client" - ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body - : redactor.response({ status: 101, headers: {}, body: event.body }).body - return { ...event, body } -} - -const comparable = (event: WebSocketEvent, asJson: boolean) => { - if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event)) - const decoded = decodeJson(event.body) - return JSON.stringify( - canonicalizeJson({ - ...event, - body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value), - }), - ) -} - -const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) => - Effect.sync(() => { - if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return - throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`) - }) - -const runHandler = (handler: (value: A) => Effect.Effect | void, value: A) => - Effect.suspend(() => { - const result = handler(value) - return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void - }) - -const runReplay = ( - state: ActiveReplay, - handler: (value: A) => Effect.Effect | void, - decode: (event: WebSocketEvent) => A, - onOpen: Effect.Effect | undefined, -) => - Effect.scoped( - Effect.gen(function* () { - const handlers = yield* FiberSet.make() - const run = yield* FiberSet.runtime(handlers)() - if (onOpen) yield* onOpen - - const drive = Effect.gen(function* () { - while (true) { - const current = yield* Ref.get(state.progress) - const event = state.interaction.events[current.position] - if (!event) return - if (yield* Ref.get(state.closed)) - return yield* Effect.die( - new Error( - `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, - ), - ) - if (event.direction === "server") { - yield* Ref.set(state.progress, { - position: current.position + 1, - changed: yield* Deferred.make(), - }) - run(runHandler(handler, decode(event))) - continue - } - yield* Deferred.await(current.changed) - } - }) - - yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers))) - yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers))) - }), - ) - -const openSnapshot = (request: WebSocketRequest, redactor: Redactor) => { - const snapshot = redactor.request({ method: "GET", url: request.url, headers: request.headers ?? {}, body: "" }) - return { url: snapshot.url, headers: snapshot.headers } -} - -const makeRecordingSocket = ( - upstream: Socket.Socket, - cassette: CassetteService.Interface, - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions, - redactor: Redactor, -) => - Effect.gen(function* () { - const active = yield* Ref.make(undefined) - const writeLock = yield* Semaphore.make(1) - - return Socket.make({ - runRaw: (handler, runOptions) => - Effect.gen(function* () { - const state: ActiveRecording = { - events: [], - eventLock: yield* Semaphore.make(1), - accepting: yield* Ref.make(true), - opened: false, - valid: true, - } - const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state]) - if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported") - yield* upstream - .runRaw( - (message) => { - if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing") - state.events.push(redactEvent(encodeEvent("server", message), redactor)) - return handler(message) - }, - { - ...runOptions, - onOpen: Effect.gen(function* () { - state.opened = true - if (runOptions?.onOpen) yield* runOptions.onOpen - }), - }, - ) - .pipe( - Effect.onExit((exit) => - writeLock.withPermit( - state.eventLock.withPermit( - Effect.gen(function* () { - yield* Ref.set(state.accepting, false) - yield* Ref.set(active, undefined) - if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return - yield* cassette - .append( - name, - { - transport: "websocket", - open: openSnapshot(request, redactor), - events: [...state.events], - }, - options.metadata, - ) - .pipe(Effect.orDie) - }), - ), - ), - ), - ) - }), - writer: upstream.writer.pipe( - Effect.map( - (write) => (message) => - writeLock.withPermit( - Effect.gen(function* () { - if (Socket.isCloseEvent(message)) return yield* write(message) - const state = yield* Ref.get(active) - if (!state || !(yield* Ref.get(state.accepting))) - return yield* Effect.die("WebSocket writer used without an active socket run") - const event = redactEvent(encodeEvent("client", message), redactor) - yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event))) - return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false)))) - }), - ), - ), - ), - }) - }) - -const makeReplaySocket = ( - cassette: CassetteService.Interface, - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions, - redactor: Redactor, -): Effect.Effect => - Effect.gen(function* () { - const replay = yield* makeReplayState(cassette, name, webSocketInteractions) - const active = yield* Ref.make(undefined) - - return Socket.make({ - runRaw: (handler, runOptions) => - Effect.gen(function* () { - const claimed = yield* replay - .claim((interaction, index) => - Effect.sync(() => { - const incoming = openSnapshot(request, redactor) - if ( - interaction && - JSON.stringify(canonicalizeJson(incoming)) === JSON.stringify(canonicalizeJson(interaction.open)) - ) - return - throw new Error( - `WebSocket open ${index + 1}: expected ${safeText(interaction?.open)}, received ${safeText(incoming)}`, - ) - }), - ) - .pipe(Effect.orDie) - const progress = yield* Ref.make({ position: 0, changed: yield* Deferred.make() }) - const writeLock = yield* Semaphore.make(1) - const state = { - interaction: claimed.interaction, - progress, - writeLock, - closed: yield* Ref.make(false), - } - const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state]) - if (occupied) return yield* Effect.die("Concurrent runs of a replayed WebSocket are not supported") - yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe( - Effect.ensuring(Ref.set(active, undefined)), - ) - }), - writer: Effect.succeed((message) => { - return Ref.get(active).pipe( - Effect.flatMap((state) => - state - ? state.writeLock.withPermit( - Effect.gen(function* () { - const current = yield* Ref.get(state.progress) - if (Socket.isCloseEvent(message)) { - yield* Ref.set(state.closed, true) - yield* Deferred.succeed(current.changed, undefined) - if (current.position === state.interaction.events.length) return - return yield* Effect.die( - new Error( - `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, - ), - ) - } - const actual = redactEvent(encodeEvent("client", message), redactor) - yield* assertEvent( - actual, - state.interaction.events[current.position], - current.position, - options.compareClientMessagesAsJson === true, - ) - yield* Ref.set(state.progress, { - position: current.position + 1, - changed: yield* Deferred.make(), - }) - yield* Deferred.succeed(current.changed, undefined) - }), - ) - : Effect.die("WebSocket writer used without an active socket run"), - ), - ) - }), - }) - }) - -const recordingLayer = ( - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions, - forcedMode?: "record" | "replay", -): Layer.Layer => - Layer.effect( - Socket.Socket, - Effect.gen(function* () { - const upstream = yield* Socket.Socket - const cassette = yield* CassetteService.Service - const redactor = make(options.redact) - if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record") - return yield* makeRecordingSocket(upstream, cassette, name, request, options, redactor) - return yield* makeReplaySocket(cassette, name, request, options, redactor) - }), - ) - -/** - * Wraps a provided `Socket.Socket` with cassette recording and replay. - * - * Supply the ordinary URL-bound Effect socket layer beneath this decorator. - * The cassette name identifies the connection; recorder configuration does not - * duplicate the transport URL. - */ -export const socket = (name: string, options: RecorderOptions = {}): Layer.Layer => - provideCassette(recordingLayer(name, { url: "" }, { ...options, compareClientMessagesAsJson: true }), options) - -/** @internal */ -export const socketLayer = ( - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" }, -): Layer.Layer => - provideCassette(recordingLayer(name, request, options, options.mode), options) - -const provideCassette = ( - layer: Layer.Layer, - options: WebSocketRecorderOptions, -) => - layer.pipe( - Layer.provide(CassetteService.fileSystem({ directory: options.directory })), - Layer.provide(NodeFileSystem.layer), - ) diff --git a/packages/http-recorder/src/types.ts b/packages/http-recorder/src/types.ts deleted file mode 100644 index 1ee01142058..00000000000 --- a/packages/http-recorder/src/types.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** Additional JSON metadata stored with a cassette. */ -export type CassetteMetadata = Record - -/** The normalized HTTP request representation used for matching. */ -export interface RequestSnapshot { - /** HTTP method. */ - readonly method: string - /** Fully qualified URL after redaction. */ - readonly url: string - /** Allowed and redacted request headers. */ - readonly headers: Record - /** Request body after redaction. */ - readonly body: string -} - -/** @internal */ -export interface ResponseSnapshot { - /** HTTP status code. */ - readonly status: number - /** Allowed and redacted response headers. */ - readonly headers: Record - /** Text body or base64-encoded binary body. */ - readonly body: string - /** Encoding used by `body`; omitted for ordinary text. */ - readonly bodyEncoding?: "text" | "base64" -} - -/** @internal */ -export interface HttpInteraction { - readonly transport: "http" - readonly request: RequestSnapshot - readonly response: ResponseSnapshot -} - -/** @internal */ -export type WebSocketEvent = - | { readonly direction: "client" | "server"; readonly kind: "text"; readonly body: string } - | { - readonly direction: "client" | "server" - readonly kind: "binary" - readonly body: string - readonly bodyEncoding: "base64" - } - -/** @internal */ -export interface WebSocketInteraction { - readonly transport: "websocket" - readonly open: { - readonly url: string - readonly headers: Record - } - readonly events: ReadonlyArray -} - -/** Returns whether an incoming HTTP request matches a recorded request. */ -export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean - -/** Additive redaction and header-preservation policy. */ -export interface RedactOptions { - /** Additional sensitive headers to retain as `[REDACTED]`. */ - readonly headers?: ReadonlyArray - /** Additional non-sensitive request headers to preserve for matching. */ - readonly allowRequestHeaders?: ReadonlyArray - /** Additional non-sensitive response headers to preserve for replay. */ - readonly allowResponseHeaders?: ReadonlyArray - /** Additional sensitive URL query parameter names. */ - readonly queryParameters?: ReadonlyArray - /** Additional JSON field names to redact recursively. */ - readonly jsonFields?: ReadonlyArray - /** Stabilizes a URL after built-in redaction. */ - readonly url?: (url: string) => string - /** Stabilizes a request, response, or text-frame body after built-in redaction. */ - readonly body?: (body: string) => string -} - -/** Options shared by HTTP recorder layers. */ -export interface RecorderOptions { - /** Cassette directory. Defaults to `/test/fixtures/recordings`. */ - readonly directory?: string - /** Additional metadata stored in the cassette. */ - readonly metadata?: CassetteMetadata - /** Additive redaction and header-preservation policy. */ - readonly redact?: RedactOptions - /** Custom HTTP request equivalence. */ - readonly match?: RequestMatcher -} - -/** @internal */ -export interface WebSocketRequest { - /** WebSocket URL. */ - readonly url: string - /** Headers used for redacted matching; the recorder does not send them. */ - readonly headers?: Record -} - -/** @internal */ -export interface WebSocketRecorderOptions { - /** Cassette directory. Defaults to `/test/fixtures/recordings`. */ - readonly directory?: string - /** Additional metadata stored in the cassette. */ - readonly metadata?: CassetteMetadata - /** Additive handshake and text-frame redaction policy. */ - readonly redact?: RedactOptions - /** Compare text client frames as canonical JSON instead of exact strings. */ - readonly compareClientMessagesAsJson?: boolean - /** WebSocket subprotocols used by `layerWebSocket`. */ - readonly protocols?: string | Array -} diff --git a/packages/http-recorder/src/websocket.ts b/packages/http-recorder/src/websocket.ts deleted file mode 100644 index 869cd3bf90e..00000000000 --- a/packages/http-recorder/src/websocket.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Effect, Option, Ref, Scope, Semaphore, Stream, SynchronizedRef } from "effect" -import type { Headers } from "effect/unstable/http" -import * as CassetteService from "./cassette.js" -import { canonicalizeJson, decodeJson, safeText } from "./matching.js" -import { makeReplayState, resolveAutoMode } from "./recorder.js" -import type { RecordReplayMode } from "./internal-effect.js" -import { make, type Redactor } from "./redactor.js" -import { webSocketInteractions, type CassetteMetadata, type WebSocketEvent } from "./schema.js" - -export interface WebSocketRequest { - readonly url: string - readonly headers: Headers.Headers -} - -export interface WebSocketConnection { - readonly sendText: (message: string) => Effect.Effect - readonly messages: Stream.Stream - readonly close: Effect.Effect -} - -export interface WebSocketExecutor { - readonly open: (request: WebSocketRequest) => Effect.Effect, E> -} - -export interface WebSocketRecordReplayOptions { - readonly name: string - readonly mode?: RecordReplayMode - readonly metadata?: CassetteMetadata - readonly cassette: CassetteService.Interface - readonly live: WebSocketExecutor - readonly redactor?: Redactor - readonly compareClientMessagesAsJson?: boolean -} - -const headersRecord = (headers: Headers.Headers): Record => - Object.fromEntries( - Object.entries(headers as Record).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ) - -const textEvent = (direction: "client" | "server", body: string): WebSocketEvent => ({ - direction, - kind: "text", - body, -}) - -const decodeEvent = (event: WebSocketEvent) => - event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64")) - -const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson }) - -const assertClientEvent = (actual: string, expected: WebSocketEvent | undefined, index: number, asJson: boolean) => - Effect.sync(() => { - const matches = - expected?.direction === "client" && - expected.kind === "text" && - JSON.stringify(asJson ? jsonOrText(actual) : actual) === - JSON.stringify(asJson ? jsonOrText(expected.body) : expected.body) - if (matches) return - throw new Error(`WebSocket client frame ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`) - }) - -export const makeWebSocketExecutor = ( - options: WebSocketRecordReplayOptions, -): Effect.Effect, never, Scope.Scope> => - Effect.gen(function* () { - const mode = options.mode ?? (yield* resolveAutoMode(options.cassette, options.name)) - const redactor = options.redactor ?? make() - const openSnapshot = (request: WebSocketRequest) => { - const snapshot = redactor.request({ - method: "GET", - url: request.url, - headers: headersRecord(request.headers), - body: "", - }) - return { url: snapshot.url, headers: snapshot.headers } - } - const redactEvent = (event: WebSocketEvent) => { - if (event.kind === "binary") return event - const body = - event.direction === "client" - ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body - : redactor.response({ status: 101, headers: {}, body: event.body }).body - return { ...event, body } - } - - if (mode === "passthrough") return options.live - - if (mode === "record") { - return { - open: (request) => - Effect.gen(function* () { - const events: WebSocketEvent[] = [] - const connection = yield* options.live.open(request) - const closed = yield* Ref.make(false) - const closeLock = yield* Semaphore.make(1) - return { - sendText: (message) => - Effect.sync(() => events.push(redactEvent(textEvent("client", message)))).pipe( - Effect.andThen(connection.sendText(message)), - ), - messages: connection.messages.pipe( - Stream.tap((message) => - Effect.sync(() => - events.push( - typeof message === "string" - ? redactEvent(textEvent("server", message)) - : { - direction: "server", - kind: "binary", - body: Buffer.from(message).toString("base64"), - bodyEncoding: "base64", - }, - ), - ), - ), - ), - close: closeLock.withPermit( - Effect.gen(function* () { - if (yield* Ref.get(closed)) return - yield* connection.close - yield* options.cassette - .append( - options.name, - { transport: "websocket", open: openSnapshot(request), events }, - options.metadata, - ) - .pipe(Effect.orDie) - yield* Ref.set(closed, true) - }), - ), - } - }), - } - } - - const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions) - return { - open: (request) => - Effect.gen(function* () { - const claimed = yield* replay - .claim((interaction, index) => - Effect.sync(() => { - const incoming = canonicalizeJson(openSnapshot(request)) - if (interaction && JSON.stringify(incoming) === JSON.stringify(canonicalizeJson(interaction.open))) - return - throw new Error(`WebSocket open ${index + 1} does not match ${safeText(incoming)}`) - }), - ) - .pipe(Effect.orDie) - const client = claimed.interaction.events.filter((event) => event.direction === "client") - const server = claimed.interaction.events.filter((event) => event.direction === "server") - const position = yield* SynchronizedRef.make(0) - return { - sendText: (message) => - SynchronizedRef.updateEffect(position, (index) => - assertClientEvent(message, client[index], index, options.compareClientMessagesAsJson === true).pipe( - Effect.as(index + 1), - ), - ), - messages: Stream.fromIterable(server).pipe(Stream.map(decodeEvent)), - close: Effect.gen(function* () { - const used = yield* SynchronizedRef.get(position) - if (used !== client.length) - return yield* Effect.die( - new Error(`WebSocket client frame count: expected ${client.length}, received ${used}`), - ) - }), - } - }), - } - }) diff --git a/packages/http-recorder/src/websocket/model.ts b/packages/http-recorder/src/websocket/model.ts new file mode 100644 index 00000000000..b5930141bb0 --- /dev/null +++ b/packages/http-recorder/src/websocket/model.ts @@ -0,0 +1,35 @@ +import { Schema } from "effect" + +export const WebSocketEventSchema = Schema.Union([ + Schema.Struct({ + direction: Schema.Literals(["client", "server"]), + kind: Schema.tag("text"), + body: Schema.String, + }), + Schema.Struct({ + direction: Schema.Literals(["client", "server"]), + kind: Schema.tag("binary"), + body: Schema.String, + bodyEncoding: Schema.Literal("base64"), + }), +]) + +export type WebSocketEvent = Schema.Schema.Type + +export const WebSocketInteractionSchema = Schema.Struct({ + transport: Schema.tag("websocket"), + connection: Schema.optional( + Schema.Struct({ + sequence: Schema.Number, + url: Schema.String, + protocols: Schema.Array(Schema.String), + close: Schema.Struct({ + code: Schema.Number, + reason: Schema.String, + }), + }), + ), + events: Schema.Array(WebSocketEventSchema), +}) + +export interface WebSocketInteraction extends Schema.Schema.Type {} diff --git a/packages/http-recorder/src/websocket/recorder.ts b/packages/http-recorder/src/websocket/recorder.ts new file mode 100644 index 00000000000..3d232c4db95 --- /dev/null +++ b/packages/http-recorder/src/websocket/recorder.ts @@ -0,0 +1,584 @@ +import { NodeFileSystem } from "@effect/platform-node-shared" +import { Deferred, Effect, Exit, FiberSet, Layer, Option, Ref, Scope, Semaphore } from "effect" +import { Socket } from "effect/unstable/socket" +import { fileSystem, type Interface, Service } from "../cassette/store.js" +import type { SocketRecorderOptions } from "../options.js" +import { make, type Redactor } from "../redaction/redactor.js" +import { canonicalizeJson, decodeJson, safeText } from "../replay/comparison.js" +import { makeReplayState, resolveAutoMode } from "../replay/state.js" +import { webSocketInteractions, type Interaction } from "../cassette/model.js" +import type { WebSocketEvent, WebSocketInteraction } from "./model.js" + +interface WebSocketRecorderOptions extends SocketRecorderOptions { + readonly compareClientMessagesAsJson?: boolean +} +interface ActiveReplay { + readonly interaction: WebSocketInteraction + readonly progress: Ref.Ref<{ readonly position: number; readonly changed: Deferred.Deferred }> + readonly writeLock: Semaphore.Semaphore + readonly closed: Ref.Ref +} +interface ActiveRecording { + readonly events: Array + readonly eventLock: Semaphore.Semaphore + readonly accepting: Ref.Ref + opened: boolean + valid: boolean +} +interface PendingRecordings { + readonly promises: Set> + readonly errors: Array +} +type Frame = string | Uint8Array + +const normalizeProtocols = (protocols?: string | Array): Array => + protocols === undefined ? [] : typeof protocols === "string" ? [protocols] : [...protocols] +const frameFromWebSocketData = async (data: unknown): Promise => { + if (typeof data === "string") return data + if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer()) + if (data instanceof ArrayBuffer) return new Uint8Array(data) + if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice() + throw new Error(`Unsupported WebSocket frame: ${Object.prototype.toString.call(data)}`) +} +const closeEvent = (code: number, reason: string): CloseEvent => { + if (typeof globalThis.CloseEvent === "function") + return new globalThis.CloseEvent("close", { code, reason, wasClean: code === 1000 }) + const event = new Event("close") + Object.defineProperties(event, { + code: { value: code }, + reason: { value: reason }, + wasClean: { value: code === 1000 }, + }) + return event as CloseEvent +} +const errorEvent = (error: unknown): ErrorEvent => { + if (typeof globalThis.ErrorEvent === "function") + return new globalThis.ErrorEvent("error", { + error, + message: error instanceof Error ? error.message : String(error), + }) + const event = new Event("error") + Object.defineProperties(event, { + error: { value: error }, + message: { value: error instanceof Error ? error.message : String(error) }, + }) + return event as ErrorEvent +} +const webSocketFacade = ( + target: EventTarget, + properties: { + readonly url: () => string + readonly readyState: () => number + readonly protocol: () => string + readonly extensions: () => string + readonly bufferedAmount: () => number + readonly send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void + readonly close: (code?: number, reason?: string) => void + }, +): globalThis.WebSocket => { + Object.defineProperties(target, { + url: { get: properties.url }, + readyState: { get: properties.readyState }, + protocol: { get: properties.protocol }, + extensions: { get: properties.extensions }, + bufferedAmount: { get: properties.bufferedAmount }, + binaryType: { value: "blob", writable: true }, + send: { value: properties.send }, + close: { value: properties.close }, + CONNECTING: { value: 0 }, + OPEN: { value: 1 }, + CLOSING: { value: 2 }, + CLOSED: { value: 3 }, + }) + for (const name of ["open", "message", "error", "close"] as const) { + let handler: ((event: Event) => unknown) | null = null + Object.defineProperty(target, `on${name}`, { + get: () => handler, + set: (next) => { + if (handler) target.removeEventListener(name, handler) + handler = typeof next === "function" ? next : null + if (handler) target.addEventListener(name, handler) + }, + }) + } + return target as globalThis.WebSocket +} + +const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent => + typeof message === "string" + ? { direction, kind: "text", body: message } + : { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" } +const decodeEvent = (event: WebSocketEvent): Frame => + event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64")) +const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => { + if (event.kind === "binary") return event + const body = + event.direction === "client" + ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body + : redactor.response({ status: 101, headers: {}, body: event.body }).body + return { ...event, body } +} +const comparable = (event: WebSocketEvent, asJson: boolean) => { + if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event)) + const decoded = decodeJson(event.body) + return JSON.stringify( + canonicalizeJson({ ...event, body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value) }), + ) +} +const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) => + Effect.sync(() => { + if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return + throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`) + }) +const runHandler = (handler: (value: A) => Effect.Effect | void, value: A) => + Effect.suspend(() => { + const result = handler(value) + return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void + }) +const runReplay = ( + state: ActiveReplay, + handler: (value: A) => Effect.Effect | void, + decode: (event: WebSocketEvent) => A, + onOpen: Effect.Effect | undefined, +) => + Effect.scoped( + Effect.gen(function* () { + const handlers = yield* FiberSet.make() + const run = yield* FiberSet.runtime(handlers)() + if (onOpen) yield* onOpen + const drive = Effect.gen(function* () { + while (true) { + const current = yield* Ref.get(state.progress) + const event = state.interaction.events[current.position] + if (!event) return + if (yield* Ref.get(state.closed)) + return yield* Effect.die( + new Error( + `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, + ), + ) + if (event.direction === "server") { + yield* Ref.set(state.progress, { position: current.position + 1, changed: yield* Deferred.make() }) + run(runHandler(handler, decode(event))) + continue + } + yield* Deferred.await(current.changed) + } + }) + yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers))) + yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers))) + }), + ) + +const makeRecordingSocket = ( + upstream: Socket.Socket, + cassette: Interface, + name: string, + options: WebSocketRecorderOptions, + redactor: Redactor, +) => + Effect.gen(function* () { + const active = yield* Ref.make(undefined) + const writeLock = yield* Semaphore.make(1) + return Socket.make({ + runRaw: (handler, runOptions) => + Effect.gen(function* () { + const state: ActiveRecording = { + events: [], + eventLock: yield* Semaphore.make(1), + accepting: yield* Ref.make(true), + opened: false, + valid: true, + } + const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state]) + if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported") + yield* upstream + .runRaw( + (message) => { + if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing") + state.events.push(redactEvent(encodeEvent("server", message), redactor)) + return handler(message) + }, + { + ...runOptions, + onOpen: Effect.gen(function* () { + state.opened = true + if (runOptions?.onOpen) yield* runOptions.onOpen + }), + }, + ) + .pipe( + Effect.onExit((exit) => + writeLock.withPermit( + state.eventLock.withPermit( + Effect.gen(function* () { + yield* Ref.set(state.accepting, false) + yield* Ref.set(active, undefined) + if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return + yield* cassette + .append( + name, + { + transport: "websocket", + events: [...state.events], + }, + options.metadata, + ) + .pipe(Effect.orDie) + }), + ), + ), + ), + ) + }), + writer: upstream.writer.pipe( + Effect.map( + (write) => (message) => + writeLock.withPermit( + Effect.gen(function* () { + if (Socket.isCloseEvent(message)) return yield* write(message) + const state = yield* Ref.get(active) + if (!state || !(yield* Ref.get(state.accepting))) + return yield* Effect.die("WebSocket writer used without an active socket run") + const event = redactEvent(encodeEvent("client", message), redactor) + yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event))) + return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false)))) + }), + ), + ), + ), + }) + }) + +const makeReplaySocket = ( + cassette: Interface, + name: string, + options: WebSocketRecorderOptions, + redactor: Redactor, +): Effect.Effect => + Effect.gen(function* () { + const replay = yield* makeReplayState(cassette, name, webSocketInteractions) + const active = yield* Ref.make(undefined) + const runLock = yield* Semaphore.make(1) + return Socket.make({ + runRaw: (handler, runOptions) => + runLock + .withPermitsIfAvailable(1)( + Effect.gen(function* () { + const claimed = yield* replay + .claim((interaction) => + interaction ? Effect.void : Effect.die("Missing recorded WebSocket interaction"), + ) + .pipe(Effect.orDie) + const state = { + interaction: claimed.interaction, + progress: yield* Ref.make({ position: 0, changed: yield* Deferred.make() }), + writeLock: yield* Semaphore.make(1), + closed: yield* Ref.make(false), + } + yield* Ref.set(active, state) + yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe( + Effect.ensuring(Ref.set(active, undefined)), + ) + }), + ) + .pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.die("Concurrent runs of a replayed WebSocket are not supported"), + onSome: () => Effect.void, + }), + ), + ), + writer: Effect.succeed((message) => + Ref.get(active).pipe( + Effect.flatMap((state) => + state + ? state.writeLock.withPermit( + Effect.gen(function* () { + const current = yield* Ref.get(state.progress) + if (Socket.isCloseEvent(message)) { + yield* Ref.set(state.closed, true) + yield* Deferred.succeed(current.changed, undefined) + if (current.position === state.interaction.events.length) return + return yield* Effect.die( + new Error( + `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, + ), + ) + } + const actual = redactEvent(encodeEvent("client", message), redactor) + yield* assertEvent( + actual, + state.interaction.events[current.position], + current.position, + options.compareClientMessagesAsJson === true, + ) + yield* Ref.set(state.progress, { + position: current.position + 1, + changed: yield* Deferred.make(), + }) + yield* Deferred.succeed(current.changed, undefined) + }), + ) + : Effect.die("WebSocket writer used without an active socket run"), + ), + ), + ), + }) + }) + +const recordingLayer = ( + name: string, + options: WebSocketRecorderOptions, + forcedMode?: "record" | "replay", +): Layer.Layer => + Layer.effect( + Socket.Socket, + Effect.gen(function* () { + const upstream = yield* Socket.Socket + const cassette = yield* Service + const redactor = make(options.redact) + if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record") + return yield* makeRecordingSocket(upstream, cassette, name, options, redactor) + return yield* makeReplaySocket(cassette, name, options, redactor) + }), + ) + +export const layerSocket = ( + name: string, + options: SocketRecorderOptions = {}, +): Layer.Layer => + provideCassette(recordingLayer(name, { ...options, compareClientMessagesAsJson: true }), options) +/** @internal */ +export const layerSocketWithMode = ( + name: string, + options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" }, +): Layer.Layer => + provideCassette(recordingLayer(name, options, options.mode), options) +const provideCassette = (layer: Layer.Layer, options: WebSocketRecorderOptions) => + layer.pipe(Layer.provide(fileSystem({ directory: options.directory })), Layer.provide(NodeFileSystem.layer)) + +const makeRecordingWebSocketConstructor = ( + upstream: Socket.WebSocketConstructor["Service"], + cassette: Interface, + name: string, + metadata: SocketRecorderOptions["metadata"], + redactor: Redactor, + pending: PendingRecordings, +): Socket.WebSocketConstructor["Service"] => { + let nextSequence = 0 + return (url, protocols) => { + const sequence = nextSequence++ + const requestedProtocols = normalizeProtocols(protocols) + const native = upstream(url, requestedProtocols) + const events: WebSocketEvent[] = [] + let opened = false + let failed = false + let closed = false + let queue = Promise.resolve() + const appendEvent = (direction: "client" | "server", data: unknown) => { + queue = queue.then(async () => { + if (failed || closed) return + try { + events.push(redactEvent(encodeEvent(direction, await frameFromWebSocketData(data)), redactor)) + } catch { + failed = true + } + }) + } + const onOpen = () => { + opened = true + } + const onMessage = (event: MessageEvent) => { + appendEvent("server", event.data) + } + const onError = () => { + failed = true + } + const onClose = (event: CloseEvent) => { + native.removeEventListener("open", onOpen) + native.removeEventListener("message", onMessage) + native.removeEventListener("error", onError) + native.removeEventListener("close", onClose) + const completion = queue.then(async () => { + closed = true + if (opened && !failed) { + const request = redactor.request({ method: "WEBSOCKET", url, headers: {}, body: "" }) + const interaction: WebSocketInteraction = { + transport: "websocket", + connection: { + sequence, + url: request.url, + protocols: requestedProtocols, + close: { code: event.code, reason: event.reason }, + }, + events: [...events], + } + events.length = 0 + await Effect.runPromise(cassette.append(name, interaction, metadata).pipe(Effect.orDie)) + } + }) + pending.promises.add(completion) + void completion.then( + () => pending.promises.delete(completion), + (error) => { + pending.promises.delete(completion) + pending.errors.push(error) + }, + ) + } + native.addEventListener("open", onOpen) + native.addEventListener("message", onMessage) + native.addEventListener("error", onError) + native.addEventListener("close", onClose) + return new Proxy(native, { + get: (target, property) => { + if (property === "send") + return (data: string | ArrayBufferLike | Blob | ArrayBufferView) => { + Reflect.apply(target.send, target, [data]) + appendEvent("client", data) + } + const value: unknown = Reflect.get(target, property, target) + return typeof value === "function" ? value.bind(target) : value + }, + set: (target, property, value) => Reflect.set(target, property, value, target), + }) + } +} + +const constructorWebSocketInteractions = (interactions: ReadonlyArray) => + webSocketInteractions(interactions) + .filter((interaction) => interaction.connection !== undefined) + .map((interaction, index) => ({ interaction, index })) + .toSorted((a, b) => a.interaction.connection!.sequence - b.interaction.connection!.sequence) + .map(({ interaction }) => interaction) + +const makeReplayWebSocketConstructor = ( + cassette: Interface, + name: string, + redactor: Redactor, +): Effect.Effect => + Effect.gen(function* () { + const replay = yield* makeReplayState(cassette, name, constructorWebSocketInteractions) + return (url, protocols) => { + const target = new EventTarget() + const requestedProtocols = normalizeProtocols(protocols) + const request = redactor.request({ method: "WEBSOCKET", url, headers: {}, body: "" }) + let readyState = 0 + let interaction: WebSocketInteraction | undefined + let position = 0 + let finished = false + let closeRequested = false + let operations = Promise.resolve() + const fail = (error: unknown) => { + if (finished) return + finished = true + readyState = 3 + target.dispatchEvent(errorEvent(error)) + } + const finish = () => { + if (finished || !interaction || position !== interaction.events.length) return + finished = true + readyState = 3 + const terminal = interaction.connection?.close ?? { code: 1000, reason: "" } + target.dispatchEvent(closeEvent(terminal.code, terminal.reason)) + } + const drive = () => { + if (!interaction || finished) return + while (interaction.events[position]?.direction === "server") { + const event = interaction.events[position++] + if (!event) break + target.dispatchEvent(new MessageEvent("message", { data: decodeEvent(event) })) + } + if (position === interaction.events.length) setTimeout(finish, 0) + } + Effect.runPromise( + replay + .claim((recorded, index) => + Effect.sync(() => { + if (!recorded) throw new Error(`Missing recorded WebSocket connection ${index + 1}`) + const connection = recorded.connection + if (!connection) throw new Error(`WebSocket interaction ${index + 1} has no connection metadata`) + if (connection.url !== request.url) + throw new Error( + `WebSocket connection ${index + 1}: expected URL ${safeText(connection.url)}, received ${safeText(request.url)}`, + ) + if ( + connection.protocols.length !== requestedProtocols.length || + connection.protocols.some((protocol, protocolIndex) => protocol !== requestedProtocols[protocolIndex]) + ) + throw new Error( + `WebSocket connection ${index + 1}: expected protocols ${safeText(connection.protocols)}, received ${safeText(requestedProtocols)}`, + ) + }), + ) + .pipe(Effect.orDie), + ).then((claimed) => { + if (closeRequested) return fail(new Error("WebSocket closed before it opened")) + interaction = claimed.interaction + readyState = 1 + target.dispatchEvent(new Event("open")) + drive() + }, fail) + return webSocketFacade(target, { + url: () => url, + readyState: () => readyState, + protocol: () => requestedProtocols[0] ?? "", + extensions: () => "", + bufferedAmount: () => 0, + send: (data) => { + if (!interaction || readyState !== 1 || closeRequested) throw new Error("WebSocket is not open") + operations = operations.then(async () => { + try { + const frame = await frameFromWebSocketData(data) + const actual = redactEvent(encodeEvent("client", frame), redactor) + Effect.runSync(assertEvent(actual, interaction?.events[position], position, true)) + position += 1 + drive() + } catch (error) { + fail(error) + } + }) + }, + close: () => { + if (closeRequested || readyState === 3) return + closeRequested = true + readyState = 2 + operations = operations.then(() => { + if (!interaction) return + if (position !== interaction.events.length) + return fail( + new Error(`WebSocket closed with unconsumed events: used ${position} of ${interaction.events.length}`), + ) + finish() + }) + }, + }) + } + }) + +export const layerWebSocketConstructor = ( + name: string, + options: SocketRecorderOptions = {}, +): Layer.Layer => + provideCassette( + Layer.effect( + Socket.WebSocketConstructor, + Effect.gen(function* () { + const upstream = yield* Socket.WebSocketConstructor + const cassette = yield* Service + const redactor = make(options.redact) + if ((yield* resolveAutoMode(cassette, name)) === "replay") + return yield* makeReplayWebSocketConstructor(cassette, name, redactor) + const pending: PendingRecordings = { promises: new Set(), errors: [] } + yield* Effect.addFinalizer(() => + Effect.promise(() => Promise.all(pending.promises)).pipe( + Effect.flatMap(() => (pending.errors.length === 0 ? Effect.void : Effect.die(pending.errors[0]))), + ), + ) + return makeRecordingWebSocketConstructor(upstream, cassette, name, options.metadata, redactor, pending) + }), + ), + options, + ) diff --git a/packages/http-recorder/sst-env.d.ts b/packages/http-recorder/sst-env.d.ts index 64441936d7a..f25b9714550 100644 --- a/packages/http-recorder/sst-env.d.ts +++ b/packages/http-recorder/sst-env.d.ts @@ -7,4 +7,4 @@ /// import "sst" -export {} \ No newline at end of file +export {} diff --git a/packages/http-recorder/test/cassette.test.ts b/packages/http-recorder/test/cassette.test.ts new file mode 100644 index 00000000000..d15e7628dea --- /dev/null +++ b/packages/http-recorder/test/cassette.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Exit } from "effect" +import { existsSync, readdirSync, writeFileSync } from "node:fs" +import type { Interaction } from "../src/cassette/model" +import { HttpRecorder } from "../src" +import { Service, hasCassetteSync, memory } from "../src/cassette/store" +import { cassetteLayer } from "../src/http/recorder" +import { failureText, post, readCassette, runFileCassette, seedCassetteDirectory, tempDirectory } from "./support" + +describe("cassette", () => { + test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => { + using server = Bun.serve({ + port: 0, + fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234"), + }) + const url = `http://127.0.0.1:${server.port}/leaky` + using directory = tempDirectory("http-recorder-unsafe-") + + const exit = await Effect.runPromise( + Effect.exit( + post(url, { ok: true }).pipe( + Effect.provide( + cassetteLayer("unsafe-record", { + directory: directory.path, + mode: "record", + }), + ), + ), + ), + ) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain("contains possible secrets") + expect(existsSync(`${directory.path}/unsafe-record.json`)).toBe(false) + }) + + test("failed memory appends leave cassette state unchanged", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const cassette = yield* Service + const interaction: Interaction = { + transport: "http", + request: { + method: "GET", + url: "https://example.test", + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: "safe" }, + } + yield* cassette.append("transactional", interaction) + yield* cassette + .append("transactional", { + ...interaction, + response: { + ...interaction.response, + body: "Bearer abcdefghijklmnopqrstuvwxyz1234", + }, + }) + .pipe(Effect.flip) + + expect(yield* cassette.read("transactional")).toEqual([interaction]) + }).pipe(Effect.provide(memory())), + ) + }) + + test("concurrent file appends preserve every interaction", async () => { + using directory = tempDirectory("http-recorder-concurrent-") + await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + yield* Effect.forEach( + Array.from({ length: 20 }, (_, index) => index), + (index) => + cassette.append("concurrent", { + transport: "http", + request: { + method: "GET", + url: `https://example.test/${index}`, + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: String(index) }, + }), + { concurrency: "unbounded" }, + ) + }), + ) + + const cassette = readCassette(`${directory.path}/concurrent.json`) + expect(cassette.interactions).toHaveLength(20) + expect(readdirSync(directory.path).filter((file) => file.endsWith(".tmp"))).toEqual([]) + }) + + test("generated metadata cannot be overridden", async () => { + using directory = tempDirectory("http-recorder-metadata-") + await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + yield* cassette.append( + "metadata", + { + transport: "http", + request: { method: "GET", url: "https://example.test", headers: {}, body: "" }, + response: { status: 200, headers: {}, body: "safe" }, + }, + { name: "wrong", recordedAt: "wrong" }, + ) + }), + ) + + const cassette = readCassette(`${directory.path}/metadata.json`) + expect(cassette.metadata?.name).toBe("metadata") + expect(cassette.metadata?.recordedAt).not.toBe("wrong") + }) + + test("reports malformed cassettes as invalid", async () => { + using directory = tempDirectory("http-recorder-invalid-") + writeFileSync(`${directory.path}/invalid.json`, "{not-json") + + const error = await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + return yield* cassette.read("invalid").pipe(Effect.flip) + }), + ) + + expect(error._tag).toBe("InvalidCassetteError") + }) + + test("rejects cassette paths outside the recordings directory", () => { + using directory = tempDirectory("http-recorder-path-") + expect(() => hasCassetteSync("../outside", { directory: directory.path })).toThrow("Invalid cassette name") + expect(() => hasCassetteSync("C:\\outside", { directory: directory.path })).toThrow("Invalid cassette name") + }) + + test("public cassette lifecycle helpers check and remove a recording", async () => { + using directory = tempDirectory("http-recorder-lifecycle-") + const options = { directory: directory.path } + expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(false) + + await seedCassetteDirectory(directory.path, "nested/example", [ + { + transport: "http", + request: { method: "GET", url: "https://example.test", headers: {}, body: "" }, + response: { status: 200, headers: {}, body: "safe" }, + }, + ]) + expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(true) + + HttpRecorder.removeCassetteSync("nested/example", options) + expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(false) + expect(() => HttpRecorder.removeCassetteSync("nested/example", options)).not.toThrow() + expect(() => HttpRecorder.removeCassetteSync("../outside", options)).toThrow("Invalid cassette name") + }) + + test("Cassette.list enumerates recorded cassette names", async () => { + using directory = tempDirectory("http-recorder-list-") + await seedCassetteDirectory(directory.path, "alpha/one", [ + { + transport: "http", + request: { + method: "GET", + url: "https://x.test/a", + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: "a" }, + }, + ]) + await seedCassetteDirectory(directory.path, "beta", [ + { + transport: "http", + request: { + method: "GET", + url: "https://x.test/b", + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: "b" }, + }, + ]) + + const names = await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + return yield* cassette.list() + }), + ) + expect(names).toEqual(["alpha/one", "beta"]) + }) +}) diff --git a/packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json b/packages/http-recorder/test/fixtures/recordings/http/multi-step.json similarity index 100% rename from packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json rename to packages/http-recorder/test/fixtures/recordings/http/multi-step.json diff --git a/packages/http-recorder/test/fixtures/recordings/record-replay/retry.json b/packages/http-recorder/test/fixtures/recordings/http/retry.json similarity index 100% rename from packages/http-recorder/test/fixtures/recordings/record-replay/retry.json rename to packages/http-recorder/test/fixtures/recordings/http/retry.json diff --git a/packages/http-recorder/test/http.test.ts b/packages/http-recorder/test/http.test.ts new file mode 100644 index 00000000000..ebba965c7df --- /dev/null +++ b/packages/http-recorder/test/http.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Exit } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { existsSync } from "node:fs" +import { isHttpInteraction } from "../src/cassette/model" +import { HttpRecorder } from "../src" +import { failureText, post, readCassette, seedCassetteDirectory, tempDirectory, withEnvironment } from "./support" + +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch("http/multi-step")))) + +const runWith = ( + name: string, + options: HttpRecorder.RecorderOptions, + effect: Effect.Effect, +) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch(name, options)))) + +describe("HTTP", () => { + test("decorates a provided HTTP client", async () => { + await Effect.runPromise( + Effect.all([post("https://example.test/echo", { step: 1 }), post("https://example.test/echo", { step: 2 })]).pipe( + Effect.provide(HttpRecorder.layer("http/multi-step")), + Effect.provide(FetchHttpClient.layer), + ), + ) + }) + + test("replay returns recorded responses in order for identical requests", async () => { + await runWith( + "http/retry", + {}, + Effect.gen(function* () { + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}') + }), + ) + }) + + test("replay reports exhaustion when more requests are made than recorded", async () => { + await run( + Effect.gen(function* () { + yield* post("https://example.test/echo", { step: 1 }) + yield* post("https://example.test/echo", { step: 2 }) + const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + }) + + test("a mismatch does not consume an interaction", async () => { + await run( + Effect.gen(function* () { + yield* post("https://example.test/echo", { step: 1 }) + const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain("$.step expected 2, received 3") + expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') + }), + ) + }) + + test("distinct requests replay in any order", async () => { + await run( + Effect.gen(function* () { + expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') + expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}') + }), + ) + }) + + test("concurrent distinct requests atomically claim their matching interactions", async () => { + const results = await run( + Effect.all([post("https://example.test/echo", { step: 2 }), post("https://example.test/echo", { step: 1 })], { + concurrency: "unbounded", + }), + ) + + expect(results).toEqual(['{"reply":"second"}', '{"reply":"first"}']) + }) + + test("concurrent replay claims each interaction once", async () => { + const results = await runWith( + "http/retry", + {}, + Effect.all( + [post("https://example.test/poll", { id: "job_1" }), post("https://example.test/poll", { id: "job_1" })], + { concurrency: "unbounded" }, + ), + ) + + expect(results.toSorted()).toEqual(['{"status":"complete"}', '{"status":"pending"}']) + }) + + test("mismatch diagnostics show redacted request differences against the expected interaction", async () => { + await run( + Effect.gen(function* () { + const exit = yield* Effect.exit( + post("https://example.test/echo?api_key=secret-value", { + step: 3, + token: "sk-123456789012345678901234", + }), + ) + const message = failureText(exit) + expect(message).toContain("url:") + expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D") + expect(message).toContain("body:") + expect(message).toContain("$.step expected 1, received 3") + expect(message).toContain('$.token expected undefined, received "[REDACTED]"') + expect(message).not.toContain("sk-123456789012345678901234") + }), + ) + }) + + test("applies custom URL redaction to mismatch errors", async () => { + const secret = "private-account" + const exit = await Effect.runPromiseExit( + post(`https://example.test/${secret}`, { step: 1 }).pipe( + Effect.provide( + HttpRecorder.layerFetch("http/multi-step", { + redact: { url: (url) => url.replace(secret, "{account}") }, + }), + ), + ), + ) + const message = failureText(exit) + + expect(message).toContain("https://example.test/{account}") + expect(message).not.toContain(secret) + }) + + test("fails when a non-empty replay cassette is completely unused", async () => { + const exit = await Effect.runPromiseExit( + Effect.void.pipe(Effect.scoped, Effect.provide(HttpRecorder.layerFetch("http/multi-step"))), + ) + + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain("Unused recorded interactions in http/multi-step: used 0 of 2") + }) + + test("allows an unused replay layer when the cassette is missing", async () => { + using directory = tempDirectory("http-recorder-unused-missing-") + await withEnvironment("CI", "true", () => + Effect.runPromise( + Effect.void.pipe( + Effect.scoped, + Effect.provide(HttpRecorder.layerFetch("missing-cassette", { directory: directory.path })), + ), + ), + ) + }) + + describe("auto mode", () => { + test("replays when the cassette exists", async () => { + using directory = tempDirectory("http-recorder-auto-") + await seedCassetteDirectory(directory.path, "auto-replay", [ + { + transport: "http", + request: { + method: "POST", + url: "https://example.test/echo", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ step: 1 }), + }, + response: { + status: 200, + headers: { "content-type": "application/json" }, + body: '{"reply":"hi"}', + }, + }, + ]) + + const result = await runWith( + "auto-replay", + { directory: directory.path }, + post("https://example.test/echo", { step: 1 }), + ) + expect(result).toBe('{"reply":"hi"}') + }) + + test("forces replay when CI=true even if cassette is missing", async () => { + using directory = tempDirectory("http-recorder-auto-ci-") + await withEnvironment("CI", "true", async () => { + const exit = await Effect.runPromise( + Effect.exit( + post("https://example.test/echo", { step: 1 }).pipe( + Effect.provide(HttpRecorder.layerFetch("missing-cassette", { directory: directory.path })), + ), + ), + ) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain('Fixture "missing-cassette" not found') + }) + }) + + test("records to disk when the cassette is missing", async () => { + using directory = tempDirectory("http-recorder-auto-record-") + using server = Bun.serve({ + port: 0, + fetch: () => + new Response('{"reply":"recorded"}', { + headers: { "content-type": "application/json" }, + }), + }) + const url = `http://127.0.0.1:${server.port}/echo` + await withEnvironment("CI", undefined, async () => { + const result = await runWith("auto-record", { directory: directory.path }, post(url, { step: 1 })) + expect(result).toBe('{"reply":"recorded"}') + expect(existsSync(`${directory.path}/auto-record.json`)).toBe(true) + }) + }) + + test("records concurrent requests in request-start order", async () => { + using directory = tempDirectory("http-recorder-order-") + const first = Promise.withResolvers() + const completed: string[] = [] + using server = Bun.serve({ + port: 0, + fetch: async (request) => { + const name = new URL(request.url).pathname.slice(1) + if (name === "first") { + await first.promise + completed.push(name) + return new Response(name) + } + completed.push(name) + first.resolve() + return new Response(name) + }, + }) + await withEnvironment("CI", undefined, async () => { + const request = (name: string) => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/${name}`)) + return yield* response.text + }) + const responses = await Effect.runPromise( + Effect.all([request("first"), request("second")], { + concurrency: "unbounded", + }).pipe(Effect.provide(HttpRecorder.layerFetch("concurrent-order", { directory: directory.path }))), + ) + const cassette = readCassette(`${directory.path}/concurrent-order.json`) + + expect(completed).toEqual(["second", "first"]) + expect(responses).toEqual(["first", "second"]) + expect(cassette.interactions.filter(isHttpInteraction).map((interaction) => interaction.request.url)).toEqual([ + `http://127.0.0.1:${server.port}/first`, + `http://127.0.0.1:${server.port}/second`, + ]) + }) + }) + + test("returns the live response while persisting its redacted snapshot", async () => { + using directory = tempDirectory("http-recorder-live-response-") + using server = Bun.serve({ + port: 0, + fetch: () => + new Response(JSON.stringify({ access_token: "live-secret", safe: true }), { + headers: { + "content-type": "application/json", + "x-request-id": "request-1", + }, + }), + }) + await withEnvironment("CI", undefined, async () => { + const body = await runWith( + "live-response", + { directory: directory.path }, + post(`http://127.0.0.1:${server.port}/response`, { ok: true }), + ) + const cassette = readCassette(`${directory.path}/live-response.json`) + const interaction = cassette.interactions.find(isHttpInteraction) + + expect(body).toBe('{"access_token":"live-secret","safe":true}') + expect(interaction?.response.body).toBe('{"access_token":"[REDACTED]","safe":true}') + }) + }) + + test("reconstructs responses with null-body statuses", async () => { + using directory = tempDirectory("http-recorder-no-content-") + using server = Bun.serve({ + port: 0, + fetch: () => new Response(null, { status: 204 }), + }) + await withEnvironment("CI", undefined, async () => { + const program = Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + return yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/empty`)) + }) + const response = await Effect.runPromise( + program.pipe(Effect.provide(HttpRecorder.layerFetch("no-content", { directory: directory.path }))), + ) + + expect(response.status).toBe(204) + }) + }) + + test("records and replays arbitrary binary responses without changing bytes", async () => { + using directory = tempDirectory("http-recorder-binary-") + const expected = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00, 0x80]) + using server = Bun.serve({ + port: 0, + fetch: () => new Response(expected, { headers: { "content-type": "image/png" } }), + }) + const url = `http://127.0.0.1:${server.port}/image.png` + await withEnvironment("CI", undefined, async () => { + const program = Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute(HttpClientRequest.get(url)) + return new Uint8Array(yield* response.arrayBuffer) + }) + const record = await Effect.runPromise( + program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory: directory.path }))), + ) + await server.stop() + const replay = await Effect.runPromise( + program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory: directory.path }))), + ) + const cassette = readCassette(`${directory.path}/binary.json`) + const interaction = cassette.interactions.find(isHttpInteraction) + + expect(record).toEqual(expected) + expect(replay).toEqual(expected) + expect(interaction?.response.bodyEncoding).toBe("base64") + }) + }) + }) +}) diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts deleted file mode 100644 index 93d7bbaded5..00000000000 --- a/packages/http-recorder/test/record-replay.test.ts +++ /dev/null @@ -1,879 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node" -import { describe, expect, test } from "bun:test" -import { Cause, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect" -import { Headers, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" -import { Socket } from "effect/unstable/socket" -import * as fs from "node:fs" -import * as os from "node:os" -import * as path from "node:path" -import { HttpRecorder } from "../src" -import { HttpRecorderInternal } from "../src/internal" -import { redactedErrorRequest } from "../src/internal-effect" -import type { Interaction } from "../src/schema" - -const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray) => - Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction)) - }).pipe( - Effect.provide(HttpRecorderInternal.Cassette.fileSystem({ directory })), - Effect.provide(NodeFileSystem.layer), - ), - ) - -const post = (url: string, body: object) => - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - const request = HttpClientRequest.post(url, { - headers: { "content-type": "application/json" }, - body: HttpBody.text(JSON.stringify(body), "application/json"), - }) - const response = yield* http.execute(request) - return yield* response.text - }) - -const run = (effect: Effect.Effect) => - Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.http("record-replay/multi-step")))) - -const runWith = ( - name: string, - options: HttpRecorder.RecorderOptions, - effect: Effect.Effect, -) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.http(name, options)))) - -const runRecorder = (effect: Effect.Effect) => - Effect.runPromise( - Effect.scoped( - effect.pipe( - Effect.provide( - HttpRecorderInternal.Cassette.fileSystem({ - directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")), - }), - ), - Effect.provide(NodeFileSystem.layer), - ), - ), - ) - -const failureText = (exit: Exit.Exit) => { - if (Exit.isSuccess(exit)) return "" - return Cause.prettyErrors(exit.cause).join("\n") -} - -describe("http-recorder", () => { - test("redacts sensitive URL query parameters", () => { - expect( - HttpRecorderInternal.redactUrl( - "https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature", - ), - ).toBe( - "https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D", - ) - }) - - test("redacts URL credentials", () => { - expect(HttpRecorderInternal.redactUrl("https://user:password@example.test/path?safe=value")).toBe( - "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value", - ) - }) - - test("applies custom URL redaction after built-in redaction", () => { - expect( - HttpRecorderInternal.redactUrl( - "https://example.test/accounts/real-account/path?key=secret-key", - undefined, - (url) => url.replace("/accounts/real-account/", "/accounts/{account}/"), - ), - ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D") - }) - - test("redacts sensitive headers when allow-listed", () => { - expect( - HttpRecorderInternal.redactHeaders( - { - authorization: "Bearer secret-token", - "content-type": "application/json", - "x-custom-token": "custom-secret", - "x-api-key": "secret-key", - "x-goog-api-key": "secret-google-key", - }, - ["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"], - ["x-custom-token"], - ), - ).toEqual({ - authorization: "[REDACTED]", - "content-type": "application/json", - "x-api-key": "[REDACTED]", - "x-custom-token": "[REDACTED]", - "x-goog-api-key": "[REDACTED]", - }) - }) - - test("redacts error requests without retaining headers, params, or body", () => { - const request = HttpClientRequest.post("https://example.test/path", { - headers: { authorization: "Bearer super-secret" }, - body: HttpBody.text("super-secret-body", "text/plain"), - }).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key")) - - expect(redactedErrorRequest(request).toJSON()).toMatchObject({ - url: "https://example.test/path", - urlParams: { params: [] }, - headers: {}, - body: { _tag: "Empty" }, - }) - }) - - test("detects secret-looking values without returning the secret", () => { - expect( - HttpRecorderInternal.secretFindings({ - version: 1, - interactions: [ - { - transport: "http", - request: { - method: "POST", - url: "https://example.test/path?key=sk-123456789012345678901234", - headers: {}, - body: JSON.stringify({ nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE" }), - }, - response: { - status: 200, - headers: {}, - body: "Bearer abcdefghijklmnopqrstuvwxyz", - }, - }, - ], - }), - ).toEqual([ - { path: "interactions[0].request.url", reason: "API key" }, - { path: "interactions[0].request.body", reason: "Google API key" }, - { path: "interactions[0].response.body", reason: "bearer token" }, - ]) - }) - - test("detects secret-looking values inside metadata", () => { - expect( - HttpRecorderInternal.secretFindings({ - version: 1, - metadata: { token: "sk-123456789012345678901234" }, - interactions: [], - }), - ).toEqual([{ path: "metadata.token", reason: "API key" }]) - }) - - test("redacts configured and common sensitive JSON fields", () => { - const redactor = HttpRecorderInternal.Redactor.make({ jsonFields: ["account_id"] }) - const request = redactor.request({ - method: "POST", - url: "https://example.test/path", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - password: "secret-password", - accessToken: "access-token", - nested: { account_id: "account-123", safe: "visible" }, - }), - }) - - expect(JSON.parse(request.body)).toEqual({ - password: "[REDACTED]", - accessToken: "[REDACTED]", - nested: { account_id: "[REDACTED]", safe: "visible" }, - }) - }) - - test("extends default header redaction and allow lists", () => { - const redactor = HttpRecorderInternal.Redactor.make({ - headers: ["x-custom-token"], - allowRequestHeaders: ["anthropic-version", "x-custom-token"], - }) - - expect( - redactor.request({ - method: "GET", - url: "https://example.test/path", - headers: { - authorization: "Bearer secret", - "content-type": "application/json", - "anthropic-version": "2023-06-01", - "x-custom-token": "secret", - }, - body: "", - }).headers, - ).toEqual({ - "anthropic-version": "2023-06-01", - "content-type": "application/json", - "x-custom-token": "[REDACTED]", - }) - }) - - test("records WebSocket frames in observed client/server order", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - const response = JSON.stringify({ type: "response.completed", token: "server-secret" }) - let receive: ((message: string | Uint8Array) => Effect.Effect | void) | undefined - const upstream = Socket.make({ - runRaw: (handler, options) => - Effect.gen(function* () { - receive = handler - if (options?.onOpen) yield* options.onOpen - receive = undefined - }), - writer: Effect.succeed(() => - Effect.suspend(() => { - const result = receive?.(response) - return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void - }), - ), - }) - - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - yield* socket.runRaw(() => {}, { - onOpen: write(JSON.stringify({ type: "response.create", token: "client-secret" })), - }) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/record", - { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - { directory, metadata: { provider: "test" }, mode: "record" }, - ).pipe(Layer.provide(Layer.succeed(Socket.Socket, upstream))), - ), - ), - ) - - expect(JSON.parse(fs.readFileSync(path.join(directory, "websocket/record.json"), "utf8"))).toMatchObject({ - interactions: [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - events: [ - { direction: "client", kind: "text", body: '{"type":"response.create","token":"[REDACTED]"}' }, - { direction: "server", kind: "text", body: '{"type":"response.completed","token":"[REDACTED]"}' }, - ], - }, - ], - }) - }) - - test("WebSocket replay preserves causal frame ordering", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/replay", [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: {} }, - events: [ - { direction: "server", kind: "text", body: '{"type":"session.created"}' }, - { direction: "client", kind: "text", body: '{"type":"response.create","prompt":"hello"}' }, - { direction: "server", kind: "text", body: '{"type":"response.completed"}' }, - ], - }, - ]) - - const received: string[] = [] - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - yield* socket.runRaw((message) => { - if (typeof message !== "string") return - received.push(message) - if (JSON.parse(message).type === "session.created") - return write('{"prompt":"hello","type":"response.create"}') - }) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/replay", - { url: "wss://example.test/realtime" }, - { directory, compareClientMessagesAsJson: true, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(received).toEqual(['{"type":"session.created"}', '{"type":"response.completed"}']) - }) - - test("the public socket decorator replays a provided Effect socket", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/public-layer", [ - { - transport: "websocket", - open: { url: "", headers: {} }, - events: [ - { direction: "client", kind: "text", body: "hello" }, - { direction: "server", kind: "text", body: "hello" }, - ], - }, - ]) - - const received: string[] = [] - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - yield* socket.runString( - (message) => - Effect.gen(function* () { - received.push(message) - yield* write(new Socket.CloseEvent(1000)) - }), - { onOpen: write("hello") }, - ) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorder.socket("websocket/public-layer", { directory }).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(received).toEqual(["hello"]) - }) - - test("WebSocket replay runs message handlers concurrently", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/concurrent-handlers", [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: {} }, - events: [ - { direction: "server", kind: "text", body: "first" }, - { direction: "server", kind: "text", body: "second" }, - ], - }, - ]) - - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const second = yield* Deferred.make() - yield* socket.runString((message) => - message === "first" ? Deferred.await(second) : Deferred.succeed(second, undefined), - ) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/concurrent-handlers", - { url: "wss://example.test/realtime" }, - { directory, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - }) - - test("WebSocket replay rejects close with unconsumed events", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/early-close", [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: {} }, - events: [{ direction: "client", kind: "text", body: "expected" }], - }, - ]) - - const exit = await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - return yield* Effect.exit(socket.runRaw(() => {}, { onOpen: write(new Socket.CloseEvent(1000)) })) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/early-close", - { url: "wss://example.test/realtime" }, - { directory, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(failureText(exit)).toContain("closed with unconsumed events") - }) - - test("failed WebSocket runs do not write complete cassettes", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - const exit = await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - return yield* Effect.exit(socket.runRaw(() => {})) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/failed-run", - { url: "wss://example.test/realtime" }, - { directory, mode: "record" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("connection failed")), - writer: Effect.succeed(() => Effect.void), - }), - ), - ), - ), - ), - ), - ) - - expect(Exit.isFailure(exit)).toBe(true) - expect(fs.existsSync(path.join(directory, "websocket/failed-run.json"))).toBe(false) - }) - - test("WebSocket replay preserves binary frame kinds across reconnects", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - const interaction = { - transport: "websocket" as const, - open: { url: "wss://example.test/binary", headers: {} }, - events: [ - { - direction: "client" as const, - kind: "binary" as const, - body: Buffer.from([1, 2]).toString("base64"), - bodyEncoding: "base64" as const, - }, - { - direction: "server" as const, - kind: "binary" as const, - body: Buffer.from([3, 4]).toString("base64"), - bodyEncoding: "base64" as const, - }, - ], - } - await seedCassetteDirectory(directory, "websocket/binary", [interaction, interaction]) - - const received: number[][] = [] - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - const run = socket.runRaw( - (message) => { - if (typeof message === "string") throw new Error("Expected a binary WebSocket frame") - received.push([...message]) - }, - { onOpen: write(new Uint8Array([1, 2])) }, - ) - yield* run - yield* run - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/binary", - { url: "wss://example.test/binary" }, - { directory, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(received).toEqual([ - [3, 4], - [3, 4], - ]) - }) - - test("replay returns recorded responses in order for identical requests", async () => { - await runWith( - "record-replay/retry", - {}, - Effect.gen(function* () { - expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') - expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}') - }), - ) - }) - - test("replay reports cursor exhaustion when more requests are made than recorded", async () => { - await run( - Effect.gen(function* () { - yield* post("https://example.test/echo", { step: 1 }) - yield* post("https://example.test/echo", { step: 2 }) - const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) - expect(Exit.isFailure(exit)).toBe(true) - }), - ) - }) - - test("replay validates each recorded request in order", async () => { - await run( - Effect.gen(function* () { - yield* post("https://example.test/echo", { step: 1 }) - const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) - expect(Exit.isFailure(exit)).toBe(true) - expect(failureText(exit)).toContain("$.step expected 2, received 3") - expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') - }), - ) - }) - - test("concurrent replay claims each interaction once", async () => { - const results = await runWith( - "record-replay/retry", - {}, - Effect.all( - [post("https://example.test/poll", { id: "job_1" }), post("https://example.test/poll", { id: "job_1" })], - { concurrency: "unbounded" }, - ), - ) - - expect(results.toSorted()).toEqual(['{"status":"complete"}', '{"status":"pending"}']) - }) - - test("replays when the cassette exists", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-")) - await seedCassetteDirectory(directory, "auto-replay", [ - { - transport: "http", - request: { - method: "POST", - url: "https://example.test/echo", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ step: 1 }), - }, - response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' }, - }, - ]) - - const result = await runWith("auto-replay", { directory }, post("https://example.test/echo", { step: 1 })) - expect(result).toBe('{"reply":"hi"}') - }) - - test("forces replay when CI=true even if cassette is missing", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-")) - const previous = process.env.CI - process.env.CI = "true" - try { - const exit = await Effect.runPromise( - Effect.exit( - post("https://example.test/echo", { step: 1 }).pipe( - Effect.provide(HttpRecorder.http("missing-cassette", { directory })), - ), - ), - ) - expect(Exit.isFailure(exit)).toBe(true) - expect(failureText(exit)).toContain('Fixture "missing-cassette" not found') - } finally { - if (previous === undefined) delete process.env.CI - else process.env.CI = previous - } - }) - - test("mismatch diagnostics show redacted request differences against the expected interaction", async () => { - await run( - Effect.gen(function* () { - const exit = yield* Effect.exit( - post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }), - ) - const message = failureText(exit) - expect(message).toContain("url:") - expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D") - expect(message).toContain("body:") - expect(message).toContain("$.step expected 1, received 3") - expect(message).toContain('$.token expected undefined, received "[REDACTED]"') - expect(message).not.toContain("sk-123456789012345678901234") - }), - ) - }) - - test("records to disk when the cassette is missing", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-record-")) - using server = Bun.serve({ - port: 0, - fetch: () => new Response('{"reply":"recorded"}', { headers: { "content-type": "application/json" } }), - }) - const url = `http://127.0.0.1:${server.port}/echo` - // CI=true forces replay; clear it so we exercise the local-dev auto-record path. - const previous = process.env.CI - delete process.env.CI - try { - const result = await runWith("auto-record", { directory }, post(url, { step: 1 })) - expect(result).toBe('{"reply":"recorded"}') - expect(fs.existsSync(path.join(directory, "auto-record.json"))).toBe(true) - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("records concurrent requests in request-start order", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-order-")) - const first = Promise.withResolvers() - const completed: string[] = [] - using server = Bun.serve({ - port: 0, - fetch: async (request) => { - const name = new URL(request.url).pathname.slice(1) - if (name === "first") { - await first.promise - completed.push(name) - return new Response(name) - } - completed.push(name) - first.resolve() - return new Response(name) - }, - }) - const previous = process.env.CI - delete process.env.CI - try { - const request = (name: string) => - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - const response = yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/${name}`)) - return yield* response.text - }) - const responses = await Effect.runPromise( - Effect.all([request("first"), request("second")], { concurrency: "unbounded" }).pipe( - Effect.provide(HttpRecorder.http("concurrent-order", { directory })), - ), - ) - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "concurrent-order.json"), "utf8")) - - expect(completed).toEqual(["second", "first"]) - expect(responses).toEqual(["first", "second"]) - expect(cassette.interactions.map((interaction: Interaction) => interaction.request.url)).toEqual([ - `http://127.0.0.1:${server.port}/first`, - `http://127.0.0.1:${server.port}/second`, - ]) - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("returns the live response while persisting its redacted snapshot", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-live-response-")) - using server = Bun.serve({ - port: 0, - fetch: () => - new Response(JSON.stringify({ access_token: "live-secret", safe: true }), { - headers: { "content-type": "application/json", "x-request-id": "request-1" }, - }), - }) - const previous = process.env.CI - delete process.env.CI - try { - const body = await runWith( - "live-response", - { directory }, - post(`http://127.0.0.1:${server.port}/response`, { ok: true }), - ) - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "live-response.json"), "utf8")) - - expect(body).toBe('{"access_token":"live-secret","safe":true}') - expect(cassette.interactions[0].response.body).toBe('{"access_token":"[REDACTED]","safe":true}') - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("reconstructs responses with null-body statuses", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-no-content-")) - using server = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 204 }) }) - const previous = process.env.CI - delete process.env.CI - try { - const program = Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - return yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/empty`)) - }) - const response = await Effect.runPromise( - program.pipe(Effect.provide(HttpRecorder.http("no-content", { directory }))), - ) - - expect(response.status).toBe(204) - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("records and replays arbitrary binary responses without changing bytes", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-binary-")) - const expected = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00, 0x80]) - using server = Bun.serve({ - port: 0, - fetch: () => new Response(expected, { headers: { "content-type": "image/png" } }), - }) - const url = `http://127.0.0.1:${server.port}/image.png` - const previous = process.env.CI - delete process.env.CI - try { - const program = Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - const response = yield* http.execute(HttpClientRequest.get(url)) - return new Uint8Array(yield* response.arrayBuffer) - }) - const record = await Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.http("binary", { directory })))) - await server.stop() - const replay = await Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.http("binary", { directory })))) - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "binary.json"), "utf8")) - - expect(record).toEqual(expected) - expect(replay).toEqual(expected) - expect(cassette.interactions[0].response.bodyEncoding).toBe("base64") - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => { - using server = Bun.serve({ port: 0, fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234") }) - const url = `http://127.0.0.1:${server.port}/leaky` - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-unsafe-")) - - const exit = await Effect.runPromise( - Effect.exit( - post(url, { ok: true }).pipe( - Effect.provide(HttpRecorderInternal.cassetteLayer("unsafe-record", { directory, mode: "record" })), - ), - ), - ) - expect(Exit.isFailure(exit)).toBe(true) - expect(failureText(exit)).toContain("contains possible secrets") - expect(fs.existsSync(path.join(directory, "unsafe-record.json"))).toBe(false) - }) - - test("failed memory appends leave cassette state unchanged", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - const interaction: Interaction = { - transport: "http", - request: { method: "GET", url: "https://example.test", headers: {}, body: "" }, - response: { status: 200, headers: {}, body: "safe" }, - } - yield* cassette.append("transactional", interaction) - yield* cassette - .append("transactional", { - ...interaction, - response: { ...interaction.response, body: "Bearer abcdefghijklmnopqrstuvwxyz1234" }, - }) - .pipe(Effect.flip) - - expect(yield* cassette.read("transactional")).toEqual([interaction]) - }).pipe(Effect.provide(HttpRecorderInternal.Cassette.memory())), - ) - }) - - test("concurrent file appends preserve every interaction", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-concurrent-")) - await Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - yield* Effect.forEach( - Array.from({ length: 20 }, (_, index) => index), - (index) => - cassette.append("concurrent", { - transport: "http", - request: { method: "GET", url: `https://example.test/${index}`, headers: {}, body: "" }, - response: { status: 200, headers: {}, body: String(index) }, - }), - { concurrency: "unbounded" }, - ) - }).pipe( - Effect.provide(HttpRecorderInternal.Cassette.fileSystem({ directory })), - Effect.provide(NodeFileSystem.layer), - ), - ) - - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "concurrent.json"), "utf8")) - expect(cassette.interactions).toHaveLength(20) - expect(fs.readdirSync(directory).filter((file) => file.endsWith(".tmp"))).toEqual([]) - }) - - test("rejects cassette paths outside the recordings directory", () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-path-")) - expect(() => HttpRecorderInternal.hasCassetteSync("../outside", { directory })).toThrow("Invalid cassette name") - expect(() => HttpRecorderInternal.hasCassetteSync("C:\\outside", { directory })).toThrow("Invalid cassette name") - }) - - test("Cassette.list enumerates recorded cassette names", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-list-")) - await seedCassetteDirectory(directory, "alpha/one", [ - { - transport: "http", - request: { method: "GET", url: "https://x.test/a", headers: {}, body: "" }, - response: { status: 200, headers: {}, body: "a" }, - }, - ]) - await seedCassetteDirectory(directory, "beta", [ - { - transport: "http", - request: { method: "GET", url: "https://x.test/b", headers: {}, body: "" }, - response: { status: 200, headers: {}, body: "b" }, - }, - ]) - - const names = await Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - return yield* cassette.list() - }).pipe( - Effect.provide(HttpRecorderInternal.Cassette.fileSystem({ directory })), - Effect.provide(NodeFileSystem.layer), - ), - ) - expect(names).toEqual(["alpha/one", "beta"]) - }) -}) diff --git a/packages/http-recorder/test/redaction.test.ts b/packages/http-recorder/test/redaction.test.ts new file mode 100644 index 00000000000..64e139b01fa --- /dev/null +++ b/packages/http-recorder/test/redaction.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test" +import { HttpBody, HttpClientRequest } from "effect/unstable/http" +import { redactedErrorRequest } from "../src/http/recorder" +import { make, redactHeaders, redactUrl } from "../src/redaction/redactor" +import { secretFindings } from "../src/redaction/secrets" + +describe("redaction", () => { + test("redacts sensitive URL query parameters", () => { + expect( + redactUrl( + "https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature", + ), + ).toBe( + "https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D", + ) + }) + + test("redacts URL credentials", () => { + expect(redactUrl("https://user:password@example.test/path?safe=value")).toBe( + "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value", + ) + }) + + test("applies custom URL redaction after built-in redaction", () => { + expect( + redactUrl("https://example.test/accounts/real-account/path?key=secret-key", undefined, (url) => + url.replace("/accounts/real-account/", "/accounts/{account}/"), + ), + ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D") + }) + + test("redacts sensitive headers when allow-listed", () => { + expect( + redactHeaders( + { + authorization: "Bearer secret-token", + "content-type": "application/json", + "x-custom-token": "custom-secret", + "x-api-key": "secret-key", + "x-goog-api-key": "secret-google-key", + }, + ["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"], + ["x-custom-token"], + ), + ).toEqual({ + authorization: "[REDACTED]", + "content-type": "application/json", + "x-api-key": "[REDACTED]", + "x-custom-token": "[REDACTED]", + "x-goog-api-key": "[REDACTED]", + }) + }) + + test("redacts error requests without retaining headers, params, or body", () => { + const request = HttpClientRequest.post("https://example.test/path", { + headers: { authorization: "Bearer super-secret" }, + body: HttpBody.text("super-secret-body", "text/plain"), + }).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key")) + + expect(redactedErrorRequest(request).toJSON()).toMatchObject({ + url: "https://example.test/path", + urlParams: { params: [] }, + headers: {}, + body: { _tag: "Empty" }, + }) + }) + + test("detects secret-looking values without returning the secret", () => { + expect( + secretFindings({ + version: 1, + interactions: [ + { + transport: "http", + request: { + method: "POST", + url: "https://example.test/path?key=sk-123456789012345678901234", + headers: {}, + body: JSON.stringify({ + nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE", + }), + }, + response: { + status: 200, + headers: {}, + body: "Bearer abcdefghijklmnopqrstuvwxyz", + }, + }, + ], + }), + ).toEqual([ + { path: "interactions[0].request.url", reason: "API key" }, + { path: "interactions[0].request.body", reason: "Google API key" }, + { path: "interactions[0].response.body", reason: "bearer token" }, + ]) + }) + + test("detects secret-looking values inside metadata", () => { + expect( + secretFindings({ + version: 1, + metadata: { token: "sk-123456789012345678901234" }, + interactions: [], + }), + ).toEqual([{ path: "metadata.token", reason: "API key" }]) + }) + + test("redacts configured and common sensitive JSON fields", () => { + const redactor = make({ + jsonFields: ["account_id"], + }) + const request = redactor.request({ + method: "POST", + url: "https://example.test/path", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + password: "secret-password", + accessToken: "access-token", + nested: { account_id: "account-123", safe: "visible" }, + }), + }) + + expect(JSON.parse(request.body)).toEqual({ + password: "[REDACTED]", + accessToken: "[REDACTED]", + nested: { account_id: "[REDACTED]", safe: "visible" }, + }) + }) + + test("preserves JSON text when no fields are redacted", () => { + const body = '{\n "id": 9007199254740993,\n "safe": true\n}' + + expect( + make().request({ + method: "POST", + url: "https://example.test/path", + headers: { "content-type": "application/json" }, + body, + }).body, + ).toBe(body) + }) + + test("extends default header redaction and allow lists", () => { + const redactor = make({ + headers: ["x-custom-token"], + allowRequestHeaders: ["anthropic-version", "x-custom-token"], + }) + + expect( + redactor.request({ + method: "GET", + url: "https://example.test/path", + headers: { + authorization: "Bearer secret", + "content-type": "application/json", + "anthropic-version": "2023-06-01", + "x-custom-token": "secret", + }, + body: "", + }).headers, + ).toEqual({ + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "x-custom-token": "[REDACTED]", + }) + }) +}) diff --git a/packages/http-recorder/test/support.ts b/packages/http-recorder/test/support.ts new file mode 100644 index 00000000000..30fa8205f22 --- /dev/null +++ b/packages/http-recorder/test/support.ts @@ -0,0 +1,61 @@ +import { NodeFileSystem } from "@effect/platform-node-shared" +import { Cause, Effect, Exit } from "effect" +import { HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { mkdtempSync, readFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { decodeCassette, type Interaction } from "../src/cassette/model" +import { Service, fileSystem } from "../src/cassette/store" + +export const tempDirectory = (prefix: string) => { + const directory = mkdtempSync(join(tmpdir(), prefix)) + return { + path: directory, + [Symbol.dispose]() { + rmSync(directory, { recursive: true, force: true }) + }, + } +} + +export const post = (url: string, body: object) => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute( + HttpClientRequest.post(url, { + headers: { "content-type": "application/json" }, + body: HttpBody.text(JSON.stringify(body), "application/json"), + }), + ) + return yield* response.text + }) + +export const readCassette = (file: string) => decodeCassette(JSON.parse(readFileSync(file, "utf8"))) + +export const runFileCassette = (directory: string, effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(fileSystem({ directory })), Effect.provide(NodeFileSystem.layer))) + +export const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray) => + runFileCassette( + directory, + Effect.gen(function* () { + const cassette = yield* Service + yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction)) + }), + ) + +export const withEnvironment = async (name: string, value: string | undefined, run: () => Promise) => { + const previous = process.env[name] + if (value === undefined) delete process.env[name] + else process.env[name] = value + try { + return await run() + } finally { + if (previous === undefined) delete process.env[name] + else process.env[name] = previous + } +} + +export const failureText = (exit: Exit.Exit) => { + if (Exit.isSuccess(exit)) return "" + return Cause.prettyErrors(exit.cause).join("\n") +} diff --git a/packages/http-recorder/test/tsconfig.json b/packages/http-recorder/test/tsconfig.json new file mode 100644 index 00000000000..97888caa286 --- /dev/null +++ b/packages/http-recorder/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true, + "declaration": false, + "module": "preserve", + "moduleResolution": "bundler" + }, + "include": ["../src", "."] +} diff --git a/packages/http-recorder/test/websocket.test.ts b/packages/http-recorder/test/websocket.test.ts new file mode 100644 index 00000000000..2d4d30cbf26 --- /dev/null +++ b/packages/http-recorder/test/websocket.test.ts @@ -0,0 +1,529 @@ +import { describe, expect, test } from "bun:test" +import { Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { Socket } from "effect/unstable/socket" +import { existsSync } from "node:fs" +import { HttpRecorder } from "../src" +import { layerSocketWithMode } from "../src/websocket/recorder" +import { failureText, readCassette, seedCassetteDirectory, tempDirectory, withEnvironment } from "./support" + +const unavailableSocket = Socket.make({ + runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), + writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), +}) + +class EchoWebSocket extends EventTarget { + readonly protocol = "" + readonly extensions = "" + bufferedAmount = 0 + binaryType: BinaryType = "blob" + readyState = 0 + + constructor(readonly url: string) { + super() + queueMicrotask(() => { + this.readyState = 1 + this.dispatchEvent(new Event("open")) + }) + } + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView) { + queueMicrotask(() => this.dispatchEvent(new MessageEvent("message", { data }))) + } + + close(code = 1000, reason = "") { + if (this.readyState === 3) return + this.readyState = 3 + this.dispatchEvent(new CloseEvent("close", { code, reason, wasClean: code === 1000 })) + } +} + +describe("WebSocket", () => { + test("constructor recording is complete when the recorder layer closes", async () => { + using directory = tempDirectory("http-recorder-websocket-constructor-") + const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor-record", { + directory: directory.path, + }).pipe( + Layer.provide( + Layer.succeed(Socket.WebSocketConstructor, (url) => new EchoWebSocket(url) as unknown as globalThis.WebSocket), + ), + ) + + await withEnvironment("CI", undefined, () => + Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket("wss://echo.example.test/one", { + protocols: ["echo.v1"], + closeCodeIsError: () => false, + }) + const write = yield* socket.writer + yield* socket.runString(() => write(new Socket.CloseEvent(1000, "complete")).pipe(Effect.orDie), { + onOpen: write("hello").pipe(Effect.orDie), + }) + }).pipe(Effect.scoped, Effect.provide(recorder)), + ), + ) + + expect(readCassette(`${directory.path}/websocket/constructor-record.json`).interactions).toEqual([ + { + transport: "websocket", + connection: { + sequence: 0, + url: "wss://echo.example.test/one", + protocols: ["echo.v1"], + close: { code: 1000, reason: "complete" }, + }, + events: [ + { direction: "client", kind: "text", body: "hello" }, + { direction: "server", kind: "text", body: "hello" }, + ], + }, + ]) + }) + + test("constructor replay validates dynamic URLs and protocols without opening a live socket", async () => { + using directory = tempDirectory("http-recorder-websocket-constructor-") + await seedCassetteDirectory(directory.path, "websocket/constructor", [ + { + transport: "websocket", + connection: { + sequence: 0, + url: "wss://events.example.test/workspaces/one", + protocols: ["events.v1"], + close: { code: 1000, reason: "complete" }, + }, + events: [ + { direction: "client", kind: "text", body: '{"type":"subscribe"}' }, + { direction: "server", kind: "text", body: '{"type":"ready"}' }, + ], + }, + ]) + const unavailableConstructor = () => { + throw new Error("unexpected live WebSocket construction") + } + const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor", { + directory: directory.path, + }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, unavailableConstructor))) + + const received = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket("wss://events.example.test/workspaces/one", { + protocols: ["events.v1"], + closeCodeIsError: () => false, + }) + const write = yield* socket.writer + const received: string[] = [] + yield* socket.runString( + (message) => { + received.push(message) + }, + { + onOpen: write('{"type":"subscribe"}').pipe(Effect.orDie), + }, + ) + return received + }).pipe(Effect.scoped, Effect.provide(recorder)), + ) + + expect(received).toEqual(['{"type":"ready"}']) + }) + + test("constructor replay rejects a different dynamic URL", async () => { + using directory = tempDirectory("http-recorder-websocket-constructor-") + await seedCassetteDirectory(directory.path, "websocket/constructor-mismatch", [ + { + transport: "websocket", + connection: { + sequence: 0, + url: "wss://events.example.test/workspaces/one", + protocols: [], + close: { code: 1000, reason: "complete" }, + }, + events: [], + }, + ]) + const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor-mismatch", { + directory: directory.path, + }).pipe( + Layer.provide( + Layer.succeed(Socket.WebSocketConstructor, () => { + throw new Error("unexpected live WebSocket construction") + }), + ), + ) + + const exit = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket("wss://events.example.test/workspaces/two") + yield* socket.runString(() => {}) + }).pipe(Effect.scoped, Effect.exit, Effect.provide(recorder)), + ) + + expect(Exit.isFailure(exit)).toBe(true) + }) + + test("records WebSocket frames in observed client/server order", async () => { + using directory = tempDirectory("http-recorder-websocket-") + const response = JSON.stringify({ + type: "response.completed", + token: "server-secret", + }) + const upstream = Socket.make({ + runRaw: (handler, options) => + Effect.gen(function* () { + if (options?.onOpen) yield* options.onOpen + const result = handler(response) + if (Effect.isEffect(result)) yield* result + }), + writer: Effect.succeed(() => Effect.void), + }) + + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + yield* socket.runRaw(() => {}, { + onOpen: write(JSON.stringify({ type: "response.create", token: "client-secret" })).pipe(Effect.orDie), + }) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/record", { + directory: directory.path, + metadata: { provider: "test" }, + mode: "record", + }).pipe(Layer.provide(Layer.succeed(Socket.Socket, upstream))), + ), + ), + ) + + expect(readCassette(`${directory.path}/websocket/record.json`)).toMatchObject({ + interactions: [ + { + transport: "websocket", + events: [ + { + direction: "client", + kind: "text", + body: '{"type":"response.create","token":"[REDACTED]"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed","token":"[REDACTED]"}', + }, + ], + }, + ], + }) + }) + + test("WebSocket replay preserves causal frame ordering", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/replay", [ + { + transport: "websocket", + events: [ + { + direction: "server", + kind: "text", + body: '{"type":"session.created"}', + }, + { + direction: "client", + kind: "text", + body: '{"type":"response.create","prompt":"hello"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed"}', + }, + ], + }, + ]) + + const received: string[] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + yield* socket.runRaw((message) => + Effect.gen(function* () { + if (typeof message !== "string") return + received.push(message) + const event: unknown = JSON.parse(message) + if (typeof event !== "object" || event === null || !("type" in event)) return + if (event.type === "session.created") yield* write('{"prompt":"hello","type":"response.create"}') + }), + ) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/replay", { + directory: directory.path, + compareClientMessagesAsJson: true, + mode: "replay", + }).pipe(Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket))), + ), + ), + ) + + expect(received).toEqual(['{"type":"session.created"}', '{"type":"response.completed"}']) + }) + + test("the public socket decorator replays a causal provider conversation", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/public-layer", [ + { + transport: "websocket", + events: [ + { + direction: "server", + kind: "text", + body: '{"type":"session.created"}', + }, + { + direction: "client", + kind: "text", + body: '{"type":"response.create","prompt":"first"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed","id":"first"}', + }, + { + direction: "client", + kind: "text", + body: '{"type":"response.create","prompt":"second"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed","id":"second"}', + }, + ], + }, + ]) + + const received: string[] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + yield* socket.runString((message) => + Effect.gen(function* () { + received.push(message) + const event: unknown = JSON.parse(message) + if (typeof event !== "object" || event === null) return + if ("type" in event && event.type === "session.created") { + yield* write('{"prompt":"first","type":"response.create"}') + return + } + if ("id" in event && event.id === "first") { + yield* write('{"prompt":"second","type":"response.create"}') + return + } + yield* write(new Socket.CloseEvent(1000, "done")) + }), + ) + }).pipe( + Effect.scoped, + Effect.provide( + HttpRecorder.layerSocket("websocket/public-layer", { directory: directory.path }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + + expect(received).toEqual([ + '{"type":"session.created"}', + '{"type":"response.completed","id":"first"}', + '{"type":"response.completed","id":"second"}', + ]) + }) + + test("WebSocket replay runs message handlers concurrently", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/concurrent-handlers", [ + { + transport: "websocket", + events: [ + { direction: "server", kind: "text", body: "first" }, + { direction: "server", kind: "text", body: "second" }, + ], + }, + ]) + + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const second = yield* Deferred.make() + yield* socket.runString((message) => + message === "first" ? Deferred.await(second) : Deferred.succeed(second, undefined), + ) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/concurrent-handlers", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + }) + + test("rejected concurrent replay does not consume the next interaction", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/concurrent-runs", [ + { transport: "websocket", events: [{ direction: "server", kind: "text", body: "first" }] }, + { transport: "websocket", events: [{ direction: "server", kind: "text", body: "second" }] }, + ]) + + const received: string[] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const first = yield* socket + .runString((message) => + Effect.gen(function* () { + received.push(message) + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) + }), + ) + .pipe(Effect.forkChild) + yield* Deferred.await(started) + + const concurrent = yield* Effect.exit(socket.runString(() => Effect.void)) + expect(failureText(concurrent)).toContain("Concurrent runs") + + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(first) + yield* socket.runString((message) => Effect.sync(() => received.push(message))) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/concurrent-runs", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + + expect(received).toEqual(["first", "second"]) + }) + + test("WebSocket replay rejects close with unconsumed events", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/early-close", [ + { + transport: "websocket", + events: [{ direction: "client", kind: "text", body: "expected" }], + }, + ]) + + const exit = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + return yield* Effect.exit( + socket.runRaw(() => {}, { + onOpen: write(new Socket.CloseEvent(1000)).pipe(Effect.orDie), + }), + ) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/early-close", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + + expect(failureText(exit)).toContain("closed with unconsumed events") + }) + + test("failed WebSocket runs do not write complete cassettes", async () => { + using directory = tempDirectory("http-recorder-websocket-") + const exit = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + return yield* Effect.exit(socket.runRaw(() => {})) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/failed-run", { directory: directory.path, mode: "record" }).pipe( + Layer.provide( + Layer.succeed( + Socket.Socket, + Socket.make({ + runRaw: () => Effect.die(new Error("connection failed")), + writer: Effect.succeed(() => Effect.void), + }), + ), + ), + ), + ), + ), + ) + + expect(Exit.isFailure(exit)).toBe(true) + expect(existsSync(`${directory.path}/websocket/failed-run.json`)).toBe(false) + }) + + test("WebSocket replay preserves binary frame kinds across reconnects", async () => { + using directory = tempDirectory("http-recorder-websocket-") + const interaction = { + transport: "websocket" as const, + events: [ + { + direction: "client" as const, + kind: "binary" as const, + body: Buffer.from([1, 2]).toString("base64"), + bodyEncoding: "base64" as const, + }, + { + direction: "server" as const, + kind: "binary" as const, + body: Buffer.from([3, 4]).toString("base64"), + bodyEncoding: "base64" as const, + }, + ], + } + await seedCassetteDirectory(directory.path, "websocket/binary", [interaction, interaction]) + + const received: number[][] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + const run = socket.runRaw( + (message) => { + if (typeof message === "string") throw new Error("Expected a binary WebSocket frame") + received.push([...message]) + }, + { onOpen: write(new Uint8Array([1, 2])).pipe(Effect.orDie) }, + ) + yield* run + yield* run + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/binary", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + + expect(received).toEqual([ + [3, 4], + [3, 4], + ]) + }) +}) diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md index 5ccd955f524..e883a9e47d1 100644 --- a/packages/llm/AGENTS.md +++ b/packages/llm/AGENTS.md @@ -320,7 +320,7 @@ recorded.effect("streams text", () => ) ``` -Replay is the default. `RECORD=true` records fresh cassettes and requires the listed env vars. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable. +Replay is the default. `RECORD=true` records fresh cassettes locally and requires the listed env vars; unset `CI` before recording because CI always forces replay. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable. Pass `provider`, `protocol`, and optional `tags` to `recordedTests(...)` / `recorded.effect.with(...)` so cassettes carry searchable metadata. Use recorded-test filters to replay or record a narrow subset without rewriting a whole file: @@ -333,6 +333,6 @@ Filters apply in replay and record mode. Combine them with `RECORD=true` when re **Binary response bodies.** Most providers stream text (SSE, JSON). The recorder treats known textual media types (`text/*`, JSON/XML structured types, JavaScript, forms, YAML, and SVG) as text and stores every other response as base64 with `bodyEncoding: "base64"`. This preserves binary formats such as AWS event-stream frames without a lossy UTF-8 round trip. -**Matching strategy.** Replay walks the cassette in record order via an internal cursor: the Nth runtime request is served by the Nth recorded interaction, and each one is validated by comparing method, URL, allow-listed headers, and the canonical JSON body. This handles tool loops (each round's request differs as history grows) and retry/polling scenarios (successive byte-identical requests with different responses) uniformly. If a test reorders its requests, re-record the cassette. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk. +**Matching strategy.** A runtime request atomically claims the first unused recorded interaction that matches its method, URL, allow-listed headers, and canonical JSON body. Distinct requests may replay in any order or concurrently. Repeated identical requests consume their matching responses in cassette order, preserving deterministic retry and polling behavior. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk. Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed. diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts index ef67c866d8d..ae09cf7120f 100644 --- a/packages/llm/test/provider/golden.recorded.test.ts +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -12,7 +12,6 @@ const openAI = OpenAI.configure({ }) const openAIChat = openAI.chat("gpt-4o-mini") const openAIResponses = openAI.responses("gpt-5.5") -const openAIResponsesWebSocket = openAI.responsesWebSocket("gpt-4.1-mini") const anthropic = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", }) @@ -89,14 +88,6 @@ describeRecordedGoldenScenarios([ { id: "image-tool-result", temperature: false, maxTokens: 40 }, ], }, - { - name: "OpenAI Responses WebSocket gpt-4.1-mini", - prefix: "openai-responses-websocket", - model: openAIResponsesWebSocket, - transport: "websocket", - requires: ["OPENAI_API_KEY"], - scenarios: ["tool-loop"], - }, { name: "Anthropic Haiku 4.5", prefix: "anthropic-messages", diff --git a/packages/llm/test/recorded-golden.ts b/packages/llm/test/recorded-golden.ts index 540662b2993..404a5028b2a 100644 --- a/packages/llm/test/recorded-golden.ts +++ b/packages/llm/test/recorded-golden.ts @@ -28,7 +28,7 @@ type TargetInput = { readonly transport?: Transport readonly prefix?: string readonly tags?: ReadonlyArray - readonly metadata?: Record + readonly metadata?: HttpRecorder.CassetteMetadata readonly options?: HttpRecorder.RecorderOptions readonly scenarios: ReadonlyArray } @@ -43,7 +43,7 @@ const defaultPrefix = (target: TargetInput) => { const metadata = (target: TargetInput) => ({ provider: target.model.provider, - protocol: target.protocol, + ...(target.protocol ? { protocol: target.protocol } : {}), route: target.model.route.id, transport: target.transport ?? "http", model: target.model.id, diff --git a/packages/llm/test/recorded-runner.ts b/packages/llm/test/recorded-runner.ts index 97d9b03f546..904a9366aca 100644 --- a/packages/llm/test/recorded-runner.ts +++ b/packages/llm/test/recorded-runner.ts @@ -1,3 +1,4 @@ +import type { HttpRecorder } from "@opencode-ai/http-recorder" import { test, type TestOptions } from "bun:test" import { Effect, type Layer } from "effect" import { testEffect } from "./lib/effect" @@ -11,7 +12,7 @@ export type RecordedGroupOptions = { readonly protocol?: string readonly requires?: ReadonlyArray readonly tags?: ReadonlyArray - readonly metadata?: Record + readonly metadata?: HttpRecorder.CassetteMetadata } export type RecordedCaseOptions = { @@ -21,7 +22,7 @@ export type RecordedCaseOptions = { readonly protocol?: string readonly requires?: ReadonlyArray readonly tags?: ReadonlyArray - readonly metadata?: Record + readonly metadata?: HttpRecorder.CassetteMetadata } export const recordedEffectGroup = < @@ -36,7 +37,7 @@ export const recordedEffectGroup = < readonly layer: (input: { readonly cassette: string readonly tags: ReadonlyArray - readonly metadata: Record + readonly metadata: HttpRecorder.CassetteMetadata readonly recording: boolean readonly options: Options readonly caseOptions: CaseOptions diff --git a/packages/llm/test/recorded-test.ts b/packages/llm/test/recorded-test.ts index 669b8de5c5a..da400857797 100644 --- a/packages/llm/test/recorded-test.ts +++ b/packages/llm/test/recorded-test.ts @@ -1,11 +1,8 @@ -import { NodeFileSystem } from "@effect/platform-node" import { HttpRecorder } from "@opencode-ai/http-recorder" -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import { Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" import * as path from "node:path" import { fileURLToPath } from "node:url" -import { LLMClient, RequestExecutor } from "../src/route" +import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route" import type { Service as LLMClientService } from "../src/route/client" import type { Service as RequestExecutorService } from "../src/route/executor" import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" @@ -14,7 +11,6 @@ import { type RecordedCaseOptions as RunnerCaseOptions, type RecordedGroupOptions, } from "./recorded-runner" -import { webSocketCassetteLayer } from "./recorded-websocket" const __dirname = path.dirname(fileURLToPath(import.meta.url)) const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings") @@ -64,31 +60,27 @@ export const recordedTests = (options: RecordedTestsOptions) => recordedEffectGroup({ duplicateLabel: "recorded cassette", options, - cassetteExists: (cassette) => HttpRecorderInternal.hasCassetteSync(cassette, { directory: FIXTURES_DIR }), + cassetteExists: (cassette) => HttpRecorder.hasCassetteSync(cassette, { directory: FIXTURES_DIR }), layer: ({ cassette, metadata, options, caseOptions, recording }) => { const recorderOptions = mergeOptions(options.options, caseOptions.options) const recorderMetadata = { ...recorderOptions?.metadata, ...metadata, } - const mode = recording ? "record" : "replay" - const cassetteService = HttpRecorderInternal.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe( - Layer.provide(NodeFileSystem.layer), - ) + if (recording) { + if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") + HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR }) + } const requestExecutor = RequestExecutor.layer.pipe( Layer.provide( - HttpRecorderInternal.recordingLayer(cassette, { - mode, + HttpRecorder.layerFetch(cassette, { + ...recorderOptions, + directory: FIXTURES_DIR, metadata: recorderMetadata, - redactor: HttpRecorderInternal.Redactor.make(recorderOptions?.redact), - match: recorderOptions?.match, - }).pipe(Layer.provide(FetchHttpClient.layer)), + }), ), ) - const deps = Layer.mergeAll( - requestExecutor, - webSocketCassetteLayer(cassette, { metadata: recorderMetadata, mode }), - ) - return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps))).pipe(Layer.provide(cassetteService)) + const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer) + return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps))) }, }) diff --git a/packages/llm/test/recorded-websocket.ts b/packages/llm/test/recorded-websocket.ts deleted file mode 100644 index afeee09b77e..00000000000 --- a/packages/llm/test/recorded-websocket.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" -import { Effect, Layer } from "effect" -import { WebSocketExecutor } from "../src/route" -import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" - -const liveWebSocket = WebSocketExecutor.open - -export const webSocketCassetteLayer = ( - cassette: string, - input: { readonly metadata?: Record; readonly mode: HttpRecorderInternal.RecordReplayMode }, -): Layer.Layer => - Layer.effect( - WebSocketExecutor.Service, - Effect.gen(function* () { - const cassetteService = yield* HttpRecorderInternal.Cassette.Service - const executor = yield* HttpRecorderInternal.makeWebSocketExecutor({ - name: cassette, - mode: input.mode, - metadata: input.metadata, - cassette: cassetteService, - live: { open: liveWebSocket }, - compareClientMessagesAsJson: true, - }) - return WebSocketExecutor.Service.of(executor) - }), - ) diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index 7ca09910bf8..36853a6d268 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -2,7 +2,6 @@ import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" import { ModelsDev } from "@opencode-ai/core/models-dev" import { HttpRecorder } from "@opencode-ai/http-recorder" -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import { describe, expect, test } from "bun:test" import { tool, type ModelMessage, type JSONValue } from "ai" import { Effect, Layer, Option, Schema, Stream } from "effect" @@ -224,7 +223,7 @@ function isSelected(scenario: RecordedScenario) { const canRun = (scenario: RecordedScenario) => shouldRecord ? scenario.canRecord() - : HttpRecorderInternal.hasCassetteSync(scenario.cassette, { directory: FIXTURES_DIR }) + : HttpRecorder.hasCassetteSync(scenario.cassette, { directory: FIXTURES_DIR }) const recordError = (scenario: RecordedScenario) => scenario.id === "openai-oauth" @@ -272,14 +271,11 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) { url: (url: string) => url.replace(/\/proxy\/connections\/[^/]+\/v1/, "/proxy/connections/{connection}/v1"), body: redactRecordedBody, } - const recordedHttp = shouldRecord - ? HttpRecorderInternal.cassetteLayer(scenario.cassette, { - directory: FIXTURES_DIR, - mode: "record", - metadata, - redactor: HttpRecorderInternal.Redactor.make(redact), - }) - : HttpRecorder.http(scenario.cassette, { directory: FIXTURES_DIR, metadata, redact }) + if (shouldRecord) { + if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") + HttpRecorder.removeCassetteSync(scenario.cassette, { directory: FIXTURES_DIR }) + } + const recordedHttp = HttpRecorder.layerFetch(scenario.cassette, { directory: FIXTURES_DIR, metadata, redact }) return AppNodeBuilder.build(LayerNode.group([Provider.node, LLM.node]), [ [LayerNodePlatform.requestExecutor, RequestExecutor.layer.pipe(Layer.provide(recordedHttp))], [RuntimeFlags.node, RuntimeFlags.layer({ experimentalNativeLlm: true })], From dd842f9862682ed21f3c0c3f380f46ef8120d481 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 8 Jul 2026 11:45:38 -0400 Subject: [PATCH 08/32] fix(cli): elect one service process --- packages/cli/src/server-process.ts | 30 +++++++- packages/cli/test/service.test.ts | 47 ++++++++++++- packages/core/src/util/effect-flock.ts | 73 +++++++++++++------- packages/core/test/util/effect-flock.test.ts | 23 ++++++ 4 files changed, 145 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 2d5c2f8bfa2..39c1e256d3f 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -7,10 +7,11 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Global } from "@opencode-ai/core/global" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { AppProcess } from "@opencode-ai/core/process" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { start } from "@opencode-ai/server/process" import { randomBytes, randomUUID } from "node:crypto" import path from "node:path" -import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect" +import { Effect, Exit, FileSystem, Logger, Option, Redacted, Schedule, Schema, Scope } from "effect" import { HttpServer } from "effect/unstable/http" import { Env } from "./env" import { ServiceConfig } from "./services/service-config" @@ -27,7 +28,7 @@ export type Options = { export const run = Effect.fn("cli.server-process.run")((options: Options) => processEffect(options).pipe( Effect.provide(Updater.layer), - Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), + Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, EffectFlock.node]))), Effect.provide(NodeServices.layer), ), ) @@ -36,6 +37,16 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home)) return yield* Effect.scoped( Effect.gen(function* () { + const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined + const lockScope = serviceOptions === undefined ? undefined : yield* acquireServiceLock(serviceOptions.file) + if ( + serviceOptions !== undefined && + lockScope !== undefined && + (yield* Service.discover(serviceOptions)) !== undefined + ) { + yield* Scope.close(lockScope, Exit.void) + return + } const environmentPassword = yield* Env.password // Keep the lease credential out of the environment inherited by tools. if (options.mode === "stdio") { @@ -55,7 +66,10 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { port: Option.fromNullishOr(options.port ?? config.port), password, }).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false }))) - if (options.mode === "service") yield* register(address, password) + if (lockScope !== undefined) { + yield* register(address, password) + yield* Scope.close(lockScope, Exit.void) + } const url = HttpServer.formatAddress(address) console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`) if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`) @@ -66,6 +80,16 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { ) }) +const acquireServiceLock = Effect.fnUntraced(function* (file: string) { + const flock = yield* EffectFlock.Service + const scope = yield* Scope.make() + yield* Effect.addFinalizer((exit) => Scope.close(scope, exit)) + yield* flock + .acquire(`service:${file}`, undefined, { staleMs: 3_000, timeoutMs: 3_000 }) + .pipe(Effect.provideService(Scope.Scope, scope)) + return scope +}) + // The latest atomic registration wins. A displaced process notices the new id, // exits, and cannot remove its successor's registration from its finalizer. const infoJson = Schema.fromJsonString(Service.Info) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 68073851c1b..ac1f9ac1cf9 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -1,7 +1,8 @@ import { NodeFileSystem } from "@effect/platform-node" +import { Service } from "@opencode-ai/client/effect" import { Global } from "@opencode-ai/core/global" import { expect, test } from "bun:test" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -24,3 +25,47 @@ test("local channel stores service config with the local service filename", asyn await fs.rm(root, { recursive: true, force: true }) } }) + +test("concurrent service processes elect one server", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-")) + const env = { + ...process.env, + HOME: root, + OPENCODE_DB: path.join(root, "opencode.db"), + OPENCODE_TEST_HOME: root, + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_STATE_HOME: path.join(root, "state"), + } + const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"] + const first = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }) + const second = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }) + + try { + const registration = path.join(root, "state", "opencode", "service-local.json") + const info = await waitForInfo(registration) + const winner = info.pid === first.pid ? first : second + const loser = info.pid === first.pid ? second : first + const exited = await Promise.race([loser.exited.then(() => true), Bun.sleep(10_000).then(() => false)]) + + expect(exited).toBe(true) + expect(winner.exitCode).toBe(null) + } finally { + first.kill("SIGTERM") + second.kill("SIGTERM") + await Promise.all([first.exited, second.exited]) + await fs.rm(root, { recursive: true, force: true }) + } +}) + +async function waitForInfo(file: string) { + for (let attempt = 0; attempt < 200; attempt++) { + const value = await Bun.file(file) + .json() + .catch(() => undefined) + if (value !== undefined) return Schema.decodeUnknownPromise(Service.Info)(value) + await Bun.sleep(50) + } + throw new Error("Timed out waiting for service registration") +} diff --git a/packages/core/src/util/effect-flock.ts b/packages/core/src/util/effect-flock.ts index b85900118a3..4b528d26022 100644 --- a/packages/core/src/util/effect-flock.ts +++ b/packages/core/src/util/effect-flock.ts @@ -36,20 +36,24 @@ export namespace EffectFlock { export type LockError = LockTimeoutError | LockCompromisedError + export interface Options { + readonly staleMs?: number + readonly timeoutMs?: number + } + // --------------------------------------------------------------------------- - // Timing (baked in — no caller ever overrides these) + // Timing defaults // --------------------------------------------------------------------------- - const STALE_MS = 60_000 - const TIMEOUT_MS = 5 * 60_000 + const DEFAULT_STALE_MS = 60_000 + const DEFAULT_TIMEOUT_MS = 5 * 60_000 const BASE_DELAY_MS = 100 const MAX_DELAY_MS = 2_000 - const HEARTBEAT_MS = Math.max(100, Math.floor(STALE_MS / 3)) - const retrySchedule = Schedule.exponential(BASE_DELAY_MS, 1.7).pipe( - Schedule.either(Schedule.spaced(MAX_DELAY_MS)), + const retrySchedule = (timeoutMs: number) => Schedule.exponential(BASE_DELAY_MS, 1.7).pipe( + Schedule.either(Schedule.spaced(Math.min(MAX_DELAY_MS, Math.max(BASE_DELAY_MS, Math.floor(timeoutMs / 10))))), Schedule.jittered, - Schedule.while((meta) => meta.elapsed < TIMEOUT_MS), + Schedule.while((meta) => meta.elapsed < timeoutMs), ) // --------------------------------------------------------------------------- @@ -73,7 +77,7 @@ export namespace EffectFlock { // --------------------------------------------------------------------------- export interface Interface { - readonly acquire: (key: string, dir?: string) => Effect.Effect + readonly acquire: (key: string, dir?: string, options?: Options) => Effect.Effect readonly withLock: { (key: string, dir?: string): (body: Effect.Effect) => Effect.Effect (body: Effect.Effect, key: string, dir?: string): Effect.Effect @@ -135,9 +139,9 @@ export namespace EffectFlock { ), ) - const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath: string) { + const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath: string, staleMs: number) { const bs = yield* safeStat(breakerPath) - if (bs && wall() - mtimeMs(bs) > STALE_MS) yield* forceRemove(breakerPath) + if (bs && wall() - mtimeMs(bs) > staleMs) yield* forceRemove(breakerPath) return false }) @@ -147,26 +151,31 @@ export namespace EffectFlock { ensuredDirs.add(dir) }) - const isStale = Effect.fnUntraced(function* (lockDir: string, heartbeatPath: string, metaPath: string) { + const isStale = Effect.fnUntraced(function* ( + lockDir: string, + heartbeatPath: string, + metaPath: string, + staleMs: number, + ) { const now = wall() const hb = yield* safeStat(heartbeatPath) - if (hb) return now - mtimeMs(hb) > STALE_MS + if (hb) return now - mtimeMs(hb) > staleMs const meta = yield* safeStat(metaPath) - if (meta) return now - mtimeMs(meta) > STALE_MS + if (meta) return now - mtimeMs(meta) > staleMs const dir = yield* safeStat(lockDir) if (!dir) return false - return now - mtimeMs(dir) > STALE_MS + return now - mtimeMs(dir) > staleMs }) // -- single lock attempt -- type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string } - const tryAcquireLockDir = (lockDir: string, key: string) => + const tryAcquireLockDir = (lockDir: string, key: string, staleMs: number) => Effect.gen(function* () { const token = randomUUID() const metaPath = path.join(lockDir, "meta.json") @@ -176,7 +185,7 @@ export namespace EffectFlock { const created = yield* atomicMkdir(lockDir) if (!created) { - if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired() + if (!(yield* isStale(lockDir, heartbeatPath, metaPath, staleMs))) return yield* new NotAcquired() // Stale — race for breaker ownership const breakerPath = lockDir + ".breaker" @@ -185,7 +194,7 @@ export namespace EffectFlock { Effect.as(true), Effect.catchIf( (e) => e.reason._tag === "AlreadyExists", - () => cleanStaleBreaker(breakerPath), + () => cleanStaleBreaker(breakerPath, staleMs), ), Effect.catchIf(isPathGone, () => Effect.succeed(false)), Effect.orDie, @@ -195,7 +204,7 @@ export namespace EffectFlock { // We own the breaker — double-check staleness, nuke, recreate const recreated = yield* Effect.gen(function* () { - if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false + if (!(yield* isStale(lockDir, heartbeatPath, metaPath, staleMs))) return false yield* forceRemove(lockDir) return yield* atomicMkdir(lockDir) }).pipe(Effect.ensuring(forceRemove(breakerPath))) @@ -218,13 +227,21 @@ export namespace EffectFlock { // -- retry wrapper (preserves Handle type) -- - const acquireHandle = (lockfile: string, key: string): Effect.Effect => - tryAcquireLockDir(lockfile, key).pipe( + const acquireHandle = ( + lockfile: string, + key: string, + options: { staleMs: number; timeoutMs: number }, + ): Effect.Effect => + tryAcquireLockDir(lockfile, key, options.staleMs).pipe( Effect.retry({ while: (err) => err._tag === "NotAcquired", - schedule: retrySchedule, + schedule: retrySchedule(options.timeoutMs), }), Effect.catchTag("NotAcquired", () => Effect.fail(new LockTimeoutError({ key }))), + Effect.timeoutOrElse({ + duration: options.timeoutMs, + orElse: () => Effect.fail(new LockTimeoutError({ key })), + }), ) // -- release -- @@ -250,19 +267,27 @@ export namespace EffectFlock { // -- build service -- - const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string) { + const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string, options: Options = {}) { const lockDir = dir ?? lockRoot + const staleMs = options.staleMs ?? DEFAULT_STALE_MS + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS yield* ensureDir(lockDir) const lockfile = path.join(lockDir, Hash.fast(key) + ".lock") // acquireRelease: acquire is uninterruptible, release is guaranteed - const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key), (handle) => release(handle)) + const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key, { staleMs, timeoutMs }), (handle) => + release(handle), + ) // Heartbeat fiber — scoped, so it's interrupted before release runs yield* fs .utimes(handle.heartbeatPath, new Date(), new Date()) - .pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), Effect.forkScoped) + .pipe( + Effect.ignore, + Effect.repeat(Schedule.spaced(Math.max(100, Math.floor(staleMs / 3)))), + Effect.forkScoped, + ) }) const withLock: Interface["withLock"] = Function.dual( diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts index a0f737a998d..ca2820737d8 100644 --- a/packages/core/test/util/effect-flock.test.ts +++ b/packages/core/test/util/effect-flock.test.ts @@ -134,6 +134,29 @@ describe("util.effect-flock", () => { }), ) + it.live( + "supports an acquisition timeout", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:timeout" + + yield* Effect.scoped( + Effect.gen(function* () { + yield* flock.acquire(key, dir) + const started = performance.now() + const error = yield* Effect.scoped( + flock.acquire(key, dir, { staleMs: 10_000, timeoutMs: 300 }), + ).pipe(Effect.flip) + expect(error._tag).toBe("LockTimeoutError") + expect(performance.now() - started).toBeLessThan(1_000) + }), + ) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + it.live( "withLock data-first", Effect.gen(function* () { From b3cf749a7f4e0c8e242321b79f0f9dd5e21f1fe0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 8 Jul 2026 11:45:41 -0400 Subject: [PATCH 09/32] fix(tui): show resolved skill name --- packages/tui/src/routes/session/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index e37fae92494..1dc2baa2ba1 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2661,9 +2661,10 @@ function Question(props: ToolProps) { } function Skill(props: ToolProps) { + const name = createMemo(() => stringValue(props.metadata.name) ?? stringValue(props.input.id)) return ( - - Skill "{stringValue(props.input.name)}" + + Skill "{name()}" ) } From e1d72fa33842af771dc1287fd5224f418e398b0f Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 8 Jul 2026 11:48:53 -0400 Subject: [PATCH 10/32] fix(simulation): stabilize screenshot artifacts (#35933) --- bun.lock | 3 +++ packages/simulation/package.json | 1 + packages/simulation/src/frontend/actions.ts | 22 ++++++++++++++++----- packages/simulation/src/frontend/png.ts | 17 +++++++++++----- packages/simulation/src/frontend/server.ts | 2 +- packages/simulation/src/protocol/index.ts | 6 +++--- 6 files changed, 37 insertions(+), 14 deletions(-) diff --git a/bun.lock b/bun.lock index bd1981f6e5d..df33000ba53 100644 --- a/bun.lock +++ b/bun.lock @@ -873,6 +873,7 @@ "name": "@opencode-ai/simulation", "version": "1.17.13", "dependencies": { + "@fontsource/adwaita-mono": "5.2.1", "@napi-rs/canvas": "1.0.2", "@opencode-ai/core": "workspace:*", "@opencode-ai/llm": "workspace:*", @@ -1724,6 +1725,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@fontsource/adwaita-mono": ["@fontsource/adwaita-mono@5.2.1", "", {}, "sha512-6+Q1UIvklJ9REijs6kv7YlRNt6yktRj0iW8H69YIugdD9P2h3eIX1AB8/9ICMfpVyVeywlsrCXg82y/LfRrjyg=="], + "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.5", "", {}, "sha512-G09N3GfuT9qj3Ax2FDZvKqZttzM3v+cco2l8uXamhKyXLdmlaUDH5o88/C3vtTHj2oT7yRKsvxz9F+BXbWKMYA=="], "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], diff --git a/packages/simulation/package.json b/packages/simulation/package.json index f9be3bfc33f..624cf32ef7e 100644 --- a/packages/simulation/package.json +++ b/packages/simulation/package.json @@ -17,6 +17,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { + "@fontsource/adwaita-mono": "5.2.1", "@napi-rs/canvas": "1.0.2", "@opencode-ai/core": "workspace:*", "@opencode-ai/llm": "workspace:*", diff --git a/packages/simulation/src/frontend/actions.ts b/packages/simulation/src/frontend/actions.ts index 068949459b7..5819d940357 100644 --- a/packages/simulation/src/frontend/actions.ts +++ b/packages/simulation/src/frontend/actions.ts @@ -1,6 +1,6 @@ -import { mkdir, mkdtemp } from "node:fs/promises" +import { mkdir } from "node:fs/promises" import { tmpdir } from "node:os" -import { dirname, join } from "node:path" +import { extname, join, resolve } from "node:path" import type { CliRenderer, Renderable } from "@opentui/core" import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing" import type { SimulationProtocol } from "../protocol" @@ -104,11 +104,23 @@ export function state(harness: Harness) { } } -export async function screenshot(harness: Harness, output?: string) { +export async function screenshot(harness: Harness, name?: string) { await harness.renderOnce() const image = SimulationPng.screenshot(harness.renderer) - const path = output ?? join(await mkdtemp(join(tmpdir(), "opencode-drive-")), "screenshot.png") - if (output) await mkdir(dirname(output), { recursive: true }) + const filename = name ?? `screenshot-${crypto.randomUUID()}` + if ( + !filename || + filename.includes("/") || + filename.includes("\\") || + extname(filename) + ) + throw new Error("screenshot name must not contain a path or extension") + const directory = resolve( + process.env.OPENCODE_DRIVE_MEDIA_DIR ?? + join(tmpdir(), "opencode-drive", "output"), + ) + await mkdir(directory, { recursive: true }) + const path = join(directory, `${filename}.png`) await Bun.write(path, image.data) return path } diff --git a/packages/simulation/src/frontend/png.ts b/packages/simulation/src/frontend/png.ts index 137a09e1be4..4f7b11c1a04 100644 --- a/packages/simulation/src/frontend/png.ts +++ b/packages/simulation/src/frontend/png.ts @@ -5,12 +5,19 @@ import { TextAttributes, type CapturedFrame, type CliRenderer, type RGBA } from const CellWidth = 10 const CellHeight = 20 const FontSize = 16 -const FontFamily = "OpenCode Screenshot" +const FontFamily = "OpenCode Mono" -GlobalFonts.registerFromPath( - fileURLToPath(new URL("../../../ui/src/assets/fonts/JetBrainsMonoNerdFontMono-Regular.woff2", import.meta.url)), - FontFamily, -) +for (const file of [ + "adwaita-mono-latin-400-normal.woff2", + "adwaita-mono-latin-700-normal.woff2", + "adwaita-mono-latin-400-italic.woff2", + "adwaita-mono-latin-700-italic.woff2", +]) { + GlobalFonts.registerFromPath( + fileURLToPath(import.meta.resolve(`@fontsource/adwaita-mono/files/${file}`)), + FontFamily, + ) +} export function screenshot(renderer: CliRenderer) { return screenshotFrame({ diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index e3149c39f83..b878bdd761f 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -17,7 +17,7 @@ async function handle( ) { switch (request.method) { case "ui.screenshot": - return SimulationActions.screenshot(harness, request.params?.path) + return SimulationActions.screenshot(harness, request.params?.name) case "ui.state": { return SimulationActions.state(harness) } diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index eda31c30bfd..a2b9fa3c29b 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -97,8 +97,8 @@ export namespace Frontend { export const RecordingFinish = Schema.String export type RecordingFinish = Schema.Schema.Type - export const ArtifactParams = Schema.Struct({ path: Schema.optional(Schema.String) }) - export interface ArtifactParams extends Schema.Schema.Type {} + export const ScreenshotParams = Schema.Struct({ name: Schema.optional(Schema.String) }) + export interface ScreenshotParams extends Schema.Schema.Type {} export const TypeParams = Schema.Struct({ text: Schema.String }) export interface TypeParams extends Schema.Schema.Type {} @@ -124,7 +124,7 @@ export namespace Frontend { Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.screenshot"), - params: Schema.optional(ArtifactParams), + params: Schema.optional(ScreenshotParams), }), Schema.Struct({ ...JsonRpc.RequestFields, From 09553d46e0c081baac81033926d80084f2ea752a Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 8 Jul 2026 12:22:04 -0400 Subject: [PATCH 11/32] refactor(simulation): remove filesystem layers (#35938) --- packages/simulation/src/backend/filesystem.ts | 390 ------------------ packages/simulation/src/backend/fs-util.ts | 215 ---------- packages/simulation/src/backend/index.ts | 9 +- 3 files changed, 1 insertion(+), 613 deletions(-) delete mode 100644 packages/simulation/src/backend/filesystem.ts delete mode 100644 packages/simulation/src/backend/fs-util.ts diff --git a/packages/simulation/src/backend/filesystem.ts b/packages/simulation/src/backend/filesystem.ts deleted file mode 100644 index 8405198b876..00000000000 --- a/packages/simulation/src/backend/filesystem.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { Effect, FileSystem, Layer, Option, Stream } from "effect" -import { systemError, type PlatformError, type SystemErrorTag } from "effect/PlatformError" -import nodeFs from "fs" -import path from "path" - -/** - * In-memory simulated `FileSystem.FileSystem`. - * - * Replaces the `NodeFileSystem` platform node when the server runs in - * simulation mode. Backed by a flat map of absolute paths to entries and - * rooted at a single directory (the simulation anchor): paths that resolve - * outside the root fail with `PermissionDenied` so host filesystem escapes - * are loud. Only the operations the app actually uses are implemented; - * everything else dies with a clear defect. - * - * Inspired by the V1 prototype on `jlongster/simulation-rebase`, rewritten - * for the V2 platform node shape without the `just-bash` dependency. - */ - -export interface Options { - readonly root: string - readonly files?: Record -} - -interface FileEntry { - readonly type: "File" - content: Uint8Array - mode: number - mtime: Date -} - -interface DirectoryEntry { - readonly type: "Directory" - mode: number - mtime: Date -} - -type Entry = FileEntry | DirectoryEntry - -export function make(options: Options): FileSystem.FileSystem { - const root = path.resolve(options.root) - const store = new Map() - const temp = { value: 0 } - const encoder = new TextEncoder() - store.set(root, makeDirectoryEntry()) - - const within = (resolved: string) => resolved === root || resolved.startsWith(withSep(root)) - - const childrenOf = (resolved: string) => [...store.keys()].filter((key) => key.startsWith(withSep(resolved))) - - const fail = ( - tag: SystemErrorTag, - method: string, - file: string, - description?: string, - ): Effect.Effect => - Effect.fail( - systemError({ _tag: tag, module: "SimulationFileSystem", method, description, pathOrDescriptor: file }), - ) - - const locate = (method: string, file: string): Effect.Effect => { - const resolved = path.resolve(root, file) - if (within(resolved)) return Effect.succeed(resolved) - return fail("PermissionDenied", method, file, "path escapes the simulated filesystem root") - } - - const requireEntry = (method: string, file: string): Effect.Effect => - locate(method, file).pipe( - Effect.flatMap((resolved) => { - const entry = store.get(resolved) - if (!entry) return fail("NotFound", method, file) - return Effect.succeed([resolved, entry] as const) - }), - ) - - const requireParentDirectory = ( - method: string, - resolved: string, - file: string, - ): Effect.Effect => { - const parent = store.get(path.dirname(resolved)) - if (parent?.type === "Directory") return Effect.void - return fail("NotFound", method, file, "parent directory does not exist") - } - - // Creates every missing directory between root and resolved (inclusive). - const ensureDirectories = (method: string, file: string, resolved: string): Effect.Effect => - Effect.suspend(() => { - const segments = path.relative(root, resolved).split(path.sep).filter(Boolean) - const conflict = segments.reduce>((current, segment) => { - if (typeof current !== "string") return current - const next = path.join(current, segment) - const entry = store.get(next) - if (entry && entry.type !== "Directory") - return fail("AlreadyExists", method, file, "path component is not a directory") - if (!entry) store.set(next, makeDirectoryEntry()) - return next - }, root) - return typeof conflict === "string" ? Effect.void : conflict - }) - - // Seed initial files, creating parents as needed. Entries outside the root are ignored. - for (const [file, content] of Object.entries(options.files ?? {})) { - const resolved = path.resolve(root, file) - if (!within(resolved)) continue - Effect.runSync(ensureDirectories("seed", file, path.dirname(resolved))) - store.set(resolved, { - type: "File", - content: typeof content === "string" ? encoder.encode(content) : content.slice(), - mode: 0o644, - mtime: new Date(), - }) - } - - // Probe operations report NotFound outside the root instead of - // PermissionDenied: walk-up loops (project discovery, findUp, globUp) - // legitimately probe ancestor directories of the anchor and must observe - // "nothing there". Content access and mutation outside the root stay loud. - const probe = (method: string, file: string): Effect.Effect => - Effect.suspend(() => { - const resolved = path.resolve(root, file) - const entry = within(resolved) ? store.get(resolved) : undefined - if (!entry) return fail("NotFound", method, file) - return Effect.succeed(entry) - }) - - const stat: FileSystem.FileSystem["stat"] = (file) => probe("stat", file).pipe(Effect.map(toInfo)) - - const access: FileSystem.FileSystem["access"] = (file) => probe("access", file).pipe(Effect.asVoid) - - const chmod: FileSystem.FileSystem["chmod"] = (file, mode) => - requireEntry("chmod", file).pipe( - Effect.map(([, entry]) => { - entry.mode = mode - }), - ) - - const realPath: FileSystem.FileSystem["realPath"] = (file) => - requireEntry("realPath", file).pipe(Effect.map(([resolved]) => resolved)) - - const readFile: FileSystem.FileSystem["readFile"] = (file) => - requireEntry("readFile", file).pipe( - Effect.flatMap(([, entry]) => { - if (entry.type !== "File") return fail("BadResource", "readFile", file, "path is a directory") - return Effect.succeed(entry.content.slice()) - }), - ) - - const writeFile: FileSystem.FileSystem["writeFile"] = (file, data, writeOptions) => - locate("writeFile", file).pipe( - Effect.flatMap((resolved) => { - const existing = store.get(resolved) - if (existing?.type === "Directory") return fail("BadResource", "writeFile", file, "path is a directory") - return requireParentDirectory("writeFile", resolved, file).pipe( - Effect.map(() => { - store.set(resolved, { - type: "File", - content: data.slice(), - mode: writeOptions?.mode ?? existing?.mode ?? 0o644, - mtime: new Date(), - }) - }), - ) - }), - ) - - const makeDirectory: FileSystem.FileSystem["makeDirectory"] = (file, dirOptions) => - locate("makeDirectory", file).pipe( - Effect.flatMap((resolved) => { - if (dirOptions?.recursive) return ensureDirectories("makeDirectory", file, resolved) - if (store.has(resolved)) return fail("AlreadyExists", "makeDirectory", file) - return requireParentDirectory("makeDirectory", resolved, file).pipe( - Effect.map(() => { - store.set(resolved, { type: "Directory", mode: dirOptions?.mode ?? 0o755, mtime: new Date() }) - }), - ) - }), - ) - - const readDirectory: FileSystem.FileSystem["readDirectory"] = (file, readOptions) => - requireEntry("readDirectory", file).pipe( - Effect.flatMap(([resolved, entry]) => { - if (entry.type !== "Directory") return fail("BadResource", "readDirectory", file, "path is not a directory") - const children = childrenOf(resolved) - const names = readOptions?.recursive - ? children.map((key) => path.relative(resolved, key)) - : children.filter((key) => path.dirname(key) === resolved).map((key) => path.basename(key)) - return Effect.succeed(names.sort((a, b) => a.localeCompare(b))) - }), - ) - - const remove: FileSystem.FileSystem["remove"] = (file, removeOptions) => - locate("remove", file).pipe( - Effect.flatMap((resolved) => { - const entry = store.get(resolved) - if (!entry) return removeOptions?.force ? Effect.void : fail("NotFound", "remove", file) - const children = childrenOf(resolved) - if (entry.type === "Directory" && children.length > 0 && !removeOptions?.recursive) - return fail("Unknown", "remove", file, "directory is not empty") - for (const key of children) store.delete(key) - store.delete(resolved) - // The root itself must always exist. - if (resolved === root) store.set(root, makeDirectoryEntry()) - return Effect.void - }), - ) - - const rename: FileSystem.FileSystem["rename"] = (oldPath, newPath) => - Effect.all([locate("rename", oldPath), locate("rename", newPath)]).pipe( - Effect.flatMap(([from, to]) => { - const entry = store.get(from) - if (!entry) return fail("NotFound", "rename", oldPath) - return requireParentDirectory("rename", to, newPath).pipe( - Effect.map(() => { - const moved = [from, ...childrenOf(from)].map((key) => [key, store.get(key)!] as const) - for (const [key] of moved) store.delete(key) - for (const key of [to, ...childrenOf(to)]) store.delete(key) - for (const [key, value] of moved) store.set(key === from ? to : to + key.slice(from.length), value) - }), - ) - }), - ) - - const copy: FileSystem.FileSystem["copy"] = (fromPath, toPath) => - Effect.all([locate("copy", fromPath), locate("copy", toPath)]).pipe( - Effect.flatMap(([from, to]) => { - const entry = store.get(from) - if (!entry) return fail("NotFound", "copy", fromPath) - return requireParentDirectory("copy", to, toPath).pipe( - Effect.map(() => { - for (const key of [from, ...childrenOf(from)]) { - const source = store.get(key)! - const target = key === from ? to : to + key.slice(from.length) - store.set( - target, - source.type === "File" - ? { ...source, content: source.content.slice(), mtime: new Date() } - : { ...source, mtime: new Date() }, - ) - } - }), - ) - }), - ) - - const copyFile: FileSystem.FileSystem["copyFile"] = (fromPath, toPath) => - readFile(fromPath).pipe(Effect.flatMap((content) => writeFile(toPath, content))) - - const makeTempDirectory: FileSystem.FileSystem["makeTempDirectory"] = (tempOptions) => - Effect.suspend(() => { - const directory = tempOptions?.directory ?? path.join(root, ".simulation-tmp") - const file = path.join(directory, `${tempOptions?.prefix ?? "tmp-"}${++temp.value}`) - return makeDirectory(file, { recursive: true }).pipe(Effect.map(() => file)) - }) - - const makeTempDirectoryScoped: FileSystem.FileSystem["makeTempDirectoryScoped"] = (tempOptions) => - Effect.acquireRelease(makeTempDirectory(tempOptions), (directory) => - remove(directory, { recursive: true, force: true }).pipe(Effect.ignore), - ) - - // Read-only file handle: enough for the read tool's stat/seek/readAlloc use. - const open: FileSystem.FileSystem["open"] = (file) => - requireEntry("open", file).pipe( - Effect.map(([resolved]) => { - const position = { value: 0 } - const contentOf = () => { - const current = store.get(resolved) - return current?.type === "File" ? current.content : new Uint8Array() - } - return { - [FileSystem.FileTypeId]: FileSystem.FileTypeId, - fd: FileSystem.FileDescriptor(0), - stat: Effect.suspend(() => stat(resolved)), - seek: (offset, from) => - Effect.sync(() => { - position.value = from === "start" ? Number(offset) : position.value + Number(offset) - }), - sync: Effect.void, - read: (buffer) => - Effect.sync(() => { - const chunk = contentOf().subarray(position.value, position.value + buffer.length) - buffer.set(chunk) - position.value += chunk.length - return FileSystem.Size(chunk.length) - }), - readAlloc: (size) => - Effect.sync(() => { - const chunk = contentOf().slice(position.value, position.value + Number(size)) - position.value += chunk.length - return chunk.length === 0 ? Option.none() : Option.some(chunk) - }), - truncate: () => unimplemented("File.truncate"), - write: () => unimplemented("File.write"), - writeAll: () => unimplemented("File.writeAll"), - } satisfies FileSystem.File - }), - ) - - return FileSystem.make({ - access, - chmod, - chown: () => unimplemented("chown"), - copy, - copyFile, - link: () => unimplemented("link"), - makeDirectory, - makeTempDirectory, - makeTempDirectoryScoped, - makeTempFile: () => unimplemented("makeTempFile"), - makeTempFileScoped: () => unimplemented("makeTempFileScoped"), - open, - readDirectory, - readFile, - readLink: () => unimplemented("readLink"), - realPath, - remove, - rename, - stat, - symlink: () => unimplemented("symlink"), - truncate: () => unimplemented("truncate"), - utimes: () => unimplemented("utimes"), - watch: () => Stream.die(new Error("SimulationFileSystem.watch is not implemented in simulation")), - writeFile, - }) -} - -/** - * Lazily constructed layer so the root defaults to `process.cwd()` at - * layer-build time (the simulation anchor directory), not at import time. - * - * When `OPENCODE_SIMULATE_STATE` points at a snapshot directory, its - * `files/` contents are read from the host once at build time and seeded - * into the in-memory tree, joined onto the anchor root. - */ -export const layer = (options?: Partial) => - Layer.sync(FileSystem.FileSystem)(() => - make({ - root: options?.root ?? process.cwd(), - files: { ...loadSnapshotFiles(process.env.OPENCODE_SIMULATE_STATE), ...options?.files }, - }), - ) - -function loadSnapshotFiles(stateDirectory: string | undefined) { - if (!stateDirectory) return {} - const snapshot = path.join(stateDirectory, "files") - if (!nodeFs.existsSync(snapshot)) return {} - const files: Record = {} - const walk = (dir: string) => { - for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) { - const file = path.join(dir, entry.name) - if (entry.isDirectory()) walk(file) - if (entry.isFile()) files[path.relative(snapshot, file)] = new Uint8Array(nodeFs.readFileSync(file)) - } - } - walk(snapshot) - return files -} - -function makeDirectoryEntry(): Entry { - return { type: "Directory", mode: 0o755, mtime: new Date() } -} - -function withSep(dir: string) { - return dir.endsWith(path.sep) ? dir : dir + path.sep -} - -function toInfo(entry: Entry): FileSystem.File.Info { - return { - type: entry.type, - mtime: Option.some(entry.mtime), - atime: Option.some(entry.mtime), - birthtime: Option.some(entry.mtime), - dev: 0, - ino: Option.none(), - mode: entry.mode, - nlink: Option.none(), - uid: Option.none(), - gid: Option.none(), - rdev: Option.none(), - size: FileSystem.Size(entry.type === "File" ? entry.content.length : 0), - blksize: Option.none(), - blocks: Option.none(), - } -} - -function unimplemented(method: string) { - return Effect.die(new Error(`SimulationFileSystem.${method} is not implemented in simulation`)) -} - -export * as SimulationFileSystem from "./filesystem" diff --git a/packages/simulation/src/backend/fs-util.ts b/packages/simulation/src/backend/fs-util.ts deleted file mode 100644 index 4ee267fd649..00000000000 --- a/packages/simulation/src/backend/fs-util.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { Effect, FileSystem, Layer } from "effect" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Glob } from "@opencode-ai/core/util/glob" -import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" -import { filesystem } from "@opencode-ai/core/effect/app-node-platform" -import path from "path" - -/** - * Simulation replacement for `FSUtil`. - * - * This implementation is intentionally self-contained and only uses the - * injected simulated `FileSystem.FileSystem`. The default FSUtil layer has a - * few helpers that reach host-node APIs directly; depending on it here makes it - * easy for mutation paths to escape or miss the in-memory project tree. - */ - -const layer = Layer.effect( - FSUtil.Service, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - - const existsSafe = Effect.fn("SimulationFSUtil.existsSafe")(function* (file: string) { - return yield* fs.exists(file).pipe(Effect.orElseSucceed(() => false)) - }) - - const isDir = Effect.fn("SimulationFSUtil.isDir")(function* (file: string) { - const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined))) - return info?.type === "Directory" - }) - - const isFile = Effect.fn("SimulationFSUtil.isFile")(function* (file: string) { - const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined))) - return info?.type === "File" - }) - - const realPath = Effect.fn("SimulationFSUtil.realPath")(function* (file: string) { - return yield* fs.realPath(file) - }) - - const stat = Effect.fn("SimulationFSUtil.stat")(function* (file: string) { - return yield* fs.stat(file) - }) - - const readFile = Effect.fn("SimulationFSUtil.readFile")(function* (file: string) { - return yield* fs.readFile(file) - }) - - const readFileString = Effect.fn("SimulationFSUtil.readFileString")(function* (file: string) { - return yield* fs.readFileString(file) - }) - - const readFileStringSafe = Effect.fn("SimulationFSUtil.readFileStringSafe")(function* (file: string) { - return yield* fs - .readFileString(file) - .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) - }) - - const readJson = Effect.fn("SimulationFSUtil.readJson")(function* (file: string) { - const text = yield* readFileString(file) - return JSON.parse(text) as unknown - }) - - const writeFile = Effect.fn("SimulationFSUtil.writeFile")(function* ( - file: string, - data: Uint8Array, - options?: Parameters[2], - ) { - return yield* fs.writeFile(file, data, options) - }) - - const writeFileString = Effect.fn("SimulationFSUtil.writeFileString")(function* ( - file: string, - data: string, - options?: Parameters[2], - ) { - return yield* fs.writeFileString(file, data, options) - }) - - const makeDirectory: FileSystem.FileSystem["makeDirectory"] = (file, options) => fs.makeDirectory(file, options) - - const ensureDir = Effect.fn("SimulationFSUtil.ensureDir")(function* (file: string) { - yield* fs.makeDirectory(file, { recursive: true }) - }) - - const writeWithDirs = Effect.fn("SimulationFSUtil.writeWithDirs")(function* ( - file: string, - content: string | Uint8Array, - mode?: number, - ) { - const write = - typeof content === "string" - ? fs.writeFileString(file, content) - : fs.writeFile(file, content) - yield* write.pipe( - Effect.catchReason("PlatformError", "NotFound", () => - fs.makeDirectory(path.dirname(file), { recursive: true }).pipe(Effect.andThen(write)), - ), - ) - if (mode !== undefined) yield* fs.chmod(file, mode) - }) - - const writeJson = Effect.fn("SimulationFSUtil.writeJson")(function* (file: string, data: unknown, mode?: number) { - yield* writeFileString(file, JSON.stringify(data, null, 2)) - if (mode !== undefined) yield* fs.chmod(file, mode) - }) - - const readDirectoryEntries = Effect.fn("SimulationFSUtil.readDirectoryEntries")(function* (dirPath: string) { - const names = yield* fs.readDirectory(dirPath) - return yield* Effect.forEach(names, (name) => - fs.stat(path.join(dirPath, name)).pipe( - Effect.map( - (info): FSUtil.DirEntry => ({ - name, - type: - info.type === "Directory" - ? "directory" - : info.type === "File" - ? "file" - : info.type === "SymbolicLink" - ? "symlink" - : "other", - }), - ), - Effect.orElseSucceed((): FSUtil.DirEntry => ({ name, type: "other" })), - ), - ) - }) - - const resolve = Effect.fn("SimulationFSUtil.resolve")(function* (input: string) { - return path.resolve(input) - }) - - const glob = Effect.fn("SimulationFSUtil.glob")(function* (pattern: string, options?: Glob.Options) { - const cwd = path.resolve(options?.cwd ?? process.cwd()) - const entries = yield* fs - .readDirectory(cwd, { recursive: true }) - .pipe(Effect.orElseSucceed(() => [] as string[])) - const matches = yield* Effect.forEach(entries, (entry) => - fs.stat(path.join(cwd, entry)).pipe( - Effect.map((info) => ({ entry, type: info.type })), - Effect.orElseSucceed(() => undefined), - ), - ) - return matches - .filter((item) => item !== undefined) - .filter((item) => options?.include === "all" || item.type === "File") - .filter((item) => Glob.match(pattern, item.entry)) - .map((item) => (options?.absolute ? path.join(cwd, item.entry) : item.entry)) - .sort((a, b) => a.localeCompare(b)) - }) - - const globUp = Effect.fn("SimulationFSUtil.globUp")(function* (pattern: string, start: string, stop?: string) { - const result: string[] = [] - let current = path.resolve(start) - while (true) { - result.push(...(yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }))) - if (stop === current) break - const parent = path.dirname(current) - if (parent === current) break - current = parent - } - return result - }) - - const up = Effect.fn("SimulationFSUtil.up")(function* (options: { targets: string[]; start: string; stop?: string }) { - const result: string[] = [] - let current = path.resolve(options.start) - while (true) { - for (const target of options.targets) { - const search = path.join(current, target) - if (yield* fs.exists(search)) result.push(search) - } - if (options.stop === current) break - const parent = path.dirname(current) - if (parent === current) break - current = parent - } - return result - }) - - const findUp = Effect.fn("SimulationFSUtil.findUp")(function* (target: string, start: string, stop?: string) { - return yield* up({ targets: [target], start, stop }) - }) - - return FSUtil.Service.of({ - ...fs, - realPath, - stat, - readFile, - readFileString, - writeFile, - writeFileString, - makeDirectory, - isDir, - isFile, - existsSafe, - readFileStringSafe, - readJson, - writeJson, - ensureDir, - writeWithDirs, - readDirectoryEntries, - resolve, - findUp, - up, - globUp, - glob, - globMatch: Glob.match, - }) - }), -) - -export const node = makeGlobalNode({ service: FSUtil.Service, layer, deps: [filesystem] }) - -export * as SimulationFSUtil from "./fs-util" diff --git a/packages/simulation/src/backend/index.ts b/packages/simulation/src/backend/index.ts index 8610867a7a3..2b8bb876e69 100644 --- a/packages/simulation/src/backend/index.ts +++ b/packages/simulation/src/backend/index.ts @@ -1,10 +1,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { filesystem, httpClient } from "@opencode-ai/core/effect/app-node-platform" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { DriveManifest } from "../manifest" import { SimulationControl } from "./control" -import { SimulationFileSystem } from "./filesystem" -import { SimulationFSUtil } from "./fs-util" import { SimulationNetwork } from "./network" import { SimulationOpenAI } from "./openai" @@ -14,8 +11,6 @@ import { SimulationOpenAI } from "./openai" * The server merges these into the app node build when `OPENCODE_SIMULATE` * is enabled, via a dynamic import so this module is never loaded eagerly. * - * - Filesystem: in-memory tree rooted at the current working directory. - * Everything under the root lives in memory; paths outside it fail loudly. * - Network: all outbound HTTP resolves against the simulated route table; * unknown destinations are denied. The driver-answered OpenAI endpoint is * registered here as the first route. @@ -32,8 +27,6 @@ export function startDriveServer() { } export const simulationReplacements: LayerNode.Replacements = [ - [filesystem, SimulationFileSystem.layer()], - [FSUtil.node, SimulationFSUtil.node], [httpClient, SimulationNetwork.layer], ] From 5b3997294757758c8d0e4fc13196a4c3211286fa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 8 Jul 2026 15:12:45 -0400 Subject: [PATCH 12/32] fix(core): await initial plugin readiness (#35755) --- packages/core/src/location-services.ts | 52 +--- packages/core/src/plugin/supervisor.ts | 116 +++++++- packages/core/src/session/error.ts | 10 + packages/core/src/session/runner/index.ts | 3 +- packages/core/src/session/runner/llm.ts | 25 +- packages/core/src/session/to-session-error.ts | 3 +- packages/core/test/config/plugin.test.ts | 2 +- packages/core/test/location-layer.test.ts | 270 +++++++++++++++++- .../core/test/session-runner-recorded.test.ts | 10 + packages/core/test/session-runner.test.ts | 71 +++++ packages/core/test/tool-subagent.test.ts | 2 + packages/opencode/src/agent/agent.ts | 2 +- packages/server/src/handlers/model.ts | 2 +- 13 files changed, 484 insertions(+), 84 deletions(-) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 661e9795eb6..7e52aea1400 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -5,29 +5,22 @@ import { Catalog } from "./catalog" import { CommandV2 } from "./command" import { Config } from "./config" import { LayerNode } from "./effect/layer-node" -import { makeLocationNode, Node } from "./effect/app-node" -import { httpClient } from "./effect/app-node-platform" +import { Node } from "./effect/app-node" import { EventV2 } from "./event" import { FileMutation } from "./file-mutation" import { FileSystem } from "./filesystem" import { FileSystemSearch } from "./filesystem/search" -import { FSUtil } from "./fs-util" import { Generate } from "./generate" import { Form } from "./form" -import { Global } from "./global" -import { LocationWatcher } from "./filesystem/location-watcher" import { Image } from "./image" +import { LocationWatcher } from "./filesystem/location-watcher" import { Integration } from "./integration" import { Location } from "./location" import { LocationMutation } from "./location-mutation" import { LocationServiceMap } from "./location-service-map" import { MCP } from "./mcp/index" -import { ModelsDev } from "./models-dev" -import { Npm } from "./npm" import { PermissionV2 } from "./permission" import { PluginV2 } from "./plugin" -import { PluginRuntime } from "./plugin/runtime" -import { SdkPlugins } from "./plugin/sdk" import { PluginSupervisor } from "./plugin/supervisor" import { ProjectCopy } from "./project/copy" import { Pty } from "./pty" @@ -35,7 +28,6 @@ import { QuestionV2 } from "./question" import { Shell } from "./shell" import { Reference } from "./reference" import { ReferenceGuidance } from "./reference/guidance" -import { Ripgrep } from "./ripgrep" import { SessionRunnerLLM } from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" import { SessionCompaction } from "./session/compaction" @@ -51,49 +43,11 @@ import { SessionInstructions } from "./session/instructions" import { McpTool } from "./tool/mcp" import { ReadToolFileSystem } from "./tool/read-filesystem" import { ToolRegistry } from "./tool/registry" -import { WebSearchTool } from "./tool/websearch" import { ToolOutputStore } from "./tool-output-store" import { Vcs } from "./vcs" export { LocationServiceMap } from "./location-service-map" -const pluginSupervisorNode = makeLocationNode({ - service: PluginSupervisor.Service, - layer: PluginSupervisor.layer, - deps: [ - PluginV2.node, - SdkPlugins.node, - AgentV2.node, - Catalog.node, - CommandV2.node, - Config.node, - EventV2.node, - FileMutation.node, - FileSystem.node, - FSUtil.node, - Global.node, - httpClient, - Image.node, - Integration.node, - Location.node, - LocationMutation.node, - ModelsDev.node, - Npm.node, - PermissionV2.node, - PluginRuntime.node, - Form.node, - ReadToolFileSystem.node, - Reference.node, - Ripgrep.node, - SessionInstructions.node, - SessionTodo.node, - Shell.node, - SkillV2.node, - ToolRegistry.toolsNode, - WebSearchTool.configNode, - ], -}) - const locationServiceNodes = [ Location.node, Config.node, @@ -104,7 +58,7 @@ const locationServiceNodes = [ Catalog.node, AISDK.node, PluginV2.node, - pluginSupervisorNode, + PluginSupervisor.node, ProjectCopy.node, ProjectCopy.refreshNode, FileSystemSearch.node, diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 6bd4f6bfac4..5b29d643ab6 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -2,18 +2,42 @@ export * as PluginSupervisor from "./supervisor" import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Event } from "@opencode-ai/schema/config" -import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect" +import { Context, Deferred, Effect, Layer, Option, Schema, Semaphore, Stream } from "effect" import path from "path" import { fileURLToPath, pathToFileURL } from "url" +import { AgentV2 } from "../agent" +import { Catalog } from "../catalog" +import { CommandV2 } from "../command" import { Config } from "../config" import { ConfigPlugin } from "../config/plugin" +import { makeLocationNode } from "../effect/app-node" +import { httpClient } from "../effect/app-node-platform" import { EventV2 } from "../event" +import { FileMutation } from "../file-mutation" +import { FileSystem } from "../filesystem" +import { Form } from "../form" import { FSUtil } from "../fs-util" +import { Global } from "../global" +import { Image } from "../image" +import { Integration } from "../integration" import { Location } from "../location" +import { LocationMutation } from "../location-mutation" +import { ModelsDev } from "../models-dev" import { Npm } from "../npm" +import { PermissionV2 } from "../permission" import { PluginV2 } from "../plugin" import { PluginPromise } from "../plugin/promise" +import { Reference } from "../reference" +import { Ripgrep } from "../ripgrep" +import { SessionInstructions } from "../session/instructions" +import { SessionTodo } from "../session/todo" +import { Shell } from "../shell" +import { SkillV2 } from "../skill" +import { ReadToolFileSystem } from "../tool/read-filesystem" +import { ToolRegistry } from "../tool/registry" +import { WebSearchTool } from "../tool/websearch" import { PluginInternal } from "./internal" +import { PluginRuntime } from "./runtime" import { SdkPlugins } from "./sdk" const PluginModule = Schema.Struct({ @@ -246,7 +270,8 @@ const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interfa }) export interface Interface { - readonly ready: Effect.Effect + /** Wait for the initial plugin generation and startup updates to settle. */ + readonly flush: Effect.Effect } export class Service extends Context.Service()("@opencode/PluginSupervisor") {} @@ -259,35 +284,96 @@ const layer = Layer.effect( const config = yield* Config.Service const events = yield* EventV2.Service const lock = Semaphore.makeUnsafe(1) - const reload = Effect.fn("PluginSupervisor.reload")(() => - lock.withPermit( + const ready = yield* Deferred.make() + let observed = 0 + let applied = -1 + + const activate = Effect.fn("PluginSupervisor.activate")(function* (target: number) { + yield* lock.withPermit( Effect.gen(function* () { + if (applied >= target) return // Resolve OpenCode's internal plugins with their privileged Location services. const internal = yield* PluginInternal.list() // Combine internal plugins with host-contributed SDK plugins in boot order. const pre = [...internal.pre, ...sdk.all()] - // Read the current layered config before resolving plugin directives and packages. - const entries = yield* config.entries() - const operations = yield* scan(entries) + const operations = yield* scan(yield* config.entries()) // Apply config operations and load enabled package plugins into one ordered generation. const plugins = yield* resolve(pre, internal.post, operations) // Replace the active generation in one scoped, batched activation. yield* registry.activate(plugins) + applied = target }), - ), - ) - yield* events.subscribe([Event.Updated, SdkPlugins.Updated]).pipe( - Stream.runForEach(() => - reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))), + ) + }) + const updates = yield* events + .subscribe([Event.Updated, SdkPlugins.Updated]) + .pipe(Stream.toQueue({ capacity: 1, strategy: "sliding" })) + const signals = yield* Stream.concat( + Stream.succeed(0), + Stream.fromQueue(updates).pipe(Stream.mapEffect(() => Effect.sync(() => ++observed))), + ).pipe(Stream.broadcast({ capacity: 1, strategy: "sliding", replay: 1 })) + const attempt = (target: number) => + activate(target).pipe( + Effect.map(() => observed === target), + Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }).pipe(Effect.as(false))), + ) + + yield* signals.pipe( + Stream.runForEach((target) => + activate(target).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))), ), Effect.forkScoped({ startImmediately: true }), ) - const fiber = yield* reload().pipe( - Effect.withSpan("PluginSupervisor.boot"), + yield* signals.pipe( + Stream.debounce("100 millis"), + Stream.mapEffect(attempt), + Stream.filter((settled) => settled), + Stream.take(1), + Stream.runDrain, + Effect.andThen(Deferred.succeed(ready, undefined)), Effect.forkScoped({ startImmediately: true }), ) - return Service.of({ ready: Fiber.join(fiber) }) + return Service.of({ flush: Deferred.await(ready) }) }), ) +const nodeLayer = layer as Layer.Layer + +export const node = makeLocationNode({ + service: Service, + layer: nodeLayer, + deps: [ + PluginV2.node, + SdkPlugins.node, + AgentV2.node, + Catalog.node, + CommandV2.node, + Config.node, + EventV2.node, + FileMutation.node, + FileSystem.node, + FSUtil.node, + Global.node, + httpClient, + Image.node, + Integration.node, + Location.node, + LocationMutation.node, + ModelsDev.node, + Npm.node, + PermissionV2.node, + PluginRuntime.node, + Form.node, + ReadToolFileSystem.node, + Reference.node, + Ripgrep.node, + SessionInstructions.node, + SessionTodo.node, + Shell.node, + SkillV2.node, + ToolRegistry.toolsNode, + WebSearchTool.configNode, + ], +}) + export { layer } diff --git a/packages/core/src/session/error.ts b/packages/core/src/session/error.ts index 7842390f3b9..3de8af3a563 100644 --- a/packages/core/src/session/error.ts +++ b/packages/core/src/session/error.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { Agent } from "@opencode-ai/schema/agent" import { SessionMessage } from "./message" import { SessionSchema } from "./schema" import { SessionError } from "@opencode-ai/schema/session-error" @@ -12,6 +13,15 @@ export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.AgentNotFoundError", { + sessionID: SessionSchema.ID, + agent: Agent.ID, +}) { + override get message() { + return `Agent not found: "${this.agent}"` + } +} + export class StepFailedError extends Schema.TaggedErrorClass()("Session.StepFailedError", { error: SessionError.Error, }) { diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index d40efb79456..910b05e7451 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -3,7 +3,7 @@ export * as SessionRunner from "./index" import type { LLMError } from "@opencode-ai/llm" import { Context, Effect } from "effect" import { SessionSchema } from "../schema" -import type { MessageDecodeError, StepFailedError, UserInterruptedError } from "../error" +import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error" import { SessionRunnerModel } from "./model" import type { Instructions } from "../../instructions/index" import type { ToolOutputStore } from "../../tool-output-store" @@ -12,6 +12,7 @@ export type RunError = | LLMError | SessionRunnerModel.Error | MessageDecodeError + | AgentNotFoundError | StepFailedError | UserInterruptedError | Instructions.InitializationBlocked diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index d757b19484e..261760568f2 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -48,11 +48,12 @@ import { SessionRunnerSystemPrompt } from "./system-prompt" import { Snapshot } from "../../snapshot" import { makeLocationNode } from "../../effect/app-node" import { llmClient } from "../../effect/app-node-platform" -import { StepFailedError, UserInterruptedError } from "../error" +import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "../error" import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" import { PluginHooks } from "../../plugin/hooks" +import { PluginSupervisor } from "../../plugin/supervisor" type StepTokens = { readonly input: number @@ -149,6 +150,7 @@ const layer = Layer.effect( const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service const title = yield* SessionTitle.Service + const plugins = yield* PluginSupervisor.Service // Title generation is a side effect of the first step; it must not delay step continuation. // Tracked per process so repeated wakes before the second user message arrives don't // re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history. @@ -209,7 +211,10 @@ const layer = Layer.effect( const session = yield* getSession(sessionID) if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) return yield* Effect.interrupt + yield* plugins.flush const agent = yield* agents.select(session.agent) + const agentInfo = agent.info + if (!agentInfo) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id }) // Establish what the model knows before admitting what the user said, so // a blocked first step leaves pending inputs untouched. const checkpoint = yield* InstructionCheckpoint.prepare( @@ -218,9 +223,6 @@ const layer = Layer.effect( loadInstructions(agent, session.id), session.id, ) - const toolFibers = yield* FiberSet.make() - const ownedToolFibers: Array> = [] - let needsContinuation = false let currentStep = step if (promotion) { let promoted = 0 @@ -236,18 +238,15 @@ const layer = Layer.effect( const providerMetadataKey = model.route.providerMetadataKey ?? model.provider const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq) const context = entries.map((entry) => entry.message) - const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps + const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps const toolMaterialization = isLastStep ? undefined - : yield* tools.materialize({ permissions: agent.info?.permissions, model }) + : yield* tools.materialize({ permissions: agentInfo.permissions, model }) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ model, providerOptions: { openai: { promptCacheKey } }, - system: [ - agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), - checkpoint.baseline, - ] + system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), checkpoint.baseline] .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), messages: [ @@ -257,6 +256,9 @@ const layer = Layer.effect( tools: toolMaterialization?.definitions ?? [], toolChoice: isLastStep ? "none" : undefined, }) + const toolFibers = yield* FiberSet.make() + const ownedToolFibers: Array> = [] + let needsContinuation = false const availableTools = new Map(request.tools.map((tool) => [tool.name, tool])) const requestEvent: SessionHooks["request"] = { sessionID: session.id, @@ -436,7 +438,7 @@ const layer = Layer.effect( if ( SessionRunnerRetry.isRetryable(llmFailure) && !publisher.hasRetryEvidence() && - (agent.info?.steps === undefined || currentStep < agent.info.steps) + (agentInfo.steps === undefined || currentStep < agentInfo.steps) ) { return yield* new SessionRunnerRetry.RetryableFailure({ cause: llmFailure, @@ -700,5 +702,6 @@ export const node = makeLocationNode({ Config.node, Snapshot.node, Database.node, + PluginSupervisor.node, ], }) diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index df84a46668d..28a015dd7cc 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -4,7 +4,7 @@ import { PermissionV2 } from "../permission" import { QuestionV2 } from "../question" import { Integration } from "../integration" import { ToolOutputStore } from "../tool-output-store" -import { StepFailedError, UserInterruptedError } from "./error" +import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error" import { SessionRunnerModel } from "./runner/model" export function toSessionError(cause: unknown): SessionError.Error { @@ -41,6 +41,7 @@ export function toSessionError(cause: unknown): SessionError.Error { if (cause instanceof ToolFailure) return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error) if (cause instanceof StepFailedError) return cause.error + if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message } if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message } if ( cause instanceof SessionRunnerModel.ModelNotSelectedError || diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 2bfe1d6c74f..75faeef99d7 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -229,7 +229,7 @@ describe("PluginSupervisor config", () => { const ready = Effect.fnUntraced(function* () { const supervisor = yield* PluginSupervisor.Service - yield* supervisor.ready + yield* supervisor.flush }) function withLocation( diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index dcfecdb0ae2..60e788f7b17 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -4,7 +4,7 @@ import { describe, expect } from "bun:test" import { Config } from "@opencode-ai/schema/config" import { Plugin } from "@opencode-ai/schema/plugin" import { Money } from "@opencode-ai/schema/money" -import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect" +import { Context, DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect" import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" @@ -54,7 +54,7 @@ describe("LocationServiceMap", () => { const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) const read = Effect.gen(function* () { const supervisor = yield* PluginSupervisor.Service - yield* supervisor.ready + yield* supervisor.flush const agents = yield* AgentV2.Service return yield* agents.get(id) }) @@ -67,6 +67,268 @@ describe("LocationServiceMap", () => { ), ) + itWithSdk.live("waits for explorer activation to complete", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "blocked-initial-activation", + effect: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))), + }), + ) + + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + yield* Deferred.await(started) + + const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.forkChild, + ) + expect(flushFiber.pollUnsafe()).toBeUndefined() + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(flushFiber) + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.timeout("1 second"), + ) + + const explorer = yield* Effect.gen(function* () { + const agents = yield* AgentV2.Service + return yield* agents.resolve("explore") + }).pipe(Effect.provide(context)) + + expect(explorer).toBeDefined() + expect(explorer?.permissions.length).toBeGreaterThan(0) + }), + ), + ), + ) + + itWithSdk.live("reruns activation for SDK plugins registered during startup", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const firstStarted = yield* Deferred.make() + const releaseFirst = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + const releaseSecond = yield* Deferred.make() + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "fixed-target-first-plugin", + effect: () => + Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))), + }), + ) + + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + yield* Deferred.await(firstStarted) + + const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.forkChild({ startImmediately: true }), + ) + yield* Effect.yieldNow + yield* sdk.register( + EffectPlugin.define({ + id: "fixed-target-second-plugin", + effect: () => + Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))), + }), + ) + + yield* Deferred.succeed(releaseFirst, undefined) + yield* Deferred.await(secondStarted) + expect(flushFiber.pollUnsafe()).toBeUndefined() + + yield* Deferred.succeed(releaseSecond, undefined) + yield* Fiber.join(flushFiber) + }), + ), + ), + ) + + itWithSdk.live("reruns activation for Config updates during startup", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const activations = { count: 0 } + const file = path.join(dir.path, "opencode.json") + yield* Effect.promise(() => fs.writeFile(file, "{}")) + const firstStarted = yield* Deferred.make() + const releaseFirst = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + const releaseSecond = yield* Deferred.make() + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "blocked-config-reload", + effect: () => + Effect.sync(() => ++activations.count).pipe( + Effect.flatMap((activation) => + activation === 1 + ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))) + : Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))), + ), + ), + }), + ) + + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + yield* Deferred.await(firstStarted) + + const events = yield* EventV2.Service + const updated = yield* events.subscribe(Config.Event.Updated).pipe( + Stream.filter((event) => event.location?.directory === dir.path), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ) + yield* Effect.promise(() => + fs.writeFile( + file, + JSON.stringify({ plugins: [path.join(import.meta.dir, "plugin/fixtures/config-effect-plugin.ts")] }), + ), + ) + yield* Fiber.join(updated) + + const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.forkChild, + ) + yield* Deferred.succeed(releaseFirst, undefined) + yield* Deferred.await(secondStarted) + expect(flushFiber.pollUnsafe()).toBeUndefined() + yield* Deferred.succeed(releaseSecond, undefined) + yield* Fiber.join(flushFiber) + expect(activations.count).toBe(2) + }), + ), + ), + ) + + itWithSdk.live("keeps flush pending while startup updates continue", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.forkChild({ startImmediately: true }), + ) + const events = yield* EventV2.Service + + yield* Effect.forEach( + Array.from({ length: 5 }), + () => events.publish(SdkPlugins.Updated, {}).pipe(Effect.andThen(Effect.sleep("50 millis"))), + { discard: true }, + ) + expect(flushFiber.pollUnsafe()).toBeUndefined() + yield* Fiber.join(flushFiber) + }), + ), + ), + ) + + itWithSdk.live("keeps flush open while later hot reload runs", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context)) + + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const completed = yield* Deferred.make() + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "post-ready-plugin", + effect: () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Deferred.succeed(completed, undefined)), + ), + }), + ) + yield* Deferred.await(started) + + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.timeout("1 second"), + ) + yield* Deferred.succeed(release, undefined) + yield* Deferred.await(completed) + }), + ), + ), + ) + + itWithSdk.live("does not cancel activation when a flush waiter is interrupted", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const completed = yield* Deferred.make() + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "interrupted-waiter-plugin", + effect: () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Deferred.succeed(completed, undefined)), + ), + }), + ) + + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + yield* Deferred.await(started) + const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.forkChild({ startImmediately: true }), + ) + yield* Fiber.interrupt(flushFiber) + + yield* Deferred.succeed(release, undefined) + yield* Deferred.await(completed) + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.timeout("500 millis"), + ) + }), + ), + ), + ) + it.live("applies ordered plugin config operations during boot", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -79,7 +341,7 @@ describe("LocationServiceMap", () => { ) const plugins = yield* Effect.gen(function* () { const plugins = yield* PluginV2.Service - yield* (yield* PluginSupervisor.Service).ready + yield* (yield* PluginSupervisor.Service).flush return yield* plugins.list() }).pipe( Effect.scoped, @@ -106,7 +368,7 @@ describe("LocationServiceMap", () => { yield* Effect.gen(function* () { const registry = yield* PluginV2.Service const supervisor = yield* PluginSupervisor.Service - yield* supervisor.ready + yield* supervisor.flush expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"]) yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] }))) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 38c017af2b2..36b8f850612 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -36,6 +36,7 @@ import { Instructions } from "@opencode-ai/core/instructions" import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" import { McpGuidance } from "@opencode-ai/core/mcp/guidance" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer } from "effect" @@ -76,6 +77,7 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.suc const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(Instructions.empty) }) const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) }) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) +const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })) const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], @@ -89,6 +91,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Config.node, config], [PermissionV2.node, permission], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [PluginSupervisor.node, pluginSupervisor], ]) const execution = Layer.effect( SessionExecution.Service, @@ -137,6 +140,7 @@ const it = testEffect( [ReferenceGuidance.node, referenceGuidance], [Config.node, config], [Snapshot.node, Snapshot.noopLayer], + [PluginSupervisor.node, pluginSupervisor], [SessionExecution.node, execution], ], ), @@ -146,6 +150,12 @@ const sessionID = SessionV2.ID.make("ses_runner_recorded") describe("SessionRunnerLLM recorded", () => { it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () => Effect.gen(function* () { + const agents = yield* AgentV2.Service + yield* agents.transform((draft) => + draft.update(AgentV2.ID.make("build"), (agent) => { + agent.mode = "primary" + }), + ) const { db } = yield* Database.Service yield* db .insert(ProjectTable) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index ef2d24445a5..35091f37b91 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -39,6 +39,7 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { QuestionTool } from "@opencode-ai/core/tool/question" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { AgentV2 } from "@opencode-ai/core/agent" @@ -307,6 +308,13 @@ const config = Layer.succeed( ]), }), ) +let pluginFlushHook = Effect.void +const pluginSupervisor = Layer.succeed( + PluginSupervisor.Service, + PluginSupervisor.Service.of({ + flush: Effect.suspend(() => pluginFlushHook), + }), +) const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], @@ -320,6 +328,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Config.node, config], [McpGuidance.node, mcpGuidance], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [PluginSupervisor.node, pluginSupervisor], ]) const execution = Layer.effect( SessionExecution.Service, @@ -374,6 +383,7 @@ const it = testEffect( [SessionExecution.node, execution], [Config.node, config], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [PluginSupervisor.node, pluginSupervisor], ], ), ) @@ -411,6 +421,7 @@ const setup = Effect.gen(function* () { systemUnavailable = false systemLoadHook = Effect.void modelResolveHook = Effect.void + pluginFlushHook = Effect.void currentModel = model skillBaselines.clear() responses = undefined @@ -423,6 +434,12 @@ const setup = Effect.gen(function* () { toolExecutionsReady = 5 activeToolExecutions = 0 maxActiveToolExecutions = 0 + const agents = yield* AgentV2.Service + yield* agents.transform((draft) => + draft.update(AgentV2.ID.make("build"), (agent) => { + agent.mode = "primary" + }), + ) yield* db .insert(ProjectTable) .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) @@ -1071,10 +1088,64 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("fails before the model request when the selected agent is unavailable", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + yield* db + .update(SessionTable) + .set({ agent: "explore" }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Inspect files" }), resume: false }) + + requests.length = 0 + response = [] + const failure = yield* session.resume(sessionID).pipe(Effect.flip) + + expect(failure).toMatchObject({ + _tag: "Session.AgentNotFoundError", + sessionID, + agent: "explore", + }) + expect(requests).toHaveLength(0) + }), + ) + + it.effect("waits for initial plugin readiness before constructing the model request", () => + Effect.gen(function* () { + yield* setup + const release = yield* Deferred.make() + pluginFlushHook = Deferred.await(release) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Wait for plugins" }), resume: false }) + + requests.length = 0 + response = [] + const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true })) + yield* Effect.yieldNow + + expect(requests).toHaveLength(0) + expect(running.pollUnsafe()).toBeUndefined() + + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(running) + expect(requests).toHaveLength(1) + }), + ) + it.effect("updates selected-agent skill guidance after an agent switch", () => Effect.gen(function* () { const session = yield* setup const events = yield* EventV2.Service + const agents = yield* AgentV2.Service + yield* agents.transform((draft) => + draft.update(AgentV2.ID.make("reviewer"), (agent) => { + agent.mode = "primary" + }), + ) skillBaselines.set(AgentV2.ID.make("build"), "Build skills") yield* admit(session, "First") diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index d2e63967105..55223a9daa9 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -20,6 +20,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionStore } from "@opencode-ai/core/session/store" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { SubagentTool } from "@opencode-ai/core/tool/subagent" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" @@ -106,6 +107,7 @@ const it = testEffect(layer) const withSubagent = (location: Location.Ref) => Effect.gen(function* () { const locations = yield* LocationServiceMap.Service + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(locations.get(location))) yield* AgentV2.Service.use((agents) => agents.transform((draft) => { // The caller identity used by executeTool; subagent permission asserts against it. diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index cd0eb32f7c8..5c819e521d8 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -101,7 +101,7 @@ const layer = Layer.effect( const skillDirs = yield* skill.dirs() const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length ? yield* Effect.gen(function* () { - yield* (yield* PluginSupervisor.Service).ready + yield* (yield* PluginSupervisor.Service).flush return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) : [] diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index b5d6ef37cf1..a6ebdb84d7f 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -20,7 +20,7 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) "model.default", Effect.fn(function* () { const plugins = yield* PluginSupervisor.Service - yield* plugins.ready.pipe( + yield* plugins.flush.pipe( Effect.timeoutOrElse({ duration: "5 seconds", orElse: () => From cedf3656746edb125d8a63aadac9648dbdde91ae Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 8 Jul 2026 15:59:49 -0400 Subject: [PATCH 13/32] 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 14/32] 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 15/32] 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 16/32] 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 17/32] 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 18/32] 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: - -