@avivbenhamo/polly-kit 0.6.0 → 0.8.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/README.md +20 -0
- package/dist/studio/client.bundle.js +81 -1
- package/dist/studio/edit.d.ts +22 -0
- package/dist/studio/edit.js +35 -0
- package/dist/studio/import.d.ts +34 -0
- package/dist/studio/import.js +93 -0
- package/dist/studio/index.d.ts +4 -0
- package/dist/studio/index.js +2 -0
- package/dist/studio/recording-id.d.ts +16 -0
- package/dist/studio/recording-id.js +35 -0
- package/dist/studio/server.js +48 -9
- package/dist/studio/stores/apply.d.ts +13 -2
- package/dist/studio/stores/apply.js +68 -1
- package/dist/studio/stores/fs-store.d.ts +11 -1
- package/dist/studio/stores/fs-store.js +26 -1
- package/dist/studio/stores/http-store.d.ts +10 -1
- package/dist/studio/stores/http-store.js +15 -0
- package/dist/studio/stores/local-storage-store.d.ts +4 -1
- package/dist/studio/stores/local-storage-store.js +16 -1
- package/dist/studio/stores/types.d.ts +32 -1
- package/dist/studio/types.d.ts +3 -1
- package/dist/studio/ui/app.js +93 -3
- package/dist/studio/ui/page.d.ts +2 -2
- package/dist/studio/ui/page.js +93 -6
- package/dist/studio/ui/render.d.ts +15 -1
- package/dist/studio/ui/render.js +33 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -244,6 +244,24 @@ import {mountPollyStudio} from '@avivbenhamo/polly-kit/studio/browser';
|
|
|
244
244
|
mountPollyStudio(document.getElementById('polly')!);
|
|
245
245
|
```
|
|
246
246
|
|
|
247
|
+
### Importing a HAR
|
|
248
|
+
|
|
249
|
+
The editor takes a HAR downloaded from the
|
|
250
|
+
[swagger-recorder](../swagger-recorder/README.md) extension — the plain flavour, not the
|
|
251
|
+
zip — and turns it into a recording. Give it the recording name your app's Polly config
|
|
252
|
+
uses; it creates the recording if there is none, and merges into it if there is. It
|
|
253
|
+
reports what it did: **added 3, replaced 2, skipped 1**.
|
|
254
|
+
|
|
255
|
+
**Where a request is in both, the imported one wins.** That is usually what you want —
|
|
256
|
+
you re-recorded because the response changed — but it means an import can overwrite a
|
|
257
|
+
response you edited here. The `replaced` count is there so that is never silent.
|
|
258
|
+
|
|
259
|
+
**Only HARs from that extension can be imported**, and the reason is not caution. Polly
|
|
260
|
+
finds a recorded response by an `_id` that hashes the request body, and a multipart body
|
|
261
|
+
is not in a HAR at all — an upload records with no `postData`. So no importer can derive
|
|
262
|
+
an id; it can only read one that was written at capture time, and that extension is the
|
|
263
|
+
only thing that writes one.
|
|
264
|
+
|
|
247
265
|
## API
|
|
248
266
|
|
|
249
267
|
| Export | From | What |
|
|
@@ -259,6 +277,8 @@ mountPollyStudio(document.getElementById('polly')!);
|
|
|
259
277
|
| `parseHar` / `findEntry` | `./studio` | Reading a HAR, with the Polly-specific quirks handled. |
|
|
260
278
|
| `applyResponsePatch` / `deleteEntry` | `./studio` | Editing an entry, returning a typed `EditError` rather than throwing. |
|
|
261
279
|
| `mountPollyStudio(root)` | `./studio/browser` | The editor with no server at all, against Polly's `persister-local-storage`. |
|
|
280
|
+
| `mergeEntries` / `rejectImport` | `./studio` | The import rules, as pure functions. |
|
|
281
|
+
| `guidForRecording(name)` | `./studio` | The directory Polly looks a recording up in. |
|
|
262
282
|
|
|
263
283
|
`startPolly` options beyond the policy config: `mode` (required), `alwaysPassthrough`,
|
|
264
284
|
`apiNamespace`, `saveDebounceMs`; on `./node`, `recordingsDir` and `logLevel` instead of
|
|
@@ -47,6 +47,15 @@ function renderRecordingList(recordings) {
|
|
|
47
47
|
}).join("");
|
|
48
48
|
return `<ul class="recordings">${rows}</ul>`;
|
|
49
49
|
}
|
|
50
|
+
function renderImportResult(result) {
|
|
51
|
+
const verb = result.created ? "Created" : "Updated";
|
|
52
|
+
const counts = [`added ${result.added}`, `replaced ${result.replaced}`];
|
|
53
|
+
if (result.skipped.length > 0)
|
|
54
|
+
counts.push(`skipped ${result.skipped.length}`);
|
|
55
|
+
const skips = result.skipped.length === 0 ? "" : `<ul class="ps-import-skips">${result.skipped.map((skip) => `<li><code>${escapeHtml(skip.url)}</code> \u2014 ${escapeHtml(skip.reason)}</li>`).join("")}</ul>`;
|
|
56
|
+
const rejectWarning = result.pollyWillReject === null ? "" : `<p class="ps-notice warn">Polly will reject this: ${escapeHtml(result.pollyWillReject)}</p>`;
|
|
57
|
+
return `<p class="ps-import-result">${verb} <code>${escapeHtml(result.id)}</code> \u2014 ${escapeHtml(counts.join(", "))}.</p>${skips}${rejectWarning}`;
|
|
58
|
+
}
|
|
50
59
|
function unrenderable(err) {
|
|
51
60
|
return escapeHtml(err instanceof Error ? err.message : String(err));
|
|
52
61
|
}
|
|
@@ -102,8 +111,13 @@ function entryDetail(entry) {
|
|
|
102
111
|
|
|
103
112
|
<div class="actions">
|
|
104
113
|
<button name="save">Save response</button>
|
|
114
|
+
<button name="duplicate" title="Append a copy to this request's group, replayed the next time the same request is made">Duplicate for next call</button>
|
|
105
115
|
<button name="delete" class="danger">Delete entry</button>
|
|
106
116
|
</div>
|
|
117
|
+
<p class="note">Duplicating adds a second answer to the <em>same</em> request:
|
|
118
|
+
Polly replays <code>_order 0</code> on the first call, this copy on the second.
|
|
119
|
+
It cannot become a different request \u2014 the <code>_id</code> is a hash of the
|
|
120
|
+
request, and an edited URL under the old hash would match nothing.</p>
|
|
107
121
|
</div>
|
|
108
122
|
</section>`;
|
|
109
123
|
}
|
|
@@ -122,6 +136,35 @@ function mountStudio(root, store) {
|
|
|
122
136
|
const recordings = required(root, "#recordings");
|
|
123
137
|
const entries = required(root, "#entries");
|
|
124
138
|
const detail = required(root, "#detail");
|
|
139
|
+
const importForm = root.querySelector("#import");
|
|
140
|
+
const importReport = root.querySelector("#import-report");
|
|
141
|
+
importForm?.addEventListener("submit", (event) => {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
void runImport();
|
|
144
|
+
});
|
|
145
|
+
async function runImport() {
|
|
146
|
+
if (importForm === null || importReport === null)
|
|
147
|
+
return;
|
|
148
|
+
importReport.textContent = "";
|
|
149
|
+
try {
|
|
150
|
+
const name = importForm.querySelector("#import-name").value;
|
|
151
|
+
const file = importForm.querySelector("#import-file").files?.[0];
|
|
152
|
+
if (file === void 0) {
|
|
153
|
+
throw new Error("choose a .har file to import");
|
|
154
|
+
}
|
|
155
|
+
const har = JSON.parse(await file.text());
|
|
156
|
+
const result = await store.importHar(name, har);
|
|
157
|
+
importReport.innerHTML = renderImportResult(result);
|
|
158
|
+
try {
|
|
159
|
+
await refreshRecordings();
|
|
160
|
+
await openRecording(result.id);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
report(err);
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
importReport.textContent = errorMessage(err);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
125
168
|
let currentId = null;
|
|
126
169
|
let current = null;
|
|
127
170
|
refreshRecordings().catch(report);
|
|
@@ -134,6 +177,11 @@ function mountStudio(root, store) {
|
|
|
134
177
|
current = { har: read.har, etag: read.etag };
|
|
135
178
|
entries.innerHTML = `<ul>${read.har.log.entries.map(renderEntryRow).join("")}</ul>`;
|
|
136
179
|
detail.innerHTML = "";
|
|
180
|
+
const recordingName = read.har.log._recordingName;
|
|
181
|
+
const nameField = importForm === null ? null : importForm.querySelector("#import-name");
|
|
182
|
+
if (nameField !== null && typeof recordingName === "string" && recordingName !== "") {
|
|
183
|
+
nameField.value = recordingName;
|
|
184
|
+
}
|
|
137
185
|
}
|
|
138
186
|
function openEntry(entryId, order) {
|
|
139
187
|
const entry = current?.har.log.entries.find((e) => String(e._id) === entryId && e._order === order);
|
|
@@ -156,6 +204,18 @@ function mountStudio(root, store) {
|
|
|
156
204
|
report(err);
|
|
157
205
|
}
|
|
158
206
|
}
|
|
207
|
+
async function duplicate(entryId, order) {
|
|
208
|
+
if (currentId === null || current === null)
|
|
209
|
+
return;
|
|
210
|
+
try {
|
|
211
|
+
const { har } = await store.duplicateEntry(currentId, entryId, order, current.etag);
|
|
212
|
+
const added = har.log.entries.filter((entry) => String(entry._id) === entryId).length - 1;
|
|
213
|
+
await openRecording(currentId);
|
|
214
|
+
openEntry(entryId, added);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
report(err);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
159
219
|
async function remove(entryId, order) {
|
|
160
220
|
if (currentId === null || current === null)
|
|
161
221
|
return;
|
|
@@ -166,8 +226,11 @@ function mountStudio(root, store) {
|
|
|
166
226
|
report(err);
|
|
167
227
|
}
|
|
168
228
|
}
|
|
229
|
+
function errorMessage(err) {
|
|
230
|
+
return err instanceof StoreError && err.code === "conflict" ? "the recording changed underneath \u2014 reload and retry" : err instanceof Error ? err.message : String(err);
|
|
231
|
+
}
|
|
169
232
|
function report(err) {
|
|
170
|
-
const message =
|
|
233
|
+
const message = errorMessage(err);
|
|
171
234
|
const banner = detail.ownerDocument.createElement("p");
|
|
172
235
|
banner.className = "problem";
|
|
173
236
|
banner.textContent = message;
|
|
@@ -196,6 +259,8 @@ function mountStudio(root, store) {
|
|
|
196
259
|
const order = Number(section.dataset["order"]);
|
|
197
260
|
if (target.getAttribute("name") === "save")
|
|
198
261
|
void save(entryId, order);
|
|
262
|
+
if (target.getAttribute("name") === "duplicate")
|
|
263
|
+
void duplicate(entryId, order);
|
|
199
264
|
if (target.getAttribute("name") === "delete")
|
|
200
265
|
void remove(entryId, order);
|
|
201
266
|
});
|
|
@@ -263,9 +328,24 @@ var HttpStore = class {
|
|
|
263
328
|
async patchEntry(id, entryId, order, etag, patch) {
|
|
264
329
|
return await this.json("PATCH", `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}`, { etag, patch });
|
|
265
330
|
}
|
|
331
|
+
/** POST to the entry being copied: this adds a member to its group. */
|
|
332
|
+
async duplicateEntry(id, entryId, order, etag) {
|
|
333
|
+
return await this.json("POST", `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`);
|
|
334
|
+
}
|
|
266
335
|
async deleteEntry(id, entryId, order, etag) {
|
|
267
336
|
return await this.json("DELETE", `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`);
|
|
268
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Not on the wire protocol: creation happens inside `/api/import`, server-side,
|
|
340
|
+
* the same way `write` happens inside PATCH. Declared so a caller holding a
|
|
341
|
+
* concrete HttpStore still type-checks against the interface's arity.
|
|
342
|
+
*/
|
|
343
|
+
async create(id, har) {
|
|
344
|
+
throw new StoreError("forbidden", "the HTTP API creates recordings through import \u2014 use importHar");
|
|
345
|
+
}
|
|
346
|
+
async importHar(recordingName, incoming) {
|
|
347
|
+
return await this.json("POST", "/api/import", { recordingName, har: incoming });
|
|
348
|
+
}
|
|
269
349
|
async json(method, path, body) {
|
|
270
350
|
const res = await fetch(`${this.basePath}${path}`, {
|
|
271
351
|
method,
|
package/dist/studio/edit.d.ts
CHANGED
|
@@ -29,6 +29,28 @@ export type EditResult = {
|
|
|
29
29
|
* wrong rather than merely stale.
|
|
30
30
|
*/
|
|
31
31
|
export declare function applyResponsePatch(har: Har, id: string, order: number, patch: ResponsePatch): EditResult;
|
|
32
|
+
/**
|
|
33
|
+
* Copies an entry to the end of its own `_id` group — "the *next* time this same
|
|
34
|
+
* request happens, reply with this".
|
|
35
|
+
*
|
|
36
|
+
* `_order` is a position within a group of identical requests, not a label:
|
|
37
|
+
* Polly derives it at replay by counting how many earlier requests produced the
|
|
38
|
+
* same `_id`, starting at 0. So a recording with two members of a group answers
|
|
39
|
+
* the first call with `_order: 0` and the second with `_order: 1`. Duplicating
|
|
40
|
+
* appends a member, which is the only honest meaning "duplicate" can have here.
|
|
41
|
+
*
|
|
42
|
+
* What it deliberately is **not** is a way to make a *different* request. The
|
|
43
|
+
* `_id` is an md5 over the request that this package never recomputes — see
|
|
44
|
+
* `import.ts` — so a copy with an edited URL would carry the original's id,
|
|
45
|
+
* match nothing at replay, and fail silently. The copy keeps the id, and the
|
|
46
|
+
* caller edits the response.
|
|
47
|
+
*
|
|
48
|
+
* The clone is inserted directly after the last member of its group rather than
|
|
49
|
+
* appended to the file, because the list is shown in file order and a copy that
|
|
50
|
+
* appeared at the bottom, pages away from its original, reads as a new request
|
|
51
|
+
* somebody else recorded.
|
|
52
|
+
*/
|
|
53
|
+
export declare function duplicateEntry(har: Har, id: string, order: number): EditResult;
|
|
32
54
|
export declare function notFound(id: string, order: number): string;
|
|
33
55
|
/**
|
|
34
56
|
* Removes one entry and closes the gap it leaves in its `_id` group.
|
package/dist/studio/edit.js
CHANGED
|
@@ -62,6 +62,41 @@ export function applyResponsePatch(har, id, order, patch) {
|
|
|
62
62
|
}
|
|
63
63
|
return { ok: true, har: next };
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Copies an entry to the end of its own `_id` group — "the *next* time this same
|
|
67
|
+
* request happens, reply with this".
|
|
68
|
+
*
|
|
69
|
+
* `_order` is a position within a group of identical requests, not a label:
|
|
70
|
+
* Polly derives it at replay by counting how many earlier requests produced the
|
|
71
|
+
* same `_id`, starting at 0. So a recording with two members of a group answers
|
|
72
|
+
* the first call with `_order: 0` and the second with `_order: 1`. Duplicating
|
|
73
|
+
* appends a member, which is the only honest meaning "duplicate" can have here.
|
|
74
|
+
*
|
|
75
|
+
* What it deliberately is **not** is a way to make a *different* request. The
|
|
76
|
+
* `_id` is an md5 over the request that this package never recomputes — see
|
|
77
|
+
* `import.ts` — so a copy with an edited URL would carry the original's id,
|
|
78
|
+
* match nothing at replay, and fail silently. The copy keeps the id, and the
|
|
79
|
+
* caller edits the response.
|
|
80
|
+
*
|
|
81
|
+
* The clone is inserted directly after the last member of its group rather than
|
|
82
|
+
* appended to the file, because the list is shown in file order and a copy that
|
|
83
|
+
* appeared at the bottom, pages away from its original, reads as a new request
|
|
84
|
+
* somebody else recorded.
|
|
85
|
+
*/
|
|
86
|
+
export function duplicateEntry(har, id, order) {
|
|
87
|
+
const source = findEntry(har, id, order);
|
|
88
|
+
if (source === undefined) {
|
|
89
|
+
return { ok: false, error: { code: 'entry-not-found', message: notFound(id, order) } };
|
|
90
|
+
}
|
|
91
|
+
const next = structuredClone(har);
|
|
92
|
+
const entries = next.log.entries;
|
|
93
|
+
const group = entries.filter((entry) => entry._id === id);
|
|
94
|
+
const last = group.reduce((at, entry) => Math.max(at, entries.indexOf(entry)), -1);
|
|
95
|
+
const copy = structuredClone(findEntry(next, id, order));
|
|
96
|
+
copy._order = group.length;
|
|
97
|
+
entries.splice(last + 1, 0, copy);
|
|
98
|
+
return { ok: true, har: next };
|
|
99
|
+
}
|
|
65
100
|
export function notFound(id, order) {
|
|
66
101
|
return `no entry with _id ${id} and _order ${order} — it may have been deleted since the page loaded`;
|
|
67
102
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Har, HarEntry } from './types.js';
|
|
2
|
+
/** The creator name the extension writes. Nothing else may be imported — see spec §3. */
|
|
3
|
+
export declare const IMPORTABLE_CREATOR_NAME = "swagger-recorder";
|
|
4
|
+
export interface ImportSkip {
|
|
5
|
+
url: string;
|
|
6
|
+
reason: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ImportOutcome {
|
|
9
|
+
entries: HarEntry[];
|
|
10
|
+
added: number;
|
|
11
|
+
replaced: number;
|
|
12
|
+
skipped: ImportSkip[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Whole-file rejection, null when the file is importable.
|
|
16
|
+
*
|
|
17
|
+
* The creator check is not caution, it is a correctness limit. An entry's `_id`
|
|
18
|
+
* is an md5 over method, url and body, and a multipart body is not in a HAR at
|
|
19
|
+
* all — so no importer can derive an id, and only this extension writes one.
|
|
20
|
+
* Accepting a DevTools export would mean writing entries with no id, or guessing.
|
|
21
|
+
*/
|
|
22
|
+
export declare function rejectImport(har: Har): string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Incoming entries folded into existing ones, keyed on `_id`.
|
|
25
|
+
*
|
|
26
|
+
* New wins, and a replacement keeps its position: the UI lists entries in file
|
|
27
|
+
* order, so a merge that moved them would read as the recording having changed
|
|
28
|
+
* more than it did.
|
|
29
|
+
*
|
|
30
|
+
* Duplicates inside the incoming file collapse rather than appending twice.
|
|
31
|
+
* Polly's `findEntry` stops at the first `_id` match, so a second copy is an
|
|
32
|
+
* entry nothing can reach, and re-importing would grow the file without bound.
|
|
33
|
+
*/
|
|
34
|
+
export declare function mergeEntries(existing: HarEntry[], incoming: HarEntry[]): ImportOutcome;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** The creator name the extension writes. Nothing else may be imported — see spec §3. */
|
|
2
|
+
export const IMPORTABLE_CREATOR_NAME = 'swagger-recorder';
|
|
3
|
+
/**
|
|
4
|
+
* Whole-file rejection, null when the file is importable.
|
|
5
|
+
*
|
|
6
|
+
* The creator check is not caution, it is a correctness limit. An entry's `_id`
|
|
7
|
+
* is an md5 over method, url and body, and a multipart body is not in a HAR at
|
|
8
|
+
* all — so no importer can derive an id, and only this extension writes one.
|
|
9
|
+
* Accepting a DevTools export would mean writing entries with no id, or guessing.
|
|
10
|
+
*/
|
|
11
|
+
export function rejectImport(har) {
|
|
12
|
+
const creator = har.log?.creator?.name;
|
|
13
|
+
if (creator !== IMPORTABLE_CREATOR_NAME) {
|
|
14
|
+
return `this HAR's creator is ${JSON.stringify(creator ?? null)} — only HARs downloaded from the swagger-recorder extension can be imported, because only they carry the Polly ids replay needs`;
|
|
15
|
+
}
|
|
16
|
+
if (!Array.isArray(har.log.entries) || har.log.entries.length === 0) {
|
|
17
|
+
return 'this HAR has no entries';
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Incoming entries folded into existing ones, keyed on `_id`.
|
|
23
|
+
*
|
|
24
|
+
* New wins, and a replacement keeps its position: the UI lists entries in file
|
|
25
|
+
* order, so a merge that moved them would read as the recording having changed
|
|
26
|
+
* more than it did.
|
|
27
|
+
*
|
|
28
|
+
* Duplicates inside the incoming file collapse rather than appending twice.
|
|
29
|
+
* Polly's `findEntry` stops at the first `_id` match, so a second copy is an
|
|
30
|
+
* entry nothing can reach, and re-importing would grow the file without bound.
|
|
31
|
+
*/
|
|
32
|
+
export function mergeEntries(existing, incoming) {
|
|
33
|
+
const entries = [...existing];
|
|
34
|
+
const positions = new Map();
|
|
35
|
+
// First-wins, not last-wins: `buildPollyHar` stamps `_order: 0` on every entry
|
|
36
|
+
// without deduping, so an existing recording can legitimately hold the same
|
|
37
|
+
// `_id` twice. Polly's `findEntry` (har.ts) resolves an id to the FIRST match,
|
|
38
|
+
// so that is the index a replacement has to target — overwriting the later
|
|
39
|
+
// occurrence would report success while changing a slot Polly never reads.
|
|
40
|
+
entries.forEach((entry, index) => {
|
|
41
|
+
if (typeof entry?._id === 'string' && !positions.has(entry._id))
|
|
42
|
+
positions.set(entry._id, index);
|
|
43
|
+
});
|
|
44
|
+
const skipped = [];
|
|
45
|
+
let added = 0;
|
|
46
|
+
let replaced = 0;
|
|
47
|
+
for (const candidate of incoming) {
|
|
48
|
+
const problem = unusable(candidate);
|
|
49
|
+
if (problem !== null) {
|
|
50
|
+
skipped.push({ url: urlOf(candidate), reason: problem });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// `_order` is stamped rather than carried: polly-kit sets
|
|
54
|
+
// `matchRequestsBy.order: false`, so Polly computes 0 for every request.
|
|
55
|
+
const entry = { ...candidate, _order: 0 };
|
|
56
|
+
const at = positions.get(entry._id);
|
|
57
|
+
if (at === undefined) {
|
|
58
|
+
positions.set(entry._id, entries.length);
|
|
59
|
+
entries.push(entry);
|
|
60
|
+
added++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// A collision with an entry this same import just added counts as neither:
|
|
64
|
+
// it was already reported as `added`, and reporting it as `replaced` too
|
|
65
|
+
// would describe one incoming entry twice.
|
|
66
|
+
const wasJustAdded = at >= existing.length;
|
|
67
|
+
entries[at] = entry;
|
|
68
|
+
if (!wasJustAdded)
|
|
69
|
+
replaced++;
|
|
70
|
+
}
|
|
71
|
+
return { entries, added, replaced, skipped };
|
|
72
|
+
}
|
|
73
|
+
/** Null when the entry can be written. A reason naming the cause otherwise. */
|
|
74
|
+
function unusable(entry) {
|
|
75
|
+
if (entry === null || typeof entry !== 'object') {
|
|
76
|
+
return 'not an object — this is not a HAR entry';
|
|
77
|
+
}
|
|
78
|
+
if (typeof entry._id !== 'string' || entry._id === '') {
|
|
79
|
+
return 'no _id: recorded by an extension version that did not write one, so Polly could never find it. Record again with the current extension.';
|
|
80
|
+
}
|
|
81
|
+
if (entry.request === null || typeof entry.request !== 'object') {
|
|
82
|
+
return 'no request — this is not a HAR entry';
|
|
83
|
+
}
|
|
84
|
+
if (entry.response === null || typeof entry.response !== 'object') {
|
|
85
|
+
return 'no response, so there is nothing to replay';
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
/** Best-effort label for the skip report. Never throws on a malformed entry. */
|
|
90
|
+
function urlOf(entry) {
|
|
91
|
+
const url = entry?.request?.url;
|
|
92
|
+
return typeof url === 'string' ? url : '(unknown url)';
|
|
93
|
+
}
|
package/dist/studio/index.d.ts
CHANGED
|
@@ -7,3 +7,7 @@ export { parseHar, findEntry, POLLY_CREATOR_NAME } from './har.js';
|
|
|
7
7
|
export { applyResponsePatch, deleteEntry } from './edit.js';
|
|
8
8
|
export type { EditError, EditResult } from './edit.js';
|
|
9
9
|
export type { Har, HarEntry, HarLog, HarRequest, HarResponse, HarContent, NvPair, ResponsePatch, } from './types.js';
|
|
10
|
+
export { mergeEntries, rejectImport, IMPORTABLE_CREATOR_NAME } from './import.js';
|
|
11
|
+
export type { ImportOutcome, ImportSkip } from './import.js';
|
|
12
|
+
export { guidForRecording, rejectRecordingName } from './recording-id.js';
|
|
13
|
+
export type { ImportResult } from './stores/types.js';
|
package/dist/studio/index.js
CHANGED
|
@@ -3,3 +3,5 @@ export { FsStore, serializeHar } from './stores/fs-store.js';
|
|
|
3
3
|
export { StoreError } from './stores/types.js';
|
|
4
4
|
export { parseHar, findEntry, POLLY_CREATOR_NAME } from './har.js';
|
|
5
5
|
export { applyResponsePatch, deleteEntry } from './edit.js';
|
|
6
|
+
export { mergeEntries, rejectImport, IMPORTABLE_CREATOR_NAME } from './import.js';
|
|
7
|
+
export { guidForRecording, rejectRecordingName } from './recording-id.js';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trimmed before it reaches Polly's hashing, not just checked: `' my-app '` and
|
|
3
|
+
* `'my-app'` hash to `my-app_4097172865` and `my-app_4014489609` — the slug half
|
|
4
|
+
* is identical, so the wrong directory looks right in a listing. Trimming here
|
|
5
|
+
* rather than in `rejectRecordingName` (a guard, not a transform) means every
|
|
6
|
+
* caller gets it whether or not they remembered to trim first.
|
|
7
|
+
*/
|
|
8
|
+
export declare function guidForRecording(recordingName: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Why an empty name is refused rather than hashed.
|
|
11
|
+
*
|
|
12
|
+
* `guidForRecording('')` is `_2166136261` — a valid directory name that would be
|
|
13
|
+
* created happily and then never read, because no app configures an empty
|
|
14
|
+
* `recordingName`. Refusing names the mistake at the point it is made.
|
|
15
|
+
*/
|
|
16
|
+
export declare function rejectRecordingName(name: string): string | null;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The directory Polly looks a recording up in.
|
|
3
|
+
*
|
|
4
|
+
* Taken from Polly's own module rather than copied. The ten lines are easy to
|
|
5
|
+
* reproduce and easy to reproduce *wrongly*: `@sindresorhus/fnv1a@2`'s default
|
|
6
|
+
* export is the legacy 32-bit number implementation, which is the one Polly
|
|
7
|
+
* calls, while its named size exports return BigInt and would yield a different
|
|
8
|
+
* directory. A copy that drifted would send every recording somewhere nothing
|
|
9
|
+
* reads — silently, since a missing directory is a normal state.
|
|
10
|
+
*
|
|
11
|
+
* `@pollyjs/core` declares no `exports` map, so the deep path resolves.
|
|
12
|
+
* `test/studio/recording-id.test.ts` pins the result, so a core that moves the
|
|
13
|
+
* file fails a test rather than changing where recordings go.
|
|
14
|
+
*/
|
|
15
|
+
import guidFor from '@pollyjs/core/src/utils/guid-for-recording.js';
|
|
16
|
+
/**
|
|
17
|
+
* Trimmed before it reaches Polly's hashing, not just checked: `' my-app '` and
|
|
18
|
+
* `'my-app'` hash to `my-app_4097172865` and `my-app_4014489609` — the slug half
|
|
19
|
+
* is identical, so the wrong directory looks right in a listing. Trimming here
|
|
20
|
+
* rather than in `rejectRecordingName` (a guard, not a transform) means every
|
|
21
|
+
* caller gets it whether or not they remembered to trim first.
|
|
22
|
+
*/
|
|
23
|
+
export function guidForRecording(recordingName) {
|
|
24
|
+
return guidFor(recordingName.trim());
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Why an empty name is refused rather than hashed.
|
|
28
|
+
*
|
|
29
|
+
* `guidForRecording('')` is `_2166136261` — a valid directory name that would be
|
|
30
|
+
* created happily and then never read, because no app configures an empty
|
|
31
|
+
* `recordingName`. Refusing names the mistake at the point it is made.
|
|
32
|
+
*/
|
|
33
|
+
export function rejectRecordingName(name) {
|
|
34
|
+
return name.trim() === '' ? 'a recording name is required' : null;
|
|
35
|
+
}
|
package/dist/studio/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { FsStore } from './stores/fs-store.js';
|
|
5
5
|
import { StoreError } from './stores/types.js';
|
|
6
|
-
import { applyResponsePatch, deleteEntry } from './edit.js';
|
|
6
|
+
import { applyResponsePatch, deleteEntry, duplicateEntry } from './edit.js';
|
|
7
7
|
import { renderPage } from './ui/page.js';
|
|
8
8
|
/**
|
|
9
9
|
* A mountable editor for Polly recordings, in the style of swagger-ui: the app serves
|
|
@@ -66,6 +66,30 @@ async function handle(req, res, ctx) {
|
|
|
66
66
|
sendJson(res, 200, { recordings: await ctx.store.list() });
|
|
67
67
|
return;
|
|
68
68
|
}
|
|
69
|
+
if (path === '/api/import' && req.method === 'POST') {
|
|
70
|
+
const body = await readJson(req, MAX_IMPORT_BYTES);
|
|
71
|
+
if (!body.ok) {
|
|
72
|
+
if (body.status === 413)
|
|
73
|
+
res.setHeader('connection', 'close');
|
|
74
|
+
sendJson(res, body.status, { error: body.error });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// A body of `null`, `[]` or `"x"` is valid JSON with no fields. Coercing to
|
|
78
|
+
// `{}` keeps the destructuring off a non-object, which would throw into the 500.
|
|
79
|
+
const fields = (typeof body.value === 'object' && body.value !== null ? body.value : {});
|
|
80
|
+
if (typeof fields.recordingName !== 'string') {
|
|
81
|
+
sendJson(res, 400, { error: 'recordingName must be a string' });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (fields.har === null || typeof fields.har !== 'object') {
|
|
85
|
+
sendJson(res, 400, { error: 'har must be a HAR object' });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// StoreError from here — a rejected creator, an empty name, a corrupt
|
|
89
|
+
// target — lands in the catch-all below, which maps `unprocessable` to 422.
|
|
90
|
+
sendJson(res, 200, await ctx.store.importHar(fields.recordingName, fields.har));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
69
93
|
// /api/recordings/:id and /api/recordings/:id/entries/:_id/:_order
|
|
70
94
|
const segments = path.split('/').filter((segment) => segment !== '');
|
|
71
95
|
const rawId = segments[2];
|
|
@@ -89,7 +113,7 @@ async function handle(req, res, ctx) {
|
|
|
89
113
|
}
|
|
90
114
|
const order = Number(segments[5]);
|
|
91
115
|
if (req.method === 'PATCH') {
|
|
92
|
-
const body = await readJson(req);
|
|
116
|
+
const body = await readJson(req, MAX_EDIT_BYTES);
|
|
93
117
|
if (!body.ok) {
|
|
94
118
|
if (body.status === 413)
|
|
95
119
|
res.setHeader('connection', 'close');
|
|
@@ -107,15 +131,23 @@ async function handle(req, res, ctx) {
|
|
|
107
131
|
await mutate(ctx, res, id, etag, (har) => deleteEntry(har, entryId, order));
|
|
108
132
|
return;
|
|
109
133
|
}
|
|
134
|
+
// POST, not PUT: this creates a member of the group rather than
|
|
135
|
+
// replacing one, and the URL it is posted to is the entry being copied.
|
|
136
|
+
if (req.method === 'POST') {
|
|
137
|
+
const etag = url.searchParams.get('etag') ?? '';
|
|
138
|
+
await mutate(ctx, res, id, etag, (har) => duplicateEntry(har, entryId, order));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
110
141
|
}
|
|
111
142
|
}
|
|
112
143
|
sendJson(res, 404, { error: 'not found' });
|
|
113
144
|
}
|
|
114
145
|
catch (err) {
|
|
115
146
|
if (err instanceof StoreError) {
|
|
116
|
-
// `unprocessable` is
|
|
117
|
-
//
|
|
118
|
-
//
|
|
147
|
+
// `unprocessable` is reachable: `/api/import` calls `store.importHar`, which raises
|
|
148
|
+
// it for a rejected creator, an empty recording name, or a corrupt target recording.
|
|
149
|
+
// Each of those — like a binary-body refusal elsewhere on the interface — is a 422,
|
|
150
|
+
// not a 403.
|
|
119
151
|
const status = err.code === 'not-found'
|
|
120
152
|
? 404
|
|
121
153
|
: err.code === 'conflict'
|
|
@@ -196,7 +228,14 @@ function decodeSegment(segment) {
|
|
|
196
228
|
* The cap on a PATCH body. Generous for the JSON around one edited HAR entry, and far
|
|
197
229
|
* below what it takes to hurt the host process.
|
|
198
230
|
*/
|
|
199
|
-
const
|
|
231
|
+
const MAX_EDIT_BYTES = 4 * 1024 * 1024;
|
|
232
|
+
/**
|
|
233
|
+
* The cap on an imported HAR, which is a whole recording rather than one entry.
|
|
234
|
+
* Matched to the recording store's own 50 MB limit (`src/store.ts`): a file the
|
|
235
|
+
* store will happily persist must not be refused on the way in, or the feature
|
|
236
|
+
* fails on exactly the recordings worth importing — the ones with image bodies.
|
|
237
|
+
*/
|
|
238
|
+
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
|
200
239
|
/**
|
|
201
240
|
* The request body, bounded and parsed.
|
|
202
241
|
*
|
|
@@ -206,7 +245,7 @@ const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
|
206
245
|
* A `SyntaxError` from `JSON.parse` is the same failure class as the malformed
|
|
207
246
|
* percent-encoding handled in `decodeSegment`: a malformed request, not a server fault.
|
|
208
247
|
*/
|
|
209
|
-
async function readJson(req) {
|
|
248
|
+
async function readJson(req, limit) {
|
|
210
249
|
const chunks = [];
|
|
211
250
|
let bytes = 0;
|
|
212
251
|
let overflowed = false;
|
|
@@ -215,7 +254,7 @@ async function readJson(req) {
|
|
|
215
254
|
bytes += buffer.length;
|
|
216
255
|
if (overflowed)
|
|
217
256
|
continue;
|
|
218
|
-
if (bytes >
|
|
257
|
+
if (bytes > limit) {
|
|
219
258
|
// Drop what was accumulated and stop accumulating, but keep draining. The cap is
|
|
220
259
|
// about retained memory, and this bounds it. Neither `req.destroy()` nor
|
|
221
260
|
// `req.pause()` works here: destroying resets the socket so a client still
|
|
@@ -228,7 +267,7 @@ async function readJson(req) {
|
|
|
228
267
|
chunks.push(buffer);
|
|
229
268
|
}
|
|
230
269
|
if (overflowed) {
|
|
231
|
-
return { ok: false, status: 413, error: `request body exceeds ${
|
|
270
|
+
return { ok: false, status: 413, error: `request body exceeds ${limit} bytes` };
|
|
232
271
|
}
|
|
233
272
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
234
273
|
if (raw === '')
|
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
import type { ReadResult, RecordingStore } from './types.js';
|
|
2
|
-
import type { ResponsePatch } from '../types.js';
|
|
1
|
+
import type { ReadResult, RecordingStore, ImportResult } from './types.js';
|
|
2
|
+
import type { Har, ResponsePatch } from '../types.js';
|
|
3
3
|
type Local = Pick<RecordingStore, 'read' | 'write'>;
|
|
4
4
|
export declare function patchEntryVia(store: Local, id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
|
|
5
5
|
export declare function deleteEntryVia(store: Local, id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
6
|
+
export declare function duplicateEntryVia(store: Local, id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
7
|
+
type Importable = Pick<RecordingStore, 'read' | 'write' | 'create'>;
|
|
8
|
+
/**
|
|
9
|
+
* The read-merge-write cycle, shared by every store that owns its own storage.
|
|
10
|
+
*
|
|
11
|
+
* A corrupt existing file is refused rather than overwritten. `read` reports
|
|
12
|
+
* unparseable JSON as `not-found`, which is indistinguishable from "absent" at
|
|
13
|
+
* this layer, so absence is established by `list` instead — the one call that
|
|
14
|
+
* tells a recording that cannot be parsed apart from one that was never written.
|
|
15
|
+
*/
|
|
16
|
+
export declare function importHarVia(store: Importable & Pick<RecordingStore, 'list'>, recordingName: string, incoming: Har): Promise<ImportResult>;
|
|
6
17
|
export {};
|