@avivbenhamo/polly-kit 0.1.0 → 0.3.0

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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -10
  3. package/dist/adapters/encoding.d.ts +7 -0
  4. package/dist/adapters/encoding.js +46 -0
  5. package/dist/adapters/undici.d.ts +36 -0
  6. package/dist/adapters/undici.js +231 -0
  7. package/dist/browser.js +5 -19
  8. package/dist/defaults.d.ts +40 -0
  9. package/dist/defaults.js +40 -0
  10. package/dist/node.d.ts +56 -0
  11. package/dist/node.js +108 -0
  12. package/dist/studio/body.d.ts +28 -0
  13. package/dist/studio/body.js +66 -0
  14. package/dist/studio/browser.d.ts +22 -0
  15. package/dist/studio/browser.js +24 -0
  16. package/dist/studio/client.bundle.js +290 -0
  17. package/dist/studio/edit.d.ts +42 -0
  18. package/dist/studio/edit.js +90 -0
  19. package/dist/studio/etag.d.ts +6 -0
  20. package/dist/studio/etag.js +14 -0
  21. package/dist/studio/har.d.ts +19 -0
  22. package/dist/studio/har.js +36 -0
  23. package/dist/studio/headers.d.ts +31 -0
  24. package/dist/studio/headers.js +59 -0
  25. package/dist/studio/index.d.ts +9 -0
  26. package/dist/studio/index.js +5 -0
  27. package/dist/studio/server.d.ts +35 -0
  28. package/dist/studio/server.js +262 -0
  29. package/dist/studio/stores/apply.d.ts +6 -0
  30. package/dist/studio/stores/apply.js +20 -0
  31. package/dist/studio/stores/fs-store.d.ts +70 -0
  32. package/dist/studio/stores/fs-store.js +186 -0
  33. package/dist/studio/stores/http-store.d.ts +30 -0
  34. package/dist/studio/stores/http-store.js +67 -0
  35. package/dist/studio/stores/local-storage-store.d.ts +25 -0
  36. package/dist/studio/stores/local-storage-store.js +109 -0
  37. package/dist/studio/stores/types.d.ts +45 -0
  38. package/dist/studio/stores/types.js +7 -0
  39. package/dist/studio/types.d.ts +92 -0
  40. package/dist/studio/types.js +7 -0
  41. package/dist/studio/ui/app.d.ts +12 -0
  42. package/dist/studio/ui/app.js +180 -0
  43. package/dist/studio/ui/client.d.ts +2 -0
  44. package/dist/studio/ui/client.js +6 -0
  45. package/dist/studio/ui/page.d.ts +53 -0
  46. package/dist/studio/ui/page.js +360 -0
  47. package/dist/studio/ui/render.d.ts +25 -0
  48. package/dist/studio/ui/render.js +171 -0
  49. package/dist/vite.js +1 -1
  50. package/package.json +49 -7
@@ -0,0 +1,70 @@
1
+ import type { RecordingStore, RecordingSummary, ReadResult } from './types.js';
2
+ import type { Har, ResponsePatch } from '../types.js';
3
+ export { StoreError } from './types.js';
4
+ export type { RecordingStore, RecordingSummary, ReadResult } from './types.js';
5
+ /**
6
+ * Polly's node-server writes with `fs.outputJsonSync(path, data, { spaces: 2 })`, which
7
+ * routes through `jsonfile`'s `stringify` — `JSON.stringify(obj, null, 2)` plus a final
8
+ * newline, since `finalEOL` defaults to true (`jsonfile/utils.js`). Matching it exactly,
9
+ * trailing newline included, is what keeps an edit showing up in git as the lines that
10
+ * changed rather than as a rewritten file — asserted directly in the tests.
11
+ */
12
+ export declare function serializeHar(har: Har): string;
13
+ export declare class FsStore implements RecordingStore {
14
+ readonly root: string;
15
+ constructor(recordingsDir: string);
16
+ /**
17
+ * Every `recording.har` under the root, corrupt ones included.
18
+ *
19
+ * A missing directory is an empty list, not an error: a project that has not recorded
20
+ * anything yet is a normal state. Corrupt files are listed with the parse error —
21
+ * this is the only place a truncated recording is distinguishable from one that was
22
+ * never written.
23
+ */
24
+ list(): Promise<RecordingSummary[]>;
25
+ private recordingIds;
26
+ private describe;
27
+ read(id: string): Promise<ReadResult>;
28
+ /**
29
+ * Read-compare-write, then temp-file-and-rename.
30
+ *
31
+ * The etag compare is what turns two people editing the same recording into a
32
+ * refusal instead of a lost edit. The rename is what stops a crash mid-write from
33
+ * leaving a half-written file — the exact state Polly can only respond to by asking
34
+ * the user to delete their recording. The temp file is a sibling of the target, so
35
+ * the rename never crosses a device, which on Windows would fail outright.
36
+ */
37
+ write(id: string, har: Har, etag: string): Promise<void>;
38
+ /**
39
+ * Removes the recording's own file, then tidies up the directory it sat in if that
40
+ * leaves it empty.
41
+ *
42
+ * Deleting the *directory* would be wrong: `recordingIds` can legitimately emit both
43
+ * `Suite/case` and `Suite/case/sub`, because Polly builds ids from nested describe/it
44
+ * names, so a recursive remove of `Suite/case` would take `Suite/case/sub` with it —
45
+ * silently, since `force` swallows the evidence. Only `ENOTEMPTY`/`ENOENT` (and
46
+ * `EEXIST`, which some Node builds report for a non-empty directory on Windows) are
47
+ * swallowed here; anything else is a real failure and is rethrown.
48
+ */
49
+ delete(id: string): Promise<void>;
50
+ patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
51
+ deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
52
+ /**
53
+ * Polly's own node-server does `path.join(recordingsDir, recording, 'recording.har')`
54
+ * with no sanitisation, and here the id arrives off an HTTP request. Resolving and
55
+ * checking containment is the difference between a recordings browser and an
56
+ * arbitrary-file reader.
57
+ *
58
+ * `isAbsolute` is the Windows clause. When the candidate lands on another root —
59
+ * `D:\secrets`, a drive-relative `D:foo`, a UNC `\\server\share`, or the `\\?\` device
60
+ * namespace — `relative` cannot express a route there and hands back the absolute
61
+ * target instead, which starts with neither `..` nor a separator and re-resolves to
62
+ * itself. Every other clause passes it.
63
+ *
64
+ * `rel === FILENAME` is the id that contributed no path segment at all: `''`, `'.'`,
65
+ * `'a/..'`. Each resolves to `<root>/recording.har`, whose parent is the root itself,
66
+ * so `delete` would have removed every recording. Checking `rel === ''` cannot catch
67
+ * them — `FILENAME` is appended before `relative` is taken, so `rel` is never empty.
68
+ */
69
+ private pathFor;
70
+ }
@@ -0,0 +1,186 @@
1
+ import { mkdir, readFile, readdir, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises';
2
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { parseHar } from '../har.js';
5
+ import { etagFor } from '../etag.js';
6
+ import { StoreError } from './types.js';
7
+ import { deleteEntryVia, patchEntryVia } from './apply.js';
8
+ export { StoreError } from './types.js';
9
+ const FILENAME = 'recording.har';
10
+ /**
11
+ * Polly's node-server writes with `fs.outputJsonSync(path, data, { spaces: 2 })`, which
12
+ * routes through `jsonfile`'s `stringify` — `JSON.stringify(obj, null, 2)` plus a final
13
+ * newline, since `finalEOL` defaults to true (`jsonfile/utils.js`). Matching it exactly,
14
+ * trailing newline included, is what keeps an edit showing up in git as the lines that
15
+ * changed rather than as a rewritten file — asserted directly in the tests.
16
+ */
17
+ export function serializeHar(har) {
18
+ return `${JSON.stringify(har, null, 2)}\n`;
19
+ }
20
+ export class FsStore {
21
+ constructor(recordingsDir) {
22
+ this.root = resolve(recordingsDir);
23
+ }
24
+ /**
25
+ * Every `recording.har` under the root, corrupt ones included.
26
+ *
27
+ * A missing directory is an empty list, not an error: a project that has not recorded
28
+ * anything yet is a normal state. Corrupt files are listed with the parse error —
29
+ * this is the only place a truncated recording is distinguishable from one that was
30
+ * never written.
31
+ */
32
+ async list() {
33
+ const ids = await this.recordingIds(this.root, '');
34
+ const summaries = await Promise.all(ids.sort().map((id) => this.describe(id)));
35
+ return summaries;
36
+ }
37
+ async recordingIds(dir, prefix) {
38
+ let entries;
39
+ try {
40
+ entries = await readdir(dir, { withFileTypes: true });
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ const found = [];
46
+ for (const entry of entries) {
47
+ if (entry.isFile() && entry.name === FILENAME && prefix !== '') {
48
+ found.push(prefix);
49
+ }
50
+ else if (entry.isDirectory()) {
51
+ const child = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
52
+ found.push(...(await this.recordingIds(join(dir, entry.name), child)));
53
+ }
54
+ }
55
+ return found;
56
+ }
57
+ async describe(id) {
58
+ const base = {
59
+ id,
60
+ entryCount: 0,
61
+ recordingName: null,
62
+ problem: null,
63
+ pollyWillReject: null,
64
+ };
65
+ let raw;
66
+ try {
67
+ raw = await readFile(this.pathFor(id), 'utf8');
68
+ }
69
+ catch (err) {
70
+ return { ...base, problem: err instanceof Error ? err.message : String(err) };
71
+ }
72
+ const parsed = parseHar(raw);
73
+ if (!parsed.ok)
74
+ return { ...base, problem: parsed.problem };
75
+ return {
76
+ ...base,
77
+ entryCount: parsed.har.log.entries.length,
78
+ recordingName: parsed.har.log._recordingName ?? null,
79
+ pollyWillReject: parsed.pollyWillReject,
80
+ };
81
+ }
82
+ async read(id) {
83
+ const path = this.pathFor(id);
84
+ let raw;
85
+ try {
86
+ raw = await readFile(path, 'utf8');
87
+ }
88
+ catch {
89
+ throw new StoreError('not-found', `no recording with id ${id}`);
90
+ }
91
+ const parsed = parseHar(raw);
92
+ if (!parsed.ok)
93
+ throw new StoreError('not-found', `recording ${id} is unreadable: ${parsed.problem}`);
94
+ return { har: parsed.har, etag: etagFor(raw), pollyWillReject: parsed.pollyWillReject };
95
+ }
96
+ /**
97
+ * Read-compare-write, then temp-file-and-rename.
98
+ *
99
+ * The etag compare is what turns two people editing the same recording into a
100
+ * refusal instead of a lost edit. The rename is what stops a crash mid-write from
101
+ * leaving a half-written file — the exact state Polly can only respond to by asking
102
+ * the user to delete their recording. The temp file is a sibling of the target, so
103
+ * the rename never crosses a device, which on Windows would fail outright.
104
+ */
105
+ async write(id, har, etag) {
106
+ const path = this.pathFor(id);
107
+ let current;
108
+ try {
109
+ current = await readFile(path, 'utf8');
110
+ }
111
+ catch {
112
+ throw new StoreError('not-found', `no recording with id ${id}`);
113
+ }
114
+ if (etagFor(current) !== etag) {
115
+ throw new StoreError('conflict', `recording ${id} changed on disk since it was read — reload before saving`);
116
+ }
117
+ await mkdir(join(path, '..'), { recursive: true });
118
+ const temp = `${path}.${randomUUID()}.tmp`;
119
+ try {
120
+ await writeFile(temp, serializeHar(har), 'utf8');
121
+ await rename(temp, path);
122
+ }
123
+ catch (err) {
124
+ await unlink(temp).catch(() => { });
125
+ throw err;
126
+ }
127
+ }
128
+ /**
129
+ * Removes the recording's own file, then tidies up the directory it sat in if that
130
+ * leaves it empty.
131
+ *
132
+ * Deleting the *directory* would be wrong: `recordingIds` can legitimately emit both
133
+ * `Suite/case` and `Suite/case/sub`, because Polly builds ids from nested describe/it
134
+ * names, so a recursive remove of `Suite/case` would take `Suite/case/sub` with it —
135
+ * silently, since `force` swallows the evidence. Only `ENOTEMPTY`/`ENOENT` (and
136
+ * `EEXIST`, which some Node builds report for a non-empty directory on Windows) are
137
+ * swallowed here; anything else is a real failure and is rethrown.
138
+ */
139
+ async delete(id) {
140
+ const path = this.pathFor(id);
141
+ await rm(path, { force: true });
142
+ try {
143
+ await rmdir(join(path, '..'));
144
+ }
145
+ catch (err) {
146
+ const code = err.code;
147
+ if (code !== 'ENOTEMPTY' && code !== 'ENOENT' && code !== 'EEXIST')
148
+ throw err;
149
+ }
150
+ }
151
+ patchEntry(id, entryId, order, etag, patch) {
152
+ return patchEntryVia(this, id, entryId, order, etag, patch);
153
+ }
154
+ deleteEntry(id, entryId, order, etag) {
155
+ return deleteEntryVia(this, id, entryId, order, etag);
156
+ }
157
+ /**
158
+ * Polly's own node-server does `path.join(recordingsDir, recording, 'recording.har')`
159
+ * with no sanitisation, and here the id arrives off an HTTP request. Resolving and
160
+ * checking containment is the difference between a recordings browser and an
161
+ * arbitrary-file reader.
162
+ *
163
+ * `isAbsolute` is the Windows clause. When the candidate lands on another root —
164
+ * `D:\secrets`, a drive-relative `D:foo`, a UNC `\\server\share`, or the `\\?\` device
165
+ * namespace — `relative` cannot express a route there and hands back the absolute
166
+ * target instead, which starts with neither `..` nor a separator and re-resolves to
167
+ * itself. Every other clause passes it.
168
+ *
169
+ * `rel === FILENAME` is the id that contributed no path segment at all: `''`, `'.'`,
170
+ * `'a/..'`. Each resolves to `<root>/recording.har`, whose parent is the root itself,
171
+ * so `delete` would have removed every recording. Checking `rel === ''` cannot catch
172
+ * them — `FILENAME` is appended before `relative` is taken, so `rel` is never empty.
173
+ */
174
+ pathFor(id) {
175
+ const candidate = resolve(this.root, id, FILENAME);
176
+ const rel = relative(this.root, candidate);
177
+ if (rel === FILENAME ||
178
+ isAbsolute(rel) ||
179
+ rel.startsWith('..') ||
180
+ rel.startsWith(`${sep}..`) ||
181
+ resolve(this.root, rel) !== candidate) {
182
+ throw new StoreError('forbidden', `recording id ${id} resolves outside the recordings directory`);
183
+ }
184
+ return candidate;
185
+ }
186
+ }
@@ -0,0 +1,30 @@
1
+ import type { RecordingStore, RecordingSummary, ReadResult } from './types.js';
2
+ import type { Har, ResponsePatch } from '../types.js';
3
+ /**
4
+ * The store the page uses under the Node mount. It exists so `mountStudio` can be
5
+ * written once: to the page this is indistinguishable from `LocalStorageStore`.
6
+ *
7
+ * `patchEntry` and `deleteEntry` are one request each, because the read-apply-write
8
+ * cycle belongs on the server side of the network — shipping a whole HAR back to be
9
+ * written would double the payload and widen the window a conflicting write can land in.
10
+ * `write` and `delete` exist to satisfy the interface and are refused; the UI never
11
+ * calls them.
12
+ */
13
+ export declare class HttpStore implements RecordingStore {
14
+ readonly basePath: string;
15
+ constructor(basePath: string);
16
+ list(): Promise<RecordingSummary[]>;
17
+ read(id: string): Promise<ReadResult>;
18
+ /**
19
+ * Not on the wire protocol. The UI edits through `patchEntry`/`deleteEntry`. The
20
+ * parameters are declared (and ignored) rather than dropped so a caller holding a
21
+ * concrete `HttpStore` — not just a `RecordingStore` — can still call this with the
22
+ * interface's arity; TypeScript checks a direct call against the class's own
23
+ * declared signature, not the interface it implements.
24
+ */
25
+ write(id: string, har: Har, etag: string): Promise<void>;
26
+ delete(id: string): Promise<void>;
27
+ patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
28
+ deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
29
+ private json;
30
+ }
@@ -0,0 +1,67 @@
1
+ import { StoreError } from './types.js';
2
+ /**
3
+ * The store the page uses under the Node mount. It exists so `mountStudio` can be
4
+ * written once: to the page this is indistinguishable from `LocalStorageStore`.
5
+ *
6
+ * `patchEntry` and `deleteEntry` are one request each, because the read-apply-write
7
+ * cycle belongs on the server side of the network — shipping a whole HAR back to be
8
+ * written would double the payload and widen the window a conflicting write can land in.
9
+ * `write` and `delete` exist to satisfy the interface and are refused; the UI never
10
+ * calls them.
11
+ */
12
+ export class HttpStore {
13
+ constructor(basePath) {
14
+ this.basePath = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
15
+ }
16
+ async list() {
17
+ const body = (await this.json('GET', '/api/recordings'));
18
+ return body.recordings;
19
+ }
20
+ async read(id) {
21
+ return (await this.json('GET', `/api/recordings/${encodeURIComponent(id)}`));
22
+ }
23
+ /**
24
+ * Not on the wire protocol. The UI edits through `patchEntry`/`deleteEntry`. The
25
+ * parameters are declared (and ignored) rather than dropped so a caller holding a
26
+ * concrete `HttpStore` — not just a `RecordingStore` — can still call this with the
27
+ * interface's arity; TypeScript checks a direct call against the class's own
28
+ * declared signature, not the interface it implements.
29
+ */
30
+ async write(id, har, etag) {
31
+ throw new StoreError('forbidden', 'the HTTP API applies edits itself — use patchEntry');
32
+ }
33
+ async delete(id) {
34
+ throw new StoreError('forbidden', 'deleting a whole recording is not exposed over the API');
35
+ }
36
+ async patchEntry(id, entryId, order, etag, patch) {
37
+ return (await this.json('PATCH', `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}`, { etag, patch }));
38
+ }
39
+ async deleteEntry(id, entryId, order, etag) {
40
+ return (await this.json('DELETE', `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`));
41
+ }
42
+ async json(method, path, body) {
43
+ const res = await fetch(`${this.basePath}${path}`, {
44
+ method,
45
+ ...(body === undefined
46
+ ? {}
47
+ : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),
48
+ });
49
+ const parsed = (await res.json().catch(() => ({})));
50
+ if (res.ok)
51
+ return parsed;
52
+ // A 404/409/403 from the API means the same thing a StoreError means, so the page
53
+ // sees one error type whichever store it happens to be holding.
54
+ const code = res.status === 404
55
+ ? 'not-found'
56
+ : res.status === 409
57
+ ? 'conflict'
58
+ : res.status === 403
59
+ ? 'forbidden'
60
+ : res.status === 422
61
+ ? 'unprocessable'
62
+ : null;
63
+ if (code !== null)
64
+ throw new StoreError(code, parsed.error ?? `${method} ${path} failed`);
65
+ throw new Error(parsed.error ?? `${method} ${path} failed with ${res.status}`);
66
+ }
67
+ }
@@ -0,0 +1,25 @@
1
+ import type { RecordingStore, RecordingSummary, ReadResult } from './types.js';
2
+ import type { Har, ResponsePatch } from '../types.js';
3
+ export interface LocalStorageStoreOptions {
4
+ key?: string;
5
+ storage?: Storage;
6
+ }
7
+ /**
8
+ * The store for an app that has no server at all. `@pollyjs/persister-local-storage`
9
+ * keeps every recording in one key holding `{ [recordingId]: har }`, so there is no
10
+ * directory to walk and no file to rename — but the interface the page renders against
11
+ * is the same one `FsStore` implements, and the page cannot tell which it has.
12
+ */
13
+ export declare class LocalStorageStore implements RecordingStore {
14
+ private readonly key;
15
+ private readonly storage;
16
+ constructor(options?: LocalStorageStoreOptions);
17
+ list(): Promise<RecordingSummary[]>;
18
+ read(id: string): Promise<ReadResult>;
19
+ write(id: string, har: Har, etag: string): Promise<void>;
20
+ delete(id: string): Promise<void>;
21
+ patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
22
+ deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
23
+ /** `null` means the key holds something that is not JSON, which `list` reports. */
24
+ private db;
25
+ }
@@ -0,0 +1,109 @@
1
+ import { parseHar } from '../har.js';
2
+ import { etagFor } from '../etag.js';
3
+ import { StoreError } from './types.js';
4
+ import { deleteEntryVia, patchEntryVia } from './apply.js';
5
+ /** Polly's default namespace. `persisterOptions.local-storage.key` can change it. */
6
+ const DEFAULT_KEY = 'pollyjs';
7
+ /**
8
+ * The store for an app that has no server at all. `@pollyjs/persister-local-storage`
9
+ * keeps every recording in one key holding `{ [recordingId]: har }`, so there is no
10
+ * directory to walk and no file to rename — but the interface the page renders against
11
+ * is the same one `FsStore` implements, and the page cannot tell which it has.
12
+ */
13
+ export class LocalStorageStore {
14
+ constructor(options = {}) {
15
+ this.key = options.key ?? DEFAULT_KEY;
16
+ const storage = options.storage ?? globalThis.localStorage;
17
+ if (storage === undefined) {
18
+ throw new Error('polly-studio: no localStorage available — pass one as options.storage');
19
+ }
20
+ this.storage = storage;
21
+ }
22
+ async list() {
23
+ const db = this.db();
24
+ if (db === null) {
25
+ return [
26
+ {
27
+ id: this.key,
28
+ entryCount: 0,
29
+ recordingName: null,
30
+ problem: `localStorage[${this.key}] is not valid JSON`,
31
+ pollyWillReject: null,
32
+ },
33
+ ];
34
+ }
35
+ return Object.keys(db)
36
+ .sort()
37
+ .map((id) => {
38
+ const parsed = parseHar(JSON.stringify(db[id]));
39
+ if (!parsed.ok) {
40
+ return { id, entryCount: 0, recordingName: null, problem: parsed.problem, pollyWillReject: null };
41
+ }
42
+ return {
43
+ id,
44
+ entryCount: parsed.har.log.entries.length,
45
+ recordingName: parsed.har.log._recordingName ?? null,
46
+ problem: null,
47
+ pollyWillReject: parsed.pollyWillReject,
48
+ };
49
+ });
50
+ }
51
+ async read(id) {
52
+ const db = this.db();
53
+ if (db === null) {
54
+ throw new StoreError('not-found', `localStorage[${this.key}] is not valid JSON`);
55
+ }
56
+ const record = db[id];
57
+ if (record === undefined)
58
+ throw new StoreError('not-found', `no recording with id ${id}`);
59
+ const raw = JSON.stringify(record);
60
+ const parsed = parseHar(raw);
61
+ if (!parsed.ok)
62
+ throw new StoreError('not-found', `recording ${id} is unreadable: ${parsed.problem}`);
63
+ return { har: parsed.har, etag: etagFor(raw), pollyWillReject: parsed.pollyWillReject };
64
+ }
65
+ async write(id, har, etag) {
66
+ const db = this.db();
67
+ if (db === null) {
68
+ throw new StoreError('not-found', `localStorage[${this.key}] is not valid JSON`);
69
+ }
70
+ const record = db[id];
71
+ if (record === undefined)
72
+ throw new StoreError('not-found', `no recording with id ${id}`);
73
+ if (etagFor(JSON.stringify(record)) !== etag) {
74
+ throw new StoreError('conflict', `recording ${id} changed since it was read — reload before saving`);
75
+ }
76
+ db[id] = har;
77
+ this.storage.setItem(this.key, JSON.stringify(db));
78
+ }
79
+ async delete(id) {
80
+ const db = this.db();
81
+ if (db === null) {
82
+ // Coalescing to `{}` here would rewrite the key as `'{}'` and discard whatever
83
+ // was actually in the corrupt blob — a delete must never launder unrelated data
84
+ // it never touched. Refuse instead, same as a write would.
85
+ throw new StoreError('unprocessable', `localStorage[${this.key}] is not valid JSON — refusing to delete, which would discard it`);
86
+ }
87
+ delete db[id];
88
+ this.storage.setItem(this.key, JSON.stringify(db));
89
+ }
90
+ patchEntry(id, entryId, order, etag, patch) {
91
+ return patchEntryVia(this, id, entryId, order, etag, patch);
92
+ }
93
+ deleteEntry(id, entryId, order, etag) {
94
+ return deleteEntryVia(this, id, entryId, order, etag);
95
+ }
96
+ /** `null` means the key holds something that is not JSON, which `list` reports. */
97
+ db() {
98
+ const raw = this.storage.getItem(this.key);
99
+ if (raw === null)
100
+ return {};
101
+ try {
102
+ const parsed = JSON.parse(raw);
103
+ return parsed !== null && typeof parsed === 'object' ? parsed : null;
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,45 @@
1
+ import type { Har, ResponsePatch } from '../types.js';
2
+ export interface RecordingSummary {
3
+ /** The recordingId. May contain slashes: Polly builds it from describe/it names. */
4
+ id: string;
5
+ entryCount: number;
6
+ recordingName: string | null;
7
+ /** Why the recording could not be read. Non-null exactly when it could not. */
8
+ problem: string | null;
9
+ /** Readable, but Polly's own `findRecording` will assert on it. */
10
+ pollyWillReject: string | null;
11
+ }
12
+ export interface ReadResult {
13
+ har: Har;
14
+ /** Content hash of what was read. Hand it back to `write` to detect a clash. */
15
+ etag: string;
16
+ pollyWillReject: string | null;
17
+ }
18
+ /**
19
+ * The one interface the page renders against. Three implementations exist — the
20
+ * filesystem, localStorage, and an HTTP client — and the page cannot tell them apart,
21
+ * which is what lets one UI serve a Node mount and a browser-only mount unchanged.
22
+ */
23
+ export interface RecordingStore {
24
+ list(): Promise<RecordingSummary[]>;
25
+ read(id: string): Promise<ReadResult>;
26
+ write(id: string, har: Har, etag: string): Promise<void>;
27
+ delete(id: string): Promise<void>;
28
+ /**
29
+ * The two edits the UI performs, as store operations rather than as "read, apply, and
30
+ * hand a whole HAR back".
31
+ *
32
+ * They are on the interface so `mountStudio` never has to ask which store it holds.
33
+ * `HttpStore` implements them as one request each, because the read-apply-write cycle
34
+ * belongs on the server side of the network; the local stores implement them by
35
+ * calling `applyEntryEdit`. Both return the recording as it now stands, so the caller
36
+ * always leaves holding a fresh etag.
37
+ */
38
+ patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
39
+ deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
40
+ }
41
+ export type StoreErrorCode = 'not-found' | 'conflict' | 'forbidden' | 'unprocessable';
42
+ export declare class StoreError extends Error {
43
+ readonly code: StoreErrorCode;
44
+ constructor(code: StoreErrorCode, message: string);
45
+ }
@@ -0,0 +1,7 @@
1
+ export class StoreError extends Error {
2
+ constructor(code, message) {
3
+ super(message);
4
+ this.name = 'StoreError';
5
+ this.code = code;
6
+ }
7
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The HAR shape Polly 6.0.6 writes. Only the fields this package reads or maintains
3
+ * are named; anything else in the file rides along untouched through `structuredClone`.
4
+ *
5
+ * Field-by-field provenance is in the spec, §2 and §5.
6
+ */
7
+ /**
8
+ * A HAR name/value pair. `_fromType` is Polly's own addition: it marks a header whose
9
+ * recorded value was an array, so that `normalizeHeaders` replays it as an array even
10
+ * when only one value survives. Dropping it silently changes what the app receives.
11
+ */
12
+ export interface NvPair {
13
+ name: string;
14
+ value: string;
15
+ _fromType?: 'array';
16
+ comment?: string;
17
+ }
18
+ export interface HarContent {
19
+ mimeType: string;
20
+ /** The stored body. Base64 text when `encoding` is `'base64'`. */
21
+ text?: string;
22
+ /** `'base64'` for a binary body, absent otherwise. Read at replay. */
23
+ encoding?: string;
24
+ size: number;
25
+ }
26
+ export interface HarResponse {
27
+ httpVersion: string;
28
+ status: number;
29
+ statusText: string;
30
+ headers: NvPair[];
31
+ headersSize: number;
32
+ cookies: unknown[];
33
+ redirectURL: string;
34
+ content: HarContent;
35
+ bodySize: number;
36
+ }
37
+ export interface HarRequest {
38
+ httpVersion: string;
39
+ url: string;
40
+ method: string;
41
+ headers: NvPair[];
42
+ headersSize: number;
43
+ queryString: NvPair[];
44
+ cookies: unknown[];
45
+ postData?: {
46
+ mimeType: string;
47
+ params: unknown[];
48
+ text?: string;
49
+ };
50
+ bodySize: number;
51
+ }
52
+ export interface HarEntry {
53
+ /** md5 of the normalized request. Never recomputed here — see spec §3. */
54
+ _id: string;
55
+ /** Count of earlier same-`_id` requests in this recording, from 0. */
56
+ _order: number;
57
+ /** Read by Polly's `expiresIn` check and used to sort entries. Preserved on edit. */
58
+ startedDateTime: string;
59
+ request: HarRequest;
60
+ response: HarResponse;
61
+ cache: Record<string, unknown>;
62
+ timings: Record<string, number>;
63
+ time: number;
64
+ }
65
+ export interface HarLog {
66
+ version: string;
67
+ creator: {
68
+ name: string;
69
+ version: string;
70
+ comment?: string;
71
+ };
72
+ _recordingName?: string;
73
+ entries: HarEntry[];
74
+ pages: unknown[];
75
+ browser?: {
76
+ name: string;
77
+ version: string;
78
+ };
79
+ }
80
+ export interface Har {
81
+ log: HarLog;
82
+ }
83
+ /**
84
+ * What the UI may change about a response. `bodyText` is always *decoded* text: the
85
+ * caller never deals in base64, `applyResponsePatch` re-encodes when the entry needs it.
86
+ */
87
+ export interface ResponsePatch {
88
+ status?: number;
89
+ statusText?: string;
90
+ headers?: NvPair[];
91
+ bodyText?: string;
92
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The HAR shape Polly 6.0.6 writes. Only the fields this package reads or maintains
3
+ * are named; anything else in the file rides along untouched through `structuredClone`.
4
+ *
5
+ * Field-by-field provenance is in the spec, §2 and §5.
6
+ */
7
+ export {};
@@ -0,0 +1,12 @@
1
+ import type { RecordingStore } from '../stores/types.js';
2
+ /**
3
+ * The page's behaviour, written against `RecordingStore` and nothing else. Under the
4
+ * Node mount the store is an `HttpStore`; in a browser-only app it is a
5
+ * `LocalStorageStore`. Nothing below can tell the difference — no branch on which store
6
+ * it holds, which is why `patchEntry` and `deleteEntry` are on the interface rather than
7
+ * being assembled here out of `read` and `write`.
8
+ *
9
+ * Every mutation re-reads afterwards rather than trusting the local copy: the etag that
10
+ * the next save depends on comes from the store, never from here.
11
+ */
12
+ export declare function mountStudio(root: Document | Element, store: RecordingStore): void;