@useairfoil/effect-vcr 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +202 -44
- package/dist/cassette-store-BunkvLoV.d.ts +59 -0
- package/dist/cassette-store-CnXX5OLB.js +60 -0
- package/dist/cassette-store-CnXX5OLB.js.map +1 -0
- package/dist/cassette-store.d.ts +3 -0
- package/dist/cassette-store.js +3 -0
- package/dist/file-system-cassette-store-DUEtpVCu.d.ts +19 -0
- package/dist/file-system-cassette-store-zWkrhhlO.js +42 -0
- package/dist/file-system-cassette-store-zWkrhhlO.js.map +1 -0
- package/dist/file-system-cassette-store.d.ts +4 -0
- package/dist/file-system-cassette-store.js +5 -0
- package/dist/index.d.ts +5 -150
- package/dist/index.js +6 -450
- package/dist/types-BsuPrIpu.js +1 -0
- package/dist/types-DWwkMzxG.d.ts +85 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +3 -0
- package/dist/vcr-http-client-BaMxQun5.js +351 -0
- package/dist/vcr-http-client-BaMxQun5.js.map +1 -0
- package/dist/vcr-http-client-Mwb8ZMsc.d.ts +26 -0
- package/dist/vcr-http-client.d.ts +4 -0
- package/dist/vcr-http-client.js +5 -0
- package/dist/vitest-state-BOynp4Va.js +14 -0
- package/dist/vitest-state-BOynp4Va.js.map +1 -0
- package/package.json +24 -4
- package/src/cassette-store.ts +25 -29
- package/src/file-system-cassette-store.ts +15 -4
- package/src/index.ts +1 -1
- package/src/sanitize.ts +14 -10
- package/src/types.ts +7 -1
- package/src/vcr-http-client.ts +148 -147
- package/dist/index.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,79 +1,237 @@
|
|
|
1
1
|
# @useairfoil/effect-vcr
|
|
2
2
|
|
|
3
|
-
An HttpClient for Effect that records
|
|
3
|
+
An `HttpClient` for Effect that records HTTP interactions to cassette files and replays them for future test runs.
|
|
4
4
|
|
|
5
|
-
The first time the following test is run, it sends the HTTP request to httpbin.org and stores the request/response pair to a cassette. All subsequent
|
|
5
|
+
The first time the following test is run, it sends the HTTP request to `httpbin.org` and stores the request/response pair to a cassette. All subsequent test runs replay the stored response and make no live HTTP requests.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
8
|
// my-program.ts
|
|
9
|
+
import { Effect } from "effect";
|
|
10
|
+
import { HttpClient } from "effect/unstable/http";
|
|
11
|
+
|
|
9
12
|
export const program = Effect.gen(function* () {
|
|
10
13
|
const client = yield* HttpClient.HttpClient;
|
|
11
|
-
const response = yield* client.get("https://httpbin.org");
|
|
14
|
+
const response = yield* client.get("https://httpbin.org/robots.txt");
|
|
12
15
|
return yield* response.text;
|
|
13
16
|
});
|
|
14
17
|
|
|
15
18
|
// my-program.test.ts
|
|
16
|
-
import {
|
|
19
|
+
import { NodeServices } from "@effect/platform-node";
|
|
20
|
+
import { FileSystemCassetteStore, VcrHttpClient } from "@useairfoil/effect-vcr";
|
|
21
|
+
import { describe, it } from "@effect/vitest";
|
|
22
|
+
import { Effect, Layer } from "effect";
|
|
23
|
+
import { FetchHttpClient } from "effect/unstable/http";
|
|
24
|
+
|
|
25
|
+
const cassetteStoreLayer = FileSystemCassetteStore.layer().pipe(Layer.provide(NodeServices.layer));
|
|
26
|
+
|
|
27
|
+
const vcrRuntimeLayer = Layer.mergeAll(
|
|
28
|
+
FetchHttpClient.layer,
|
|
29
|
+
NodeServices.layer,
|
|
30
|
+
cassetteStoreLayer,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const vcrLayer = VcrHttpClient.layer({
|
|
34
|
+
vcrName: "httpbin",
|
|
35
|
+
mode: "auto",
|
|
36
|
+
}).pipe(Layer.provide(vcrRuntimeLayer));
|
|
17
37
|
|
|
18
38
|
describe("my awesome program", () => {
|
|
19
|
-
it.effect("works", () =>
|
|
20
|
-
Effect.gen(function* () {
|
|
21
|
-
yield* program;
|
|
22
|
-
}),
|
|
23
|
-
).pipe(
|
|
24
|
-
// Wrap the HttpClient with the VCR
|
|
25
|
-
Effect.provide(VcrHttpClient.layer()),
|
|
26
|
-
// Store cassettes on the file system
|
|
27
|
-
Effect.provide(FileSystemCassetteStore.layer()),
|
|
28
|
-
// HttpClient based on fetch
|
|
29
|
-
Effect.provide(FetchHttpClient.layer),
|
|
30
|
-
// Node is used to access the file system
|
|
31
|
-
Effect.provide(NodeServices.layer),
|
|
32
|
-
);
|
|
39
|
+
it.effect("works", () => program.pipe(Effect.provide(vcrLayer)));
|
|
33
40
|
});
|
|
34
41
|
```
|
|
35
42
|
|
|
36
|
-
The main use case for the VCR is to
|
|
43
|
+
The main use case for the VCR is to let open source projects run tests against a real upstream API without exposing secrets to forks.
|
|
44
|
+
|
|
45
|
+
- project owners generate cassettes locally by talking to the real API
|
|
46
|
+
- external contributors run tests against committed cassettes
|
|
47
|
+
- CI runs deterministically from cassettes
|
|
48
|
+
- scheduled or manual runs can still hit the live API to catch upstream drift
|
|
37
49
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
50
|
+
## Package shape
|
|
51
|
+
|
|
52
|
+
Root exports:
|
|
53
|
+
|
|
54
|
+
- `CassetteStore`
|
|
55
|
+
- `FileSystemCassetteStore`
|
|
56
|
+
- `VcrHttpClient`
|
|
57
|
+
- `VcrConfig`
|
|
58
|
+
- `VcrMode`
|
|
59
|
+
- `VcrRequest`
|
|
60
|
+
- `VcrResponse`
|
|
61
|
+
- `VcrEntry`
|
|
62
|
+
- `Cassette`
|
|
63
|
+
- `CassetteFile`
|
|
64
|
+
|
|
65
|
+
Focused subpath exports are also available:
|
|
66
|
+
|
|
67
|
+
- `@useairfoil/effect-vcr/cassette-store`
|
|
68
|
+
- `@useairfoil/effect-vcr/file-system-cassette-store`
|
|
69
|
+
- `@useairfoil/effect-vcr/types`
|
|
70
|
+
- `@useairfoil/effect-vcr/vcr-http-client`
|
|
42
71
|
|
|
43
72
|
## Usage
|
|
44
73
|
|
|
45
|
-
Use the VCR by providing
|
|
74
|
+
Use the VCR by providing `VcrHttpClient.layer(...)` to the Effect being tested.
|
|
75
|
+
|
|
76
|
+
- `VcrHttpClient` wraps an existing `HttpClient` and adds record/replay behavior
|
|
77
|
+
- `FileSystemCassetteStore` persists cassette files to disk
|
|
78
|
+
|
|
79
|
+
`VcrHttpClient.layer(...)` depends on:
|
|
46
80
|
|
|
47
|
-
-
|
|
48
|
-
-
|
|
81
|
+
- a live `HttpClient` implementation such as `FetchHttpClient.layer`
|
|
82
|
+
- `Path.Path` for cassette name resolution
|
|
83
|
+
- `CassetteStore.CassetteStore` for persistence
|
|
84
|
+
|
|
85
|
+
`FileSystemCassetteStore.layer(...)` depends on:
|
|
86
|
+
|
|
87
|
+
- `FileSystem.FileSystem`
|
|
88
|
+
- `Path.Path`
|
|
89
|
+
|
|
90
|
+
In Node tests, `NodeServices.layer` satisfies those platform services.
|
|
49
91
|
|
|
50
92
|
## Configuration and defaults
|
|
51
93
|
|
|
52
94
|
### VcrHttpClient
|
|
53
95
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
96
|
+
```ts
|
|
97
|
+
type VcrConfig = {
|
|
98
|
+
readonly vcrName?: string;
|
|
99
|
+
readonly cassetteName?: string;
|
|
100
|
+
readonly mode?: "record" | "replay" | "auto";
|
|
101
|
+
readonly redact?: {
|
|
102
|
+
readonly requestHeaders?: ReadonlyArray<string>;
|
|
103
|
+
readonly responseHeaders?: ReadonlyArray<string>;
|
|
104
|
+
readonly requestBodyKeys?: ReadonlyArray<string>;
|
|
105
|
+
readonly responseBodyKeys?: ReadonlyArray<string>;
|
|
106
|
+
};
|
|
107
|
+
readonly matchIgnore?: {
|
|
108
|
+
readonly requestHeaders?: ReadonlyArray<string>;
|
|
109
|
+
readonly requestBodyKeys?: ReadonlyArray<string>;
|
|
110
|
+
};
|
|
111
|
+
readonly match?: (request: VcrRequest, entry: VcrEntry) => boolean;
|
|
112
|
+
};
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- `vcrName?: string`
|
|
116
|
+
- logical VCR name used by `ACK_DISABLE_VCR`
|
|
117
|
+
- `cassetteName?: string`
|
|
118
|
+
- cassette file basename or full file name
|
|
119
|
+
- `users` resolves to `users.cassette`
|
|
120
|
+
- `users.cassette` is preserved as-is
|
|
121
|
+
- when omitted in Vitest, the cassette file defaults to `<test-file>.cassette` and the current test name becomes the export key inside that file
|
|
122
|
+
- `mode?: "record" | "replay" | "auto"`
|
|
123
|
+
- `record`: always call the live client and write a cassette
|
|
124
|
+
- `replay`: only serve from cassette; missing entries fail
|
|
125
|
+
- `auto`: replay if the cassette exists, otherwise record; when `CI=true`, missing cassettes fail instead of recording
|
|
126
|
+
- `redact`
|
|
127
|
+
- remove sensitive headers or JSON body keys before writing to disk
|
|
128
|
+
- `matchIgnore`
|
|
129
|
+
- ignore request headers or JSON body keys when computing the request lookup key
|
|
130
|
+
- `match`
|
|
131
|
+
- custom request matcher for advanced lookup behavior
|
|
132
|
+
|
|
133
|
+
Defaults:
|
|
134
|
+
|
|
135
|
+
- `mode` defaults to `"auto"`
|
|
136
|
+
- `authorization` is ignored for matching by default
|
|
137
|
+
- `authorization` is redacted from recorded request headers by default
|
|
69
138
|
|
|
70
139
|
### FileSystemCassetteStore
|
|
71
140
|
|
|
72
|
-
- `cassetteDir?: string
|
|
141
|
+
- `cassetteDir?: string`
|
|
142
|
+
- changes the cassette root directory
|
|
143
|
+
- when omitted in Vitest, cassettes are written to the `__cassettes__` directory beside the test file
|
|
144
|
+
- outside Vitest, provide `cassetteDir` explicitly
|
|
145
|
+
|
|
146
|
+
## Matching and redaction
|
|
147
|
+
|
|
148
|
+
`matchIgnore` and `redact` solve different problems.
|
|
149
|
+
|
|
150
|
+
- `matchIgnore` changes how requests are matched to cassette entries
|
|
151
|
+
- `redact` changes what is persisted to disk
|
|
152
|
+
|
|
153
|
+
Typical usage:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
const vcrLayer = VcrHttpClient.layer({
|
|
157
|
+
vcrName: "shopify-products",
|
|
158
|
+
mode: "auto",
|
|
159
|
+
matchIgnore: {
|
|
160
|
+
requestHeaders: ["x-shopify-access-token", "authorization"],
|
|
161
|
+
},
|
|
162
|
+
redact: {
|
|
163
|
+
requestHeaders: ["x-shopify-access-token", "authorization"],
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Use `matchIgnore` when a request field should not affect cassette identity.
|
|
169
|
+
|
|
170
|
+
Use `redact` when a field should never be written to disk.
|
|
73
171
|
|
|
74
172
|
## Runtime configuration
|
|
75
173
|
|
|
76
174
|
The VCR is controlled by the following environment variables:
|
|
77
175
|
|
|
78
|
-
- `CI=true
|
|
79
|
-
-
|
|
176
|
+
- `CI=true`
|
|
177
|
+
- if the VCR mode is `auto`, tests with a missing cassette fail instead of recording
|
|
178
|
+
- `ACK_DISABLE_VCR=<...>`
|
|
179
|
+
- disables the VCR and falls back to the wrapped live client
|
|
180
|
+
- `*` disables all configured VCRs
|
|
181
|
+
- a comma-separated list disables only matching `vcrName` values
|
|
182
|
+
|
|
183
|
+
When the VCR is disabled, cassette files are not updated.
|
|
184
|
+
|
|
185
|
+
## Common patterns
|
|
186
|
+
|
|
187
|
+
### Build the VCR runtime once
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
const cassetteStoreLayer = FileSystemCassetteStore.layer().pipe(Layer.provide(NodeServices.layer));
|
|
191
|
+
|
|
192
|
+
const vcrRuntimeLayer = Layer.mergeAll(
|
|
193
|
+
FetchHttpClient.layer,
|
|
194
|
+
NodeServices.layer,
|
|
195
|
+
cassetteStoreLayer,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const vcrLayer = VcrHttpClient.layer({
|
|
199
|
+
vcrName: "example",
|
|
200
|
+
mode: "auto",
|
|
201
|
+
}).pipe(Layer.provide(vcrRuntimeLayer));
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
This keeps the dependency graph explicit:
|
|
205
|
+
|
|
206
|
+
- `FileSystemCassetteStore.layer(...)` gets its platform services from `NodeServices.layer`
|
|
207
|
+
- `VcrHttpClient.layer(...)` gets its live client, `Path.Path`, and cassette store from `vcrRuntimeLayer`
|
|
208
|
+
|
|
209
|
+
### Override config in tests
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
import { ConfigProvider, Layer } from "effect";
|
|
213
|
+
|
|
214
|
+
const vcrTestRuntimeLayer = Layer.mergeAll(
|
|
215
|
+
vcrRuntimeLayer,
|
|
216
|
+
ConfigProvider.layer(
|
|
217
|
+
ConfigProvider.fromUnknown({
|
|
218
|
+
CI: false,
|
|
219
|
+
ACK_DISABLE_VCR: "",
|
|
220
|
+
}),
|
|
221
|
+
),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const vcrLayer = VcrHttpClient.layer({
|
|
225
|
+
vcrName: "example",
|
|
226
|
+
mode: "auto",
|
|
227
|
+
}).pipe(Layer.provide(vcrTestRuntimeLayer));
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Use focused subpath imports
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
import * as VcrHttpClient from "@useairfoil/effect-vcr/vcr-http-client";
|
|
234
|
+
import * as FileSystemCassetteStore from "@useairfoil/effect-vcr/file-system-cassette-store";
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Use root imports for normal package consumption and subpath imports when you want a narrower surface.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { n as CassetteFile, t as Cassette } from "./types-DWwkMzxG.js";
|
|
2
|
+
import { Context, Effect } from "effect";
|
|
3
|
+
import * as effect_Types0 from "effect/Types";
|
|
4
|
+
import * as effect_Cause0 from "effect/Cause";
|
|
5
|
+
|
|
6
|
+
//#region src/cassette-store.d.ts
|
|
7
|
+
declare namespace cassette_store_d_exports {
|
|
8
|
+
export { CassetteStore, CassetteStoreError, CassetteStoreOperation, CassetteStoreService, createEmptyCassette, createEmptyCassetteFile, make, parseCassette, toStoreError };
|
|
9
|
+
}
|
|
10
|
+
type CassetteStoreOperation = "exists" | "load" | "save" | "loadOrInit" | "resolveRoot";
|
|
11
|
+
/**
|
|
12
|
+
* Storage contract for VCR cassettes.
|
|
13
|
+
*
|
|
14
|
+
*/
|
|
15
|
+
interface CassetteStoreService {
|
|
16
|
+
/** Returns true if a cassette with the given name exists. */
|
|
17
|
+
readonly exists: (name: string) => Effect.Effect<boolean, CassetteStoreError>;
|
|
18
|
+
/** Loads a cassette by name, returning a `CassetteFile` if found. */
|
|
19
|
+
readonly load: (name: string) => Effect.Effect<CassetteFile, CassetteStoreError>;
|
|
20
|
+
/** Saves a cassette by name, overwriting any existing cassette with the same name. */
|
|
21
|
+
readonly save: (name: string, content: CassetteFile) => Effect.Effect<void, CassetteStoreError>;
|
|
22
|
+
/** Loads a cassette by name, initializing a new empty cassette if not found. */
|
|
23
|
+
readonly loadOrInit: (name: string) => Effect.Effect<CassetteFile, CassetteStoreError>;
|
|
24
|
+
}
|
|
25
|
+
declare const CassetteStore_base: Context.ServiceClass<CassetteStore, "@useairfoil/effect-vcr/CassetteStore", CassetteStoreService>;
|
|
26
|
+
/**
|
|
27
|
+
* Effect service tag for the cassette store.
|
|
28
|
+
*/
|
|
29
|
+
declare class CassetteStore extends CassetteStore_base {}
|
|
30
|
+
/**
|
|
31
|
+
* Creates a new empty cassette with a timestamp and format version.
|
|
32
|
+
*/
|
|
33
|
+
declare const createEmptyCassette: () => Effect.Effect<Cassette, never, never>;
|
|
34
|
+
declare const createEmptyCassetteFile: () => Effect.Effect<CassetteFile>;
|
|
35
|
+
declare const CassetteStoreError_base: new <A extends Record<string, any> = {}>(args: effect_Types0.VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => effect_Cause0.YieldableError & {
|
|
36
|
+
readonly _tag: "CassetteStoreError";
|
|
37
|
+
} & Readonly<A>;
|
|
38
|
+
/**
|
|
39
|
+
* Structured error for cassette persistence operations.
|
|
40
|
+
*
|
|
41
|
+
* `operation` indicates which store function failed.
|
|
42
|
+
* `path` is the file or directory path that was targeted.
|
|
43
|
+
*/
|
|
44
|
+
declare class CassetteStoreError extends CassetteStoreError_base<{
|
|
45
|
+
readonly operation: CassetteStoreOperation;
|
|
46
|
+
readonly path: string;
|
|
47
|
+
readonly message?: string;
|
|
48
|
+
readonly cause?: unknown;
|
|
49
|
+
}> {}
|
|
50
|
+
declare const toStoreError: (operation: CassetteStoreOperation, path: string, cause?: unknown, message?: string) => CassetteStoreError;
|
|
51
|
+
/**
|
|
52
|
+
* Parses cassette JSON and maps invalid JSON to a store error.
|
|
53
|
+
*/
|
|
54
|
+
declare const parseCassette: (content: string, path: string) => Effect.Effect<CassetteFile, CassetteStoreError, never>;
|
|
55
|
+
/** Creates a `CassetteStore` implementation that uses the given `impl` object. */
|
|
56
|
+
declare const make: (impl: CassetteStoreService) => CassetteStoreService;
|
|
57
|
+
//#endregion
|
|
58
|
+
export { cassette_store_d_exports as a, make as c, CassetteStoreService as i, parseCassette as l, CassetteStoreError as n, createEmptyCassette as o, CassetteStoreOperation as r, createEmptyCassetteFile as s, CassetteStore as t, toStoreError as u };
|
|
59
|
+
//# sourceMappingURL=cassette-store-BunkvLoV.d.ts.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { t as __export } from "./chunk-Bp6m_JJh.js";
|
|
2
|
+
import { Context, Data, DateTime, Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/cassette-store.ts
|
|
5
|
+
var cassette_store_exports = /* @__PURE__ */ __export({
|
|
6
|
+
CassetteStore: () => CassetteStore,
|
|
7
|
+
CassetteStoreError: () => CassetteStoreError,
|
|
8
|
+
createEmptyCassette: () => createEmptyCassette,
|
|
9
|
+
createEmptyCassetteFile: () => createEmptyCassetteFile,
|
|
10
|
+
make: () => make,
|
|
11
|
+
parseCassette: () => parseCassette,
|
|
12
|
+
toStoreError: () => toStoreError
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* Effect service tag for the cassette store.
|
|
16
|
+
*/
|
|
17
|
+
var CassetteStore = class extends Context.Service()("@useairfoil/effect-vcr/CassetteStore") {};
|
|
18
|
+
/**
|
|
19
|
+
* Creates a new empty cassette with a timestamp and format version.
|
|
20
|
+
*/
|
|
21
|
+
const createEmptyCassette = Effect.fnUntraced(function* () {
|
|
22
|
+
const now = yield* DateTime.now;
|
|
23
|
+
return {
|
|
24
|
+
meta: {
|
|
25
|
+
createdAt: DateTime.formatIso(now),
|
|
26
|
+
version: "1"
|
|
27
|
+
},
|
|
28
|
+
entries: {}
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
const createEmptyCassetteFile = () => Effect.map(createEmptyCassette(), (cassette) => ({ exports: { default: cassette } }));
|
|
32
|
+
/**
|
|
33
|
+
* Structured error for cassette persistence operations.
|
|
34
|
+
*
|
|
35
|
+
* `operation` indicates which store function failed.
|
|
36
|
+
* `path` is the file or directory path that was targeted.
|
|
37
|
+
*/
|
|
38
|
+
var CassetteStoreError = class extends Data.TaggedError("CassetteStoreError") {};
|
|
39
|
+
const toStoreError = (operation, path, cause, message) => new CassetteStoreError({
|
|
40
|
+
operation,
|
|
41
|
+
path,
|
|
42
|
+
cause,
|
|
43
|
+
message
|
|
44
|
+
});
|
|
45
|
+
/**
|
|
46
|
+
* Parses cassette JSON and maps invalid JSON to a store error.
|
|
47
|
+
*/
|
|
48
|
+
const parseCassette = (content, path) => Effect.try({
|
|
49
|
+
try: () => JSON.parse(content),
|
|
50
|
+
catch: (error) => toStoreError("load", path, error, "Invalid JSON")
|
|
51
|
+
}).pipe(Effect.flatMap((parsed) => {
|
|
52
|
+
if (!parsed || typeof parsed !== "object" || !("exports" in parsed)) return Effect.fail(toStoreError("load", path, void 0, "Invalid cassette file format"));
|
|
53
|
+
return Effect.succeed(parsed);
|
|
54
|
+
}));
|
|
55
|
+
/** Creates a `CassetteStore` implementation that uses the given `impl` object. */
|
|
56
|
+
const make = (impl) => CassetteStore.of({ ...impl });
|
|
57
|
+
|
|
58
|
+
//#endregion
|
|
59
|
+
export { createEmptyCassetteFile as a, toStoreError as c, createEmptyCassette as i, CassetteStoreError as n, make as o, cassette_store_exports as r, parseCassette as s, CassetteStore as t };
|
|
60
|
+
//# sourceMappingURL=cassette-store-CnXX5OLB.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cassette-store-CnXX5OLB.js","names":[],"sources":["../src/cassette-store.ts"],"sourcesContent":["import { Context, Data, DateTime, Effect } from \"effect\";\n\nimport type { Cassette, CassetteFile } from \"./types\";\n\nexport type CassetteStoreOperation = \"exists\" | \"load\" | \"save\" | \"loadOrInit\" | \"resolveRoot\";\n\n/**\n * Storage contract for VCR cassettes.\n *\n */\nexport interface CassetteStoreService {\n /** Returns true if a cassette with the given name exists. */\n readonly exists: (name: string) => Effect.Effect<boolean, CassetteStoreError>;\n\n /** Loads a cassette by name, returning a `CassetteFile` if found. */\n readonly load: (name: string) => Effect.Effect<CassetteFile, CassetteStoreError>;\n\n /** Saves a cassette by name, overwriting any existing cassette with the same name. */\n readonly save: (name: string, content: CassetteFile) => Effect.Effect<void, CassetteStoreError>;\n\n /** Loads a cassette by name, initializing a new empty cassette if not found. */\n readonly loadOrInit: (name: string) => Effect.Effect<CassetteFile, CassetteStoreError>;\n}\n\n/**\n * Effect service tag for the cassette store.\n */\nexport class CassetteStore extends Context.Service<CassetteStore, CassetteStoreService>()(\n \"@useairfoil/effect-vcr/CassetteStore\",\n) {}\n\n/**\n * Creates a new empty cassette with a timestamp and format version.\n */\nexport const createEmptyCassette = Effect.fnUntraced(function* (): Effect.fn.Return<Cassette> {\n const now = yield* DateTime.now;\n return {\n meta: {\n createdAt: DateTime.formatIso(now),\n version: \"1\",\n },\n entries: {},\n };\n});\n\nexport const createEmptyCassetteFile = (): Effect.Effect<CassetteFile> =>\n Effect.map(createEmptyCassette(), (cassette) => ({\n exports: { default: cassette },\n }));\n\n/**\n * Structured error for cassette persistence operations.\n *\n * `operation` indicates which store function failed.\n * `path` is the file or directory path that was targeted.\n */\nexport class CassetteStoreError extends Data.TaggedError(\"CassetteStoreError\")<{\n readonly operation: CassetteStoreOperation;\n readonly path: string;\n readonly message?: string;\n readonly cause?: unknown;\n}> {}\n\nexport const toStoreError = (\n operation: CassetteStoreOperation,\n path: string,\n cause?: unknown,\n message?: string,\n) =>\n new CassetteStoreError({\n operation,\n path,\n cause,\n message,\n });\n\n/**\n * Parses cassette JSON and maps invalid JSON to a store error.\n */\nexport const parseCassette = (content: string, path: string) =>\n Effect.try({\n try: () => JSON.parse(content) as CassetteFile,\n catch: (error) => toStoreError(\"load\", path, error, \"Invalid JSON\"),\n }).pipe(\n Effect.flatMap((parsed) => {\n if (!parsed || typeof parsed !== \"object\" || !(\"exports\" in parsed)) {\n return Effect.fail(toStoreError(\"load\", path, undefined, \"Invalid cassette file format\"));\n }\n return Effect.succeed(parsed);\n }),\n );\n\n/** Creates a `CassetteStore` implementation that uses the given `impl` object. */\nexport const make = (impl: CassetteStoreService): CassetteStoreService =>\n CassetteStore.of({ ...impl });\n"],"mappings":";;;;;;;;;;;;;;;;AA2BA,IAAa,gBAAb,cAAmC,QAAQ,SAA8C,CACvF,uCACD,CAAC;;;;AAKF,MAAa,sBAAsB,OAAO,WAAW,aAAyC;CAC5F,MAAM,MAAM,OAAO,SAAS;AAC5B,QAAO;EACL,MAAM;GACJ,WAAW,SAAS,UAAU,IAAI;GAClC,SAAS;GACV;EACD,SAAS,EAAE;EACZ;EACD;AAEF,MAAa,gCACX,OAAO,IAAI,qBAAqB,GAAG,cAAc,EAC/C,SAAS,EAAE,SAAS,UAAU,EAC/B,EAAE;;;;;;;AAQL,IAAa,qBAAb,cAAwC,KAAK,YAAY,qBAAqB,CAK3E;AAEH,MAAa,gBACX,WACA,MACA,OACA,YAEA,IAAI,mBAAmB;CACrB;CACA;CACA;CACA;CACD,CAAC;;;;AAKJ,MAAa,iBAAiB,SAAiB,SAC7C,OAAO,IAAI;CACT,WAAW,KAAK,MAAM,QAAQ;CAC9B,QAAQ,UAAU,aAAa,QAAQ,MAAM,OAAO,eAAe;CACpE,CAAC,CAAC,KACD,OAAO,SAAS,WAAW;AACzB,KAAI,CAAC,UAAU,OAAO,WAAW,YAAY,EAAE,aAAa,QAC1D,QAAO,OAAO,KAAK,aAAa,QAAQ,MAAM,QAAW,+BAA+B,CAAC;AAE3F,QAAO,OAAO,QAAQ,OAAO;EAC7B,CACH;;AAGH,MAAa,QAAQ,SACnB,cAAc,GAAG,EAAE,GAAG,MAAM,CAAC"}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import "./types-DWwkMzxG.js";
|
|
2
|
+
import { c as make, i as CassetteStoreService, l as parseCassette, n as CassetteStoreError, o as createEmptyCassette, r as CassetteStoreOperation, s as createEmptyCassetteFile, t as CassetteStore, u as toStoreError } from "./cassette-store-BunkvLoV.js";
|
|
3
|
+
export { CassetteStore, CassetteStoreError, CassetteStoreOperation, CassetteStoreService, createEmptyCassette, createEmptyCassetteFile, make, parseCassette, toStoreError };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { a as createEmptyCassetteFile, c as toStoreError, i as createEmptyCassette, n as CassetteStoreError, o as make, s as parseCassette, t as CassetteStore } from "./cassette-store-CnXX5OLB.js";
|
|
2
|
+
|
|
3
|
+
export { CassetteStore, CassetteStoreError, createEmptyCassette, createEmptyCassetteFile, make, parseCassette, toStoreError };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { n as CassetteStoreError, t as CassetteStore } from "./cassette-store-BunkvLoV.js";
|
|
2
|
+
import { FileSystem, Layer, Path } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/file-system-cassette-store.d.ts
|
|
5
|
+
declare namespace file_system_cassette_store_d_exports {
|
|
6
|
+
export { FileSystemCassetteStoreConfig, layer };
|
|
7
|
+
}
|
|
8
|
+
type FileSystemCassetteStoreConfig = {
|
|
9
|
+
readonly cassetteDir?: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* FileSystem-backed cassette store.
|
|
13
|
+
*
|
|
14
|
+
* Requires a platform FileSystem layer (Node/Bun) to be provided by the user.
|
|
15
|
+
*/
|
|
16
|
+
declare const layer: (config?: FileSystemCassetteStoreConfig) => Layer.Layer<CassetteStore, CassetteStoreError, FileSystem.FileSystem | Path.Path>;
|
|
17
|
+
//#endregion
|
|
18
|
+
export { file_system_cassette_store_d_exports as n, layer as r, FileSystemCassetteStoreConfig as t };
|
|
19
|
+
//# sourceMappingURL=file-system-cassette-store-DUEtpVCu.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { t as __export } from "./chunk-Bp6m_JJh.js";
|
|
2
|
+
import { a as createEmptyCassetteFile, c as toStoreError, o as make, s as parseCassette, t as CassetteStore } from "./cassette-store-CnXX5OLB.js";
|
|
3
|
+
import { t as getVitestState } from "./vitest-state-BOynp4Va.js";
|
|
4
|
+
import { Effect, FileSystem, Layer, Path } from "effect";
|
|
5
|
+
|
|
6
|
+
//#region src/file-system-cassette-store.ts
|
|
7
|
+
var file_system_cassette_store_exports = /* @__PURE__ */ __export({ layer: () => layer });
|
|
8
|
+
/**
|
|
9
|
+
* FileSystem-backed cassette store.
|
|
10
|
+
*
|
|
11
|
+
* Requires a platform FileSystem layer (Node/Bun) to be provided by the user.
|
|
12
|
+
*/
|
|
13
|
+
const layer = (config = {}) => Layer.effect(CassetteStore)(Effect.gen(function* () {
|
|
14
|
+
const fs = yield* FileSystem.FileSystem;
|
|
15
|
+
const path = yield* Path.Path;
|
|
16
|
+
let rootDir;
|
|
17
|
+
if (config.cassetteDir) rootDir = config.cassetteDir;
|
|
18
|
+
else {
|
|
19
|
+
const { testPath } = getVitestState();
|
|
20
|
+
if (!testPath) return yield* Effect.fail(toStoreError("resolveRoot", "__cassettes__", void 0, "VCR cassette directory could not be inferred. Provide cassetteDir when not running in Vitest."));
|
|
21
|
+
const testFolder = path.dirname(testPath);
|
|
22
|
+
rootDir = path.join(testFolder, "__cassettes__");
|
|
23
|
+
}
|
|
24
|
+
const fullPath = (name) => path.join(rootDir, name);
|
|
25
|
+
const load = (name) => fs.readFileString(fullPath(name)).pipe(Effect.mapError((error) => toStoreError("load", name, error)), Effect.flatMap((content) => parseCassette(content, name)));
|
|
26
|
+
const save = (name, cassette) => {
|
|
27
|
+
const filePath = fullPath(name);
|
|
28
|
+
const dir = path.dirname(filePath);
|
|
29
|
+
return (dir === "" ? Effect.void : fs.makeDirectory(dir, { recursive: true }).pipe(Effect.mapError((error) => toStoreError("save", dir, error)))).pipe(Effect.andThen(fs.writeFileString(filePath, JSON.stringify(cassette, null, 2)).pipe(Effect.mapError((error) => toStoreError("save", name, error)))));
|
|
30
|
+
};
|
|
31
|
+
const loadOrInit = (name) => fs.exists(fullPath(name)).pipe(Effect.mapError((error) => toStoreError("loadOrInit", name, error)), Effect.flatMap((exists) => exists ? load(name) : createEmptyCassetteFile().pipe(Effect.tap((cassette) => save(name, cassette)))));
|
|
32
|
+
return make({
|
|
33
|
+
exists: (name) => fs.exists(fullPath(name)).pipe(Effect.mapError((error) => toStoreError("exists", name, error))),
|
|
34
|
+
load,
|
|
35
|
+
save,
|
|
36
|
+
loadOrInit
|
|
37
|
+
});
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
//#endregion
|
|
41
|
+
export { layer as n, file_system_cassette_store_exports as t };
|
|
42
|
+
//# sourceMappingURL=file-system-cassette-store-zWkrhhlO.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-system-cassette-store-zWkrhhlO.js","names":["CassetteStore.CassetteStore","rootDir: string","CassetteStore.toStoreError","CassetteStore.parseCassette","CassetteStore.createEmptyCassetteFile","CassetteStore.make"],"sources":["../src/file-system-cassette-store.ts"],"sourcesContent":["import { Effect, Layer, FileSystem, Path } from \"effect\";\n\nimport type { CassetteFile } from \"./types\";\n\nimport * as CassetteStore from \"./cassette-store\";\nimport { getVitestState } from \"./vitest-state\";\n\nexport type FileSystemCassetteStoreConfig = {\n readonly cassetteDir?: string;\n};\n\n/**\n * FileSystem-backed cassette store.\n *\n * Requires a platform FileSystem layer (Node/Bun) to be provided by the user.\n */\nexport const layer = (config: FileSystemCassetteStoreConfig = {}) =>\n Layer.effect(CassetteStore.CassetteStore)(\n Effect.gen(function* () {\n const fs = yield* FileSystem.FileSystem;\n const path = yield* Path.Path;\n\n let rootDir: string;\n if (config.cassetteDir) {\n rootDir = config.cassetteDir;\n } else {\n const { testPath } = getVitestState();\n if (!testPath) {\n return yield* Effect.fail(\n CassetteStore.toStoreError(\n \"resolveRoot\",\n \"__cassettes__\",\n undefined,\n \"VCR cassette directory could not be inferred. Provide cassetteDir when not running in Vitest.\",\n ),\n );\n }\n const testFolder = path.dirname(testPath);\n rootDir = path.join(testFolder, \"__cassettes__\");\n }\n\n const fullPath = (name: string) => path.join(rootDir, name);\n\n const load = (name: string) =>\n fs.readFileString(fullPath(name)).pipe(\n Effect.mapError((error) => CassetteStore.toStoreError(\"load\", name, error)),\n Effect.flatMap((content) => CassetteStore.parseCassette(content, name)),\n );\n\n const save = (name: string, cassette: CassetteFile) => {\n const filePath = fullPath(name);\n const dir = path.dirname(filePath);\n const ensureDir =\n dir === \"\"\n ? Effect.void\n : fs\n .makeDirectory(dir, { recursive: true })\n .pipe(Effect.mapError((error) => CassetteStore.toStoreError(\"save\", dir, error)));\n return ensureDir.pipe(\n Effect.andThen(\n fs\n .writeFileString(filePath, JSON.stringify(cassette, null, 2))\n .pipe(Effect.mapError((error) => CassetteStore.toStoreError(\"save\", name, error))),\n ),\n );\n };\n\n const loadOrInit = (name: string) =>\n fs.exists(fullPath(name)).pipe(\n Effect.mapError((error) => CassetteStore.toStoreError(\"loadOrInit\", name, error)),\n Effect.flatMap((exists) =>\n exists\n ? load(name)\n : CassetteStore.createEmptyCassetteFile().pipe(\n Effect.tap((cassette) => save(name, cassette)),\n ),\n ),\n );\n\n return CassetteStore.make({\n exists: (name: string) =>\n fs\n .exists(fullPath(name))\n .pipe(Effect.mapError((error) => CassetteStore.toStoreError(\"exists\", name, error))),\n load,\n save,\n loadOrInit,\n });\n }),\n );\n"],"mappings":";;;;;;;;;;;;AAgBA,MAAa,SAAS,SAAwC,EAAE,KAC9D,MAAM,OAAOA,cAA4B,CACvC,OAAO,IAAI,aAAa;CACtB,MAAM,KAAK,OAAO,WAAW;CAC7B,MAAM,OAAO,OAAO,KAAK;CAEzB,IAAIC;AACJ,KAAI,OAAO,YACT,WAAU,OAAO;MACZ;EACL,MAAM,EAAE,aAAa,gBAAgB;AACrC,MAAI,CAAC,SACH,QAAO,OAAO,OAAO,KACnBC,aACE,eACA,iBACA,QACA,gGACD,CACF;EAEH,MAAM,aAAa,KAAK,QAAQ,SAAS;AACzC,YAAU,KAAK,KAAK,YAAY,gBAAgB;;CAGlD,MAAM,YAAY,SAAiB,KAAK,KAAK,SAAS,KAAK;CAE3D,MAAM,QAAQ,SACZ,GAAG,eAAe,SAAS,KAAK,CAAC,CAAC,KAChC,OAAO,UAAU,UAAUA,aAA2B,QAAQ,MAAM,MAAM,CAAC,EAC3E,OAAO,SAAS,YAAYC,cAA4B,SAAS,KAAK,CAAC,CACxE;CAEH,MAAM,QAAQ,MAAc,aAA2B;EACrD,MAAM,WAAW,SAAS,KAAK;EAC/B,MAAM,MAAM,KAAK,QAAQ,SAAS;AAOlC,UALE,QAAQ,KACJ,OAAO,OACP,GACG,cAAc,KAAK,EAAE,WAAW,MAAM,CAAC,CACvC,KAAK,OAAO,UAAU,UAAUD,aAA2B,QAAQ,KAAK,MAAM,CAAC,CAAC,EACxE,KACf,OAAO,QACL,GACG,gBAAgB,UAAU,KAAK,UAAU,UAAU,MAAM,EAAE,CAAC,CAC5D,KAAK,OAAO,UAAU,UAAUA,aAA2B,QAAQ,MAAM,MAAM,CAAC,CAAC,CACrF,CACF;;CAGH,MAAM,cAAc,SAClB,GAAG,OAAO,SAAS,KAAK,CAAC,CAAC,KACxB,OAAO,UAAU,UAAUA,aAA2B,cAAc,MAAM,MAAM,CAAC,EACjF,OAAO,SAAS,WACd,SACI,KAAK,KAAK,GACVE,yBAAuC,CAAC,KACtC,OAAO,KAAK,aAAa,KAAK,MAAM,SAAS,CAAC,CAC/C,CACN,CACF;AAEH,QAAOC,KAAmB;EACxB,SAAS,SACP,GACG,OAAO,SAAS,KAAK,CAAC,CACtB,KAAK,OAAO,UAAU,UAAUH,aAA2B,UAAU,MAAM,MAAM,CAAC,CAAC;EACxF;EACA;EACA;EACD,CAAC;EACF,CACH"}
|