From f7221dd032e7814045930eb656c0cc3e6a12098c Mon Sep 17 00:00:00 2001 From: Ahmad Pathan Date: Fri, 28 Aug 2026 10:51:00 +0000 Subject: [PATCH 1/2] Add development container setup --- .devcontainer/Dockerfile | 19 + .devcontainer/devcontainer.json | 7 + .../http-recorder/src/cassette.ts.gcov.html | 255 +++++++++++ .../llm/http-recorder/src/index-sort-f.html | 179 ++++++++ .../llm/http-recorder/src/index-sort-l.html | 179 ++++++++ packages/llm/http-recorder/src/index.html | 179 ++++++++ .../src/internal-effect.ts.gcov.html | 265 ++++++++++++ .../http-recorder/src/internal.ts.gcov.html | 91 ++++ .../http-recorder/src/matching.ts.gcov.html | 182 ++++++++ .../http-recorder/src/recorder.ts.gcov.html | 138 ++++++ .../http-recorder/src/redaction.ts.gcov.html | 193 +++++++++ .../http-recorder/src/redactor.ts.gcov.html | 211 +++++++++ .../llm/http-recorder/src/schema.ts.gcov.html | 163 +++++++ .../llm/http-recorder/src/socket.ts.gcov.html | 402 ++++++++++++++++++ .../http-recorder/src/websocket.ts.gcov.html | 249 +++++++++++ packages/llm/schema/src/index-sort-f.html | 107 +++++ packages/llm/schema/src/index-sort-l.html | 107 +++++ packages/llm/schema/src/index.html | 107 +++++ packages/llm/schema/src/llm.ts.gcov.html | 104 +++++ packages/llm/schema/src/schema.ts.gcov.html | 106 +++++ 20 files changed, 3243 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 packages/llm/http-recorder/src/cassette.ts.gcov.html create mode 100644 packages/llm/http-recorder/src/index-sort-f.html create mode 100644 packages/llm/http-recorder/src/index-sort-l.html create mode 100644 packages/llm/http-recorder/src/index.html create mode 100644 packages/llm/http-recorder/src/internal-effect.ts.gcov.html create mode 100644 packages/llm/http-recorder/src/internal.ts.gcov.html create mode 100644 packages/llm/http-recorder/src/matching.ts.gcov.html create mode 100644 packages/llm/http-recorder/src/recorder.ts.gcov.html create mode 100644 packages/llm/http-recorder/src/redaction.ts.gcov.html create mode 100644 packages/llm/http-recorder/src/redactor.ts.gcov.html create mode 100644 packages/llm/http-recorder/src/schema.ts.gcov.html create mode 100644 packages/llm/http-recorder/src/socket.ts.gcov.html create mode 100644 packages/llm/http-recorder/src/websocket.ts.gcov.html create mode 100644 packages/llm/schema/src/index-sort-f.html create mode 100644 packages/llm/schema/src/index-sort-l.html create mode 100644 packages/llm/schema/src/index.html create mode 100644 packages/llm/schema/src/llm.ts.gcov.html create mode 100644 packages/llm/schema/src/schema.ts.gcov.html diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..cc5b8593 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/devcontainers/base:ubuntu + +USER root + +RUN apt-get update \ + && apt-get install -y \ + curl \ + unzip \ + git \ + python3 \ + python3-pip \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +USER vscode + +RUN curl -fsSL https://bun.sh/install | bash + +ENV PATH="/home/vscode/.bun/bin:${PATH}" \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..88b7925c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,7 @@ +{ + "name": "1A Dev Container", + "build": { + "dockerfile": "Dockerfile" + }, + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/packages/llm/http-recorder/src/cassette.ts.gcov.html b/packages/llm/http-recorder/src/cassette.ts.gcov.html new file mode 100644 index 00000000..0ca5807e --- /dev/null +++ b/packages/llm/http-recorder/src/cassette.ts.gcov.html @@ -0,0 +1,255 @@ + + + + + + + LCOV - lcov.info - ../http-recorder/src/cassette.ts + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../http-recorder/src - cassette.tsCoverageTotalHit
Test:lcov.infoLines:38.5 %14355
Test Date:2026-08-28 10:46:17Functions:-00
+
+ + + + + + + + +

+
            Line data    Source code
+
+       1           41 : import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect"
+       2           30 : import * as fs from "node:fs"
+       3           34 : import * as path from "node:path"
+       4           69 : import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction.js"
+       5           61 : import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema.js"
+       6              : 
+       7           93 : const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
+       8              : 
+       9           96 : export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
+      10           28 :   cassetteName: Schema.String,
+      11            0 : }) {
+      12            0 :   override get message() {
+      13            1 :     return `Cassette "${this.cassetteName}" not found`
+      14              :   }
+      15            1 : }
+      16              : 
+      17           92 : export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
+      18           30 :   cassetteName: Schema.String,
+      19           44 :   findings: Schema.Array(SecretFindingSchema),
+      20            0 : }) {
+      21            0 :   override get message() {
+      22            0 :     return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
+      23            0 :       .map((finding) => `${finding.path} (${finding.reason})`)
+      24            1 :       .join(", ")}`
+      25              :   }
+      26            1 : }
+      27              : 
+      28              : export interface Interface {
+      29              :   readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
+      30              :   readonly append: (
+      31              :     name: string,
+      32              :     interaction: Interaction,
+      33              :     metadata?: CassetteMetadata,
+      34              :   ) => Effect.Effect<void, UnsafeCassetteError>
+      35              :   readonly exists: (name: string) => Effect.Effect<boolean>
+      36              :   readonly list: () => Effect.Effect<ReadonlyArray<string>>
+      37              : }
+      38              : 
+      39           88 : export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
+      40              : 
+      41           43 : const cassettePath = (directory: string, name: string) => {
+      42          107 :   if (!name || path.isAbsolute(name) || path.win32.isAbsolute(name) || name.split(/[\\/]/).includes(".."))
+      43            2 :     throw new Error(`Invalid cassette name "${name}"`)
+      44           39 :   const root = path.resolve(directory)
+      45           52 :   const target = path.resolve(root, `${name}.json`)
+      46           47 :   const relative = path.relative(root, target)
+      47           75 :   if (!relative || relative.startsWith("..") || path.isAbsolute(relative))
+      48            2 :     throw new Error(`Invalid cassette name "${name}"`)
+      49           15 :   return target
+      50              : }
+      51              : 
+      52           53 : export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
+      53           79 :   fs.existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name))
+      54              : 
+      55            0 : const buildCassette = (
+      56            0 :   name: string,
+      57            0 :   interactions: ReadonlyArray<Interaction>,
+      58            0 :   metadata: CassetteMetadata | undefined,
+      59            0 : ): Cassette => ({
+      60            0 :   version: 1,
+      61            0 :   metadata: { name, recordedAt: new Date().toISOString(), ...metadata },
+      62              :   interactions,
+      63            2 : })
+      64              : 
+      65           23 : const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
+      66              : 
+      67           86 : const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema))
+      68              : 
+      69            0 : const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) =>
+      70            2 :   findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings }))
+      71              : 
+      72           25 : export const fileSystem = (
+      73           17 :   options: { readonly directory?: string } = {},
+      74              : ): Layer.Layer<Service, never, FileSystem.FileSystem> =>
+      75           13 :   Layer.effect(
+      76            9 :     Service,
+      77           15 :     Effect.gen(function* () {
+      78           42 :       const fs = yield* FileSystem.FileSystem
+      79           64 :       const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
+      80           27 :       const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
+      81           46 :       const appendLock = yield* Semaphore.make(1)
+      82              : 
+      83           56 :       const pathFor = (name: string) => cassettePath(directory, name)
+      84              : 
+      85            0 :       const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
+      86            0 :         Effect.gen(function* () {
+      87            0 :           const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
+      88            0 :           const nested = yield* Effect.forEach(entries, (entry) => {
+      89            0 :             const full = path.join(current, entry)
+      90            0 :             return fs.stat(full).pipe(
+      91            0 :               Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
+      92            0 :               Effect.catch(() => Effect.succeed([] as string[])),
+      93            0 :             )
+      94            0 :           })
+      95              :           return nested.flat()
+      96            3 :         })
+      97              : 
+      98           23 :       return Service.of({
+      99           15 :         read: (name) =>
+     100           38 :           fs.readFileString(pathFor(name)).pipe(
+     101           52 :             Effect.map((raw) => parseCassette(raw).interactions),
+     102           13 :             Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
+     103            5 :           ),
+     104            0 :         append: (name, interaction, metadata) =>
+     105            0 :           appendLock.withPermit(
+     106            0 :             Effect.gen(function* () {
+     107            0 :               const entry = recorded.get(name) ?? { interactions: [], findings: [] }
+     108            0 :               const interactions = [...entry.interactions, interaction]
+     109            0 :               const interactionFindings = [...entry.findings, ...secretFindings(interaction)]
+     110            0 :               const cassette = buildCassette(name, interactions, metadata)
+     111            0 :               const findings = [...interactionFindings, ...secretFindings(cassette.metadata ?? {})]
+     112            0 :               yield* failIfUnsafe(name, findings)
+     113            0 :               const target = pathFor(name)
+     114            0 :               yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.orDie)
+     115            0 :               const temporary = `${target}.${crypto.randomUUID()}.tmp`
+     116            0 :               yield* fs.writeFileString(temporary, formatCassette(cassette)).pipe(
+     117            0 :                 Effect.flatMap(() => fs.rename(temporary, target)),
+     118            0 :                 Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.catch(() => Effect.void))),
+     119            0 :                 Effect.orDie,
+     120            0 :               )
+     121            0 :               recorded.set(name, { interactions, findings: interactionFindings })
+     122              :             }),
+     123            5 :           ),
+     124            0 :         exists: (name) =>
+     125            0 :           fs.access(pathFor(name)).pipe(
+     126            0 :             Effect.as(true),
+     127              :             Effect.catch(() => Effect.succeed(false)),
+     128            5 :           ),
+     129            0 :         list: () =>
+     130            0 :           walk(directory).pipe(
+     131            0 :             Effect.map((files) =>
+     132            0 :               files
+     133            0 :                 .filter((file) => file.endsWith(".json"))
+     134            0 :                 .map((file) =>
+     135            0 :                   path
+     136            0 :                     .relative(directory, file)
+     137            0 :                     .replace(/\\/g, "/")
+     138            0 :                     .replace(/\.json$/, ""),
+     139            0 :                 )
+     140            0 :                 .toSorted((a, b) => a.localeCompare(b)),
+     141              :             ),
+     142            2 :           ),
+     143            2 :       })
+     144            1 :     }),
+     145            2 :   )
+     146              : 
+     147            0 : export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
+     148            0 :   Layer.sync(Service, () => {
+     149            0 :     const stored = new Map<string, Interaction[]>(
+     150            0 :       Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
+     151            0 :     )
+     152            0 :     const accumulatedFindings = new Map<string, SecretFinding[]>()
+     153            0 :     const appendLock = Semaphore.makeUnsafe(1)
+     154            0 : 
+     155            0 :     return Service.of({
+     156            0 :       read: (name) =>
+     157            0 :         stored.has(name)
+     158            0 :           ? Effect.succeed(stored.get(name) ?? [])
+     159            0 :           : Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
+     160            0 :       append: (name, interaction, metadata) =>
+     161            0 :         appendLock.withPermit(
+     162            0 :           Effect.suspend(() => {
+     163            0 :             const interactions = [...(stored.get(name) ?? []), interaction]
+     164            0 :             const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)]
+     165            0 :             const allFindings = metadata ? [...findings, ...secretFindings({ name, ...metadata })] : findings
+     166            0 :             return failIfUnsafe(name, allFindings).pipe(
+     167            0 :               Effect.tap(() =>
+     168            0 :                 Effect.sync(() => {
+     169            0 :                   stored.set(name, interactions)
+     170            0 :                   accumulatedFindings.set(name, findings)
+     171            0 :                 }),
+     172            0 :               ),
+     173            0 :             )
+     174            0 :           }),
+     175            0 :         ),
+     176            0 :       exists: (name) => Effect.sync(() => stored.has(name)),
+     177            0 :       list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
+     178              :     })
+     179            1 :   })
+        
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/http-recorder/src/index-sort-f.html b/packages/llm/http-recorder/src/index-sort-f.html new file mode 100644 index 00000000..b40b1929 --- /dev/null +++ b/packages/llm/http-recorder/src/index-sort-f.html @@ -0,0 +1,179 @@ + + + + + + + LCOV - lcov.info - ../http-recorder/src + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../http-recorder/srcCoverageTotalHit
Test:lcov.infoLines:43.7 %1063465
Test Date:2026-08-28 10:46:17Functions:-00
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

File Sort by file nameLine Coverage Sort by line coverage
Rate Total Hit
cassette.ts +
38.5%38.5%
+
38.5 %14355
internal-effect.ts +
54.2%54.2%
+
54.2 %14478
internal.ts +
100.0%
+
100.0 %88
matching.ts +
51.2%51.2%
+
51.2 %8644
recorder.ts +
76.2%76.2%
+
76.2 %4232
redaction.ts +
77.1%77.1%
+
77.1 %9674
redactor.ts +
80.2%80.2%
+
80.2 %9173
schema.ts +
98.2%98.2%
+
98.2 %5554
socket.ts +
8.1%8.1%
+
8.1 %27022
websocket.ts +
19.5%19.5%
+
19.5 %12825
Note: 'Function Coverage' columns elided as function owner is not identified.
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/http-recorder/src/index-sort-l.html b/packages/llm/http-recorder/src/index-sort-l.html new file mode 100644 index 00000000..074631ba --- /dev/null +++ b/packages/llm/http-recorder/src/index-sort-l.html @@ -0,0 +1,179 @@ + + + + + + + LCOV - lcov.info - ../http-recorder/src + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../http-recorder/srcCoverageTotalHit
Test:lcov.infoLines:43.7 %1063465
Test Date:2026-08-28 10:46:17Functions:-00
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

File Sort by file nameLine Coverage Sort by line coverage
Rate Total Hit
socket.ts +
8.1%8.1%
+
8.1 %27022
websocket.ts +
19.5%19.5%
+
19.5 %12825
cassette.ts +
38.5%38.5%
+
38.5 %14355
matching.ts +
51.2%51.2%
+
51.2 %8644
internal-effect.ts +
54.2%54.2%
+
54.2 %14478
recorder.ts +
76.2%76.2%
+
76.2 %4232
redaction.ts +
77.1%77.1%
+
77.1 %9674
redactor.ts +
80.2%80.2%
+
80.2 %9173
schema.ts +
98.2%98.2%
+
98.2 %5554
internal.ts +
100.0%
+
100.0 %88
Note: 'Function Coverage' columns elided as function owner is not identified.
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/http-recorder/src/index.html b/packages/llm/http-recorder/src/index.html new file mode 100644 index 00000000..53eb4299 --- /dev/null +++ b/packages/llm/http-recorder/src/index.html @@ -0,0 +1,179 @@ + + + + + + + LCOV - lcov.info - ../http-recorder/src + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../http-recorder/srcCoverageTotalHit
Test:lcov.infoLines:43.7 %1063465
Test Date:2026-08-28 10:46:17Functions:-00
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

File Sort by file nameLine Coverage Sort by line coverage
Rate Total Hit
cassette.ts +
38.5%38.5%
+
38.5 %14355
internal-effect.ts +
54.2%54.2%
+
54.2 %14478
internal.ts +
100.0%
+
100.0 %88
matching.ts +
51.2%51.2%
+
51.2 %8644
recorder.ts +
76.2%76.2%
+
76.2 %4232
redaction.ts +
77.1%77.1%
+
77.1 %9674
redactor.ts +
80.2%80.2%
+
80.2 %9173
schema.ts +
98.2%98.2%
+
98.2 %5554
socket.ts +
8.1%8.1%
+
8.1 %27022
websocket.ts +
19.5%19.5%
+
19.5 %12825
Note: 'Function Coverage' columns elided as function owner is not identified.
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/http-recorder/src/internal-effect.ts.gcov.html b/packages/llm/http-recorder/src/internal-effect.ts.gcov.html new file mode 100644 index 00000000..198eb883 --- /dev/null +++ b/packages/llm/http-recorder/src/internal-effect.ts.gcov.html @@ -0,0 +1,265 @@ + + + + + + + LCOV - lcov.info - ../http-recorder/src/internal-effect.ts + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../http-recorder/src - internal-effect.tsCoverageTotalHit
Test:lcov.infoLines:54.2 %14478
Test Date:2026-08-28 10:46:17Functions:-00
+
+ + + + + + + + +

+
            Line data    Source code
+
+       1           55 : import { NodeFileSystem } from "@effect/platform-node"
+       2           62 : import { Deferred, Effect, Layer, Option, Ref } from "effect"
+       3          160 : import {
+       4              :   FetchHttpClient,
+       5              :   Headers,
+       6              :   HttpBody,
+       7              :   HttpClient,
+       8              :   HttpClientError,
+       9              :   HttpClientRequest,
+      10              :   HttpClientResponse,
+      11              :   UrlParams,
+      12              : } from "effect/unstable/http"
+      13           49 : import * as CassetteService from "./cassette.js"
+      14           65 : import { defaultMatcher, selectSequential } from "./matching.js"
+      15           65 : import { makeReplayState, resolveAutoMode } from "./recorder.js"
+      16           37 : import { make, type Redactor } from "./redactor.js"
+      17           43 : import { redactUrl } from "./redaction.js"
+      18           47 : import { httpInteractions } from "./schema.js"
+      19              : import type { CassetteMetadata, HttpInteraction, RequestMatcher, ResponseSnapshot } from "./types.js"
+      20              : 
+      21           26 : export { defaultMatcher }
+      22              : 
+      23              : export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
+      24              : 
+      25              : export interface RecordReplayOptions {
+      26              :   readonly mode?: RecordReplayMode
+      27              :   readonly directory?: string
+      28              :   readonly metadata?: CassetteMetadata
+      29              :   readonly redactor?: Redactor
+      30              :   readonly match?: RequestMatcher
+      31              : }
+      32              : 
+      33           37 : const TEXT_CONTENT_TYPES = new Set([
+      34           24 :   "application/graphql",
+      35           27 :   "application/javascript",
+      36           21 :   "application/json",
+      37           20 :   "application/sql",
+      38           38 :   "application/x-www-form-urlencoded",
+      39           20 :   "application/xml",
+      40           21 :   "application/yaml",
+      41           16 :   "image/svg+xml",
+      42            3 : ])
+      43              : 
+      44            0 : const isTextContentType = (contentType: string | undefined) => {
+      45            0 :   const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase()
+      46            0 :   if (!mediaType) return false
+      47            0 :   return (
+      48            0 :     mediaType.startsWith("text/") ||
+      49            0 :     mediaType.endsWith("+json") ||
+      50            0 :     mediaType.endsWith("+xml") ||
+      51            2 :     TEXT_CONTENT_TYPES.has(mediaType)
+      52              :   )
+      53              : }
+      54              : 
+      55            0 : const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
+      56            0 :   response.arrayBuffer.pipe(
+      57            0 :     Effect.map((bytes) =>
+      58            0 :       isTextContentType(contentType)
+      59            0 :         ? { body: new TextDecoder().decode(bytes) }
+      60            0 :         : { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const },
+      61              :     ),
+      62            2 :   )
+      63              : 
+      64           39 : const decodeResponseBody = (snapshot: ResponseSnapshot) =>
+      65           89 :   snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
+      66              : 
+      67           50 : const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snapshot: ResponseSnapshot) =>
+      68           27 :   HttpClientResponse.fromWeb(
+      69            9 :     request,
+      70           13 :     new Response(
+      71          108 :       request.method === "HEAD" || snapshot.status === 204 || snapshot.status === 205 || snapshot.status === 304
+      72            2 :         ? null
+      73           29 :         : decodeResponseBody(snapshot),
+      74            8 :       snapshot,
+      75            1 :     ),
+      76            2 :   )
+      77              : 
+      78            0 : export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
+      79            0 :   HttpClientRequest.makeWith(
+      80            0 :     request.method,
+      81            0 :     redactUrl(request.url),
+      82            0 :     UrlParams.empty,
+      83            0 :     Option.none(),
+      84            0 :     Headers.empty,
+      85              :     HttpBody.empty,
+      86            2 :   )
+      87              : 
+      88            0 : const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) =>
+      89            0 :   new HttpClientError.HttpClientError({
+      90              :     reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }),
+      91            2 :   })
+      92              : 
+      93           29 : export const recordingLayer = (
+      94            6 :   name: string,
+      95           17 :   options: Omit<RecordReplayOptions, "directory"> = {},
+      96              : ): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient | CassetteService.Service> =>
+      97           13 :   Layer.effect(
+      98           23 :     HttpClient.HttpClient,
+      99           15 :     Effect.gen(function* () {
+     100           48 :       const upstream = yield* HttpClient.HttpClient
+     101           57 :       const cassetteService = yield* CassetteService.Service
+     102           46 :       const redactor = options.redactor ?? make()
+     103           48 :       const match = options.match ?? defaultMatcher
+     104           43 :       const requested = options.mode ?? "auto"
+     105           48 :       const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
+     106              : 
+     107           36 :       const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
+     108           17 :         Effect.gen(function* () {
+     109           75 :           const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
+     110           31 :           return redactor.request({
+     111           25 :             method: web.method,
+     112           19 :             url: web.url,
+     113           57 :             headers: Object.fromEntries(web.headers.entries()),
+     114           47 :             body: yield* Effect.promise(() => web.text()),
+     115            3 :           })
+     116            3 :         })
+     117              : 
+     118           32 :       if (mode === "passthrough") return upstream
+     119              : 
+     120           22 :       if (mode === "record") {
+     121            0 :         const initial = yield* Deferred.make<void>()
+     122            0 :         yield* Deferred.succeed(initial, undefined)
+     123            0 :         const tail = yield* Ref.make(initial)
+     124            0 :         return HttpClient.make((request) =>
+     125            0 :           Effect.gen(function* () {
+     126            0 :             const completed = yield* Deferred.make<void>()
+     127            0 :             const previous = yield* Ref.modify(tail, (current) => [current, completed])
+     128            0 :             return yield* Effect.gen(function* () {
+     129            0 :               const incoming = yield* snapshotRequest(request)
+     130            0 :               const response = yield* upstream.execute(request)
+     131            0 :               const captured = yield* captureResponseBody(response, response.headers["content-type"])
+     132            0 :               const responseSnapshot: ResponseSnapshot = {
+     133            0 :                 status: response.status,
+     134            0 :                 headers: response.headers as Record<string, string>,
+     135            0 :                 ...captured,
+     136            0 :               }
+     137            0 :               const interaction: HttpInteraction = {
+     138            0 :                 transport: "http",
+     139            0 :                 request: incoming,
+     140            0 :                 response: redactor.response(responseSnapshot),
+     141            0 :               }
+     142            0 :               yield* Deferred.await(previous)
+     143            0 :               yield* cassetteService
+     144            0 :                 .append(name, interaction, options.metadata)
+     145            0 :                 .pipe(
+     146            0 :                   Effect.catchTag("UnsafeCassetteError", (error) =>
+     147            0 :                     Effect.fail(transportError(request, error.message)),
+     148            0 :                   ),
+     149            0 :                 )
+     150            0 :               return responseFromSnapshot(request, responseSnapshot)
+     151              :             }).pipe(Effect.ensuring(Deferred.succeed(completed, undefined)))
+     152              :           }),
+     153            0 :         )
+     154            2 :       }
+     155              : 
+     156           81 :       const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
+     157           35 :       return HttpClient.make((request) =>
+     158           17 :         Effect.gen(function* () {
+     159           53 :           const incoming = yield* snapshotRequest(request)
+     160           30 :           const claimed = yield* replay
+     161           50 :             .claim((interaction, index, interactions) => {
+     162           76 :               const result = selectSequential(interactions, incoming, match, index)
+     163           48 :               if (result.interaction) return Effect.void
+     164            0 :               return Effect.fail(
+     165            0 :                 transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`),
+     166            0 :               )
+     167            2 :             })
+     168            5 :             .pipe(
+     169            0 :               Effect.mapError((error) =>
+     170            0 :                 error._tag === "CassetteNotFoundError"
+     171            0 :                   ? transportError(
+     172            0 :                       request,
+     173            0 :                       `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`,
+     174            0 :                     )
+     175              :                   : error,
+     176            1 :               ),
+     177            6 :             )
+     178           67 :           return responseFromSnapshot(request, claimed.interaction.response)
+     179              :         }),
+     180            1 :       )
+     181            1 :     }),
+     182            2 :   )
+     183              : 
+     184            0 : export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
+     185            0 :   recordingLayer(name, options).pipe(
+     186            0 :     Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
+     187            0 :     Layer.provide(FetchHttpClient.layer),
+     188              :     Layer.provide(NodeFileSystem.layer),
+     189            1 :   )
+        
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/http-recorder/src/internal.ts.gcov.html b/packages/llm/http-recorder/src/internal.ts.gcov.html new file mode 100644 index 00000000..158cc281 --- /dev/null +++ b/packages/llm/http-recorder/src/internal.ts.gcov.html @@ -0,0 +1,91 @@ + + + + + + + LCOV - lcov.info - ../http-recorder/src/internal.ts + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../http-recorder/src - internal.tsCoverageTotalHit
Test:lcov.infoLines:100.0 %88
Test Date:2026-08-28 10:46:17Functions:-00
+
+ + + + + + + + +

+
            Line data    Source code
+
+       1           92 : export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette.js"
+       2           69 : export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./internal-effect.js"
+       3           74 : export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction.js"
+       4           42 : export { socketLayer } from "./socket.js"
+       5           55 : export {
+       6              :   makeWebSocketExecutor,
+       7              :   type WebSocketConnection,
+       8              :   type WebSocketExecutor,
+       9              :   type WebSocketRecordReplayOptions,
+      10              :   type WebSocketRequest,
+      11              : } from "./websocket.js"
+      12           42 : export * as Cassette from "./cassette.js"
+      13           42 : export * as Redactor from "./redactor.js"
+      14              : 
+      15           53 : export * as HttpRecorderInternal from "./internal.js"
+        
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/http-recorder/src/matching.ts.gcov.html b/packages/llm/http-recorder/src/matching.ts.gcov.html new file mode 100644 index 00000000..9d88f339 --- /dev/null +++ b/packages/llm/http-recorder/src/matching.ts.gcov.html @@ -0,0 +1,182 @@ + + + + + + + LCOV - lcov.info - ../http-recorder/src/matching.ts + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../http-recorder/src - matching.tsCoverageTotalHit
Test:lcov.infoLines:51.2 %8644
Test Date:2026-08-28 10:46:17Functions:-00
+
+ + + + + + + + +

+
            Line data    Source code
+
+       1           40 : import { Option, Schema } from "effect"
+       2           58 : import { REDACTED, secretFindings } from "./redaction.js"
+       3              : import type { HttpInteraction, RequestMatcher, RequestSnapshot } from "./types.js"
+       4              : 
+       5           56 : const JsonValue = Schema.fromJsonString(Schema.Unknown)
+       6           64 : export const decodeJson = Schema.decodeUnknownOption(JsonValue)
+       7              : 
+       8           26 : const isRecord = (value: unknown): value is Record<string, unknown> =>
+       9           69 :   value !== null && typeof value === "object" && !Array.isArray(value)
+      10              : 
+      11           44 : export const canonicalizeJson = (value: unknown): unknown => {
+      12           64 :   if (Array.isArray(value)) return value.map(canonicalizeJson)
+      13           25 :   if (isRecord(value)) {
+      14           26 :     return Object.fromEntries(
+      15           19 :       Object.keys(value)
+      16           11 :         .toSorted()
+      17           47 :         .map((key) => [key, canonicalizeJson(value[key])]),
+      18            1 :     )
+      19            2 :   }
+      20           14 :   return value
+      21              : }
+      22              : 
+      23              : export type { RequestMatcher } from "./types.js"
+      24              : 
+      25           45 : export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
+      26           18 :   JSON.stringify({
+      27           26 :     method: snapshot.method,
+      28           20 :     url: snapshot.url,
+      29           46 :     headers: canonicalizeJson(snapshot.headers),
+      30           51 :     body: Option.match(decodeJson(snapshot.body), {
+      31           12 :       onNone: () => snapshot.body,
+      32           26 :       onSome: canonicalizeJson,
+      33            3 :     }),
+      34            2 :   })
+      35              : 
+      36           52 : export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
+      37           60 :   canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
+      38              : 
+      39            0 : export const safeText = (value: unknown) => {
+      40            0 :   if (value === undefined) return "undefined"
+      41            0 :   if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
+      42            0 :   const text = JSON.stringify(value)
+      43            0 :   if (!text) return typeof value
+      44            2 :   return text.length > 300 ? `${text.slice(0, 300)}...` : text
+      45              : }
+      46              : 
+      47           17 : const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
+      48              : 
+      49            0 : const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
+      50            0 :   if (Object.is(expected, received)) return []
+      51            0 :   if (isRecord(expected) && isRecord(received)) {
+      52            0 :     return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
+      53            0 :       .toSorted()
+      54            0 :       .flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit))
+      55            0 :       .slice(0, limit)
+      56            0 :   }
+      57            0 :   if (Array.isArray(expected) && Array.isArray(received)) {
+      58            0 :     return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
+      59            0 :       .flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
+      60            0 :       .slice(0, limit)
+      61            0 :   }
+      62            2 :   return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
+      63              : }
+      64              : 
+      65            0 : const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
+      66            0 :   [...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
+      67            0 :     if (expected[key] === received[key]) return []
+      68            0 :     if (expected[key] === undefined) return [`  ${key} unexpected ${safeText(received[key])}`]
+      69            0 :     if (received[key] === undefined) return [`  ${key} missing expected ${safeText(expected[key])}`]
+      70              :     return [`  ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
+      71            2 :   })
+      72              : 
+      73            0 : export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray<string> => {
+      74            0 :   const lines: string[] = []
+      75            0 :   if (expected.method !== received.method) {
+      76            0 :     lines.push("method:", `  expected ${expected.method}, received ${received.method}`)
+      77            0 :   }
+      78            0 :   if (expected.url !== received.url) {
+      79            0 :     lines.push("url:", `  expected ${expected.url}`, `  received ${received.url}`)
+      80            0 :   }
+      81            0 :   const headers = headerDiffs(expected.headers, received.headers)
+      82            0 :   if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
+      83            0 :   const expectedBody = jsonBody(expected.body)
+      84            0 :   const receivedBody = jsonBody(received.body)
+      85            0 :   const body =
+      86            0 :     expectedBody !== undefined && receivedBody !== undefined
+      87            0 :       ? valueDiffs(expectedBody, receivedBody).map((line) => `  ${line}`)
+      88            0 :       : expected.body === received.body
+      89            0 :         ? []
+      90            0 :         : [`  expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
+      91            0 :   if (body.length > 0) lines.push("body:", ...body)
+      92            2 :   return lines
+      93              : }
+      94              : 
+      95           31 : export const selectSequential = (
+      96           14 :   interactions: ReadonlyArray<HttpInteraction>,
+      97           10 :   incoming: RequestSnapshot,
+      98            7 :   match: RequestMatcher,
+      99           10 :   index: number,
+     100            3 : ): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
+     101           42 :   const interaction = interactions[index]
+     102           22 :   if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
+     103           45 :   if (!match(incoming, interaction.request))
+     104            2 :     return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
+     105           35 :   return { interaction, detail: "" }
+     106              : }
+        
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/http-recorder/src/recorder.ts.gcov.html b/packages/llm/http-recorder/src/recorder.ts.gcov.html new file mode 100644 index 00000000..c1c48e2c --- /dev/null +++ b/packages/llm/http-recorder/src/recorder.ts.gcov.html @@ -0,0 +1,138 @@ + + + + + + + LCOV - lcov.info - ../http-recorder/src/recorder.ts + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../http-recorder/src - recorder.tsCoverageTotalHit
Test:lcov.infoLines:76.2 %4232
Test Date:2026-08-28 10:46:17Functions:-00
+
+ + + + + + + + +

+
            Line data    Source code
+
+       1           49 : import { Effect, Scope, SynchronizedRef } from "effect"
+       2              : import type * as CassetteService from "./cassette.js"
+       3              : import type { CassetteNotFoundError } from "./cassette.js"
+       4              : import type { Interaction } from "./schema.js"
+       5              : 
+       6            0 : const isCI = () => {
+       7            0 :   const value = process.env.CI
+       8            2 :   return value !== undefined && value !== "" && value !== "false" && value !== "0"
+       9              : }
+      10              : 
+      11            0 : export const resolveAutoMode = (
+      12            0 :   cassette: CassetteService.Interface,
+      13            0 :   name: string,
+      14            0 : ): Effect.Effect<"record" | "replay" | "passthrough"> =>
+      15            0 :   Effect.gen(function* () {
+      16            0 :     if (isCI()) return "replay"
+      17              :     return (yield* cassette.exists(name)) ? "replay" : "record"
+      18            2 :   })
+      19              : 
+      20              : export interface ReplayState<T> {
+      21              :   readonly claim: <E>(
+      22              :     validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray<T>) => Effect.Effect<void, E>,
+      23              :   ) => Effect.Effect<{ readonly interaction: T; readonly index: number }, CassetteNotFoundError | E>
+      24              : }
+      25              : 
+      26           30 : export const makeReplayState = <T>(
+      27           10 :   cassette: CassetteService.Interface,
+      28            6 :   name: string,
+      29           12 :   project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
+      30              : ): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
+      31           15 :   Effect.gen(function* () {
+      32           83 :     const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
+      33           50 :     const position = yield* SynchronizedRef.make(0)
+      34              : 
+      35           32 :     yield* Effect.addFinalizer(() =>
+      36           17 :       Effect.gen(function* () {
+      37           54 :         const used = yield* SynchronizedRef.get(position)
+      38           49 :         if (used === 0) return yield* Effect.void
+      39           56 :         const interactions = yield* load.pipe(Effect.orDie)
+      40           36 :         if (used < interactions.length)
+      41            0 :           return yield* Effect.die(
+      42            0 :             new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`),
+      43            4 :           )
+      44           26 :         return yield* Effect.void
+      45              :       }),
+      46            4 :     )
+      47              : 
+      48           12 :     return {
+      49           20 :       claim: (validate) =>
+      50           38 :         Effect.flatMap(load, (interactions) =>
+      51           49 :           SynchronizedRef.modifyEffect(position, (index) =>
+      52           19 :             Effect.gen(function* () {
+      53           46 :               const interaction = interactions[index]
+      54           56 :               yield* validate(interaction, index, interactions)
+      55           37 :               if (interaction === undefined)
+      56            6 :                 return yield* Effect.die("Replay validation accepted a missing interaction")
+      57           43 :               return [{ interaction, index }, index + 1] as const
+      58              :             }),
+      59              :           ),
+      60            2 :         ),
+      61            1 :     }
+      62            1 :   })
+        
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/http-recorder/src/redaction.ts.gcov.html b/packages/llm/http-recorder/src/redaction.ts.gcov.html new file mode 100644 index 00000000..8df86efb --- /dev/null +++ b/packages/llm/http-recorder/src/redaction.ts.gcov.html @@ -0,0 +1,193 @@ + + + + + + + LCOV - lcov.info - ../http-recorder/src/redaction.ts + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../http-recorder/src - redaction.tsCoverageTotalHit
Test:lcov.infoLines:77.1 %9674
Test Date:2026-08-28 10:46:17Functions:-00
+
+ + + + + + + + +

+
            Line data    Source code
+
+       1           32 : import { Schema } from "effect"
+       2              : 
+       3           37 : export const REDACTED = "[REDACTED]"
+       4              : 
+       5           33 : const DEFAULT_REDACT_HEADERS = [
+       6           18 :   "authorization",
+       7           11 :   "cookie",
+       8           24 :   "proxy-authorization",
+       9           15 :   "set-cookie",
+      10           14 :   "x-api-key",
+      11           25 :   "x-amz-security-token",
+      12           17 :   "x-goog-api-key",
+      13            2 : ]
+      14              : 
+      15           31 : const DEFAULT_REDACT_QUERY = [
+      16           17 :   "access_token",
+      17           12 :   "api-key",
+      18           12 :   "api_key",
+      19           11 :   "apikey",
+      20            9 :   "code",
+      21            8 :   "key",
+      22           14 :   "signature",
+      23            8 :   "sig",
+      24           10 :   "token",
+      25           21 :   "x-amz-credential",
+      26           25 :   "x-amz-security-token",
+      27           18 :   "x-amz-signature",
+      28            2 : ]
+      29              : 
+      30           26 : const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [
+      31           78 :   { label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i },
+      32           72 :   { label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ },
+      33           75 :   { label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
+      34           69 :   { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ },
+      35           72 :   { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
+      36           72 :   { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ },
+      37           72 :   { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
+      38            2 : ]
+      39              : 
+      40           85 : const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i
+      41           65 : const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"])
+      42              : 
+      43            0 : const envSecrets = () =>
+      44            0 :   Object.entries(process.env).flatMap(([name, value]) => {
+      45            0 :     if (!value) return []
+      46            0 :     if (!ENV_SECRET_NAMES.test(name)) return []
+      47            0 :     if (value.length < 12) return []
+      48            0 :     if (SAFE_ENV_VALUES.has(value.toLowerCase())) return []
+      49              :     return [{ name, value }]
+      50            2 :   })
+      51              : 
+      52           16 : const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key)
+      53              : 
+      54            0 : const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => {
+      55            0 :   if (typeof value === "string") return [{ path: base, value }]
+      56            0 :   if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`))
+      57            0 :   if (value && typeof value === "object") {
+      58            0 :     return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key)))
+      59            0 :   }
+      60            2 :   return []
+      61              : }
+      62              : 
+      63           41 : const redactionSet = (values: ReadonlyArray<string> | undefined, defaults: ReadonlyArray<string>) =>
+      64           74 :   new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase()))
+      65              : 
+      66              : export type UrlRedactor = (url: string) => string
+      67              : 
+      68           24 : export const redactUrl = (
+      69            5 :   raw: string,
+      70           30 :   query: ReadonlyArray<string> = DEFAULT_REDACT_QUERY,
+      71           16 :   urlRedactor?: UrlRedactor,
+      72            3 : ) => {
+      73           28 :   if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw
+      74           27 :   const url = new URL(raw)
+      75           22 :   if (url.username) url.username = REDACTED
+      76           22 :   if (url.password) url.password = REDACTED
+      77           61 :   const redacted = redactionSet(query, DEFAULT_REDACT_QUERY)
+      78           47 :   for (const key of url.searchParams.keys()) {
+      79           43 :     if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED)
+      80            2 :   }
+      81           56 :   return urlRedactor?.(url.toString()) ?? url.toString()
+      82              : }
+      83              : 
+      84           28 : export const redactHeaders = (
+      85            9 :   headers: Record<string, string>,
+      86            7 :   allow: ReadonlyArray<string>,
+      87           36 :   redact: ReadonlyArray<string> = DEFAULT_REDACT_HEADERS,
+      88            3 : ) => {
+      89           65 :   const allowed = new Set(allow.map((name) => name.toLowerCase()))
+      90           64 :   const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS)
+      91           26 :   return Object.fromEntries(
+      92           24 :     Object.entries(headers)
+      93           50 :       .map(([name, value]) => [name.toLowerCase(), value] as const)
+      94           36 :       .filter(([name]) => allowed.has(name))
+      95           58 :       .map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const)
+      96           40 :       .toSorted(([a], [b]) => a.localeCompare(b)),
+      97            3 :   )
+      98              : }
+      99              : 
+     100           51 : export const SecretFindingSchema = Schema.Struct({
+     101           22 :   path: Schema.String,
+     102           22 :   reason: Schema.String,
+     103            3 : })
+     104              : export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
+     105              : 
+     106            0 : export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> => {
+     107            0 :   const environment = envSecrets()
+     108            0 :   return stringEntries(value).flatMap((entry) => [
+     109            0 :     ...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({
+     110            0 :       path: entry.path,
+     111            0 :       reason: item.label,
+     112            0 :     })),
+     113            0 :     ...environment
+     114            0 :       .filter((item) => entry.value.includes(item.value))
+     115            0 :       .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
+     116            1 :   ])
+     117              : }
+        
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/http-recorder/src/redactor.ts.gcov.html b/packages/llm/http-recorder/src/redactor.ts.gcov.html new file mode 100644 index 00000000..5acf4947 --- /dev/null +++ b/packages/llm/http-recorder/src/redactor.ts.gcov.html @@ -0,0 +1,211 @@ + + + + + + + LCOV - lcov.info - ../http-recorder/src/redactor.ts + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../http-recorder/src - redactor.tsCoverageTotalHit
Test:lcov.infoLines:80.2 %9173
Test Date:2026-08-28 10:46:17Functions:-00
+
+ + + + + + + + +

+
            Line data    Source code
+
+       1           32 : import { Option } from "effect"
+       2           43 : import { decodeJson } from "./matching.js"
+       3           68 : import { REDACTED, redactHeaders, redactUrl } from "./redaction.js"
+       4              : import type { RedactOptions, RequestSnapshot, ResponseSnapshot } from "./types.js"
+       5              : 
+       6              : export type { RedactOptions } from "./types.js"
+       7              : 
+       8           81 : export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
+       9           57 : export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
+      10              : 
+      11           17 : const identity = <T>(value: T) => value
+      12              : 
+      13              : export interface Redactor {
+      14              :   readonly request: (snapshot: RequestSnapshot) => RequestSnapshot
+      15              :   readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot
+      16              : }
+      17              : 
+      18           42 : export const compose = (...redactors: ReadonlyArray<Partial<Redactor>>): Redactor => {
+      19           80 :   const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined)
+      20           82 :   const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined)
+      21           12 :   return {
+      22           95 :     request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot),
+      23           36 :     response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot),
+      24            3 :   }
+      25              : }
+      26              : 
+      27              : export interface HeaderOptions {
+      28              :   readonly allow?: ReadonlyArray<string>
+      29              :   readonly redact?: ReadonlyArray<string>
+      30              : }
+      31              : 
+      32           50 : export const requestHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
+      33           31 :   request: (snapshot) => ({
+      34           13 :     ...snapshot,
+      35          100 :     headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact),
+      36            2 :   }),
+      37            2 : })
+      38              : 
+      39           51 : export const responseHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
+      40            0 :   response: (snapshot) => ({
+      41            0 :     ...snapshot,
+      42            0 :     headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact),
+      43            1 :   }),
+      44            2 : })
+      45              : 
+      46              : export interface UrlOptions {
+      47              :   readonly query?: ReadonlyArray<string>
+      48              :   readonly transform?: (url: string) => string
+      49              : }
+      50              : 
+      51           39 : export const url = (options: UrlOptions = {}): Partial<Redactor> => ({
+      52          103 :   request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }),
+      53            2 : })
+      54              : 
+      55            0 : export const body = (transform: (parsed: unknown) => unknown): Partial<Redactor> => ({
+      56            0 :   request: (snapshot) => ({
+      57            0 :     ...snapshot,
+      58            0 :     body: Option.match(decodeJson(snapshot.body), {
+      59            0 :       onNone: () => snapshot.body,
+      60            0 :       onSome: (parsed) => JSON.stringify(transform(parsed)),
+      61            0 :     }),
+      62              :   }),
+      63            2 : })
+      64              : 
+      65              : export interface DefaultRedactorOverrides {
+      66              :   readonly requestHeaders?: HeaderOptions
+      67              :   readonly responseHeaders?: HeaderOptions
+      68              :   readonly url?: UrlOptions
+      69              :   readonly body?: (parsed: unknown) => unknown
+      70              : }
+      71              : 
+      72           37 : const DEFAULT_REDACT_JSON_FIELDS = [
+      73           17 :   "access_token",
+      74           12 :   "api_key",
+      75           11 :   "apikey",
+      76           18 :   "client_secret",
+      77           13 :   "password",
+      78           18 :   "refresh_token",
+      79           11 :   "secret",
+      80            8 :   "token",
+      81            2 : ]
+      82              : 
+      83           79 : const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase()
+      84              : 
+      85           45 : const redactJsonFields = (value: unknown, fields: ReadonlySet<string>): unknown => {
+      86           86 :   if (Array.isArray(value)) return value.map((item) => redactJsonFields(item, fields))
+      87           57 :   if (!value || typeof value !== "object") return value
+      88           26 :   return Object.fromEntries(
+      89           48 :     Object.entries(value).map(([key, child]) => [
+      90            8 :       key,
+      91           67 :       fields.has(normalizeField(key)) ? REDACTED : redactJsonFields(child, fields),
+      92            1 :     ]),
+      93            3 :   )
+      94              : }
+      95              : 
+      96           50 : const redactBody = (value: string, fields: ReadonlySet<string>, transform: ((body: string) => string) | undefined) => {
+      97           54 :   const redacted = Option.match(decodeJson(value), {
+      98           12 :     onNone: () => value,
+      99           68 :     onSome: (parsed) => JSON.stringify(redactJsonFields(parsed, fields)),
+     100            5 :   })
+     101           42 :   return transform?.(redacted) ?? redacted
+     102              : }
+     103              : 
+     104           39 : export const make = (options: RedactOptions = {}): Redactor => {
+     105          107 :   const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField))
+     106           15 :   return compose(
+     107           20 :     requestHeaders({
+     108          104 :       allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])],
+     109           25 :       redact: options.headers,
+     110            4 :     }),
+     111           21 :     responseHeaders({
+     112          106 :       allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])],
+     113           25 :       redact: options.headers,
+     114            4 :     }),
+     115           65 :     url({ query: options.queryParameters, transform: options.url }),
+     116            5 :     {
+     117           33 :       request: (snapshot) => ({
+     118           15 :         ...snapshot,
+     119           57 :         body: redactBody(snapshot.body, fields, options.body),
+     120            6 :       }),
+     121            0 :       response: (snapshot) => ({
+     122            0 :         ...snapshot,
+     123            0 :         body: redactBody(snapshot.body, fields, options.body),
+     124            2 :       }),
+     125            1 :     },
+     126            3 :   )
+     127              : }
+     128              : 
+     129            0 : export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor =>
+     130            0 :   compose(
+     131            0 :     requestHeaders(overrides.requestHeaders),
+     132            0 :     responseHeaders(overrides.responseHeaders),
+     133            0 :     url(overrides.url),
+     134              :     ...(overrides.body ? [body(overrides.body)] : []),
+     135            1 :   )
+        
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/http-recorder/src/schema.ts.gcov.html b/packages/llm/http-recorder/src/schema.ts.gcov.html new file mode 100644 index 00000000..afedcc8e --- /dev/null +++ b/packages/llm/http-recorder/src/schema.ts.gcov.html @@ -0,0 +1,163 @@ + + + + + + + LCOV - lcov.info - ../http-recorder/src/schema.ts + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../http-recorder/src - schema.tsCoverageTotalHit
Test:lcov.infoLines:98.2 %5554
Test Date:2026-08-28 10:46:17Functions:-00
+
+ + + + + + + + +

+
            Line data    Source code
+
+       1           32 : import { Schema } from "effect"
+       2              : import type {
+       3              :   CassetteMetadata,
+       4              :   HttpInteraction,
+       5              :   RequestSnapshot,
+       6              :   ResponseSnapshot,
+       7              :   WebSocketEvent,
+       8              :   WebSocketInteraction,
+       9              : } from "./types.js"
+      10              : 
+      11              : export type {
+      12              :   CassetteMetadata,
+      13              :   HttpInteraction,
+      14              :   RequestSnapshot,
+      15              :   ResponseSnapshot,
+      16              :   WebSocketEvent,
+      17              :   WebSocketInteraction,
+      18              : } from "./types.js"
+      19              : 
+      20           53 : export const RequestSnapshotSchema = Schema.Struct({
+      21           24 :   method: Schema.String,
+      22           21 :   url: Schema.String,
+      23           55 :   headers: Schema.Record(Schema.String, Schema.String),
+      24           20 :   body: Schema.String,
+      25            3 : })
+      26              : 
+      27           54 : export const ResponseSnapshotSchema = Schema.Struct({
+      28           24 :   status: Schema.Number,
+      29           55 :   headers: Schema.Record(Schema.String, Schema.String),
+      30           22 :   body: Schema.String,
+      31           67 :   bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])),
+      32            3 : })
+      33              : 
+      34           83 : export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown)
+      35              : 
+      36           53 : export const HttpInteractionSchema = Schema.Struct({
+      37           32 :   transport: Schema.tag("http"),
+      38           33 :   request: RequestSnapshotSchema,
+      39           33 :   response: ResponseSnapshotSchema,
+      40            3 : })
+      41              : 
+      42           51 : export const WebSocketEventSchema = Schema.Union([
+      43           19 :   Schema.Struct({
+      44           53 :     direction: Schema.Literals(["client", "server"]),
+      45           29 :     kind: Schema.tag("text"),
+      46           21 :     body: Schema.String,
+      47            5 :   }),
+      48           19 :   Schema.Struct({
+      49           53 :     direction: Schema.Literals(["client", "server"]),
+      50           31 :     kind: Schema.tag("binary"),
+      51           24 :     body: Schema.String,
+      52           40 :     bodyEncoding: Schema.Literal("base64"),
+      53            3 :   }),
+      54            3 : ])
+      55              : 
+      56           58 : export const WebSocketInteractionSchema = Schema.Struct({
+      57           37 :   transport: Schema.tag("websocket"),
+      58           25 :   open: Schema.Struct({
+      59           23 :     url: Schema.String,
+      60           54 :     headers: Schema.Record(Schema.String, Schema.String),
+      61            5 :   }),
+      62           43 :   events: Schema.Array(WebSocketEventSchema),
+      63            3 : })
+      64              : 
+      65          103 : export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe(
+      66           33 :   Schema.toTaggedUnion("transport"),
+      67            3 : )
+      68              : export type Interaction = Schema.Schema.Type<typeof InteractionSchema>
+      69              : 
+      70           63 : export const isHttpInteraction = InteractionSchema.guards.http
+      71              : 
+      72           73 : export const isWebSocketInteraction = InteractionSchema.guards.websocket
+      73              : 
+      74           87 : export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
+      75              : 
+      76            0 : export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
+      77            2 :   interactions.filter(isWebSocketInteraction)
+      78              : 
+      79           46 : export const CassetteSchema = Schema.Struct({
+      80           29 :   version: Schema.Literal(1),
+      81           52 :   metadata: Schema.optional(CassetteMetadataSchema),
+      82           46 :   interactions: Schema.Array(InteractionSchema),
+      83            3 : })
+      84              : export type Cassette = Schema.Schema.Type<typeof CassetteSchema>
+      85              : 
+      86           71 : export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema)
+      87           63 : export const encodeCassette = Schema.encodeSync(CassetteSchema)
+        
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/http-recorder/src/socket.ts.gcov.html b/packages/llm/http-recorder/src/socket.ts.gcov.html new file mode 100644 index 00000000..656732c2 --- /dev/null +++ b/packages/llm/http-recorder/src/socket.ts.gcov.html @@ -0,0 +1,402 @@ + + + + + + + LCOV - lcov.info - ../http-recorder/src/socket.ts + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../http-recorder/src - socket.tsCoverageTotalHit
Test:lcov.infoLines:8.1 %27022
Test Date:2026-08-28 10:46:17Functions:-00
+
+ + + + + + + + +

+
            Line data    Source code
+
+       1           55 : import { NodeFileSystem } from "@effect/platform-node"
+       2           81 : import { Deferred, Effect, Exit, FiberSet, Layer, Ref, Scope, Semaphore } from "effect"
+       3           48 : import { Socket } from "effect/unstable/socket"
+       4           49 : import * as CassetteService from "./cassette.js"
+       5           71 : import { canonicalizeJson, decodeJson, safeText } from "./matching.js"
+       6           65 : import { makeReplayState, resolveAutoMode } from "./recorder.js"
+       7           37 : import { make, type Redactor } from "./redactor.js"
+       8           52 : import { webSocketInteractions } from "./schema.js"
+       9              : import type {
+      10              :   RecorderOptions,
+      11              :   WebSocketEvent,
+      12              :   WebSocketInteraction,
+      13              :   WebSocketRecorderOptions,
+      14              :   WebSocketRequest,
+      15              : } from "./types.js"
+      16              : 
+      17              : interface ActiveReplay {
+      18              :   readonly interaction: WebSocketInteraction
+      19              :   readonly progress: Ref.Ref<{ readonly position: number; readonly changed: Deferred.Deferred<void> }>
+      20              :   readonly writeLock: Semaphore.Semaphore
+      21              :   readonly closed: Ref.Ref<boolean>
+      22              : }
+      23              : 
+      24              : interface ActiveRecording {
+      25              :   readonly events: Array<WebSocketEvent>
+      26              :   readonly eventLock: Semaphore.Semaphore
+      27              :   readonly accepting: Ref.Ref<boolean>
+      28              :   opened: boolean
+      29              :   valid: boolean
+      30              : }
+      31              : 
+      32              : type Frame = string | Uint8Array
+      33              : 
+      34            0 : const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent =>
+      35            0 :   typeof message === "string"
+      36            0 :     ? { direction, kind: "text", body: message }
+      37            2 :     : { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
+      38              : 
+      39            0 : const decodeEvent = (event: WebSocketEvent): Frame =>
+      40            2 :   event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64"))
+      41              : 
+      42            0 : const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => {
+      43            0 :   if (event.kind === "binary") return event
+      44            0 :   const body =
+      45            0 :     event.direction === "client"
+      46            0 :       ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body
+      47            0 :       : redactor.response({ status: 101, headers: {}, body: event.body }).body
+      48            2 :   return { ...event, body }
+      49              : }
+      50              : 
+      51            0 : const comparable = (event: WebSocketEvent, asJson: boolean) => {
+      52            0 :   if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event))
+      53            0 :   const decoded = decodeJson(event.body)
+      54            0 :   return JSON.stringify(
+      55            0 :     canonicalizeJson({
+      56            0 :       ...event,
+      57            0 :       body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value),
+      58            0 :     }),
+      59            2 :   )
+      60              : }
+      61              : 
+      62            0 : const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) =>
+      63            0 :   Effect.sync(() => {
+      64            0 :     if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return
+      65              :     throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`)
+      66            2 :   })
+      67              : 
+      68            0 : const runHandler = <A, E, R>(handler: (value: A) => Effect.Effect<unknown, E, R> | void, value: A) =>
+      69            0 :   Effect.suspend(() => {
+      70            0 :     const result = handler(value)
+      71              :     return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void
+      72            2 :   })
+      73              : 
+      74            0 : const runReplay = <A, E, R>(
+      75            0 :   state: ActiveReplay,
+      76            0 :   handler: (value: A) => Effect.Effect<unknown, E, R> | void,
+      77            0 :   decode: (event: WebSocketEvent) => A,
+      78            0 :   onOpen: Effect.Effect<void> | undefined,
+      79            0 : ) =>
+      80            0 :   Effect.scoped(
+      81            0 :     Effect.gen(function* () {
+      82            0 :       const handlers = yield* FiberSet.make<unknown, E>()
+      83            0 :       const run = yield* FiberSet.runtime(handlers)<R>()
+      84            0 :       if (onOpen) yield* onOpen
+      85            0 : 
+      86            0 :       const drive = Effect.gen(function* () {
+      87            0 :         while (true) {
+      88            0 :           const current = yield* Ref.get(state.progress)
+      89            0 :           const event = state.interaction.events[current.position]
+      90            0 :           if (!event) return
+      91            0 :           if (yield* Ref.get(state.closed))
+      92            0 :             return yield* Effect.die(
+      93            0 :               new Error(
+      94            0 :                 `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
+      95            0 :               ),
+      96            0 :             )
+      97            0 :           if (event.direction === "server") {
+      98            0 :             yield* Ref.set(state.progress, {
+      99            0 :               position: current.position + 1,
+     100            0 :               changed: yield* Deferred.make<void>(),
+     101            0 :             })
+     102            0 :             run(runHandler(handler, decode(event)))
+     103            0 :             continue
+     104            0 :           }
+     105            0 :           yield* Deferred.await(current.changed)
+     106            0 :         }
+     107            0 :       })
+     108            0 : 
+     109            0 :       yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers)))
+     110            0 :       yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers)))
+     111              :     }),
+     112            2 :   )
+     113              : 
+     114            0 : const openSnapshot = (request: WebSocketRequest, redactor: Redactor) => {
+     115            0 :   const snapshot = redactor.request({ method: "GET", url: request.url, headers: request.headers ?? {}, body: "" })
+     116            2 :   return { url: snapshot.url, headers: snapshot.headers }
+     117              : }
+     118              : 
+     119            0 : const makeRecordingSocket = (
+     120            0 :   upstream: Socket.Socket,
+     121            0 :   cassette: CassetteService.Interface,
+     122            0 :   name: string,
+     123            0 :   request: WebSocketRequest,
+     124            0 :   options: WebSocketRecorderOptions,
+     125            0 :   redactor: Redactor,
+     126            0 : ) =>
+     127            0 :   Effect.gen(function* () {
+     128            0 :     const active = yield* Ref.make<ActiveRecording | undefined>(undefined)
+     129            0 :     const writeLock = yield* Semaphore.make(1)
+     130            0 : 
+     131            0 :     return Socket.make({
+     132            0 :       runRaw: (handler, runOptions) =>
+     133            0 :         Effect.gen(function* () {
+     134            0 :           const state: ActiveRecording = {
+     135            0 :             events: [],
+     136            0 :             eventLock: yield* Semaphore.make(1),
+     137            0 :             accepting: yield* Ref.make(true),
+     138            0 :             opened: false,
+     139            0 :             valid: true,
+     140            0 :           }
+     141            0 :           const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state])
+     142            0 :           if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported")
+     143            0 :           yield* upstream
+     144            0 :             .runRaw(
+     145            0 :               (message) => {
+     146            0 :                 if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing")
+     147            0 :                 state.events.push(redactEvent(encodeEvent("server", message), redactor))
+     148            0 :                 return handler(message)
+     149            0 :               },
+     150            0 :               {
+     151            0 :                 ...runOptions,
+     152            0 :                 onOpen: Effect.gen(function* () {
+     153            0 :                   state.opened = true
+     154            0 :                   if (runOptions?.onOpen) yield* runOptions.onOpen
+     155            0 :                 }),
+     156            0 :               },
+     157            0 :             )
+     158            0 :             .pipe(
+     159            0 :               Effect.onExit((exit) =>
+     160            0 :                 writeLock.withPermit(
+     161            0 :                   state.eventLock.withPermit(
+     162            0 :                     Effect.gen(function* () {
+     163            0 :                       yield* Ref.set(state.accepting, false)
+     164            0 :                       yield* Ref.set(active, undefined)
+     165            0 :                       if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return
+     166            0 :                       yield* cassette
+     167            0 :                         .append(
+     168            0 :                           name,
+     169            0 :                           {
+     170            0 :                             transport: "websocket",
+     171            0 :                             open: openSnapshot(request, redactor),
+     172            0 :                             events: [...state.events],
+     173            0 :                           },
+     174            0 :                           options.metadata,
+     175            0 :                         )
+     176            0 :                         .pipe(Effect.orDie)
+     177            0 :                     }),
+     178            0 :                   ),
+     179            0 :                 ),
+     180            0 :               ),
+     181            0 :             )
+     182            0 :         }),
+     183            0 :       writer: upstream.writer.pipe(
+     184            0 :         Effect.map(
+     185            0 :           (write) => (message) =>
+     186            0 :             writeLock.withPermit(
+     187            0 :               Effect.gen(function* () {
+     188            0 :                 if (Socket.isCloseEvent(message)) return yield* write(message)
+     189            0 :                 const state = yield* Ref.get(active)
+     190            0 :                 if (!state || !(yield* Ref.get(state.accepting)))
+     191            0 :                   return yield* Effect.die("WebSocket writer used without an active socket run")
+     192            0 :                 const event = redactEvent(encodeEvent("client", message), redactor)
+     193            0 :                 yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event)))
+     194            0 :                 return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false))))
+     195            0 :               }),
+     196            0 :             ),
+     197            0 :         ),
+     198            0 :       ),
+     199              :     })
+     200            2 :   })
+     201              : 
+     202            0 : const makeReplaySocket = (
+     203            0 :   cassette: CassetteService.Interface,
+     204            0 :   name: string,
+     205            0 :   request: WebSocketRequest,
+     206            0 :   options: WebSocketRecorderOptions,
+     207            0 :   redactor: Redactor,
+     208            0 : ): Effect.Effect<Socket.Socket, never, Scope.Scope> =>
+     209            0 :   Effect.gen(function* () {
+     210            0 :     const replay = yield* makeReplayState(cassette, name, webSocketInteractions)
+     211            0 :     const active = yield* Ref.make<ActiveReplay | undefined>(undefined)
+     212            0 : 
+     213            0 :     return Socket.make({
+     214            0 :       runRaw: (handler, runOptions) =>
+     215            0 :         Effect.gen(function* () {
+     216            0 :           const claimed = yield* replay
+     217            0 :             .claim((interaction, index) =>
+     218            0 :               Effect.sync(() => {
+     219            0 :                 const incoming = openSnapshot(request, redactor)
+     220            0 :                 if (
+     221            0 :                   interaction &&
+     222            0 :                   JSON.stringify(canonicalizeJson(incoming)) === JSON.stringify(canonicalizeJson(interaction.open))
+     223            0 :                 )
+     224            0 :                   return
+     225            0 :                 throw new Error(
+     226            0 :                   `WebSocket open ${index + 1}: expected ${safeText(interaction?.open)}, received ${safeText(incoming)}`,
+     227            0 :                 )
+     228            0 :               }),
+     229            0 :             )
+     230            0 :             .pipe(Effect.orDie)
+     231            0 :           const progress = yield* Ref.make({ position: 0, changed: yield* Deferred.make<void>() })
+     232            0 :           const writeLock = yield* Semaphore.make(1)
+     233            0 :           const state = {
+     234            0 :             interaction: claimed.interaction,
+     235            0 :             progress,
+     236            0 :             writeLock,
+     237            0 :             closed: yield* Ref.make(false),
+     238            0 :           }
+     239            0 :           const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state])
+     240            0 :           if (occupied) return yield* Effect.die("Concurrent runs of a replayed WebSocket are not supported")
+     241            0 :           yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe(
+     242            0 :             Effect.ensuring(Ref.set(active, undefined)),
+     243            0 :           )
+     244            0 :         }),
+     245            0 :       writer: Effect.succeed((message) => {
+     246            0 :         return Ref.get(active).pipe(
+     247            0 :           Effect.flatMap((state) =>
+     248            0 :             state
+     249            0 :               ? state.writeLock.withPermit(
+     250            0 :                   Effect.gen(function* () {
+     251            0 :                     const current = yield* Ref.get(state.progress)
+     252            0 :                     if (Socket.isCloseEvent(message)) {
+     253            0 :                       yield* Ref.set(state.closed, true)
+     254            0 :                       yield* Deferred.succeed(current.changed, undefined)
+     255            0 :                       if (current.position === state.interaction.events.length) return
+     256            0 :                       return yield* Effect.die(
+     257            0 :                         new Error(
+     258            0 :                           `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
+     259            0 :                         ),
+     260            0 :                       )
+     261            0 :                     }
+     262            0 :                     const actual = redactEvent(encodeEvent("client", message), redactor)
+     263            0 :                     yield* assertEvent(
+     264            0 :                       actual,
+     265            0 :                       state.interaction.events[current.position],
+     266            0 :                       current.position,
+     267            0 :                       options.compareClientMessagesAsJson === true,
+     268            0 :                     )
+     269            0 :                     yield* Ref.set(state.progress, {
+     270            0 :                       position: current.position + 1,
+     271            0 :                       changed: yield* Deferred.make<void>(),
+     272            0 :                     })
+     273            0 :                     yield* Deferred.succeed(current.changed, undefined)
+     274            0 :                   }),
+     275            0 :                 )
+     276            0 :               : Effect.die("WebSocket writer used without an active socket run"),
+     277            0 :           ),
+     278            0 :         )
+     279            0 :       }),
+     280              :     })
+     281            2 :   })
+     282              : 
+     283            0 : const recordingLayer = (
+     284            0 :   name: string,
+     285            0 :   request: WebSocketRequest,
+     286            0 :   options: WebSocketRecorderOptions,
+     287            0 :   forcedMode?: "record" | "replay",
+     288            0 : ): Layer.Layer<Socket.Socket, never, Socket.Socket | CassetteService.Service> =>
+     289            0 :   Layer.effect(
+     290            0 :     Socket.Socket,
+     291            0 :     Effect.gen(function* () {
+     292            0 :       const upstream = yield* Socket.Socket
+     293            0 :       const cassette = yield* CassetteService.Service
+     294            0 :       const redactor = make(options.redact)
+     295            0 :       if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record")
+     296            0 :         return yield* makeRecordingSocket(upstream, cassette, name, request, options, redactor)
+     297            0 :       return yield* makeReplaySocket(cassette, name, request, options, redactor)
+     298              :     }),
+     299            2 :   )
+     300              : 
+     301              : /**
+     302              :  * Wraps a provided `Socket.Socket` with cassette recording and replay.
+     303              :  *
+     304              :  * Supply the ordinary URL-bound Effect socket layer beneath this decorator.
+     305              :  * The cassette name identifies the connection; recorder configuration does not
+     306              :  * duplicate the transport URL.
+     307              :  */
+     308            0 : export const socket = (name: string, options: RecorderOptions = {}): Layer.Layer<Socket.Socket, never, Socket.Socket> =>
+     309            2 :   provideCassette(recordingLayer(name, { url: "" }, { ...options, compareClientMessagesAsJson: true }), options)
+     310              : 
+     311              : /** @internal */
+     312            0 : export const socketLayer = (
+     313            0 :   name: string,
+     314            0 :   request: WebSocketRequest,
+     315            0 :   options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" },
+     316            0 : ): Layer.Layer<Socket.Socket, never, Socket.Socket> =>
+     317            2 :   provideCassette(recordingLayer(name, request, options, options.mode), options)
+     318              : 
+     319            0 : const provideCassette = (
+     320            0 :   layer: Layer.Layer<Socket.Socket, never, Socket.Socket | CassetteService.Service>,
+     321            0 :   options: WebSocketRecorderOptions,
+     322            0 : ) =>
+     323            0 :   layer.pipe(
+     324            0 :     Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
+     325              :     Layer.provide(NodeFileSystem.layer),
+     326            1 :   )
+        
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/http-recorder/src/websocket.ts.gcov.html b/packages/llm/http-recorder/src/websocket.ts.gcov.html new file mode 100644 index 00000000..c6205a80 --- /dev/null +++ b/packages/llm/http-recorder/src/websocket.ts.gcov.html @@ -0,0 +1,249 @@ + + + + + + + LCOV - lcov.info - ../http-recorder/src/websocket.ts + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../http-recorder/src - websocket.tsCoverageTotalHit
Test:lcov.infoLines:19.5 %12825
Test Date:2026-08-28 10:46:17Functions:-00
+
+ + + + + + + + +

+
            Line data    Source code
+
+       1           81 : import { Effect, Option, Ref, Scope, Semaphore, Stream, SynchronizedRef } from "effect"
+       2              : import type { Headers } from "effect/unstable/http"
+       3              : import * as CassetteService from "./cassette.js"
+       4           71 : import { canonicalizeJson, decodeJson, safeText } from "./matching.js"
+       5           65 : import { makeReplayState, resolveAutoMode } from "./recorder.js"
+       6              : import type { RecordReplayMode } from "./internal-effect.js"
+       7           37 : import { make, type Redactor } from "./redactor.js"
+       8           52 : import { webSocketInteractions, type CassetteMetadata, type WebSocketEvent } from "./schema.js"
+       9              : 
+      10              : export interface WebSocketRequest {
+      11              :   readonly url: string
+      12              :   readonly headers: Headers.Headers
+      13              : }
+      14              : 
+      15              : export interface WebSocketConnection<E> {
+      16              :   readonly sendText: (message: string) => Effect.Effect<void, E>
+      17              :   readonly messages: Stream.Stream<string | Uint8Array, E>
+      18              :   readonly close: Effect.Effect<void>
+      19              : }
+      20              : 
+      21              : export interface WebSocketExecutor<E> {
+      22              :   readonly open: (request: WebSocketRequest) => Effect.Effect<WebSocketConnection<E>, E>
+      23              : }
+      24              : 
+      25              : export interface WebSocketRecordReplayOptions<E> {
+      26              :   readonly name: string
+      27              :   readonly mode?: RecordReplayMode
+      28              :   readonly metadata?: CassetteMetadata
+      29              :   readonly cassette: CassetteService.Interface
+      30              :   readonly live: WebSocketExecutor<E>
+      31              :   readonly redactor?: Redactor
+      32              :   readonly compareClientMessagesAsJson?: boolean
+      33              : }
+      34              : 
+      35            0 : const headersRecord = (headers: Headers.Headers): Record<string, string> =>
+      36            0 :   Object.fromEntries(
+      37            0 :     Object.entries(headers as Record<string, unknown>).filter(
+      38            0 :       (entry): entry is [string, string] => typeof entry[1] === "string",
+      39              :     ),
+      40            2 :   )
+      41              : 
+      42            0 : const textEvent = (direction: "client" | "server", body: string): WebSocketEvent => ({
+      43            0 :   direction,
+      44            0 :   kind: "text",
+      45              :   body,
+      46            2 : })
+      47              : 
+      48            0 : const decodeEvent = (event: WebSocketEvent) =>
+      49            2 :   event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64"))
+      50              : 
+      51           19 : const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
+      52              : 
+      53            0 : const assertClientEvent = (actual: string, expected: WebSocketEvent | undefined, index: number, asJson: boolean) =>
+      54            0 :   Effect.sync(() => {
+      55            0 :     const matches =
+      56            0 :       expected?.direction === "client" &&
+      57            0 :       expected.kind === "text" &&
+      58            0 :       JSON.stringify(asJson ? jsonOrText(actual) : actual) ===
+      59            0 :         JSON.stringify(asJson ? jsonOrText(expected.body) : expected.body)
+      60            0 :     if (matches) return
+      61              :     throw new Error(`WebSocket client frame ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`)
+      62            2 :   })
+      63              : 
+      64           36 : export const makeWebSocketExecutor = <E>(
+      65           12 :   options: WebSocketRecordReplayOptions<E>,
+      66              : ): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
+      67           15 :   Effect.gen(function* () {
+      68           88 :     const mode = options.mode ?? (yield* resolveAutoMode(options.cassette, options.name))
+      69           46 :     const redactor = options.redactor ?? make()
+      70            0 :     const openSnapshot = (request: WebSocketRequest) => {
+      71            0 :       const snapshot = redactor.request({
+      72            0 :         method: "GET",
+      73            0 :         url: request.url,
+      74            0 :         headers: headersRecord(request.headers),
+      75            0 :         body: "",
+      76            0 :       })
+      77            3 :       return { url: snapshot.url, headers: snapshot.headers }
+      78              :     }
+      79            0 :     const redactEvent = (event: WebSocketEvent) => {
+      80            0 :       if (event.kind === "binary") return event
+      81            0 :       const body =
+      82            0 :         event.direction === "client"
+      83            0 :           ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body
+      84            0 :           : redactor.response({ status: 101, headers: {}, body: event.body }).body
+      85            3 :       return { ...event, body }
+      86              :     }
+      87              : 
+      88           32 :     if (mode === "passthrough") return options.live
+      89              : 
+      90           22 :     if (mode === "record") {
+      91            0 :       return {
+      92            0 :         open: (request) =>
+      93            0 :           Effect.gen(function* () {
+      94            0 :             const events: WebSocketEvent[] = []
+      95            0 :             const connection = yield* options.live.open(request)
+      96            0 :             const closed = yield* Ref.make(false)
+      97            0 :             const closeLock = yield* Semaphore.make(1)
+      98            0 :             return {
+      99            0 :               sendText: (message) =>
+     100            0 :                 Effect.sync(() => events.push(redactEvent(textEvent("client", message)))).pipe(
+     101            0 :                   Effect.andThen(connection.sendText(message)),
+     102            0 :                 ),
+     103            0 :               messages: connection.messages.pipe(
+     104            0 :                 Stream.tap((message) =>
+     105            0 :                   Effect.sync(() =>
+     106            0 :                     events.push(
+     107            0 :                       typeof message === "string"
+     108            0 :                         ? redactEvent(textEvent("server", message))
+     109            0 :                         : {
+     110            0 :                             direction: "server",
+     111            0 :                             kind: "binary",
+     112            0 :                             body: Buffer.from(message).toString("base64"),
+     113            0 :                             bodyEncoding: "base64",
+     114            0 :                           },
+     115            0 :                     ),
+     116            0 :                   ),
+     117            0 :                 ),
+     118            0 :               ),
+     119            0 :               close: closeLock.withPermit(
+     120            0 :                 Effect.gen(function* () {
+     121            0 :                   if (yield* Ref.get(closed)) return
+     122            0 :                   yield* connection.close
+     123            0 :                   yield* options.cassette
+     124            0 :                     .append(
+     125            0 :                       options.name,
+     126            0 :                       { transport: "websocket", open: openSnapshot(request), events },
+     127            0 :                       options.metadata,
+     128            0 :                     )
+     129            0 :                     .pipe(Effect.orDie)
+     130            0 :                   yield* Ref.set(closed, true)
+     131            0 :                 }),
+     132            0 :               ),
+     133              :             }
+     134            0 :           }),
+     135            0 :       }
+     136            2 :     }
+     137              : 
+     138           95 :     const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions)
+     139           12 :     return {
+     140            0 :       open: (request) =>
+     141            0 :         Effect.gen(function* () {
+     142            0 :           const claimed = yield* replay
+     143            0 :             .claim((interaction, index) =>
+     144            0 :               Effect.sync(() => {
+     145            0 :                 const incoming = canonicalizeJson(openSnapshot(request))
+     146            0 :                 if (interaction && JSON.stringify(incoming) === JSON.stringify(canonicalizeJson(interaction.open)))
+     147            0 :                   return
+     148            0 :                 throw new Error(`WebSocket open ${index + 1} does not match ${safeText(incoming)}`)
+     149            0 :               }),
+     150            0 :             )
+     151            0 :             .pipe(Effect.orDie)
+     152            0 :           const client = claimed.interaction.events.filter((event) => event.direction === "client")
+     153            0 :           const server = claimed.interaction.events.filter((event) => event.direction === "server")
+     154            0 :           const position = yield* SynchronizedRef.make(0)
+     155            0 :           return {
+     156            0 :             sendText: (message) =>
+     157            0 :               SynchronizedRef.updateEffect(position, (index) =>
+     158            0 :                 assertClientEvent(message, client[index], index, options.compareClientMessagesAsJson === true).pipe(
+     159            0 :                   Effect.as(index + 1),
+     160            0 :                 ),
+     161            0 :               ),
+     162            0 :             messages: Stream.fromIterable(server).pipe(Stream.map(decodeEvent)),
+     163            0 :             close: Effect.gen(function* () {
+     164            0 :               const used = yield* SynchronizedRef.get(position)
+     165            0 :               if (used !== client.length)
+     166            0 :                 return yield* Effect.die(
+     167            0 :                   new Error(`WebSocket client frame count: expected ${client.length}, received ${used}`),
+     168            0 :                 )
+     169            0 :             }),
+     170              :           }
+     171            2 :         }),
+     172            1 :     }
+     173            1 :   })
+        
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/schema/src/index-sort-f.html b/packages/llm/schema/src/index-sort-f.html new file mode 100644 index 00000000..dfe12af6 --- /dev/null +++ b/packages/llm/schema/src/index-sort-f.html @@ -0,0 +1,107 @@ + + + + + + + LCOV - lcov.info - ../schema/src + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../schema/srcCoverageTotalHit
Test:lcov.infoLines:95.1 %4139
Test Date:2026-08-28 10:46:17Functions:-00
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

File Sort by file nameLine Coverage Sort by line coverage
Rate Total Hit
llm.ts +
100.0%
+
100.0 %1919
schema.ts +
90.9%90.9%
+
90.9 %2220
Note: 'Function Coverage' columns elided as function owner is not identified.
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/schema/src/index-sort-l.html b/packages/llm/schema/src/index-sort-l.html new file mode 100644 index 00000000..c48cf212 --- /dev/null +++ b/packages/llm/schema/src/index-sort-l.html @@ -0,0 +1,107 @@ + + + + + + + LCOV - lcov.info - ../schema/src + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../schema/srcCoverageTotalHit
Test:lcov.infoLines:95.1 %4139
Test Date:2026-08-28 10:46:17Functions:-00
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

File Sort by file nameLine Coverage Sort by line coverage
Rate Total Hit
schema.ts +
90.9%90.9%
+
90.9 %2220
llm.ts +
100.0%
+
100.0 %1919
Note: 'Function Coverage' columns elided as function owner is not identified.
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/schema/src/index.html b/packages/llm/schema/src/index.html new file mode 100644 index 00000000..aa73fde1 --- /dev/null +++ b/packages/llm/schema/src/index.html @@ -0,0 +1,107 @@ + + + + + + + LCOV - lcov.info - ../schema/src + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../schema/srcCoverageTotalHit
Test:lcov.infoLines:95.1 %4139
Test Date:2026-08-28 10:46:17Functions:-00
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

File Sort by file nameLine Coverage Sort by line coverage
Rate Total Hit
llm.ts +
100.0%
+
100.0 %1919
schema.ts +
90.9%90.9%
+
90.9 %2220
Note: 'Function Coverage' columns elided as function owner is not identified.
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/schema/src/llm.ts.gcov.html b/packages/llm/schema/src/llm.ts.gcov.html new file mode 100644 index 00000000..5d53b57a --- /dev/null +++ b/packages/llm/schema/src/llm.ts.gcov.html @@ -0,0 +1,104 @@ + + + + + + + LCOV - lcov.info - ../schema/src/llm.ts + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../schema/src - llm.tsCoverageTotalHit
Test:lcov.infoLines:100.0 %1919
Test Date:2026-08-28 10:46:17Functions:-00
+
+ + + + + + + + +

+
            Line data    Source code
+
+       1           29 : export * as LLM from "./llm"
+       2              : 
+       3           32 : import { Schema } from "effect"
+       4           36 : import { optional } from "./schema"
+       5              : 
+       6          118 : export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({
+       7           35 :   identifier: "LLM.ProviderMetadata",
+       8            3 : })
+       9              : export type ProviderMetadata = Schema.Schema.Type<typeof ProviderMetadata>
+      10              : 
+      11              : export interface ToolTextContent extends Schema.Schema.Type<typeof ToolTextContent> {}
+      12           47 : export const ToolTextContent = Schema.Struct({
+      13           31 :   type: Schema.Literal("text"),
+      14           20 :   text: Schema.String,
+      15           48 : }).annotate({ identifier: "Tool.TextContent" })
+      16              : 
+      17              : export interface ToolFileContent extends Schema.Schema.Type<typeof ToolFileContent> {}
+      18           47 : export const ToolFileContent = Schema.Struct({
+      19           31 :   type: Schema.Literal("file"),
+      20           21 :   uri: Schema.String,
+      21           22 :   mime: Schema.String,
+      22           30 :   name: optional(Schema.String),
+      23           48 : }).annotate({ identifier: "Tool.FileContent" })
+      24              : 
+      25           75 : export const ToolContent = Schema.Union([ToolTextContent, ToolFileContent])
+      26           35 :   .pipe(Schema.toTaggedUnion("type"))
+      27           44 :   .annotate({ identifier: "LLM.ToolContent" })
+      28              : export type ToolContent = Schema.Schema.Type<typeof ToolContent>
+        
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + diff --git a/packages/llm/schema/src/schema.ts.gcov.html b/packages/llm/schema/src/schema.ts.gcov.html new file mode 100644 index 00000000..c414d8f2 --- /dev/null +++ b/packages/llm/schema/src/schema.ts.gcov.html @@ -0,0 +1,106 @@ + + + + + + + LCOV - lcov.info - ../schema/src/schema.ts + + + + + + + + + + + + + + +
LCOV - code coverage report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current view:top level - ../schema/src - schema.tsCoverageTotalHit
Test:lcov.infoLines:90.9 %2220
Test Date:2026-08-28 10:46:17Functions:-00
+
+ + + + + + + + +

+
            Line data    Source code
+
+       1           64 : import { DateTime, Option, Schema, SchemaGetter } from "effect"
+       2              : 
+       3           69 : export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
+       4           81 : export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
+       5              : 
+       6           77 : export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath"))
+       7              : export type RelativePath = typeof RelativePath.Type
+       8              : 
+       9           77 : export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath"))
+      10              : export type AbsolutePath = typeof AbsolutePath.Type
+      11              : 
+      12           34 : export const optional = <S extends Schema.Top>(schema: S) =>
+      13           32 :   Schema.optionalKey(schema).pipe(
+      14           59 :     Schema.decodeTo(Schema.optional(Schema.toType(schema)), {
+      15           54 :       decode: SchemaGetter.passthrough({ strict: false }),
+      16           55 :       encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)),
+      17            1 :     }),
+      18            2 :   )
+      19              : 
+      20           21 : export const statics =
+      21            0 :   <S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
+      22            0 :   (schema: S): S & M =>
+      23            2 :     Object.assign(schema, methods(schema))
+      24              : 
+      25           55 : export const DateTimeUtcFromMillis = Schema.Finite.pipe(
+      26           39 :   Schema.decodeTo(Schema.DateTimeUtc, {
+      27           34 :     decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)),
+      28           32 :     encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)),
+      29            1 :   }),
+      30            2 : )
+        
+
+
+ + + + +
Generated by: LCOV version 2.0-1
+
+ + + From 23af8185bcb77f9c350ff2820e66460f3b269610 Mon Sep 17 00:00:00 2001 From: Ahmad Pathan Date: Fri, 28 Aug 2026 12:37:33 +0000 Subject: [PATCH 2/2] Refactor getStatusText to reduce multiple returns --- packages/web/src/components/Share.test.ts | 28 +++++++++++++++++++++++ packages/web/src/components/Share.tsx | 23 +++++++++++++------ 2 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 packages/web/src/components/Share.test.ts diff --git a/packages/web/src/components/Share.test.ts b/packages/web/src/components/Share.test.ts new file mode 100644 index 00000000..78e01481 --- /dev/null +++ b/packages/web/src/components/Share.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test" +import { getStatusText } from "./Share" + +const messages = { + status_connected_waiting: "Connected", + status_connecting: "Connecting", + status_disconnected: "Disconnected", + status_reconnecting: "Reconnecting", + status_error: "Error", + status_unknown: "Unknown", +} + +describe("getStatusText", () => { + test("returns the correct message for connection statuses", () => { + expect(getStatusText(["connected"], messages)).toBe("Connected") + expect(getStatusText(["connecting"], messages)).toBe("Connecting") + expect(getStatusText(["disconnected"], messages)).toBe("Disconnected") + expect(getStatusText(["reconnecting"], messages)).toBe("Reconnecting") + }) + + test("uses a supplied error message", () => { + expect(getStatusText(["error", "Custom error"], messages)).toBe("Custom error") + }) + + test("falls back to the default error message", () => { + expect(getStatusText(["error"], messages)).toBe("Error") + }) +}) \ No newline at end of file diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index 04514b21..83bff5f2 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -21,21 +21,30 @@ function scrollToAnchor(id: string) { el.scrollIntoView({ behavior: "smooth" }) } -function getStatusText(status: [Status, string?], messages: Record): string { +export function getStatusText(status: [Status, string?], messages: Record): string { + let text: string + switch (status[0]) { case "connected": - return messages.status_connected_waiting + text = messages.status_connected_waiting + break case "connecting": - return messages.status_connecting + text = messages.status_connecting + break case "disconnected": - return messages.status_disconnected + text = messages.status_disconnected + break case "reconnecting": - return messages.status_reconnecting + text = messages.status_reconnecting + break case "error": - return status[1] || messages.status_error + text = status[1] || messages.status_error + break default: - return messages.status_unknown + text = messages.status_unknown } + + return text } export default function Share(props: {