add mock filesystem

This commit is contained in:
James Long 2026-05-13 17:39:45 -04:00
commit 62bd7c82d3
11 changed files with 1965 additions and 1 deletions

View file

@ -28,6 +28,7 @@ export const Flag = {
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
OPENCODE_MOCK: truthy("OPENCODE_MOCK"),
// Experimental
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(

View file

@ -0,0 +1,881 @@
# Property-Based TUI And Backend Testing Plan
Status: rough architectural draft.
This document sketches an incremental path for property-based, deterministic-simulation-style testing of the opencode TUI against a real opencode backend, without hitting external network services.
## Goals
- Drive the TUI as the primary user surface.
- Exercise the real backend request, session, message, tool, permission, and event pipelines.
- Replace external effects with deterministic local services.
- Record enough information to replay failures.
- Build a semantic model of UI actions, backend requests, and state transitions over time.
- Start with a small useful runner, then grow toward deterministic simulation testing.
## Non-Goals For The First Pass
- Full fake-clock replacement for every `setTimeout`, `Date.now`, and animation path.
- Exhaustive exploration of every visual TUI state.
- Web app testing.
- Real external LLM/provider, MCP, webfetch, websearch, update, or share network calls.
## Current Code Map
### TUI
- TUI startup is centered in `packages/opencode/src/cli/cmd/tui/thread.ts` and `packages/opencode/src/cli/cmd/tui/app.tsx`.
- `TuiThreadCommand` starts a worker, builds an in-process fetch/event transport when possible, and calls `tui(...)`.
- `tui(...)` creates the OpenTUI `CliRenderer`, creates the keymap, and renders the Solid app.
- `SDKProvider` in `context/sdk.tsx` owns SDK creation, custom fetch injection, event subscription, event batching, retry, and timers.
- `SyncProvider` in `context/sync.tsx` projects backend events into TUI state: sessions, messages, parts, permissions, questions, todos, diffs, MCP, formatter, LSP, and VCS.
- `RouteProvider` in `context/route.tsx` owns route state.
- `PromptRefProvider` in `context/prompt.tsx` exposes the current prompt ref.
- The main prompt is `component/prompt/index.tsx`. It exposes `set`, `reset`, and `submit` through `PromptRef`, and its submit path eventually calls SDK session APIs.
- `keymap.tsx` centralizes base keymap registration and re-exports `useBindings`; app and prompt commands are registered through this layer.
- `plugin/api.tsx` already centralizes access to renderer, route, keymap, state, SDK client, dialog, KV, and event APIs. This is a useful model for a test harness API.
### Backend
- `packages/opencode/src/server/server.ts` exposes `Server.Default().app.request(...)`, which is useful for in-process HTTP tests.
- `packages/opencode/src/server/routes/instance/httpapi/server.ts` assembles all routes and provides production service layers.
- `createRoutes(...)` currently provides concrete production layers inside the route builder, including `Provider.defaultLayer`, `MCP.defaultLayer`, `ToolRegistry.defaultLayer`, `AppFileSystem.defaultLayer`, and `FetchHttpClient.layer`.
- `groups/session.ts` and `handlers/session.ts` define the important session HTTP surface: create, prompt, prompt_async, command, shell, abort, permission response, message reads, revert, and update paths.
- `SessionPrompt.Service` in `session/prompt.ts` creates user messages, resolves prompt parts, resolves tools, loops over LLM/tool calls, and writes messages/parts.
- `LLM.Service` in `session/llm.ts` is the main provider seam. It calls `Provider.Service.getLanguage(...)` and then `streamText(...)`.
- `Provider.Service` in `provider/provider.ts` can dynamically load provider SDKs and may install packages or use network. Simulation should use the normal provider path with a local mock provider/model and sandbox/network guards, not wholesale service replacement.
- `MCP.Service` in `mcp/index.ts` can open remote HTTP/SSE connections or local child processes. Simulation should keep normal app startup and disable/configure MCP by default; only add a narrow MCP control seam when a test needs MCP states.
- `ToolRegistry.Service` in `tool/registry.ts` exposes built-in and plugin tools. Filesystem tools should run against the mock filesystem in simulation mode; process/network tools must be disabled or replaced.
- `Database.Path` in `storage/db.ts` is controlled by `OPENCODE_DB` and supports `:memory:`. Tests already reset/close DB state.
- `Identifier` in `id/id.ts`, many `Date.now()` calls, and some `Math.random()` use are determinism hazards.
### Previous `jlongster/fuzz-backend` Branch
Useful ideas:
- A mock AI SDK provider emitted real language-model stream chunks.
- The old branch showed that a compact scripted action format works, but scripts must be supplied through simulation control APIs instead of user prompt text.
- Actions included `text`, `thinking`, `tool_call`, `list_tools`, and `error`.
- Step selection by counting tool-result rounds after the last user message was a good fit for model/tool loops.
- The runner drove the backend through HTTP plus SSE, waited for `session.status` to become idle, and then inspected messages.
- `/experimental/tool` discovery plus schema-based fake input generation was a useful generation seed.
- The TUI runner used an internal component to select the mock model, set prompt text through `PromptRef`, submit, and wait for idle.
- The differential runner normalized volatile fields and compared runs.
- The runner forced SQLite to `:memory:` so each run started with a clean in-process database.
- The runner used macOS `sandbox-exec` to deny external network and host filesystem access around the whole app process.
- The branch included a mock filesystem direction; the concept is correct and should be made complete enough for backend tools and app services instead of relying on real workspace files.
Ideas to avoid or rework:
- Do not hardcode the mock provider into normal provider discovery.
- Do not use unseeded `Math.random()`.
- Do not make the user-visible prompt text carry hidden control instructions at all.
- Do not implement a partial mock filesystem and assume all filesystem effects are covered; the backend mock filesystem must be a first-class simulation service with explicit unsupported-operation failures.
## Core Design Decision: Endpoint Control, Not Prompt Control
The primary harness should control backend behavior through a test-only simulation control endpoint or in-process control service. The TUI should then submit ordinary prompt text through the normal UI.
This is better than embedding control data in the prompt because:
- It keeps prompt contents realistic, so prompt UI behavior can be tested independently from backend scripting.
- It keeps transcripts and message history understandable.
- It works for non-prompt workflows like command palette actions, session summarization, permission flows, shell mode, model switching, and future MCP controls.
- It lets the runner prepare backend state before the next UI action.
- It gives us a natural place to force future backend state, such as MCP state, tool results, filesystem state, provider errors, and pending permission/question state.
- It makes replay traces explicit: `control.enqueueLLM(...)`, then `ui.submitPrompt(...)`.
There should be no JSON-in-prompt fallback. If no endpoint-enqueued script matches a model request, the mock LLM should fail with a clear simulation error. This keeps user-visible prompt text realistic and makes replay traces explicit.
## High-Level Architecture
The system has five layers:
1. Simulation backend services.
2. TUI driver and observation harness.
3. Semantic UI and backend graph builder.
4. Property runner, generator, replay, and shrinker.
5. Later DST controls for clock, timers, schedulers, and async ordering.
The initial runner loop should look like this:
```text
seed -> start isolated backend -> mount TUI -> observe state
repeat N times:
choose next UI action from current semantic state
optionally enqueue backend script/control data
execute the UI action
wait for quiescence
record UI/backend/network/event observations
run relevant properties
update semantic graph
on failure:
persist replay trace and human-readable report
```
## Simulation Backend Services
### Production App With Narrow Overrides
The runner should load the normal app by default. Avoid building a separate test route tree or installing a broad graph of mock services. The goal is to run production wiring and only override the few core effect boundaries that must be deterministic.
The first required override is `AppFileSystem.Service`, so backend-visible files come from the in-memory mock filesystem. Other overrides should be added only when the app cannot be controlled through configuration, the simulation control endpoint, or the sandbox policy.
Possible narrow shape:
```ts
// Conceptual API, not final names.
export function createRoutes(input?: {
cors?: CorsOptions
overrides?: {
appFileSystem?: Layer.Layer<AppFileSystem.Service>
}
}) {
return productionRoutesWithProductionServices(input)
}
```
The important part is not the exact type. The important part is that simulation mode should not need to re-provide provider, MCP, tool registry, network, or most backend services. It should load the whole app and make the smallest viable changes, starting with the filesystem boundary.
### Simulation Control State
Add simulation-only control state that owns deterministic run state. This is not a replacement for app services; it is the small state store used by control endpoints and the mock provider.
Proposed source location:
- `packages/opencode/src/testing/simulation/service.ts`
- `packages/opencode/src/testing/simulation/provider.ts`
- `packages/opencode/src/testing/simulation/filesystem.ts`
- `packages/opencode/src/testing/simulation/httpapi.ts`
- `packages/opencode/src/testing/simulation/network.ts`
- `packages/opencode/src/testing/simulation/runner.ts`
The service should be instance-scoped where possible and keyed by a `runID`.
Core responsibilities:
- Hold seeded RNG state.
- Hold queued LLM scripts.
- Hold mock filesystem state.
- Record UI action IDs, backend request IDs, events, tool calls, and state changes.
- Enforce network policy.
- Provide snapshots for replay/failure reports.
- Reset state between runs.
Conceptual control API:
```ts
type SimulationControl = {
reset(input: { runID: string; seed: string }): Effect.Effect<void>
enqueueLLM(input: { runID: string; match?: LLMScriptMatch; script: LLMScript }): Effect.Effect<void>
snapshot(input: { runID: string }): Effect.Effect<SimulationSnapshot>
recordAction(input: UIActionRecord): Effect.Effect<void>
recordRequest(input: BackendRequestRecord): Effect.Effect<void>
recordEvent(input: BackendEventRecord): Effect.Effect<void>
}
```
### Control Endpoint
Add a gated endpoint under the instance HTTP API, probably `/experimental/simulation/*`.
Suggested endpoints:
- `POST /experimental/simulation/reset`
- `POST /experimental/simulation/llm/enqueue`
- `GET /experimental/simulation/snapshot`
- `POST /experimental/simulation/action/start`
- `POST /experimental/simulation/action/end`
Access should be impossible in normal production use unless explicit simulation mode is enabled. If the endpoint is added to the typed HttpApi surface, regenerate the JS SDK with `./packages/sdk/js/script/build.ts`. The runner can also call the endpoint with raw fetch to avoid making this a public user-facing API.
### Mock LLM Provider
The main LLM mock should be a real provider/model path, not a replacement for `SessionPrompt` or a wholesale replacement of `Provider.Service`.
Preferred seam:
- Register or configure a local simulation provider/model through the normal provider system.
- Implement its language model with an AI SDK-compatible mock language model adapted to the current AI SDK version.
- Let the existing `LLM.Service` call `streamText(...)`, process tools, and emit normal stream events.
This preserves more of the real backend path than replacing `LLM.Service` or `Provider.Service` directly.
The mock model should read the next script from `Simulation.Service` using request context:
- `runID`
- `sessionID`
- `messageID` or last user message ID
- model/provider ID
- tool round number
If no endpoint-enqueued script matches, the mock model should fail with a typed simulation error that includes the run ID, session ID, model, and tool round. Silent default responses and prompt parsing would hide missing runner setup.
Script action schema:
```ts
type LLMScriptAction =
| { type: "text"; content: string }
| { type: "thinking"; content: string }
| { type: "tool_call"; name: string; input: Record<string, unknown> }
| { type: "list_tools" }
| { type: "error"; message: string }
type LLMScript = {
steps: LLMScriptAction[][]
usage?: { inputTokens: number; outputTokens: number; totalTokens: number }
finish?: "stop" | "tool-calls" | "error" | "length" | "unknown"
}
```
Keep the old rule that step `0` runs before tool results and step `N` runs after `N` tool-result rounds. The endpoint-backed service should also record which script step was consumed so replay reports are explicit.
### Database, Filesystem, Tools, MCP, And Network
Initial policy:
- Force `OPENCODE_DB=:memory:` for the simulation backend process. This should be a hard simulation-mode invariant, not a per-test preference.
- Run the app/backend under macOS `sandbox-exec` by default for local simulation runs, following the old branch's technique: deny external network, deny host filesystem writes, and only allow the minimum paths needed to boot the process and communicate over loopback/in-process transports.
- Add a first-class backend mock filesystem and provide it by overriding `AppFileSystem.Service` instead of relying on a real temp workspace for app-visible files.
- Use the normal provider system with a local simulation provider/model; do not replace `Provider.Service` unless a later implementation proves a small seam is unavoidable.
- Keep MCP on the normal app path and disable/configure it by default so it starts no network or child processes. Add narrow MCP controls later only for tests that explicitly target MCP states.
- Use sandbox/network guards to reject non-loopback network. Only override `HttpClient.HttpClient` if a minimal core override is needed to make failures typed and observable.
- Disable or fake webfetch, websearch, share, update, repo clone, and other external tools through normal config/tool policy where possible.
- Run read/glob/grep/write/edit against the mock filesystem, not the host filesystem.
- Treat bash/shell execution as opt-in and fake it by default, because real process execution bypasses the mock filesystem and sandbox policy is the last line of defense.
Later policy:
- Add deterministic fake child process and shell tools.
- Add deterministic fake LSP/file-watcher events.
The mock filesystem should be authoritative for backend-visible project files. The host filesystem should only be used for runner artifacts, bundled source/config needed to start the app, and sandbox-allowed runtime plumbing. Any app path that escapes the mock filesystem should fail with a typed simulation error so missing coverage is obvious.
### In-Memory Database
Simulation mode should set the database to memory globally for the backend process:
```text
OPENCODE_DB=:memory:
```
This must happen before any import path evaluates `storage/db.ts`, because `Database.Path` is computed at module load. The simulation bootstrap should own process startup so this cannot be missed. Each run should start from an empty DB and seed any required sessions/state through public services or simulation controls.
### Sandbox Isolation
The local runner should reuse the old branch's `sandbox-exec` setup on macOS as the starting implementation. Specifically, adapt `jlongster/fuzz-backend:packages/opencode/src/provider/sdk/mock/sandbox.sb` and `jlongster/fuzz-backend:packages/opencode/src/provider/sdk/mock/run` into the new simulation runner layout.
The old setup did the right first-order thing:
- `sandbox-exec -f ... -D HOME=$HOME bun --preload ... src/index.ts serve`
- Start from `(allow default)` so the process can boot.
- Deny all network with `(deny network*)`.
- Re-allow localhost network for local server/TUI communication.
- Deny all filesystem writes with `(deny file-write*)`.
- Deny reads from sensitive user config/state directories like `$HOME/.local` and `$HOME/.config`.
The sandbox is not the primary abstraction for deterministic behavior; it is the safety boundary that proves missed hooks cannot touch the host filesystem or external network.
Initial sandbox policy should stay close to the old branch:
- Deny outbound network except loopback when a real listener is used.
- Deny host filesystem writes inside the sandbox. The parent runner can write trace artifacts outside the sandbox after collecting them over stdout, HTTP, or another explicit control channel.
- Deny reads from user config/state locations unless explicitly mounted as test fixtures.
- Fail fast when a denied operation happens so the trace records a simulation escape.
The mock filesystem and network guards should still exist inside the app. `sandbox-exec` catches leaks; it should not be the mechanism that normal simulated I/O depends on.
### Backend Mock Filesystem
The mock filesystem should be implemented as a real simulation service, not as test fixture files on disk.
Responsibilities:
- Store files, directories, symlinks if needed, executable bits if needed, mtimes, and binary/text content in memory.
- Provide deterministic path resolution for workspace root, current directory, home, config, state, and temp paths.
- Expose operations needed by `AppFileSystem.Service`, read/write/edit tools, glob/grep/ripgrep equivalents, config reads, snapshot/diff, and prompt file attachment resolution.
- Emit deterministic file change/snapshot events when writes happen.
- Make unsupported operations fail explicitly with typed simulation errors.
- Support seeding initial file trees from JSON fixtures and serializing filesystem state into replay traces.
Implementation should prefer a narrow `AppFileSystem.Service` override first. If file/ripgrep services or filesystem tools bypass that boundary, make the smallest targeted change to route them through the mock filesystem rather than replacing the whole tool registry. If some backend paths still import `@/util/filesystem` or direct host filesystem APIs, use the same Bun preload/plugin technique from the old branch to redirect those imports in simulation mode. The sandbox should then catch any remaining direct `fs`, `Bun.file`, or child-process access that was not routed through the mock filesystem.
### Backend Quiescence
The first useful quiescence definition should be pragmatic:
- No session has `session.status.type === "busy"`.
- The TUI sync queue has flushed.
- The runner has seen all events produced by the current action.
- The renderer has completed at least one frame after the last event.
- No pending simulation-controlled LLM stream or tool call remains.
This is not full DST yet. It is enough to avoid racing the next action against obvious async work.
## TUI Driver And Observation Harness
### Harness Injection
Do not add another hardcoded environment-runner component like the old `Mock` component. Instead, extend `tui(...)`/`App` with an optional test harness hook.
Conceptual shape:
```ts
export function tui(input: {
url: string
args: Args
config: TuiConfig.Resolved
fetch?: typeof fetch
events?: EventSource
testing?: TuiHarness.Input
})
```
The `App` can mount a tiny `TuiHarnessProbe` only when `testing` is provided. The probe exposes the same kinds of context that `plugin/api.tsx` already gathers:
- route
- keymap
- prompt ref
- sync state
- SDK client
- renderer
- dialog state
- KV state
- event bus
- local model/agent state
This gives tests a stable internal API without coupling to a specific visible component.
### Driver Modes
Start with two modes:
- Semantic in-process mode: uses context APIs, keymap commands, prompt refs, SDK fetch wrappers, and renderer snapshots. This is the main property runner.
- Terminal/PTY mode: later, spawn the real binary in a PTY, inject bytes, and read terminal snapshots. This catches lower-level terminal regressions but is slower.
The semantic mode should still exercise OpenTUI rendering, Solid state, keymap registration, SDK calls, backend routes, SSE/events, and prompt submit flows.
### Action Types
Initial action types:
```ts
type UIAction =
| { type: "command"; command: string }
| { type: "prompt.set"; text: string }
| { type: "prompt.submit"; text?: string; llm?: LLMScript }
| { type: "key"; key: string; modifiers?: string[] }
| { type: "paste"; text: string }
| { type: "click"; elementID: string }
| { type: "wait"; condition: "idle" | "frame"; timeoutMs: number }
```
The runner should prefer semantic commands first. Raw key and mouse actions are useful, but command-level actions are easier to shrink and replay.
### Observation Types
Every action should record before/after observations:
```ts
type UIObservation = {
route: unknown
dialogDepth: number
focusedElement?: string
semanticElements: SemanticElement[]
bufferHash?: string
visibleText?: string
syncSummary: SyncSummary
errors: SimulationError[]
}
```
Initial `visibleText` can come from renderer/test-render snapshots where available. Later PTY mode should capture the terminal buffer directly.
### TUI State Changers To Track
Frontend state can change because of:
- Keyboard events.
- Mouse events.
- Paste and IME submit deferrals.
- Terminal resize and theme detection.
- SDK HTTP responses.
- SDK event stream messages.
- Timers used for batching, focus, prompt submit, animations, retry, and placeholders.
- Local KV, prompt history, prompt stash, model recents/favorites.
- Plugin registration, routes, slots, commands, events, and toasts.
- Clipboard/selection flows.
- Process signals and terminal suspend/resume.
The first runner does not need full control over all of these. It should record them when they occur and gradually move high-impact sources under simulation control.
## Semantic UI Graph
### Semantic Registry
Add a TUI semantic registry that components can use to announce interactive elements and available actions.
Conceptual element shape:
```ts
type SemanticElement = {
id: string
role: "prompt" | "command" | "dialog" | "dialog-option" | "permission" | "question" | "message" | "route"
label: string
enabled: boolean
visible: boolean
state?: Record<string, unknown>
bounds?: { x: number; y: number; width: number; height: number }
actions: SemanticAction[]
}
```
First components to instrument:
- `Prompt` for text input, submit, shell mode, slash commands, file/agent attachments.
- App commands registered in `app.tsx`.
- Prompt commands registered in `component/prompt/index.tsx`.
- `DialogSelect` for option movement/filter/select.
- Permission and question overlays.
- Route state in `RouteProvider`.
- Session message parts, especially tool and error parts.
This should be additive metadata. It should not change rendering behavior.
### Graph Shape
The graph should abstract states instead of storing every concrete UI snapshot.
```ts
type SemanticState = {
id: string
route: string
dialog?: string
elementSignature: string
backendSignature?: string
}
type SemanticTransition = {
id: string
from: string
to: string
action: UIAction
uiChanged: string[]
backendRequests: BackendRequestRecord[]
backendEvents: BackendEventRecord[]
coverage: string[]
failures: SimulationFailure[]
}
```
State hashing should initially normalize volatile IDs/timestamps. As deterministic IDs/clocks land, less normalization will be needed.
### Discovery Pass
The discovery runner randomly chooses from currently available semantic actions, executes them, observes transitions, and writes a graph artifact.
Suggested output:
- `.opencode/simulation/ui-graph.json`
- `.opencode/simulation/backend-graph.json`
- `.opencode/simulation/runs/<runID>.jsonl`
The graph is not expected to be perfect. It should answer practical questions:
- What actions are available from each abstract UI state?
- Which actions produce backend requests?
- Which actions open dialogs, create sessions, request permissions, create tool parts, or show errors?
- Which action sequences reach the prompt, session, permission, question, model selection, MCP, and session list states?
### Directed Runner
After discovery, the directed runner should use the graph to bias generation toward requested targets.
Examples:
- "Focus prompt submit with tool calls."
- "Exercise permission approve/reject flows."
- "Exercise session list and route changes."
- "Exercise backend prompt_async and SessionPrompt loop states."
The directed runner can plan a route through the graph to a target state, then run generated variants from there.
## Mapping UI Actions To Backend Requests
Every generated action should have an `actionID`.
The TUI fetch wrapper should add headers:
- `x-opencode-simulation-run`
- `x-opencode-simulation-action`
- `x-opencode-simulation-step`
The backend should record request spans:
```ts
type BackendRequestRecord = {
runID: string
actionID?: string
requestID: string
method: string
path: string
endpoint?: string
status: number
startedAt: number
endedAt: number
}
```
Async work needs explicit correlation. For example, `prompt_async` returns before the session run finishes. The handler should attach the current `actionID` to the created user message/session run in `Simulation.Service`, so later LLM/tool/session events can be attributed to the same UI action.
Backend events should also be recorded:
```ts
type BackendEventRecord = {
runID: string
actionID?: string
eventID: string
type: string
sessionID?: string
messageID?: string
domains: string[]
}
```
This gives the graph the important edge information: UI action -> HTTP request -> backend state/event changes -> TUI sync changes.
## Backend Semantic Analysis
Backend semantic analysis should start from cheap instrumentation:
- HTTP endpoint entry/exit.
- Bus/SyncEvent publications.
- Session status changes.
- Message and part writes.
- Permission and question asks/replies.
- Tool start/finish/error.
- LLM stream start/finish/error.
The first backend state domains:
- `session`
- `message`
- `part`
- `permission`
- `question`
- `todo`
- `tool`
- `mcp`
- `filesystem`
- `network`
- `status`
Later, add DB snapshots or table-level hashes for deeper invariants. Do not read and diff the whole database after every action until we know it is needed.
## Property API
Properties should be ordinary TypeScript functions registered with the runner.
Conceptual API:
```ts
type Property = {
name: string
domains: string[]
check: (ctx: PropertyContext) => Promise<void>
}
```
`domains` lets the runner skip checks when unrelated state changed.
Example properties:
```ts
property({
name: "app.does-not-crash",
domains: ["tui", "backend"],
async check(ctx) {
ctx.expect(ctx.tui.errors).toEqual([])
ctx.expect(ctx.backend.errors).toEqual([])
},
})
```
Built-in properties for pass one:
- The app does not crash. This includes uncaught TUI render errors and unhandled backend failures caused by the generated action.
Later properties:
- No non-loopback network call.
- Session eventually becomes idle after prompt-like actions.
- No pending tool call remains after idle.
- Every TUI-visible session message has valid message/part schemas.
- Permission/question overlays correspond to backend pending requests.
- Replay trace can be parsed and rerun.
- Text should not flicker across stable frames.
- Dialog focus should remain valid.
- Route state and visible route agree.
- Backend DB invariants hold after every endpoint group.
- Tool call lifecycle events are balanced.
- No generated action sequence can strand a session in busy state.
## Failure Reports And Replay
On failure, persist a trace and a concise report.
Trace should include:
- Seed and run configuration.
- Initial mock filesystem fixture and workspace/config path mapping.
- Simulation control calls.
- UI action sequence.
- LLM scripts consumed.
- HTTP request records.
- Backend events.
- UI observations before/after each action.
- Property checks and failure details.
- Normalization version.
Human report should include:
- Failed property name.
- Seed and action index.
- Minimal replay command.
- The last N UI actions.
- The backend requests/events caused by the failing action.
- Visible TUI text/buffer before and after.
- Any session/message/tool IDs relevant to the failure.
Initial replay can simply rerun the exact trace. Shrinking can come later.
Shrinking plan:
- Delete contiguous chunks of actions.
- Reduce generated prompt text.
- Reduce LLM scripts to fewer actions/steps.
- Prefer semantic action shrinking over raw key shrinking.
- Preserve explicit control calls needed to reproduce backend state.
## DST Roadmap
Full deterministic simulation testing requires more than seeded random actions. It requires control over time and async scheduling. Build this gradually.
### Stage 1: Record And Normalize
- Seed RNG for the runner.
- Normalize timestamps and generated IDs in traces.
- Record timer registrations and delayed events where easy.
- Use quiescence waits instead of fake time.
### Stage 2: Deterministic Data Sources
- Add deterministic ID generation behind an injectable service or simulation mode.
- Replace `Math.random()` usage in TUI placeholders/tests with seeded RNG in simulation mode.
- Replace provider, MCP, network, and unsafe tools with deterministic services.
### Stage 3: Controlled Clock
- Move high-impact backend `Date.now()` call sites to Effect clock/time services.
- Add a simulation clock service.
- Let the runner advance logical time.
### Stage 4: Controlled Timers And Event Loop
- Wrap TUI timer use through a scheduler service where practical.
- Expose SDK event batching timers to the harness.
- Let the runner advance timers as part of quiescence.
### Stage 5: Async Interleaving Exploration
- Randomize or systematically vary ordering of queued events, LLM chunks, tool completions, and sync flushes.
- Replay exact interleavings from traces.
## Implementation Passes
### Pass 1: Backend-Only Deterministic Prompt Runner
Deliverables:
- Local mock LLM provider/model registered through the normal provider path.
- Simulation control state and raw or typed control endpoint.
- Sandboxed backend runner that loads the normal app and applies only narrow core overrides, starting with `AppFileSystem.Service`.
- Process bootstrap that forces `OPENCODE_DB=:memory:` before backend modules load.
- Initial backend mock filesystem service with seeded fixture support.
- macOS `sandbox-exec` runner wrapper that denies external network and host filesystem access.
- Seeded generation of LLM scripts based on available tools.
- Replay trace for backend-only prompt runs.
Scope:
- Create session.
- Seed mock filesystem contents.
- Enqueue LLM script.
- Call `prompt_async`.
- Wait for idle over events.
- Assert basic backend properties.
Validation:
- Run 10 to 100 generated backend prompt cases without external network.
- Prove filesystem reads/writes hit the mock filesystem and not the host filesystem.
- Prove tool-call, text, reasoning, and error scripts hit the real `SessionPrompt` and `SessionProcessor` path.
### Pass 2: TUI Prompt Smoke Runner
Deliverables:
- Optional `testing` hook in `tui(...)`/`App`.
- In-process TUI harness exposing prompt ref, route, sync, keymap, SDK, and renderer.
- Fetch/event wrappers that add simulation action headers and record requests/events.
- A runner action that enqueues LLM script, sets prompt text through TUI, submits, waits for idle, and checks no crash.
Scope:
- Prompt input and submit only.
- Normal text and one tool-call script.
- Real backend, fake external services.
Validation:
- Run TUI -> prompt -> backend -> LLM script -> tool/result -> TUI message display.
- Persist and replay a trace.
### Pass 3: Semantic UI Registry
Deliverables:
- `TuiSemanticProvider` and registry API.
- Instrument prompt, app commands, prompt commands, route state, dialog select, permission, and question components.
- Snapshot current semantic elements from the harness.
- Random semantic action generator.
Scope:
- Commands, prompt text/submit, dialog option select, permission approve/reject, question answer/reject.
Validation:
- Generate random semantic actions for a fixed number of steps.
- Build a small UI transition graph.
- Replay any generated sequence.
### Pass 4: Directed Property Runner
Deliverables:
- Property registration API.
- Domain-based property filtering.
- Built-in no-crash/no-network/session-idle/tool-lifecycle properties.
- Directed generation targets based on semantic graph.
Scope:
- User asks for a focus area and iteration/depth count.
- Runner biases actions toward graph paths related to that area.
Validation:
- `prompt submit with tool calls` target produces many prompt/tool/session variants.
- `permission flows` target reaches permission UI and exercises approve/reject.
### Pass 5: Backend Graph And Endpoint Mapping
Deliverables:
- Request and event correlation by action ID.
- Backend domain change records.
- Endpoint/action graph export.
- Basic backend state signatures.
Scope:
- Session, message, part, permission, question, todo, tool, and status domains.
Validation:
- Given a UI action, report which backend endpoints and state domains changed.
- Given a backend endpoint/domain, report UI actions that reached it.
### Pass 6: Determinism Hardening
Deliverables:
- Seeded RNG everywhere in the runner.
- Deterministic ID/time mode for high-impact backend paths.
- Timer registration recording.
- More complete network/process guards.
- Complete backend mock filesystem coverage for configured filesystem tools and app services.
Scope:
- Reduce trace normalization.
- Make failures replay reliably across machines.
Validation:
- Same seed and trace produce same observations modulo approved volatile fields.
### Pass 7: Shrinking And Differential Runs
Deliverables:
- Action sequence shrinker.
- Prompt/script shrinker.
- Dual-run differential runner similar in spirit to the old branch.
- Stable normalization rules for diff output.
Scope:
- Compare current branch against baseline or two configurations.
Validation:
- Induced failure shrinks to a short reproducible action trace.
- Differential runner reports meaningful semantic diffs, not timestamp/ID noise.
## Suggested Initial File Layout
```text
packages/opencode/src/testing/simulation/service.ts
packages/opencode/src/testing/simulation/provider.ts
packages/opencode/src/testing/simulation/filesystem.ts
packages/opencode/src/testing/simulation/httpapi.ts
packages/opencode/src/testing/simulation/network.ts
packages/opencode/src/testing/simulation/mcp.ts
packages/opencode/src/testing/simulation/tool-registry.ts
packages/opencode/src/testing/simulation/sandbox.sb
packages/opencode/src/testing/simulation/run.ts
packages/opencode/src/cli/cmd/tui/testing/harness.tsx
packages/opencode/src/cli/cmd/tui/testing/semantic.tsx
packages/opencode/test/property/backend-runner.test.ts
packages/opencode/test/property/tui-runner.test.ts
packages/opencode/test/property/properties.ts
packages/opencode/test/property/generator.ts
```
If we want the harness code completely out of production bundles, keep more of it under `test/property`. The server route, TUI optional hook, and any simulation-gated services that the app imports need to live under `src`.
## Open Questions To Resolve During Implementation
- Should the simulation endpoint be a typed HttpApi route that regenerates SDK, or an internal raw route used only by the runner?
- Should the first mock model target the current AI SDK provider interface directly, or temporarily fake `LLM.Service` while the provider mock is adapted?
- How much renderer tree metadata does OpenTUI expose for stable bounds and visible text snapshots?
- Which tools should be enabled by default in generation: read/glob/grep/todo only, or write/edit against the mock filesystem too?
- Where should trace artifacts live by default so they are easy to inspect but not accidentally committed?
## Recommended Starting Point
Start with Pass 1 and Pass 2.
The smallest useful end-to-end test is:
1. Start an isolated backend under `sandbox-exec` with `OPENCODE_DB=:memory:`, simulation provider, fake MCP, guarded network, and seeded mock filesystem.
2. Mount the TUI with a harness hook and in-process fetch/event transport.
3. Enqueue an LLM script through simulation control.
4. Set the prompt to ordinary text like `hello` and submit through `PromptRef`.
5. Wait for session idle and TUI sync.
6. Assert no TUI/backend errors and that the expected assistant text/tool part appears.
7. Persist a replay trace containing the seed, control call, UI action, requests, events, and observations.
This gives immediate value while leaving room for semantic graph discovery, directed properties, failure shrinking, and true DST controls later.

View file

@ -0,0 +1,32 @@
# Property-Based TUI Testing
Status: split planning docs. The first doc is the one to refine before implementation.
The goal is to drive the TUI against the real opencode app/backend while replacing external effects with deterministic simulation boundaries. We should load the normal app by default and keep overrides narrow.
## Decisions
- No JSON-in-prompt control protocol.
- Use a backend control endpoint for LLM scripts and simulation state.
- Force `OPENCODE_DB=:memory:` in simulation runs before backend modules load.
- Reuse the old branch's `sandbox-exec` wrapper/policy as the local safety boundary.
- Build a first-class mock filesystem by overriding `AppFileSystem.Service`.
- Build a mock `FetchHttpClient` boundary for outbound network responses.
- First built-in property: the app does not crash.
## Implementation Docs
- [01 First Pass](./property-based-tui-testing/01-first-pass.md): concrete implementation plan for mock filesystem, mock HTTP client, control endpoint, mock LLM provider, OpenTUI fake renderer research, and a basic TUI action generator.
- [02 Semantic Discovery](./property-based-tui-testing/02-semantic-discovery.md): speculative UI/backend semantic graph work. Refine before implementation.
- [03 Properties And Replay](./property-based-tui-testing/03-properties-and-replay.md): speculative property API, traces, reports, and shrinking. Refine before implementation.
- [04 DST Hardening](./property-based-tui-testing/04-dst-hardening.md): speculative deterministic clock/timer/async work. Refine before implementation.
- [05 Reference Notes](./property-based-tui-testing/05-reference-notes.md): current code map and prior-branch notes.
## Project Todos
- [ ] Finish and approve `01-first-pass.md`.
- [ ] Implement the first-pass simulation environment.
- [ ] Run a TUI-driven no-crash smoke generator against the simulated backend.
- [ ] Revisit and refine `02-semantic-discovery.md` before semantic graph work.
- [ ] Revisit and refine `03-properties-and-replay.md` before adding more properties.
- [ ] Revisit and refine `04-dst-hardening.md` before adding fake time or async interleaving control.

View file

@ -0,0 +1,263 @@
# 01 First Pass
Status: concrete implementation plan. Refine this document before coding.
This pass should produce the smallest end-to-end system that can drive the TUI against the real app/backend in a deterministic simulation environment and assert only that the app does not crash.
## Scope
Build these pieces first:
- Mock `AppFileSystem.Service` layer.
- Mock `FetchHttpClient` layer with schema-generated responses through `toArbitrary()`.
- Backend simulation control endpoint.
- Mock LLM provider controlled by the endpoint.
- OpenTUI fake renderer/screen-buffer/interactable-element access.
- Basic action generator that drives the TUI forward.
## Non-Goals
- No semantic graph yet.
- No advanced properties beyond no-crash.
- No fake clock/timer control yet.
- No shrinking yet.
- No broad replacement of app services.
## Architecture Rules
- Load the normal app by default.
- Keep overrides narrow and explicit.
- The first core overrides are `AppFileSystem.Service` and `FetchHttpClient.layer`.
- Do not replace `Provider.Service`, `SessionPrompt.Service`, `ToolRegistry.Service`, or the route tree wholesale unless we prove a narrow seam is impossible.
- Force `OPENCODE_DB=:memory:` before any code imports `storage/db.ts`.
- Run local simulation under `sandbox-exec` using the old branch setup as the starting point.
- Do not use prompt text for simulation control.
## Target End-To-End Flow
1. Start opencode through the simulation runner.
2. Runner sets `OPENCODE_DB=:memory:` before backend modules load.
3. Runner installs the mock filesystem and mock HTTP client as narrow core overrides.
4. Runner starts under `sandbox-exec` with host writes denied and external network denied.
5. Runner mounts the TUI with a fake OpenTUI renderer instead of a real terminal.
6. Test calls the simulation endpoint to seed filesystem/network/LLM state.
7. Action generator performs one TUI action.
8. Backend handles real app requests and uses endpoint-provided LLM scripts.
9. Runner waits for quiescence.
10. Built-in no-crash property checks TUI and backend errors.
## Step 1: Mock AppFileSystem
Goal: backend-visible project/config/state files live in memory and never hit the host filesystem.
Implementation shape:
- Add `packages/opencode/src/testing/simulation/filesystem.ts`.
- Implement an in-memory filesystem that can back `AppFileSystem.Service`.
- Seed it from JSON fixtures supplied through the simulation endpoint or runner config.
- Serialize it into replay traces.
- Fail unsupported operations with typed simulation errors instead of silently falling back to host FS.
- Add a minimal route/server startup override for `AppFileSystem.Service` only.
- Use the old branch's Bun preload/plugin redirection only for code paths that bypass `AppFileSystem.Service`.
- Let `sandbox-exec` catch any remaining direct `fs`, `Bun.file`, or process-level filesystem access.
Required capabilities:
- Files and directories.
- Text and binary content.
- Deterministic `stat` metadata.
- Deterministic path resolution for workspace root, cwd, home, config, state, and temp.
- Reads and writes used by tools and config loading.
- Directory listing and recursive traversal for glob/grep equivalents.
- Snapshot/diff support or enough primitives for existing snapshot code to work.
Todos:
- [x] Inspect `AppFileSystem.Service` interface and all methods used by backend code.
- [x] List direct `@/util/filesystem`, `fs`, and `Bun.file` bypasses that matter in simulation mode.
- [x] Define mock filesystem data model and fixture JSON format.
- [x] Implement the `AppFileSystem.Service` layer.
- [x] Add typed errors for unsupported operations and host-FS escapes.
- [x] Add activation path from the simulation runner into app startup.
- [x] Add a tiny fixture that includes `opencode.json`, a workspace root, and a few files.
- [ ] Verify read/glob/grep/write/edit use the mock filesystem.
- [ ] Verify sandbox denies host writes when a bypass is introduced.
## Step 2: Mock FetchHttpClient
Goal: no backend code makes external network calls. Calls either return generated deterministic mock data or fail with a typed simulation error.
Implementation shape:
- Add `packages/opencode/src/testing/simulation/network.ts`.
- Provide a narrow replacement for `FetchHttpClient.layer` / `HttpClient.HttpClient` in simulation startup.
- Allow loopback only when needed for local app/TUI communication.
- Deny all non-loopback network by default.
- Add a response registry controlled by the simulation endpoint.
- For registered schemas, generate deterministic data with `toArbitrary()` and the run seed.
Schema inference problem:
- Raw HTTP requests do not always carry the desired response schema.
- First implementation should find where schema information exists for each network call path.
- If the schema is not available from the raw `HttpClient` call, add a small registry keyed by request matcher and schema.
- The endpoint can register `{ matcher, schema, seedOffset }`, and the mock client can call `toArbitrary(schema)` to generate the response.
- Unknown requests should fail loudly instead of returning generic data.
Todos:
- [ ] Locate all backend uses of `HttpClient.HttpClient`, raw `fetch`, provider SDK fetches, webfetch/websearch/share/update paths.
- [ ] Decide where `toArbitrary()` lives or which package exports it.
- [ ] Define request matcher shape: method, URL pattern, headers, body predicate.
- [ ] Define schema registration shape for generated responses.
- [ ] Implement seeded response generation with `toArbitrary()`.
- [ ] Add loopback allowlist handling.
- [ ] Add typed simulation error for unregistered non-loopback request.
- [ ] Verify sandbox also blocks external network if mock client is bypassed.
## Step 3: Control Endpoint And Mock LLM Provider
Goal: tests control backend behavior through an endpoint, and the model follows endpoint-provided scripts through the real prompt/session pipeline.
Implementation shape:
- Add simulation control state under `packages/opencode/src/testing/simulation/service.ts`.
- Add HTTP routes under a simulation-gated path like `/experimental/simulation/*`.
- Keep the route inaccessible unless simulation mode is explicitly enabled.
- Register/configure a local mock provider/model through the normal provider path.
- The mock model reads scripts from simulation control state.
- No JSON-in-prompt fallback.
- Missing script means typed simulation error.
Initial endpoints:
- `POST /experimental/simulation/reset`
- `POST /experimental/simulation/filesystem/seed`
- `POST /experimental/simulation/network/register`
- `POST /experimental/simulation/llm/enqueue`
- `GET /experimental/simulation/snapshot`
Initial LLM script:
```ts
type LLMScriptAction =
| { type: "text"; content: string }
| { type: "thinking"; content: string }
| { type: "tool_call"; name: string; input: Record<string, unknown> }
| { type: "list_tools" }
| { type: "error"; message: string }
type LLMScript = {
steps: LLMScriptAction[][]
usage?: { inputTokens: number; outputTokens: number; totalTokens: number }
finish?: "stop" | "tool-calls" | "error" | "length" | "unknown"
}
```
Keep the old useful rule: step `0` runs before tool results, step `N` runs after `N` tool-result rounds.
Todos:
- [ ] Define simulation mode activation flag/env.
- [ ] Add simulation control state and reset semantics.
- [ ] Add gated simulation endpoints.
- [ ] Decide raw route vs typed HttpApi route. If typed, regenerate JS SDK.
- [ ] Implement mock provider/model on the normal provider path.
- [ ] Port the useful stream chunk behavior from the old branch to the current AI SDK interface.
- [ ] Make missing scripts fail with a typed simulation error.
- [ ] Record consumed script step in simulation snapshot.
- [ ] Verify `session.prompt_async` exercises real `SessionPrompt` and `SessionProcessor`.
## Step 4: OpenTUI Fake Renderer And Interactable Elements
Goal: run the TUI without a real terminal, inspect the screen buffer, and discover/act on interactable elements.
Known starting points:
- Current TUI creates a real renderer in `packages/opencode/src/cli/cmd/tui/app.tsx` through `createCliRenderer(...)`.
- Existing tests use `@opentui/solid` `testRender(...)`.
- Existing tests use `@opentui/core/testing` `createTestRenderer(...)` for renderer snapshots.
Implementation shape:
- Add a renderer factory/testing hook to `tui(...)` so tests can pass a fake renderer.
- Do not render to a real terminal in simulation mode.
- Investigate OpenTUI APIs for walking the render tree and extracting focusable/clickable/editable elements.
- Investigate OpenTUI APIs for reading the screen buffer from the fake renderer.
- If OpenTUI does not expose enough semantic information, add a small TUI semantic registry later. Do not block first pass on a full registry.
Todos:
- [ ] Inspect `@opentui/core/testing` `createTestRenderer` capabilities.
- [ ] Inspect `@opentui/solid` `testRender` capabilities.
- [ ] Determine how to get a screen buffer string/snapshot from the fake renderer.
- [ ] Determine how to iterate renderables and identify interactable elements.
- [ ] Add a minimal renderer factory override to `tui(...)` or app startup.
- [ ] Expose prompt ref, route, sync state, keymap, and renderer to the simulation harness.
- [ ] Verify TUI starts in fake renderer with no real terminal output.
- [ ] Verify screen buffer can be captured after a render.
## Step 5: Basic Action Generator
Goal: drive the TUI forward with generated actions and assert only that the app does not crash.
Implementation shape:
- Add a seeded action generator under `packages/opencode/test/property` or `packages/opencode/src/testing/simulation` depending on whether it needs production imports.
- Start with a tiny action set: submit prompt, key command, paste/type text, click/select visible interactable.
- Prefer OpenTUI/fake-renderer interactions over direct component refs where possible.
- Allow direct prompt ref use for the very first smoke path if OpenTUI interaction APIs are not ready.
- After each action, wait for basic quiescence.
- Built-in property is only `app.does-not-crash`.
Initial no-crash check:
```ts
property({
name: "app.does-not-crash",
domains: ["tui", "backend"],
async check(ctx) {
ctx.expect(ctx.tui.errors).toEqual([])
ctx.expect(ctx.backend.errors).toEqual([])
},
})
```
Todos:
- [ ] Define `UIAction` union for the first pass.
- [ ] Implement seeded RNG for action selection.
- [ ] Generate ordinary prompt text and enqueue matching LLM scripts through the control endpoint.
- [ ] Execute actions through fake renderer/OpenTUI APIs where available.
- [ ] Add temporary prompt-ref execution path if needed for first smoke.
- [ ] Wait for quiescence after each action.
- [ ] Capture screen buffer and backend snapshot after each action.
- [ ] Check only `app.does-not-crash`.
- [ ] Persist a simple replay trace with seed, filesystem fixture, network registrations, LLM scripts, actions, and observations.
## First Milestone
The first milestone is one deterministic run that:
- Starts under `sandbox-exec`.
- Uses `OPENCODE_DB=:memory:`.
- Seeds the mock filesystem.
- Mounts the TUI using a fake renderer.
- Enqueues an LLM script through the control endpoint.
- Submits an ordinary prompt through the TUI.
- Receives a mocked model response through the real session pipeline.
- Captures a screen buffer.
- Passes the no-crash property.
## First-Pass Todos
- [x] Mock filesystem layer works.
- [ ] Mock FetchHttpClient works for registered schemas and fails unknown network.
- [ ] Control endpoint can seed filesystem, register network schemas, enqueue LLM scripts, and snapshot state.
- [ ] Mock provider/model consumes endpoint scripts through the real LLM path.
- [ ] TUI runs with fake renderer.
- [ ] Runner can inspect screen buffer.
- [ ] Runner can identify at least one interactable path to submit a prompt.
- [ ] Basic action generator executes multiple deterministic steps.
- [ ] No-crash property runs after each step.
- [ ] Replay trace is written outside the sandbox.

View file

@ -0,0 +1,103 @@
# 02 Semantic Discovery
Status: speculative. Refine before implementation.
This phase starts after the first-pass action generator can drive the TUI and assert that the app does not crash.
## Goal
Build a semantic map of TUI states, available actions, backend requests, and backend state changes. This lets later runs focus on workflows instead of random screen poking.
## UI Semantics
We need a way to know what the runner can interact with on the current screen.
Preferred order:
- Use OpenTUI render tree/fake renderer APIs if they expose interactable elements.
- Add a small TUI semantic registry only for missing metadata.
- Avoid large per-component instrumentation at first.
Potential semantic element shape:
```ts
type SemanticElement = {
id: string
role: "prompt" | "command" | "dialog" | "dialog-option" | "permission" | "question" | "message" | "route"
label: string
enabled: boolean
visible: boolean
state?: Record<string, unknown>
bounds?: { x: number; y: number; width: number; height: number }
actions: SemanticAction[]
}
```
## Backend Mapping
Every generated UI action should have an action ID. TUI requests should include simulation headers so backend observations can be correlated.
Headers:
- `x-opencode-simulation-run`
- `x-opencode-simulation-action`
- `x-opencode-simulation-step`
Record requests and events with enough metadata to answer:
- Which UI actions produced which backend requests?
- Which backend domains changed?
- Which TUI states became reachable?
- Which generated path caused the crash if a crash happens?
Backend domains to consider later:
- `session`
- `message`
- `part`
- `permission`
- `question`
- `todo`
- `tool`
- `mcp`
- `filesystem`
- `network`
- `status`
## Graph Shape
The graph should abstract states rather than storing every concrete buffer.
```ts
type SemanticState = {
id: string
route: string
dialog?: string
elementSignature: string
backendSignature?: string
}
type SemanticTransition = {
id: string
from: string
to: string
action: UIAction
uiChanged: string[]
backendRequests: BackendRequestRecord[]
backendEvents: BackendEventRecord[]
failures: SimulationFailure[]
}
```
## Todos
- [ ] Reassess OpenTUI APIs after first-pass fake renderer work.
- [ ] Decide whether a TUI semantic registry is needed.
- [ ] Add action IDs to generated actions.
- [ ] Add action headers to TUI fetch wrapper.
- [ ] Record backend request spans.
- [ ] Record backend events and changed domains.
- [ ] Define normalized UI state signatures.
- [ ] Build first UI transition graph artifact.
- [ ] Build first backend endpoint/domain graph artifact.
- [ ] Use graph to bias action generation toward a selected workflow.

View file

@ -0,0 +1,99 @@
# 03 Properties And Replay
Status: speculative. Refine before implementation.
The first pass only checks that the app does not crash. Add more properties only after the basic runner and traces are stable.
## Property API
Properties should be ordinary TypeScript functions registered with the runner.
```ts
type Property = {
name: string
domains: string[]
check: (ctx: PropertyContext) => Promise<void>
}
```
The `domains` field lets the runner skip checks when unrelated state changed.
First pass property:
```ts
property({
name: "app.does-not-crash",
domains: ["tui", "backend"],
async check(ctx) {
ctx.expect(ctx.tui.errors).toEqual([])
ctx.expect(ctx.backend.errors).toEqual([])
},
})
```
Later candidate properties:
- No non-loopback network call.
- Session eventually becomes idle after prompt-like actions.
- No pending tool call remains after idle.
- Every TUI-visible session message has valid message/part schemas.
- Permission/question overlays correspond to backend pending requests.
- Replay trace can be parsed and rerun.
- Text should not flicker across stable frames.
- Dialog focus should remain valid.
- Route state and visible route agree.
- Backend DB invariants hold after endpoint groups.
- Tool call lifecycle events are balanced.
- No generated action sequence can strand a session in busy state.
## Failure Reports
Failure reports should be human-readable and point to the smallest useful context.
Report fields:
- Failed property name.
- Seed and action index.
- Minimal replay command.
- Last N UI actions.
- Backend requests/events caused by the failing action.
- Visible TUI buffer before and after.
- Relevant session/message/tool IDs.
## Replay Trace
Trace fields:
- Seed and run configuration.
- Mock filesystem fixture and workspace/config path mapping.
- Mock network schema registrations.
- Simulation control calls.
- LLM scripts consumed.
- UI action sequence.
- HTTP request records.
- Backend events.
- UI observations before/after each action.
- Property checks and failure details.
- Normalization version.
## Shrinking
Shrinking should come after exact replay is reliable.
Candidate shrink steps:
- Delete contiguous chunks of actions.
- Reduce generated prompt text.
- Reduce LLM scripts to fewer actions/steps.
- Prefer semantic action shrinking over raw key shrinking.
- Preserve control calls needed to reproduce backend state.
## Todos
- [ ] Keep first pass to `app.does-not-crash` only.
- [ ] Define trace JSON schema after first runner exists.
- [ ] Write replay command that reruns an exact trace.
- [ ] Add readable failure report formatter.
- [ ] Add network property after mock network is stable.
- [ ] Add session/tool lifecycle properties after backend mapping is stable.
- [ ] Add shrinker only after replay is deterministic.

View file

@ -0,0 +1,52 @@
# 04 DST Hardening
Status: speculative. Refine before implementation.
This phase moves from seeded generation plus replay toward deterministic simulation testing. Do not start here; first get the app running under the first-pass simulation environment.
## Stage 1: Record And Normalize
- Seed RNG for the runner.
- Normalize timestamps and generated IDs in traces.
- Record timer registrations and delayed events where easy.
- Use quiescence waits instead of fake time.
## Stage 2: Deterministic Data Sources
- Add deterministic ID generation behind a narrow simulation mode if normalization becomes too noisy.
- Replace `Math.random()` usage in simulation-facing paths with seeded RNG.
- Keep provider, filesystem, and network deterministic through the first-pass simulation boundaries.
## Stage 3: Controlled Clock
- Move high-impact backend `Date.now()` call sites to Effect clock/time services where practical.
- Add a simulation clock service.
- Let the runner advance logical time.
## Stage 4: Controlled Timers And Event Loop
- Wrap TUI timer use through a scheduler service where practical.
- Expose SDK event batching timers to the harness.
- Let the runner advance timers as part of quiescence.
## Stage 5: Async Interleaving Exploration
- Randomize or systematically vary ordering of queued events, LLM chunks, tool completions, and sync flushes.
- Replay exact interleavings from traces.
## Differential Runs
Later, reuse the useful idea from the old branch's differential runner:
- Run the same trace against two app versions or two configurations.
- Normalize volatile fields.
- Report semantic diffs instead of timestamp/ID noise.
## Todos
- [ ] Define which nondeterminism remains after first-pass replay.
- [ ] Decide whether deterministic IDs are needed or trace normalization is enough.
- [ ] Identify highest-impact `Date.now()` call sites.
- [ ] Design a minimal simulation clock only if needed.
- [ ] Design timer control only after fake renderer/action runner behavior is stable.
- [ ] Add differential runner after trace replay is reliable.

View file

@ -0,0 +1,69 @@
# 05 Reference Notes
Status: reference material. Keep this short and update as implementation discovers new seams.
## Current TUI Map
- `packages/opencode/src/cli/cmd/tui/thread.ts`: starts the TUI worker and in-process transport.
- `packages/opencode/src/cli/cmd/tui/app.tsx`: creates the OpenTUI renderer/keymap and renders the Solid app.
- `packages/opencode/src/cli/cmd/tui/context/sdk.tsx`: SDK client, custom fetch, event source, event batching.
- `packages/opencode/src/cli/cmd/tui/context/sync.tsx`: projects backend events into TUI state.
- `packages/opencode/src/cli/cmd/tui/context/route.tsx`: route state.
- `packages/opencode/src/cli/cmd/tui/context/prompt.tsx`: current prompt ref.
- `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`: prompt input and submit path.
- `packages/opencode/src/cli/cmd/tui/keymap.tsx`: base keymap registration and `useBindings` exports.
- `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`: useful model for harness context exposure.
## Current Backend Map
- `packages/opencode/src/server/server.ts`: exposes `Server.Default().app.request(...)`.
- `packages/opencode/src/server/routes/instance/httpapi/server.ts`: route tree and production layers.
- `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts`: prompt/prompt_async/session endpoints.
- `packages/opencode/src/session/prompt.ts`: real prompt loop, tool resolution, LLM orchestration.
- `packages/opencode/src/session/llm.ts`: provider language model seam and `streamText(...)` call.
- `packages/opencode/src/provider/provider.ts`: normal provider discovery/loading path.
- `packages/opencode/src/mcp/index.ts`: MCP network/process seam.
- `packages/opencode/src/tool/registry.ts`: built-in and plugin tool registry.
- `packages/opencode/src/storage/db.ts`: `OPENCODE_DB` and `:memory:` support.
- `packages/opencode/src/id/id.ts`: timestamp/random ID generation.
## Prior Branch Notes
Branch: `jlongster/fuzz-backend`.
Useful ideas to reuse:
- Mock AI SDK provider emitted real language-model stream chunks.
- Compact LLM script action format worked well.
- Step selection by counting tool-result rounds worked well.
- HTTP/SSE backend runner waited for `session.status` idle.
- Tool discovery and schema-shaped fake input generation were useful.
- TUI runner used internal prompt ref to submit scripted prompts.
- Differential runner normalized volatile fields and compared runs.
- SQLite was forced to `:memory:`.
- `sandbox-exec` denied external network and host filesystem access.
- Bun preload/plugin direction can catch imports that bypass service boundaries.
Things to avoid:
- No JSON-in-prompt protocol or fallback.
- No unseeded `Math.random()` in generated actions.
- No partial mock filesystem that silently falls back to host FS.
- No broad replacement of app service graph when a narrow override works.
## Old Sandbox Setup
Starting files on prior branch:
- `packages/opencode/src/provider/sdk/mock/sandbox.sb`
- `packages/opencode/src/provider/sdk/mock/run`
Important behavior:
- `sandbox-exec -f ... -D HOME=$HOME bun --preload ... src/index.ts serve`
- `(allow default)` so the process can boot.
- `(deny network*)` with localhost re-allowed.
- `(deny file-write*)`.
- Deny reads from `$HOME/.local` and `$HOME/.config`.
Adapt this setup into the new simulation runner layout rather than inventing a new sandbox policy first.

View file

@ -10,6 +10,7 @@ import {
} from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Auth } from "@/auth"
@ -54,6 +55,7 @@ import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { Workspace } from "@/control-plane/workspace"
import { SimulationFileSystem } from "@/testing/simulation/filesystem"
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth"
@ -230,7 +232,7 @@ export function createRoutes(
Workspace.defaultLayer,
Worktree.appLayer,
Bus.layer,
AppFileSystem.defaultLayer,
Flag.OPENCODE_MOCK ? SimulationFileSystem.layer({ root: "/opencode" }) : AppFileSystem.defaultLayer,
FetchHttpClient.layer,
HttpServer.layerServices,
]),

View file

@ -0,0 +1,406 @@
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Glob } from "@opencode-ai/core/util/glob"
import { Effect, FileSystem, Layer, Option, Stream } from "effect"
import { badArgument, systemError, type PlatformError } from "effect/PlatformError"
import path from "path"
type Entry =
| { readonly type: "directory"; readonly mode: number; readonly modified: Date }
| { readonly type: "file"; readonly mode: number; readonly modified: Date; readonly content: Uint8Array }
export interface Options {
readonly root: string
readonly files?: Record<string, string | Uint8Array>
}
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const notFound = (method: string, file: string) =>
systemError({
_tag: "NotFound",
module: "SimulationFileSystem",
method,
description: "No such file or directory",
pathOrDescriptor: file,
})
const alreadyExists = (method: string, file: string) =>
systemError({
_tag: "AlreadyExists",
module: "SimulationFileSystem",
method,
description: "Path already exists",
pathOrDescriptor: file,
})
const permissionDenied = (method: string, file: string) =>
systemError({
_tag: "PermissionDenied",
module: "SimulationFileSystem",
method,
description: "Path is outside the simulated filesystem root",
pathOrDescriptor: file,
})
const unsupported = (method: string) =>
badArgument({
module: "SimulationFileSystem",
method,
description: "Operation is not supported by the simulated filesystem",
})
export function make(options: Options) {
const root = path.resolve(options.root)
const entries = new Map<string, Entry>()
const temp = { value: 0 }
const normalize = (method: string, file: string): string | PlatformError => {
const resolved = path.resolve(root, file)
if (resolved === root || AppFileSystem.contains(root, resolved)) return resolved
return permissionDenied(method, file)
}
const touch = () => new Date(0)
const ensureParentDirs = (file: string) => {
const parent = path.dirname(file)
if (parent === file) return
if (entries.has(parent)) return
ensureParentDirs(parent)
entries.set(parent, { type: "directory", mode: 0o755, modified: touch() })
}
const entry = (method: string, file: string) => {
const normalized = normalize(method, file)
if (typeof normalized !== "string") return normalized
return entries.get(normalized) ?? notFound(method, file)
}
const descendants = (dir: string) =>
[...entries.keys()].filter((item) => item !== dir && AppFileSystem.contains(dir, item))
const children = (dir: string) =>
[...entries.keys()]
.filter((item) => item !== dir && path.dirname(item) === dir)
.sort((a, b) => path.basename(a).localeCompare(path.basename(b)))
const writeBytes = (method: string, file: string, content: Uint8Array, mode?: number) => {
const normalized = normalize(method, file)
if (typeof normalized !== "string") return Effect.fail(normalized)
const parent = entries.get(path.dirname(normalized))
if (!parent) return Effect.fail(notFound(method, path.dirname(file)))
if (parent.type !== "directory") return Effect.fail(notFound(method, path.dirname(file)))
entries.set(normalized, { type: "file", mode: mode ?? 0o644, modified: touch(), content: content.slice() })
return Effect.void
}
entries.set(root, { type: "directory", mode: 0o755, modified: touch() })
for (const [file, content] of Object.entries(options.files ?? {})) {
const normalized = normalize("seed", file)
if (typeof normalized !== "string") continue
ensureParentDirs(normalized)
entries.set(normalized, {
type: "file",
mode: 0o644,
modified: touch(),
content: typeof content === "string" ? encoder.encode(content) : content.slice(),
})
}
const base = FileSystem.make({
access: (file) =>
Effect.gen(function* () {
const result = entry("access", file)
if (result instanceof Error) return yield* result
}),
chmod: (file, mode) =>
Effect.gen(function* () {
const result = entry("chmod", file)
if (result instanceof Error) return yield* result
entries.set(path.resolve(root, file), { ...result, mode })
}),
chown: () => Effect.fail(unsupported("chown")),
copy: (fromPath, toPath) =>
Effect.gen(function* () {
const from = entry("copy", fromPath)
if (from instanceof Error) return yield* from
if (from.type === "directory") return yield* unsupported("copy")
yield* writeBytes("copy", toPath, from.content, from.mode)
}),
copyFile: (fromPath, toPath) =>
Effect.gen(function* () {
const from = entry("copyFile", fromPath)
if (from instanceof Error) return yield* from
if (from.type !== "file") return yield* notFound("copyFile", fromPath)
yield* writeBytes("copyFile", toPath, from.content, from.mode)
}),
link: () => Effect.fail(unsupported("link")),
makeDirectory: (file, methodOptions) =>
Effect.gen(function* () {
const normalized = normalize("makeDirectory", file)
if (typeof normalized !== "string") return yield* normalized
const existing = entries.get(normalized)
if (existing?.type === "directory") return
if (existing) return yield* alreadyExists("makeDirectory", file)
if (methodOptions?.recursive) {
ensureParentDirs(normalized)
entries.set(normalized, { type: "directory", mode: methodOptions.mode ?? 0o755, modified: touch() })
return
}
const parent = entries.get(path.dirname(normalized))
if (parent?.type !== "directory") return yield* notFound("makeDirectory", path.dirname(file))
entries.set(normalized, { type: "directory", mode: methodOptions?.mode ?? 0o755, modified: touch() })
}),
makeTempDirectory: (methodOptions) =>
Effect.gen(function* () {
const directory = methodOptions?.directory ?? root
const name = `${methodOptions?.prefix ?? "tmp-"}${++temp.value}`
const file = path.join(directory, name)
yield* base.makeDirectory(file, { recursive: true })
return path.resolve(root, file)
}),
makeTempDirectoryScoped: (methodOptions) =>
Effect.acquireRelease(
base.makeTempDirectory(methodOptions),
(file) => base.remove(file, { recursive: true, force: true }).pipe(Effect.ignore),
),
makeTempFile: (methodOptions) =>
Effect.gen(function* () {
const directory = methodOptions?.directory ?? root
const file = path.join(directory, `${methodOptions?.prefix ?? "tmp-"}${++temp.value}${methodOptions?.suffix ?? ""}`)
yield* writeBytes("makeTempFile", file, new Uint8Array())
return path.resolve(root, file)
}),
makeTempFileScoped: (methodOptions) =>
Effect.acquireRelease(base.makeTempFile(methodOptions), (file) => base.remove(file, { force: true }).pipe(Effect.ignore)),
open: (file) =>
Effect.gen(function* () {
let position = 0
const readCurrent = () => {
const result = entry("open", file)
return result instanceof Error || result.type !== "file" ? undefined : result.content
}
const current = readCurrent()
if (!current) return yield* notFound("open", file)
return {
[FileSystem.FileTypeId]: FileSystem.FileTypeId,
fd: FileSystem.FileDescriptor(0),
stat: base.stat(file),
seek: (offset, from) =>
Effect.sync(() => {
position = from === "start" ? Number(offset) : position + Number(offset)
}),
sync: Effect.void,
read: (buffer) =>
Effect.sync(() => {
const content = readCurrent() ?? new Uint8Array()
const chunk = content.slice(position, position + buffer.length)
buffer.set(chunk)
position += chunk.length
return FileSystem.Size(chunk.length)
}),
readAlloc: (size) =>
Effect.sync(() => {
const content = readCurrent() ?? new Uint8Array()
const chunk = content.slice(position, position + Number(size))
position += chunk.length
return chunk.length === 0 ? Option.none() : Option.some(chunk)
}),
truncate: (size) => base.truncate(file, size),
write: () => Effect.fail(unsupported("file.write")),
writeAll: () => Effect.fail(unsupported("file.writeAll")),
}
}),
readDirectory: (file, methodOptions) =>
Effect.gen(function* () {
const normalized = normalize("readDirectory", file)
if (typeof normalized !== "string") return yield* normalized
const current = entries.get(normalized)
if (current?.type !== "directory") return yield* notFound("readDirectory", file)
const items = methodOptions?.recursive ? descendants(normalized) : children(normalized)
return items.map((item) => path.relative(normalized, item))
}),
readFile: (file) =>
Effect.gen(function* () {
const result = entry("readFile", file)
if (result instanceof Error) return yield* result
if (result.type !== "file") return yield* notFound("readFile", file)
return result.content.slice()
}),
readLink: () => Effect.fail(unsupported("readLink")),
realPath: (file) =>
Effect.gen(function* () {
const normalized = normalize("realPath", file)
if (typeof normalized !== "string") return yield* normalized
const current = entries.get(normalized)
if (!current) return yield* notFound("realPath", file)
return normalized
}),
remove: (file, methodOptions) =>
Effect.gen(function* () {
const normalized = normalize("remove", file)
if (typeof normalized !== "string") return yield* normalized
const current = entries.get(normalized)
if (!current) {
if (methodOptions?.force) return
return yield* notFound("remove", file)
}
if (current.type === "directory" && descendants(normalized).length > 0 && !methodOptions?.recursive) {
return yield* systemError({
_tag: "BadResource",
module: "SimulationFileSystem",
method: "remove",
description: "Directory is not empty",
pathOrDescriptor: file,
})
}
for (const item of descendants(normalized)) entries.delete(item)
entries.delete(normalized)
}),
rename: (oldPath, newPath) =>
Effect.gen(function* () {
const oldNormalized = normalize("rename", oldPath)
if (typeof oldNormalized !== "string") return yield* oldNormalized
const newNormalized = normalize("rename", newPath)
if (typeof newNormalized !== "string") return yield* newNormalized
const current = entries.get(oldNormalized)
if (!current) return yield* notFound("rename", oldPath)
ensureParentDirs(newNormalized)
entries.set(newNormalized, current)
entries.delete(oldNormalized)
for (const item of descendants(oldNormalized)) {
const child = entries.get(item)
if (!child) continue
entries.set(path.join(newNormalized, path.relative(oldNormalized, item)), child)
entries.delete(item)
}
}),
stat: (file) =>
Effect.gen(function* () {
const result = entry("stat", file)
if (result instanceof Error) return yield* result
return {
type: result.type === "directory" ? "Directory" : "File",
mtime: Option.some(result.modified),
atime: Option.some(result.modified),
birthtime: Option.some(result.modified),
dev: 0,
ino: Option.none(),
mode: result.mode,
nlink: Option.none(),
uid: Option.none(),
gid: Option.none(),
rdev: Option.none(),
size: FileSystem.Size(result.type === "file" ? result.content.length : 0),
blksize: Option.none(),
blocks: Option.none(),
} satisfies FileSystem.File.Info
}),
symlink: () => Effect.fail(unsupported("symlink")),
truncate: (file, size = 0) =>
Effect.gen(function* () {
const result = entry("truncate", file)
if (result instanceof Error) return yield* result
if (result.type !== "file") return yield* notFound("truncate", file)
const next = new Uint8Array(Number(size))
next.set(result.content.slice(0, next.length))
entries.set(path.resolve(root, file), { ...result, content: next, modified: touch() })
}),
utimes: (file, _atime, mtime) =>
Effect.gen(function* () {
const result = entry("utimes", file)
if (result instanceof Error) return yield* result
entries.set(path.resolve(root, file), { ...result, modified: typeof mtime === "number" ? new Date(mtime) : mtime })
}),
watch: () => Stream.fail(unsupported("watch")),
writeFile: (file, content, methodOptions) => writeBytes("writeFile", file, content, methodOptions?.mode),
})
const glob = (pattern: string, globOptions?: Glob.Options) =>
Effect.gen(function* () {
const cwd = path.resolve(root, globOptions?.cwd ?? root)
const normalized = normalize("glob", cwd)
if (typeof normalized !== "string") return yield* normalized
const matches = [...entries.entries()]
.filter(([, item]) => globOptions?.include === "all" || item.type === "file")
.map(([file]) => ({ file, relative: path.relative(normalized, file) }))
.filter((item) => item.relative && !item.relative.startsWith("..") && Glob.match(pattern, item.relative))
.map((item) => (globOptions?.absolute ? item.file : item.relative))
.sort((a, b) => a.localeCompare(b))
return matches
})
const service = AppFileSystem.Service.of({
...base,
isDir: (file) => base.stat(file).pipe(Effect.map((info) => info.type === "Directory"), Effect.catch(() => Effect.succeed(false))),
isFile: (file) => base.stat(file).pipe(Effect.map((info) => info.type === "File"), Effect.catch(() => Effect.succeed(false))),
existsSafe: (file) => base.exists(file).pipe(Effect.orElseSucceed(() => false)),
readFileStringSafe: (file) => base.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined))),
readJson: (file) => base.readFileString(file).pipe(Effect.map((content) => JSON.parse(content))),
writeJson: (file, data, mode) =>
base.writeFileString(file, JSON.stringify(data, null, 2)).pipe(Effect.andThen(mode ? base.chmod(file, mode) : Effect.void)),
ensureDir: (file) => base.makeDirectory(file, { recursive: true }),
writeWithDirs: (file, content, mode) =>
Effect.gen(function* () {
yield* base.makeDirectory(path.dirname(file), { recursive: true })
if (typeof content === "string") yield* base.writeFileString(file, content, mode ? { mode } : undefined)
else yield* base.writeFile(file, content, mode ? { mode } : undefined)
}),
readDirectoryEntries: (file) =>
Effect.gen(function* () {
const normalized = normalize("readDirectoryEntries", file)
if (typeof normalized !== "string") return yield* normalized
const current = entries.get(normalized)
if (current?.type !== "directory") return yield* notFound("readDirectoryEntries", file)
return children(normalized).map((child) => {
const item = entries.get(child)
return {
name: path.basename(child),
type: item?.type === "directory" ? "directory" : item?.type === "file" ? "file" : "other",
} satisfies AppFileSystem.DirEntry
})
}),
findUp: (target, start, stop) =>
service.up({ targets: [target], start, stop }),
up: (methodOptions) =>
Effect.gen(function* () {
const result: string[] = []
let current = path.resolve(root, methodOptions.start)
const stop = methodOptions.stop ? path.resolve(root, methodOptions.stop) : undefined
while (true) {
for (const target of methodOptions.targets) {
const file = path.join(current, target)
if (yield* base.exists(file)) result.push(file)
}
if (stop === current) break
const parent = path.dirname(current)
if (parent === current || !AppFileSystem.contains(root, parent)) break
current = parent
}
return result
}),
globUp: (pattern, start, stop) =>
Effect.gen(function* () {
const result: string[] = []
let current = path.resolve(root, start)
const normalizedStop = stop ? path.resolve(root, stop) : undefined
while (true) {
result.push(...(yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true })))
if (normalizedStop === current) break
const parent = path.dirname(current)
if (parent === current || !AppFileSystem.contains(root, parent)) break
current = parent
}
return result
}),
glob,
globMatch: Glob.match,
})
return service
}
export const layer = (options: Options) => Layer.succeed(AppFileSystem.Service)(make(options))
export * as SimulationFileSystem from "./filesystem"

View file

@ -0,0 +1,56 @@
import { describe, expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Exit } from "effect"
import path from "path"
import { SimulationFileSystem } from "../../../src/testing/simulation/filesystem"
import { testEffect } from "../../lib/effect"
const root = "/simulation"
const it = testEffect(
SimulationFileSystem.layer({
root,
files: {
"opencode.json": JSON.stringify({ model: "test/model" }),
"README.md": "hello",
"src/index.ts": "export const value = 1\n",
"src/data.json": JSON.stringify({ ok: true }),
},
}),
)
describe("SimulationFileSystem", () => {
it.effect("reads seeded files and writes nested files", () =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
expect(yield* fs.readFileString(path.join(root, "README.md"))).toBe("hello")
yield* fs.writeWithDirs(path.join(root, "tmp", "result.txt"), "done")
expect(yield* fs.readFileString(path.join(root, "tmp", "result.txt"))).toBe("done")
expect(yield* fs.isDir(path.join(root, "tmp"))).toBe(true)
expect(yield* fs.isFile(path.join(root, "tmp", "result.txt"))).toBe(true)
}),
)
it.effect("lists directory entries and globs in memory", () =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
expect(yield* fs.readDirectoryEntries(path.join(root, "src"))).toEqual([
{ name: "data.json", type: "file" },
{ name: "index.ts", type: "file" },
])
expect(yield* fs.glob("**/*.ts", { cwd: root })).toEqual(["src/index.ts"])
expect(yield* fs.globUp("*.md", path.join(root, "src"), root)).toEqual([path.join(root, "README.md")])
}),
)
it.effect("denies paths outside the simulated root", () =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const exit = yield* fs.readFileString("/etc/passwd").pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
})