@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.
- package/LICENSE +21 -0
- package/README.md +148 -10
- package/dist/adapters/encoding.d.ts +7 -0
- package/dist/adapters/encoding.js +46 -0
- package/dist/adapters/undici.d.ts +36 -0
- package/dist/adapters/undici.js +231 -0
- package/dist/browser.js +5 -19
- package/dist/defaults.d.ts +40 -0
- package/dist/defaults.js +40 -0
- package/dist/node.d.ts +56 -0
- package/dist/node.js +108 -0
- package/dist/studio/body.d.ts +28 -0
- package/dist/studio/body.js +66 -0
- package/dist/studio/browser.d.ts +22 -0
- package/dist/studio/browser.js +24 -0
- package/dist/studio/client.bundle.js +290 -0
- package/dist/studio/edit.d.ts +42 -0
- package/dist/studio/edit.js +90 -0
- package/dist/studio/etag.d.ts +6 -0
- package/dist/studio/etag.js +14 -0
- package/dist/studio/har.d.ts +19 -0
- package/dist/studio/har.js +36 -0
- package/dist/studio/headers.d.ts +31 -0
- package/dist/studio/headers.js +59 -0
- package/dist/studio/index.d.ts +9 -0
- package/dist/studio/index.js +5 -0
- package/dist/studio/server.d.ts +35 -0
- package/dist/studio/server.js +262 -0
- package/dist/studio/stores/apply.d.ts +6 -0
- package/dist/studio/stores/apply.js +20 -0
- package/dist/studio/stores/fs-store.d.ts +70 -0
- package/dist/studio/stores/fs-store.js +186 -0
- package/dist/studio/stores/http-store.d.ts +30 -0
- package/dist/studio/stores/http-store.js +67 -0
- package/dist/studio/stores/local-storage-store.d.ts +25 -0
- package/dist/studio/stores/local-storage-store.js +109 -0
- package/dist/studio/stores/types.d.ts +45 -0
- package/dist/studio/stores/types.js +7 -0
- package/dist/studio/types.d.ts +92 -0
- package/dist/studio/types.js +7 -0
- package/dist/studio/ui/app.d.ts +12 -0
- package/dist/studio/ui/app.js +180 -0
- package/dist/studio/ui/client.d.ts +2 -0
- package/dist/studio/ui/client.js +6 -0
- package/dist/studio/ui/page.d.ts +53 -0
- package/dist/studio/ui/page.js +360 -0
- package/dist/studio/ui/render.d.ts +25 -0
- package/dist/studio/ui/render.js +171 -0
- package/dist/vite.js +1 -1
- 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,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,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;
|