Open History to see the last commit
1import {
2 ProviderReplayEntry,
3 ProviderReplayNdjsonRecord,
4 ProviderReplayTranscript,
5 type ProviderReplayTranscriptHeader,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11
12export 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
25export 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
34export 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
43export const ProviderReplayNdjsonParseError = Schema.Union([
44 ProviderReplayNdjsonLineParseError,
45 ProviderReplayNdjsonMissingHeaderError,
46 ProviderReplayNdjsonEmptyError,
47]);
48export type ProviderReplayNdjsonParseError = typeof ProviderReplayNdjsonParseError.Type;
49
50export type ProviderReplayTranscriptMetadata = Omit<ProviderReplayTranscript, "entries">;
51
52const REPLAY_TRANSCRIPT_WORKSPACE_PLACEHOLDER = "<workspace>";
53
54function 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
72export function materializeReplayTranscriptWorkspace(
73 transcript: ProviderReplayTranscript,
74 workspace: string,
75): ProviderReplayTranscript {
76 return {
77 ...transcript,
78 entries: transcript.entries.map((entry) =>
79 entry.type === "expect_outbound"
80 ? {
81 ...entry,
82 frame: materializeWorkspacePlaceholder(entry.frame, workspace),
83 }
84 : entry,
85 ),
86 };
87}
88
89const decodeReplayRecord = Schema.decodeUnknownSync(
90 Schema.fromJsonString(ProviderReplayNdjsonRecord),
91);
92const decodeTranscript = Schema.decodeUnknownSync(ProviderReplayTranscript);
93
94function parseReplayRecord(
95 line: string,
96 lineNumber: number,
97): Effect.Effect<ProviderReplayNdjsonRecord, ProviderReplayNdjsonLineParseError> {
98 return Effect.try({
99 try: () => decodeReplayRecord(line),
100 catch: (cause) =>
101 new ProviderReplayNdjsonLineParseError({
102 lineNumber,
103 line,
104 cause,
105 }),
106 });
107}
108
109function metadataFromHeader(
110 header: ProviderReplayTranscriptHeader,
111): ProviderReplayTranscriptMetadata {
112 const { type: _type, ...metadata } = header;
113 return metadata;
114}
115
116export function decodeProviderReplayNdjson(
117 input: string,
118 fallbackMetadata?: ProviderReplayTranscriptMetadata,
119): Effect.Effect<ProviderReplayTranscript, ProviderReplayNdjsonParseError> {
120 return Effect.gen(function* () {
121 const lines = input
122 .split(/\r?\n/u)
123 .map((line, index) => ({ line: line.trim(), lineNumber: index + 1 }))
124 .filter(({ line }) => line.length > 0);
125
126 if (lines.length === 0) {
127 return yield* new ProviderReplayNdjsonEmptyError();
128 }
129
130 const firstRecord = yield* parseReplayRecord(lines[0]!.line, lines[0]!.lineNumber);
131 const metadata =
132 firstRecord.type === "transcript_start" ? metadataFromHeader(firstRecord) : fallbackMetadata;
133
134 if (!metadata) {
135 return yield* new ProviderReplayNdjsonMissingHeaderError();
136 }
137
138 const entries: Array<ProviderReplayEntry> = [];
139 if (firstRecord.type !== "transcript_start") {
140 entries.push(firstRecord);
141 }
142
143 for (const { line, lineNumber } of lines.slice(1)) {
144 const record = yield* parseReplayRecord(line, lineNumber);
145 if (record.type === "transcript_start") {
146 return yield* new ProviderReplayNdjsonLineParseError({
147 lineNumber,
148 line,
149 cause: "transcript_start is only valid as the first replay record",
150 });
151 }
152 entries.push(record);
153 }
154
155 return decodeTranscript({
156 ...metadata,
157 entries,
158 });
159 });
160}
161
162/** Use Path to resolve Windows drive/UNC fixture URLs into native paths. */
163export const readProviderReplayTranscript = Effect.fn("readProviderReplayTranscript")(function* (
164 file: URL,
165) {
166 const fs = yield* FileSystem.FileSystem;
167 const path = yield* Path.Path;
168 const text = yield* fs.readFileString(yield* path.fromFileUrl(file));
169 return yield* decodeProviderReplayNdjson(text);
170});