| 1 | import { |
| 2 | ProviderReplayEntry, |
| 3 | ProviderReplayNdjsonRecord, |
| 4 | ProviderReplayTranscript, |
| 5 | type ProviderReplayTranscriptHeader, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Path from "effect/Path"; |
| 10 | import * as Schema from "effect/Schema"; |
| 11 | |
| 12 | export class ProviderReplayNdjsonLineParseError extends Schema.TaggedError<ProviderReplayNdjsonLineParseError>()( |
| 13 | "ProviderReplayNdjsonLineParseError", |
| 14 | { |
| 15 | lineNumber: Schema.Number, |
| 16 | line: Schema.String, |
| 17 | cause: Schema.optional(Schema.Defect()), |
| 18 | }, |
| 19 | ) { |
| 20 | override get message(): string { |
| 21 | return `Failed to parse provider replay NDJSON line ${this.lineNumber}.`; |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | export class ProviderReplayNdjsonMissingHeaderError extends Schema.TaggedError<ProviderReplayNdjsonMissingHeaderError>()( |
| 26 | "ProviderReplayNdjsonMissingHeaderError", |
| 27 | {}, |
| 28 | ) { |
| 29 | override get message(): string { |
| 30 | return "Provider replay NDJSON requires a transcript_start header or fallback transcript metadata."; |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | export class ProviderReplayNdjsonEmptyError extends Schema.TaggedError<ProviderReplayNdjsonEmptyError>()( |
| 35 | "ProviderReplayNdjsonEmptyError", |
| 36 | {}, |
| 37 | ) { |
| 38 | override get message(): string { |
| 39 | return "Provider replay NDJSON did not contain any records."; |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | export const ProviderReplayNdjsonParseError = Schema.Union([ |
| 44 | ProviderReplayNdjsonLineParseError, |
| 45 | ProviderReplayNdjsonMissingHeaderError, |
| 46 | ProviderReplayNdjsonEmptyError, |
| 47 | ]); |
| 48 | export type ProviderReplayNdjsonParseError = typeof ProviderReplayNdjsonParseError.Type; |
| 49 | |
| 50 | export type ProviderReplayTranscriptMetadata = Omit<ProviderReplayTranscript, "entries">; |
| 51 | |
| 52 | const REPLAY_TRANSCRIPT_WORKSPACE_PLACEHOLDER = "<workspace>"; |
| 53 | |
| 54 | function materializeWorkspacePlaceholder(value: unknown, workspace: string): unknown { |
| 55 | if (value === REPLAY_TRANSCRIPT_WORKSPACE_PLACEHOLDER) { |
| 56 | return workspace; |
| 57 | } |
| 58 | if (Array.isArray(value)) { |
| 59 | return value.map((entry) => materializeWorkspacePlaceholder(entry, workspace)); |
| 60 | } |
| 61 | if (typeof value !== "object" || value === null) { |
| 62 | return value; |
| 63 | } |
| 64 | return Object.fromEntries( |
| 65 | Object.entries(value).map(([key, entry]) => [ |
| 66 | key, |
| 67 | materializeWorkspacePlaceholder(entry, workspace), |
| 68 | ]), |
| 69 | ); |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * Resolves portable fixture placeholders before a replay driver sees the transcript. |
| 74 | * The resulting outbound frames still use exact structural equality during replay. |
| 75 | */ |
| 76 | export function materializeReplayTranscriptWorkspace( |
| 77 | transcript: ProviderReplayTranscript, |
| 78 | workspace: string, |
| 79 | ): ProviderReplayTranscript { |
| 80 | return { |
| 81 | ...transcript, |
| 82 | entries: transcript.entries.map((entry) => |
| 83 | entry.type === "expect_outbound" |
| 84 | ? { |
| 85 | ...entry, |
| 86 | frame: materializeWorkspacePlaceholder(entry.frame, workspace), |
| 87 | } |
| 88 | : entry, |
| 89 | ), |
| 90 | }; |
| 91 | } |
| 92 | |
| 93 | const decodeReplayRecord = Schema.decodeUnknownSync( |
| 94 | Schema.fromJsonString(ProviderReplayNdjsonRecord), |
| 95 | ); |
| 96 | const decodeTranscript = Schema.decodeUnknownSync(ProviderReplayTranscript); |
| 97 | |
| 98 | function parseReplayRecord( |
| 99 | line: string, |
| 100 | lineNumber: number, |
| 101 | ): Effect.Effect<ProviderReplayNdjsonRecord, ProviderReplayNdjsonLineParseError> { |
| 102 | return Effect.try({ |
| 103 | try: () => decodeReplayRecord(line), |
| 104 | catch: (cause) => |
| 105 | new ProviderReplayNdjsonLineParseError({ |
| 106 | lineNumber, |
| 107 | line, |
| 108 | cause, |
| 109 | }), |
| 110 | }); |
| 111 | } |
| 112 | |
| 113 | function metadataFromHeader( |
| 114 | header: ProviderReplayTranscriptHeader, |
| 115 | ): ProviderReplayTranscriptMetadata { |
| 116 | const { type: _type, ...metadata } = header; |
| 117 | return metadata; |
| 118 | } |
| 119 | |
| 120 | export function decodeProviderReplayNdjson( |
| 121 | input: string, |
| 122 | fallbackMetadata?: ProviderReplayTranscriptMetadata, |
| 123 | ): Effect.Effect<ProviderReplayTranscript, ProviderReplayNdjsonParseError> { |
| 124 | return Effect.gen(function* () { |
| 125 | const lines = input |
| 126 | .split(/\r?\n/u) |
| 127 | .map((line, index) => ({ line: line.trim(), lineNumber: index + 1 })) |
| 128 | .filter(({ line }) => line.length > 0); |
| 129 | |
| 130 | if (lines.length === 0) { |
| 131 | return yield* new ProviderReplayNdjsonEmptyError(); |
| 132 | } |
| 133 | |
| 134 | const firstRecord = yield* parseReplayRecord(lines[0]!.line, lines[0]!.lineNumber); |
| 135 | const metadata = |
| 136 | firstRecord.type === "transcript_start" ? metadataFromHeader(firstRecord) : fallbackMetadata; |
| 137 | |
| 138 | if (!metadata) { |
| 139 | return yield* new ProviderReplayNdjsonMissingHeaderError(); |
| 140 | } |
| 141 | |
| 142 | const entries: Array<ProviderReplayEntry> = []; |
| 143 | if (firstRecord.type !== "transcript_start") { |
| 144 | entries.push(firstRecord); |
| 145 | } |
| 146 | |
| 147 | for (const { line, lineNumber } of lines.slice(1)) { |
| 148 | const record = yield* parseReplayRecord(line, lineNumber); |
| 149 | if (record.type === "transcript_start") { |
| 150 | return yield* new ProviderReplayNdjsonLineParseError({ |
| 151 | lineNumber, |
| 152 | line, |
| 153 | cause: "transcript_start is only valid as the first replay record", |
| 154 | }); |
| 155 | } |
| 156 | entries.push(record); |
| 157 | } |
| 158 | |
| 159 | return decodeTranscript({ |
| 160 | ...metadata, |
| 161 | entries, |
| 162 | }); |
| 163 | }); |
| 164 | } |
| 165 | |
| 166 | /** |
| 167 | * Reads a provider replay transcript from a `file:` URL and decodes it. |
| 168 | * Conversion goes through the `Path` service so drive-letter and UNC fixture |
| 169 | * URLs resolve to native paths on Windows instead of `/C:/...` pathname strings. |
| 170 | */ |
| 171 | export const readProviderReplayTranscript = Effect.fn("readProviderReplayTranscript")(function* ( |
| 172 | file: URL, |
| 173 | ) { |
| 174 | const fs = yield* FileSystem.FileSystem; |
| 175 | const path = yield* Path.Path; |
| 176 | const text = yield* fs.readFileString(yield* path.fromFileUrl(file)); |
| 177 | return yield* decodeProviderReplayNdjson(text); |
| 178 | }); |