@avivbenhamo/polly-kit 0.6.0 → 0.7.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 +58 -1
- 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 +40 -8
- package/dist/studio/stores/apply.d.ts +12 -2
- package/dist/studio/stores/apply.js +64 -0
- package/dist/studio/stores/fs-store.d.ts +10 -1
- package/dist/studio/stores/fs-store.js +23 -1
- package/dist/studio/stores/http-store.d.ts +8 -1
- package/dist/studio/stores/http-store.js +11 -0
- package/dist/studio/stores/local-storage-store.d.ts +3 -1
- package/dist/studio/stores/local-storage-store.js +13 -1
- package/dist/studio/stores/types.d.ts +25 -0
- package/dist/studio/types.d.ts +3 -1
- package/dist/studio/ui/app.js +73 -3
- package/dist/studio/ui/page.d.ts +2 -2
- package/dist/studio/ui/page.js +56 -0
- package/dist/studio/ui/render.d.ts +15 -1
- package/dist/studio/ui/render.js +28 -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
|
}
|
|
@@ -122,6 +131,35 @@ function mountStudio(root, store) {
|
|
|
122
131
|
const recordings = required(root, "#recordings");
|
|
123
132
|
const entries = required(root, "#entries");
|
|
124
133
|
const detail = required(root, "#detail");
|
|
134
|
+
const importForm = root.querySelector("#import");
|
|
135
|
+
const importReport = root.querySelector("#import-report");
|
|
136
|
+
importForm?.addEventListener("submit", (event) => {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
void runImport();
|
|
139
|
+
});
|
|
140
|
+
async function runImport() {
|
|
141
|
+
if (importForm === null || importReport === null)
|
|
142
|
+
return;
|
|
143
|
+
importReport.textContent = "";
|
|
144
|
+
try {
|
|
145
|
+
const name = importForm.querySelector("#import-name").value;
|
|
146
|
+
const file = importForm.querySelector("#import-file").files?.[0];
|
|
147
|
+
if (file === void 0) {
|
|
148
|
+
throw new Error("choose a .har file to import");
|
|
149
|
+
}
|
|
150
|
+
const har = JSON.parse(await file.text());
|
|
151
|
+
const result = await store.importHar(name, har);
|
|
152
|
+
importReport.innerHTML = renderImportResult(result);
|
|
153
|
+
try {
|
|
154
|
+
await refreshRecordings();
|
|
155
|
+
await openRecording(result.id);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
report(err);
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
importReport.textContent = errorMessage(err);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
125
163
|
let currentId = null;
|
|
126
164
|
let current = null;
|
|
127
165
|
refreshRecordings().catch(report);
|
|
@@ -134,6 +172,11 @@ function mountStudio(root, store) {
|
|
|
134
172
|
current = { har: read.har, etag: read.etag };
|
|
135
173
|
entries.innerHTML = `<ul>${read.har.log.entries.map(renderEntryRow).join("")}</ul>`;
|
|
136
174
|
detail.innerHTML = "";
|
|
175
|
+
const recordingName = read.har.log._recordingName;
|
|
176
|
+
const nameField = importForm === null ? null : importForm.querySelector("#import-name");
|
|
177
|
+
if (nameField !== null && typeof recordingName === "string" && recordingName !== "") {
|
|
178
|
+
nameField.value = recordingName;
|
|
179
|
+
}
|
|
137
180
|
}
|
|
138
181
|
function openEntry(entryId, order) {
|
|
139
182
|
const entry = current?.har.log.entries.find((e) => String(e._id) === entryId && e._order === order);
|
|
@@ -166,8 +209,11 @@ function mountStudio(root, store) {
|
|
|
166
209
|
report(err);
|
|
167
210
|
}
|
|
168
211
|
}
|
|
212
|
+
function errorMessage(err) {
|
|
213
|
+
return err instanceof StoreError && err.code === "conflict" ? "the recording changed underneath \u2014 reload and retry" : err instanceof Error ? err.message : String(err);
|
|
214
|
+
}
|
|
169
215
|
function report(err) {
|
|
170
|
-
const message =
|
|
216
|
+
const message = errorMessage(err);
|
|
171
217
|
const banner = detail.ownerDocument.createElement("p");
|
|
172
218
|
banner.className = "problem";
|
|
173
219
|
banner.textContent = message;
|
|
@@ -266,6 +312,17 @@ var HttpStore = class {
|
|
|
266
312
|
async deleteEntry(id, entryId, order, etag) {
|
|
267
313
|
return await this.json("DELETE", `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`);
|
|
268
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* Not on the wire protocol: creation happens inside `/api/import`, server-side,
|
|
317
|
+
* the same way `write` happens inside PATCH. Declared so a caller holding a
|
|
318
|
+
* concrete HttpStore still type-checks against the interface's arity.
|
|
319
|
+
*/
|
|
320
|
+
async create(id, har) {
|
|
321
|
+
throw new StoreError("forbidden", "the HTTP API creates recordings through import \u2014 use importHar");
|
|
322
|
+
}
|
|
323
|
+
async importHar(recordingName, incoming) {
|
|
324
|
+
return await this.json("POST", "/api/import", { recordingName, har: incoming });
|
|
325
|
+
}
|
|
269
326
|
async json(method, path, body) {
|
|
270
327
|
const res = await fetch(`${this.basePath}${path}`, {
|
|
271
328
|
method,
|
|
@@ -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
|
@@ -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');
|
|
@@ -113,9 +137,10 @@ async function handle(req, res, ctx) {
|
|
|
113
137
|
}
|
|
114
138
|
catch (err) {
|
|
115
139
|
if (err instanceof StoreError) {
|
|
116
|
-
// `unprocessable` is
|
|
117
|
-
//
|
|
118
|
-
//
|
|
140
|
+
// `unprocessable` is reachable: `/api/import` calls `store.importHar`, which raises
|
|
141
|
+
// it for a rejected creator, an empty recording name, or a corrupt target recording.
|
|
142
|
+
// Each of those — like a binary-body refusal elsewhere on the interface — is a 422,
|
|
143
|
+
// not a 403.
|
|
119
144
|
const status = err.code === 'not-found'
|
|
120
145
|
? 404
|
|
121
146
|
: err.code === 'conflict'
|
|
@@ -196,7 +221,14 @@ function decodeSegment(segment) {
|
|
|
196
221
|
* The cap on a PATCH body. Generous for the JSON around one edited HAR entry, and far
|
|
197
222
|
* below what it takes to hurt the host process.
|
|
198
223
|
*/
|
|
199
|
-
const
|
|
224
|
+
const MAX_EDIT_BYTES = 4 * 1024 * 1024;
|
|
225
|
+
/**
|
|
226
|
+
* The cap on an imported HAR, which is a whole recording rather than one entry.
|
|
227
|
+
* Matched to the recording store's own 50 MB limit (`src/store.ts`): a file the
|
|
228
|
+
* store will happily persist must not be refused on the way in, or the feature
|
|
229
|
+
* fails on exactly the recordings worth importing — the ones with image bodies.
|
|
230
|
+
*/
|
|
231
|
+
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
|
200
232
|
/**
|
|
201
233
|
* The request body, bounded and parsed.
|
|
202
234
|
*
|
|
@@ -206,7 +238,7 @@ const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
|
206
238
|
* A `SyntaxError` from `JSON.parse` is the same failure class as the malformed
|
|
207
239
|
* percent-encoding handled in `decodeSegment`: a malformed request, not a server fault.
|
|
208
240
|
*/
|
|
209
|
-
async function readJson(req) {
|
|
241
|
+
async function readJson(req, limit) {
|
|
210
242
|
const chunks = [];
|
|
211
243
|
let bytes = 0;
|
|
212
244
|
let overflowed = false;
|
|
@@ -215,7 +247,7 @@ async function readJson(req) {
|
|
|
215
247
|
bytes += buffer.length;
|
|
216
248
|
if (overflowed)
|
|
217
249
|
continue;
|
|
218
|
-
if (bytes >
|
|
250
|
+
if (bytes > limit) {
|
|
219
251
|
// Drop what was accumulated and stop accumulating, but keep draining. The cap is
|
|
220
252
|
// about retained memory, and this bounds it. Neither `req.destroy()` nor
|
|
221
253
|
// `req.pause()` works here: destroying resets the socket so a client still
|
|
@@ -228,7 +260,7 @@ async function readJson(req) {
|
|
|
228
260
|
chunks.push(buffer);
|
|
229
261
|
}
|
|
230
262
|
if (overflowed) {
|
|
231
|
-
return { ok: false, status: 413, error: `request body exceeds ${
|
|
263
|
+
return { ok: false, status: 413, error: `request body exceeds ${limit} bytes` };
|
|
232
264
|
}
|
|
233
265
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
234
266
|
if (raw === '')
|
|
@@ -1,6 +1,16 @@
|
|
|
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
|
+
type Importable = Pick<RecordingStore, 'read' | 'write' | 'create'>;
|
|
7
|
+
/**
|
|
8
|
+
* The read-merge-write cycle, shared by every store that owns its own storage.
|
|
9
|
+
*
|
|
10
|
+
* A corrupt existing file is refused rather than overwritten. `read` reports
|
|
11
|
+
* unparseable JSON as `not-found`, which is indistinguishable from "absent" at
|
|
12
|
+
* this layer, so absence is established by `list` instead — the one call that
|
|
13
|
+
* tells a recording that cannot be parsed apart from one that was never written.
|
|
14
|
+
*/
|
|
15
|
+
export declare function importHarVia(store: Importable & Pick<RecordingStore, 'list'>, recordingName: string, incoming: Har): Promise<ImportResult>;
|
|
6
16
|
export {};
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { applyResponsePatch, deleteEntry as deleteEntryIn } from '../edit.js';
|
|
2
|
+
import { POLLY_CREATOR_NAME } from '../har.js';
|
|
3
|
+
import { mergeEntries, rejectImport } from '../import.js';
|
|
4
|
+
import { guidForRecording, rejectRecordingName } from '../recording-id.js';
|
|
2
5
|
import { StoreError } from './types.js';
|
|
3
6
|
export function patchEntryVia(store, id, entryId, order, etag, patch) {
|
|
4
7
|
return applyEntryEdit(store, id, etag, (har) => applyResponsePatch(har, entryId, order, patch));
|
|
@@ -18,3 +21,64 @@ async function applyEntryEdit(store, id, etag, apply) {
|
|
|
18
21
|
await store.write(id, result.har, read.etag);
|
|
19
22
|
return store.read(id);
|
|
20
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* The read-merge-write cycle, shared by every store that owns its own storage.
|
|
26
|
+
*
|
|
27
|
+
* A corrupt existing file is refused rather than overwritten. `read` reports
|
|
28
|
+
* unparseable JSON as `not-found`, which is indistinguishable from "absent" at
|
|
29
|
+
* this layer, so absence is established by `list` instead — the one call that
|
|
30
|
+
* tells a recording that cannot be parsed apart from one that was never written.
|
|
31
|
+
*/
|
|
32
|
+
export async function importHarVia(store, recordingName, incoming) {
|
|
33
|
+
const badName = rejectRecordingName(recordingName);
|
|
34
|
+
if (badName !== null)
|
|
35
|
+
throw new StoreError('unprocessable', badName);
|
|
36
|
+
// Trimmed once, here, rather than left for `guidForRecording` to trim on its own:
|
|
37
|
+
// without this, a padded name would hash to the correct (trimmed) directory but
|
|
38
|
+
// `newRecording` below would still write the untrimmed name into `_recordingName`
|
|
39
|
+
// — a file that disagrees with its own location, and one that would then round-trip
|
|
40
|
+
// the padding straight back into the import form's prefill (`openRecording`, I-2b).
|
|
41
|
+
const name = recordingName.trim();
|
|
42
|
+
const rejection = rejectImport(incoming);
|
|
43
|
+
if (rejection !== null)
|
|
44
|
+
throw new StoreError('unprocessable', rejection);
|
|
45
|
+
const id = guidForRecording(name);
|
|
46
|
+
const summary = (await store.list()).find((candidate) => candidate.id === id);
|
|
47
|
+
if (summary !== undefined && summary.problem !== null) {
|
|
48
|
+
throw new StoreError('unprocessable', `recording ${id} is on disk but unreadable (${summary.problem}) — repair or delete it before importing, rather than having this overwrite it`);
|
|
49
|
+
}
|
|
50
|
+
const existing = summary === undefined ? null : await store.read(id);
|
|
51
|
+
const outcome = mergeEntries(existing?.har.log.entries ?? [], incoming.log.entries);
|
|
52
|
+
if (existing === null) {
|
|
53
|
+
await store.create(id, newRecording(name, outcome.entries));
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
await store.write(id, { ...existing.har, log: { ...existing.har.log, entries: outcome.entries } }, existing.etag);
|
|
57
|
+
}
|
|
58
|
+
const after = await store.read(id);
|
|
59
|
+
return {
|
|
60
|
+
...after,
|
|
61
|
+
id,
|
|
62
|
+
added: outcome.added,
|
|
63
|
+
replaced: outcome.replaced,
|
|
64
|
+
skipped: outcome.skipped,
|
|
65
|
+
created: existing === null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The log shell real Polly writes, minus `browser` and `pages`: Polly emits them,
|
|
70
|
+
* nothing reads them, and swagger-recorder's Polly flavour already proves a
|
|
71
|
+
* recording without them replays.
|
|
72
|
+
*/
|
|
73
|
+
function newRecording(recordingName, entries) {
|
|
74
|
+
return {
|
|
75
|
+
log: {
|
|
76
|
+
_recordingName: recordingName,
|
|
77
|
+
version: '1.2',
|
|
78
|
+
// Asserted on by `Persister.findRecording`. Anything else and Polly asks
|
|
79
|
+
// the user to delete the file.
|
|
80
|
+
creator: { name: POLLY_CREATOR_NAME, version: '6.0.6', comment: 'persister:rest' },
|
|
81
|
+
entries,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { RecordingStore, RecordingSummary, ReadResult } from './types.js';
|
|
1
|
+
import type { RecordingStore, RecordingSummary, ReadResult, ImportResult } from './types.js';
|
|
2
2
|
import type { Har, ResponsePatch } from '../types.js';
|
|
3
3
|
export { StoreError } from './types.js';
|
|
4
4
|
export type { RecordingStore, RecordingSummary, ReadResult } from './types.js';
|
|
5
|
+
export type { ImportResult } from './types.js';
|
|
5
6
|
/**
|
|
6
7
|
* Polly's node-server writes with `fs.outputJsonSync(path, data, { spaces: 2 })`, which
|
|
7
8
|
* routes through `jsonfile`'s `stringify` — `JSON.stringify(obj, null, 2)` plus a final
|
|
@@ -49,6 +50,14 @@ export declare class FsStore implements RecordingStore {
|
|
|
49
50
|
delete(id: string): Promise<void>;
|
|
50
51
|
patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
|
|
51
52
|
deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
53
|
+
/**
|
|
54
|
+
* `write` is read-compare-write and refuses a file that is not there, which is
|
|
55
|
+
* right for an edit and wrong for a creation. `wx` is what makes this a creation
|
|
56
|
+
* rather than a truncation: if the file appeared since `importHarVia` looked, the
|
|
57
|
+
* open fails instead of discarding whatever landed.
|
|
58
|
+
*/
|
|
59
|
+
create(id: string, har: Har): Promise<void>;
|
|
60
|
+
importHar(recordingName: string, incoming: Har): Promise<ImportResult>;
|
|
52
61
|
/**
|
|
53
62
|
* Polly's own node-server does `path.join(recordingsDir, recording, 'recording.har')`
|
|
54
63
|
* with no sanitisation, and here the id arrives off an HTTP request. Resolving and
|
|
@@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import { parseHar } from '../har.js';
|
|
5
5
|
import { etagFor } from '../etag.js';
|
|
6
6
|
import { StoreError } from './types.js';
|
|
7
|
-
import { deleteEntryVia, patchEntryVia } from './apply.js';
|
|
7
|
+
import { deleteEntryVia, importHarVia, patchEntryVia } from './apply.js';
|
|
8
8
|
export { StoreError } from './types.js';
|
|
9
9
|
const FILENAME = 'recording.har';
|
|
10
10
|
/**
|
|
@@ -154,6 +154,28 @@ export class FsStore {
|
|
|
154
154
|
deleteEntry(id, entryId, order, etag) {
|
|
155
155
|
return deleteEntryVia(this, id, entryId, order, etag);
|
|
156
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* `write` is read-compare-write and refuses a file that is not there, which is
|
|
159
|
+
* right for an edit and wrong for a creation. `wx` is what makes this a creation
|
|
160
|
+
* rather than a truncation: if the file appeared since `importHarVia` looked, the
|
|
161
|
+
* open fails instead of discarding whatever landed.
|
|
162
|
+
*/
|
|
163
|
+
async create(id, har) {
|
|
164
|
+
const path = this.pathFor(id);
|
|
165
|
+
await mkdir(join(path, '..'), { recursive: true });
|
|
166
|
+
try {
|
|
167
|
+
await writeFile(path, serializeHar(har), { encoding: 'utf8', flag: 'wx' });
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
if (err.code === 'EEXIST') {
|
|
171
|
+
throw new StoreError('conflict', `recording ${id} appeared while it was being created — reload and retry`);
|
|
172
|
+
}
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
importHar(recordingName, incoming) {
|
|
177
|
+
return importHarVia(this, recordingName, incoming);
|
|
178
|
+
}
|
|
157
179
|
/**
|
|
158
180
|
* Polly's own node-server does `path.join(recordingsDir, recording, 'recording.har')`
|
|
159
181
|
* with no sanitisation, and here the id arrives off an HTTP request. Resolving and
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RecordingStore, RecordingSummary, ReadResult } from './types.js';
|
|
1
|
+
import type { RecordingStore, RecordingSummary, ReadResult, ImportResult } from './types.js';
|
|
2
2
|
import type { Har, ResponsePatch } from '../types.js';
|
|
3
3
|
/**
|
|
4
4
|
* The store the page uses under the Node mount. It exists so `mountStudio` can be
|
|
@@ -26,5 +26,12 @@ export declare class HttpStore implements RecordingStore {
|
|
|
26
26
|
delete(id: string): Promise<void>;
|
|
27
27
|
patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
|
|
28
28
|
deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
29
|
+
/**
|
|
30
|
+
* Not on the wire protocol: creation happens inside `/api/import`, server-side,
|
|
31
|
+
* the same way `write` happens inside PATCH. Declared so a caller holding a
|
|
32
|
+
* concrete HttpStore still type-checks against the interface's arity.
|
|
33
|
+
*/
|
|
34
|
+
create(id: string, har: Har): Promise<void>;
|
|
35
|
+
importHar(recordingName: string, incoming: Har): Promise<ImportResult>;
|
|
29
36
|
private json;
|
|
30
37
|
}
|
|
@@ -39,6 +39,17 @@ export class HttpStore {
|
|
|
39
39
|
async deleteEntry(id, entryId, order, etag) {
|
|
40
40
|
return (await this.json('DELETE', `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`));
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Not on the wire protocol: creation happens inside `/api/import`, server-side,
|
|
44
|
+
* the same way `write` happens inside PATCH. Declared so a caller holding a
|
|
45
|
+
* concrete HttpStore still type-checks against the interface's arity.
|
|
46
|
+
*/
|
|
47
|
+
async create(id, har) {
|
|
48
|
+
throw new StoreError('forbidden', 'the HTTP API creates recordings through import — use importHar');
|
|
49
|
+
}
|
|
50
|
+
async importHar(recordingName, incoming) {
|
|
51
|
+
return (await this.json('POST', '/api/import', { recordingName, har: incoming }));
|
|
52
|
+
}
|
|
42
53
|
async json(method, path, body) {
|
|
43
54
|
const res = await fetch(`${this.basePath}${path}`, {
|
|
44
55
|
method,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RecordingStore, RecordingSummary, ReadResult } from './types.js';
|
|
1
|
+
import type { RecordingStore, RecordingSummary, ReadResult, ImportResult } from './types.js';
|
|
2
2
|
import type { Har, ResponsePatch } from '../types.js';
|
|
3
3
|
export interface LocalStorageStoreOptions {
|
|
4
4
|
key?: string;
|
|
@@ -20,6 +20,8 @@ export declare class LocalStorageStore implements RecordingStore {
|
|
|
20
20
|
delete(id: string): Promise<void>;
|
|
21
21
|
patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
|
|
22
22
|
deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
23
|
+
create(id: string, har: Har): Promise<void>;
|
|
24
|
+
importHar(recordingName: string, incoming: Har): Promise<ImportResult>;
|
|
23
25
|
/** `null` means the key holds something that is not JSON, which `list` reports. */
|
|
24
26
|
private db;
|
|
25
27
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parseHar } from '../har.js';
|
|
2
2
|
import { etagFor } from '../etag.js';
|
|
3
3
|
import { StoreError } from './types.js';
|
|
4
|
-
import { deleteEntryVia, patchEntryVia } from './apply.js';
|
|
4
|
+
import { deleteEntryVia, importHarVia, patchEntryVia } from './apply.js';
|
|
5
5
|
/** Polly's default namespace. `persisterOptions.local-storage.key` can change it. */
|
|
6
6
|
const DEFAULT_KEY = 'pollyjs';
|
|
7
7
|
/**
|
|
@@ -93,6 +93,18 @@ export class LocalStorageStore {
|
|
|
93
93
|
deleteEntry(id, entryId, order, etag) {
|
|
94
94
|
return deleteEntryVia(this, id, entryId, order, etag);
|
|
95
95
|
}
|
|
96
|
+
async create(id, har) {
|
|
97
|
+
const db = this.db();
|
|
98
|
+
if (db === null)
|
|
99
|
+
throw new StoreError('unprocessable', `localStorage[${this.key}] is not valid JSON`);
|
|
100
|
+
if (id in db) {
|
|
101
|
+
throw new StoreError('conflict', `recording ${id} appeared while it was being created — reload and retry`);
|
|
102
|
+
}
|
|
103
|
+
this.storage.setItem(this.key, JSON.stringify({ ...db, [id]: har }));
|
|
104
|
+
}
|
|
105
|
+
importHar(recordingName, incoming) {
|
|
106
|
+
return importHarVia(this, recordingName, incoming);
|
|
107
|
+
}
|
|
96
108
|
/** `null` means the key holds something that is not JSON, which `list` reports. */
|
|
97
109
|
db() {
|
|
98
110
|
const raw = this.storage.getItem(this.key);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Har, ResponsePatch } from '../types.js';
|
|
2
|
+
import type { ImportSkip } from '../import.js';
|
|
2
3
|
export interface RecordingSummary {
|
|
3
4
|
/** The recordingId. May contain slashes: Polly builds it from describe/it names. */
|
|
4
5
|
id: string;
|
|
@@ -15,6 +16,14 @@ export interface ReadResult {
|
|
|
15
16
|
etag: string;
|
|
16
17
|
pollyWillReject: string | null;
|
|
17
18
|
}
|
|
19
|
+
export interface ImportResult extends ReadResult {
|
|
20
|
+
/** The recordingId the name resolved to, so the UI can open what it just wrote. */
|
|
21
|
+
id: string;
|
|
22
|
+
added: number;
|
|
23
|
+
replaced: number;
|
|
24
|
+
skipped: ImportSkip[];
|
|
25
|
+
created: boolean;
|
|
26
|
+
}
|
|
18
27
|
/**
|
|
19
28
|
* The one interface the page renders against. Three implementations exist — the
|
|
20
29
|
* filesystem, localStorage, and an HTTP client — and the page cannot tell them apart,
|
|
@@ -37,6 +46,22 @@ export interface RecordingStore {
|
|
|
37
46
|
*/
|
|
38
47
|
patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
|
|
39
48
|
deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
49
|
+
/**
|
|
50
|
+
* Write a recording that does not exist yet. Unlike `write` there is no etag to
|
|
51
|
+
* compare, because there is nothing to compare against — and it must fail rather
|
|
52
|
+
* than truncate if the file appeared since the caller last looked.
|
|
53
|
+
*/
|
|
54
|
+
create(id: string, har: Har): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Import a HAR from the swagger-recorder extension, creating the recording or
|
|
57
|
+
* merging into it.
|
|
58
|
+
*
|
|
59
|
+
* Takes a NAME, not an id: the name is what the user knows and what the app's
|
|
60
|
+
* Polly config holds, while the id is a hash the UI should never compute. On the
|
|
61
|
+
* interface for the same reason `patchEntry` and `deleteEntry` are — the
|
|
62
|
+
* read-merge-write cycle belongs on the server side of the network.
|
|
63
|
+
*/
|
|
64
|
+
importHar(recordingName: string, incoming: Har): Promise<ImportResult>;
|
|
40
65
|
}
|
|
41
66
|
export type StoreErrorCode = 'not-found' | 'conflict' | 'forbidden' | 'unprocessable';
|
|
42
67
|
export declare class StoreError extends Error {
|
package/dist/studio/types.d.ts
CHANGED
|
@@ -71,7 +71,9 @@ export interface HarLog {
|
|
|
71
71
|
};
|
|
72
72
|
_recordingName?: string;
|
|
73
73
|
entries: HarEntry[];
|
|
74
|
-
|
|
74
|
+
/** Optional: real Polly emits it, nothing in this package reads it, and the shell
|
|
75
|
+
* `newRecording` (`stores/apply.ts`) writes on import omits it entirely. */
|
|
76
|
+
pages?: unknown[];
|
|
75
77
|
browser?: {
|
|
76
78
|
name: string;
|
|
77
79
|
version: string;
|
package/dist/studio/ui/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { renderEntryDetail, renderEntryRow, renderRecordingList } from './render.js';
|
|
1
|
+
import { renderEntryDetail, renderEntryRow, renderImportResult, renderRecordingList, } from './render.js';
|
|
2
2
|
import { StoreError } from '../stores/types.js';
|
|
3
3
|
/**
|
|
4
4
|
* The page's behaviour, written against `RecordingStore` and nothing else. Under the
|
|
@@ -14,6 +14,58 @@ export function mountStudio(root, store) {
|
|
|
14
14
|
const recordings = required(root, '#recordings');
|
|
15
15
|
const entries = required(root, '#entries');
|
|
16
16
|
const detail = required(root, '#detail');
|
|
17
|
+
const importForm = root.querySelector('#import');
|
|
18
|
+
const importReport = root.querySelector('#import-report');
|
|
19
|
+
// Optional: the Node mount (`page.ts`'s `STUDIO_MARKUP`) and the browser-only mount
|
|
20
|
+
// (`browser.ts`, which injects that same `STUDIO_MARKUP`) both ship this form, so
|
|
21
|
+
// `#import` is present and `LocalStorageStore.importHar` reachable in either. The only
|
|
22
|
+
// root that lacks it is a test fake that predates this markup, and a missing form must
|
|
23
|
+
// not throw the whole page away before a single recording has been listed.
|
|
24
|
+
importForm?.addEventListener('submit', (event) => {
|
|
25
|
+
event.preventDefault();
|
|
26
|
+
void runImport();
|
|
27
|
+
});
|
|
28
|
+
async function runImport() {
|
|
29
|
+
if (importForm === null || importReport === null)
|
|
30
|
+
return;
|
|
31
|
+
// Cleared before the attempt, not just on success: a failed import must not leave
|
|
32
|
+
// the previous import's success line sitting next to a form that just failed — see
|
|
33
|
+
// Final whole-branch review, Finding I-4.
|
|
34
|
+
importReport.textContent = '';
|
|
35
|
+
try {
|
|
36
|
+
// The field reads live inside the try along with everything else: a missing
|
|
37
|
+
// `#import-name` or `#import-file` must surface the same way any other import
|
|
38
|
+
// failure does, not as an unhandled rejection — see Finding M-2.
|
|
39
|
+
const name = importForm.querySelector('#import-name').value;
|
|
40
|
+
const file = importForm.querySelector('#import-file').files?.[0];
|
|
41
|
+
if (file === undefined) {
|
|
42
|
+
throw new Error('choose a .har file to import');
|
|
43
|
+
}
|
|
44
|
+
// Parsed here rather than posted raw so a file that is not JSON fails with
|
|
45
|
+
// a message naming the file, before a 50 MB upload.
|
|
46
|
+
const har = JSON.parse(await file.text());
|
|
47
|
+
const result = await store.importHar(name, har);
|
|
48
|
+
importReport.innerHTML = renderImportResult(result);
|
|
49
|
+
try {
|
|
50
|
+
// A separate try: the import itself already succeeded and `importReport`
|
|
51
|
+
// already shows that. A failure refreshing the list or opening the result is
|
|
52
|
+
// a different problem — reported the normal way, rather than the outer catch
|
|
53
|
+
// overwriting the success line with what would read as the import having failed.
|
|
54
|
+
await refreshRecordings();
|
|
55
|
+
await openRecording(result.id);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
report(err);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
// Into `#import-report`, right next to the form that failed, rather than the
|
|
63
|
+
// distant `detail` banner `report()` uses for everything else — see Finding I-4.
|
|
64
|
+
// `textContent`, never `innerHTML`: this is an error message, not markup `render.ts`
|
|
65
|
+
// already escaped.
|
|
66
|
+
importReport.textContent = errorMessage(err);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
17
69
|
let currentId = null;
|
|
18
70
|
let current = null;
|
|
19
71
|
// `.catch(report)` rather than a bare `void`: an unawaited rejection here would be an
|
|
@@ -33,6 +85,19 @@ export function mountStudio(root, store) {
|
|
|
33
85
|
current = { har: read.har, etag: read.etag };
|
|
34
86
|
entries.innerHTML = `<ul>${read.har.log.entries.map(renderEntryRow).join('')}</ul>`;
|
|
35
87
|
detail.innerHTML = '';
|
|
88
|
+
// Spec §5.5: the import name prefills from the opened recording's `_recordingName`,
|
|
89
|
+
// so merging is one gesture and creating is one gesture plus typing a name. This is
|
|
90
|
+
// also the spec's own ergonomic defence against Finding I-2 (a padded name hashing
|
|
91
|
+
// to a different, identical-looking directory) — prefilling from a name Polly itself
|
|
92
|
+
// already wrote means fewer names get typed by hand at all.
|
|
93
|
+
const recordingName = read.har.log._recordingName;
|
|
94
|
+
// `importForm?.querySelector(...)` is `undefined`, not `null`, when `importForm`
|
|
95
|
+
// itself is absent — `=== null` alone would miss that and then dereference
|
|
96
|
+
// `undefined` below, so the guard checks `importForm` directly.
|
|
97
|
+
const nameField = importForm === null ? null : importForm.querySelector('#import-name');
|
|
98
|
+
if (nameField !== null && typeof recordingName === 'string' && recordingName !== '') {
|
|
99
|
+
nameField.value = recordingName;
|
|
100
|
+
}
|
|
36
101
|
}
|
|
37
102
|
/**
|
|
38
103
|
* `entryId` always arrives as a string, because it came off `dataset`. `e._id` is
|
|
@@ -77,12 +142,17 @@ export function mountStudio(root, store) {
|
|
|
77
142
|
report(err);
|
|
78
143
|
}
|
|
79
144
|
}
|
|
80
|
-
|
|
81
|
-
|
|
145
|
+
// Shared by `report` (the `detail`-pane banner) and `runImport`'s catch (`#import-report`):
|
|
146
|
+
// both want the same friendlier wording for a conflict, neither wants a raw stack string.
|
|
147
|
+
function errorMessage(err) {
|
|
148
|
+
return err instanceof StoreError && err.code === 'conflict'
|
|
82
149
|
? 'the recording changed underneath — reload and retry'
|
|
83
150
|
: err instanceof Error
|
|
84
151
|
? err.message
|
|
85
152
|
: String(err);
|
|
153
|
+
}
|
|
154
|
+
function report(err) {
|
|
155
|
+
const message = errorMessage(err);
|
|
86
156
|
// `document.createElement` would reach past `root` for a document `mountStudio`
|
|
87
157
|
// was never given — harmless today since the Node mount and the browser mount
|
|
88
158
|
// both happen to run in one document, but `root` exists precisely so this
|
package/dist/studio/ui/page.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The body, shared with the browser-only mount in Task 12 so the two cannot drift into
|
|
3
3
|
* two different editors.
|
|
4
4
|
*/
|
|
5
|
-
export declare const STUDIO_MARKUP = "\n<div class=\"polly-studio\">\n<header class=\"ps-bar\">\n <div class=\"ps-brand\">\n <h1>polly-studio</h1>\n <p class=\"ps-tagline\">Change what a recorded request replies with, then save it back to the HAR.</p>\n </div>\n <p class=\"warn banner\">Polly's <code>keepUnusedRequests</code> defaults to <code>false</code>:\n the next record run deletes every entry it did not exercise, including the ones you edit here.\n Set <code>persisterOptions.keepUnusedRequests: true</code> before editing.</p>\n</header>\n<main class=\"ps-panes\">\n <section class=\"ps-pane ps-pane--list\">\n <h2 class=\"ps-pane-title\">Recordings</h2>\n <nav id=\"recordings\" class=\"ps-scroll\">Loading\u2026</nav>\n </section>\n <section class=\"ps-pane ps-pane--list\">\n <h2 class=\"ps-pane-title\">Requests</h2>\n <div id=\"entries\" class=\"ps-scroll\"></div>\n </section>\n <section class=\"ps-pane ps-pane--editor\">\n <h2 class=\"ps-pane-title\">Recorded exchange</h2>\n <div id=\"detail\" class=\"ps-scroll\"></div>\n </section>\n</main>\n</div>";
|
|
5
|
+
export declare const STUDIO_MARKUP = "\n<div class=\"polly-studio\">\n<header class=\"ps-bar\">\n <div class=\"ps-brand\">\n <h1>polly-studio</h1>\n <p class=\"ps-tagline\">Change what a recorded request replies with, then save it back to the HAR.</p>\n </div>\n <p class=\"warn banner\">Polly's <code>keepUnusedRequests</code> defaults to <code>false</code>:\n the next record run deletes every entry it did not exercise, including the ones you edit here.\n Set <code>persisterOptions.keepUnusedRequests: true</code> before editing.</p>\n</header>\n<main class=\"ps-panes\">\n <section class=\"ps-pane ps-pane--list\">\n <h2 class=\"ps-pane-title\">Recordings</h2>\n <nav id=\"recordings\" class=\"ps-scroll\">Loading\u2026</nav>\n <form id=\"import\" class=\"ps-import\">\n <label for=\"import-name\">Import a HAR from the swagger-recorder extension</label>\n <input id=\"import-name\" name=\"recordingName\" placeholder=\"recording name, e.g. my-app\" />\n <input id=\"import-file\" name=\"har\" type=\"file\" accept=\".har,application/json\" />\n <button type=\"submit\">Import</button>\n <div id=\"import-report\" aria-live=\"polite\"></div>\n </form>\n </section>\n <section class=\"ps-pane ps-pane--list\">\n <h2 class=\"ps-pane-title\">Requests</h2>\n <div id=\"entries\" class=\"ps-scroll\"></div>\n </section>\n <section class=\"ps-pane ps-pane--editor\">\n <h2 class=\"ps-pane-title\">Recorded exchange</h2>\n <div id=\"detail\" class=\"ps-scroll\"></div>\n </section>\n</main>\n</div>";
|
|
6
6
|
/**
|
|
7
7
|
* The HTML shell.
|
|
8
8
|
*
|
|
@@ -50,4 +50,4 @@ export declare const PAGE_STYLE = "\nhtml { color-scheme: light; }\nhtml, body {
|
|
|
50
50
|
* rules restyled the host app's own type and margins as a side effect of mounting an
|
|
51
51
|
* editor. Scoping is the only thing preventing that, so new rules need the prefix too.
|
|
52
52
|
*/
|
|
53
|
-
export declare const STUDIO_STYLE = "\n.polly-studio, .polly-studio *, .polly-studio *::before, .polly-studio *::after { box-sizing: border-box; }\n.polly-studio {\n --ps-paper: #eaeef5;\n --ps-surface: #ffffff;\n --ps-sunken: #f1f5fb;\n --ps-ink: #111722;\n --ps-ink-soft: #4d5867;\n --ps-ink-faint: #838e9d;\n --ps-line: #dce3ed;\n --ps-line-strong: #c3ccda;\n --ps-accent: #3d2fbd;\n --ps-accent-deep: #2e2298;\n --ps-accent-line: #c8c2f0;\n --ps-accent-soft: #eeecfb;\n --ps-ok: #0a6b3d; --ps-ok-line: #b6ddc6; --ps-ok-soft: #e8f4ed;\n --ps-warn: #875700; --ps-warn-line: #ecd3a3; --ps-warn-soft: #fbf2e1;\n --ps-bad: #b3261e; --ps-bad-line: #edbdb9; --ps-bad-soft: #fceceb;\n --ps-mono: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n\n color-scheme: light;\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n font-size: 15px;\n line-height: 1.5;\n color: var(--ps-ink);\n background: var(--ps-paper);\n display: flex;\n flex-direction: column;\n height: 100%;\n min-height: 34rem;\n}\n\n/* Title bar ------------------------------------------------------------ */\n.polly-studio .ps-bar {\n display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1.75rem;\n padding: .8rem 1.25rem;\n background: var(--ps-surface);\n border-bottom: 1px solid var(--ps-line);\n}\n.polly-studio .ps-brand { display: flex; align-items: baseline; flex-wrap: wrap; gap: .1rem .75rem; }\n.polly-studio h1 { margin: 0; font-size: 1.0625rem; font-weight: 650; letter-spacing: -.015em; }\n.polly-studio .ps-tagline { margin: 0; font-size: .8125rem; color: var(--ps-ink-soft); }\n.polly-studio .banner {\n flex: 1 1 26rem; min-width: 16rem; margin: 0;\n padding: .45rem .7rem;\n font-size: .78rem; line-height: 1.45;\n background: var(--ps-warn-soft);\n border: 1px solid var(--ps-warn-line); border-left-width: 3px;\n border-radius: 0 7px 7px 0;\n}\n.polly-studio code {\n font-family: var(--ps-mono); font-size: .92em;\n background: rgba(17,23,34,.05); border-radius: 4px; padding: 0 .22em;\n}\n\n/* Panes ---------------------------------------------------------------- */\n.polly-studio .ps-panes {\n flex: 1; min-height: 0;\n display: grid; gap: .9rem;\n grid-template-columns: 17rem 24rem minmax(0, 1fr);\n padding: .9rem 1.25rem 1.25rem;\n}\n.polly-studio .ps-pane {\n display: flex; flex-direction: column; min-height: 0;\n border: 1px solid var(--ps-line); border-radius: 12px;\n background: var(--ps-surface);\n overflow: hidden;\n}\n/* The two browsers read as one surface you pick from; the editor reads as the page you\n work on. Same border, different floor \u2014 not three identical cards. */\n.polly-studio .ps-pane--list { background: var(--ps-sunken); }\n.polly-studio .ps-pane-title {\n margin: 0; padding: .5rem .75rem;\n font-size: .8125rem; font-weight: 600; color: var(--ps-ink-soft);\n background: var(--ps-surface);\n border-bottom: 1px solid var(--ps-line);\n}\n.polly-studio .ps-pane--editor .ps-pane-title { box-shadow: inset 3px 0 0 var(--ps-accent); }\n.polly-studio .ps-scroll { flex: 1; min-height: 0; overflow: auto; padding: .5rem; }\n.polly-studio .ps-pane--editor .ps-scroll { padding: .9rem 1rem 1.25rem; }\n\n/* Recording list ------------------------------------------------------- */\n.polly-studio ul { list-style: none; margin: 0; padding: 0; }\n.polly-studio .recordings, .polly-studio #entries ul { display: grid; gap: .3rem; }\n/* Each row is its own bordered card rather than a hover-only highlight: a recorded URL\n wraps to three or four lines often enough that without one, two rows read as one. */\n.polly-studio button.recording, .polly-studio button.entry {\n width: 100%; text-align: left; font: inherit;\n color: var(--ps-ink); background: var(--ps-surface);\n border: 1px solid var(--ps-line); border-radius: 8px;\n padding: .4rem .5rem; cursor: pointer;\n transition: background-color .12s ease, border-color .12s ease;\n}\n.polly-studio button.recording:hover, .polly-studio button.entry:hover {\n border-color: var(--ps-line-strong);\n}\n/* :focus, not :focus-visible \u2014 a click selects, and the selection has to be visible\n whether it came from the mouse or the keyboard. app.ts keeps no selected state. */\n.polly-studio button.recording:focus, .polly-studio button.entry:focus {\n outline: none; background: var(--ps-accent-soft); border-color: var(--ps-accent-line);\n}\n.polly-studio button.recording:focus-visible, .polly-studio button.entry:focus-visible {\n outline: 2px solid var(--ps-accent); outline-offset: 1px;\n}\n.polly-studio button.recording { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: .5rem; }\n.polly-studio .ps-rec-name { font-family: var(--ps-mono); font-size: .8125rem; overflow-wrap: anywhere; }\n.polly-studio .count {\n font-family: var(--ps-mono); font-size: .6875rem; color: var(--ps-ink-soft);\n background: var(--ps-paper); border: 1px solid var(--ps-line);\n border-radius: 999px; padding: 0 .42rem;\n}\n.polly-studio .ps-rec-note { margin: .1rem 0 .3rem .5rem; font-size: .75rem; line-height: 1.4; }\n\n/* Request list --------------------------------------------------------- */\n.polly-studio button.entry {\n display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto;\n align-items: baseline; gap: .1rem .5rem;\n}\n.polly-studio .method {\n font-family: var(--ps-mono); font-size: .6875rem; font-weight: 700;\n letter-spacing: .02em; color: var(--ps-ink-soft); min-width: 2.6rem;\n}\n.polly-studio .status {\n font-family: var(--ps-mono); font-size: .6875rem; font-weight: 700;\n border: 1px solid; border-radius: 5px; padding: 0 .3rem;\n}\n.polly-studio .status.s2 { color: var(--ps-ok); background: var(--ps-ok-soft); border-color: var(--ps-ok-line); }\n.polly-studio .status.s3 { color: var(--ps-warn); background: var(--ps-warn-soft); border-color: var(--ps-warn-line); }\n.polly-studio .status.s4, .polly-studio .status.s5 { color: var(--ps-bad); background: var(--ps-bad-soft); border-color: var(--ps-bad-line); }\n.polly-studio .url { font-family: var(--ps-mono); font-size: .78rem; overflow-wrap: anywhere; }\n.polly-studio .order { font-family: var(--ps-mono); font-size: .6875rem; color: var(--ps-ink-faint); }\n\n/* Detail --------------------------------------------------------------- */\n.polly-studio .detail { display: grid; gap: .85rem; }\n.polly-studio .ps-block {\n border: 1px solid var(--ps-line); border-radius: 10px;\n padding: .85rem .95rem 1rem;\n}\n.polly-studio .ps-block--request { background: var(--ps-sunken); }\n.polly-studio .detail h2 {\n margin: 0 0 .45rem; font-size: .9375rem; font-weight: 650; letter-spacing: -.01em;\n display: flex; align-items: baseline; gap: .5rem;\n}\n.polly-studio .detail h2 small {\n font-size: .6875rem; font-weight: 500; color: var(--ps-ink-soft);\n background: var(--ps-paper); border: 1px solid var(--ps-line);\n border-radius: 999px; padding: .05rem .45rem;\n}\n.polly-studio .note { margin: 0 0 .75rem; font-size: .78rem; line-height: 1.5; color: var(--ps-ink-soft); max-width: 68ch; }\n.polly-studio dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .3rem .9rem; margin: 0; font-size: .8125rem; }\n.polly-studio dt { font-weight: 600; color: var(--ps-ink-soft); }\n.polly-studio dd { margin: 0; font-family: var(--ps-mono); overflow-wrap: anywhere; }\n.polly-studio .ps-group-title {\n margin: 1rem 0 .4rem; font-size: .75rem; font-weight: 600; color: var(--ps-ink-soft);\n}\n\n/* Fields --------------------------------------------------------------- */\n.polly-studio .ps-fields { display: grid; grid-template-columns: 7rem minmax(0, 1fr); gap: .55rem; }\n.polly-studio label.ps-field { display: grid; gap: .25rem; margin: 0; }\n.polly-studio .ps-field-label { font-size: .75rem; font-weight: 600; color: var(--ps-ink-soft); }\n.polly-studio .ps-input, .polly-studio textarea {\n width: 100%; min-width: 0;\n font-family: var(--ps-mono); font-size: .8125rem; color: var(--ps-ink);\n background: var(--ps-surface);\n border: 1px solid var(--ps-line-strong); border-radius: 8px;\n padding: .4rem .55rem;\n transition: border-color .12s ease, box-shadow .12s ease;\n}\n.polly-studio .ps-input:hover, .polly-studio textarea:hover, .polly-studio .header-row:hover { border-color: #a9b5c6; }\n.polly-studio .ps-input:focus, .polly-studio textarea:focus {\n outline: none; border-color: var(--ps-accent); box-shadow: 0 0 0 3px var(--ps-accent-soft);\n}\n.polly-studio textarea { display: block; line-height: 1.55; resize: vertical; min-height: 11rem; tab-size: 2; }\n\n/* A header is one name:value pair, so it is one control split by a hairline, not two\n inputs that happen to sit side by side. */\n.polly-studio .headers { display: grid; gap: .35rem; }\n.polly-studio .header-row {\n display: grid; grid-template-columns: minmax(6rem, 13rem) minmax(0, 1fr);\n background: var(--ps-surface);\n border: 1px solid var(--ps-line-strong); border-radius: 8px;\n overflow: hidden;\n transition: border-color .12s ease, box-shadow .12s ease;\n}\n.polly-studio .header-row:focus-within { border-color: var(--ps-accent); box-shadow: 0 0 0 3px var(--ps-accent-soft); }\n.polly-studio .header-row input {\n width: 100%; min-width: 0;\n font-family: var(--ps-mono); font-size: .8125rem; color: var(--ps-ink);\n background: transparent; border: 0; padding: .38rem .55rem;\n}\n.polly-studio .header-row input:focus { outline: none; }\n.polly-studio .header-row input[name^=\"headerName\"] {\n color: var(--ps-accent-deep); font-weight: 600;\n background: var(--ps-sunken); border-right: 1px solid var(--ps-line);\n}\n\n/* Actions -------------------------------------------------------------- */\n.polly-studio .actions {\n display: flex; flex-wrap: wrap; gap: .5rem;\n margin-top: 1rem; padding-top: .9rem;\n border-top: 1px solid var(--ps-line);\n}\n.polly-studio .actions button {\n font: inherit; font-size: .8125rem; font-weight: 600;\n padding: .45rem .95rem; border-radius: 8px; cursor: pointer;\n border: 1px solid transparent;\n transition: background-color .12s ease, border-color .12s ease;\n}\n.polly-studio .actions button[name=\"save\"] { background: var(--ps-accent); color: #fff; }\n.polly-studio .actions button[name=\"save\"]:hover { background: var(--ps-accent-deep); }\n.polly-studio .actions button.danger { background: var(--ps-surface); color: var(--ps-bad); border-color: var(--ps-bad-line); }\n.polly-studio .actions button.danger:hover { background: var(--ps-bad-soft); }\n.polly-studio .actions button:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }\n\n/* Notices and empty states --------------------------------------------- */\n.polly-studio .warn { color: var(--ps-warn); }\n.polly-studio .problem { color: var(--ps-bad); }\n.polly-studio .ps-notice {\n margin: 0; padding: .55rem .7rem; font-size: .8125rem; line-height: 1.5;\n background: var(--ps-warn-soft); border: 1px solid var(--ps-warn-line);\n border-left-width: 3px; border-radius: 0 8px 8px 0;\n}\n/* report() prepends a bare <p class=\"problem\"> here; it is the only alert the page has. */\n.polly-studio #detail > .problem {\n margin: 0 0 .85rem; padding: .55rem .7rem; font-size: .8125rem;\n background: var(--ps-bad-soft); border: 1px solid var(--ps-bad-line);\n border-left-width: 3px; border-radius: 0 8px 8px 0;\n}\n.polly-studio section.problem { margin: 0; }\n.polly-studio li.problem { padding: .4rem .5rem; font-size: .8125rem; }\n.polly-studio .empty {\n margin: 0; padding: 1.5rem .75rem; text-align: center;\n font-size: .8125rem; line-height: 1.55; color: var(--ps-ink-faint);\n}\n/* The same class inside the editor is a field standing in for a control, not a pane with\n nothing in it \u2014 centring it there would read as a broken row between two left-aligned\n groups. */\n.polly-studio .ps-block .empty { padding: .3rem 0 .1rem; text-align: left; }\n/* The two panes app.ts blanks rather than filling with a placeholder. ::before keeps the\n hint out of the markup, so nothing here has to be re-added after every innerHTML wipe. */\n.polly-studio #entries:empty::before, .polly-studio #detail:empty::before {\n display: block; padding: 1.75rem .75rem; text-align: center;\n font-size: .8125rem; color: var(--ps-ink-faint);\n}\n.polly-studio #entries:empty::before { content: \"Pick a recording to see the requests it captured.\"; }\n.polly-studio #detail:empty::before { content: \"Pick a request to edit the response it replays.\"; }\n\n@media (max-width: 72rem) {\n .polly-studio { height: auto; }\n .polly-studio .ps-panes { grid-template-columns: minmax(0, 1fr); }\n .polly-studio .ps-pane--list .ps-scroll { max-height: 18rem; }\n}\n@media (max-width: 34rem) {\n .polly-studio .ps-fields { grid-template-columns: minmax(0, 1fr); }\n .polly-studio .header-row { grid-template-columns: minmax(0, 1fr); }\n .polly-studio .header-row input[name^=\"headerName\"] { border-right: 0; border-bottom: 1px solid var(--ps-line); }\n}\n@media (prefers-reduced-motion: reduce) {\n .polly-studio * { transition: none !important; }\n}\n";
|
|
53
|
+
export declare const STUDIO_STYLE = "\n.polly-studio, .polly-studio *, .polly-studio *::before, .polly-studio *::after { box-sizing: border-box; }\n.polly-studio {\n --ps-paper: #eaeef5;\n --ps-surface: #ffffff;\n --ps-sunken: #f1f5fb;\n --ps-ink: #111722;\n --ps-ink-soft: #4d5867;\n --ps-ink-faint: #838e9d;\n --ps-line: #dce3ed;\n --ps-line-strong: #c3ccda;\n --ps-accent: #3d2fbd;\n --ps-accent-deep: #2e2298;\n --ps-accent-line: #c8c2f0;\n --ps-accent-soft: #eeecfb;\n --ps-ok: #0a6b3d; --ps-ok-line: #b6ddc6; --ps-ok-soft: #e8f4ed;\n --ps-warn: #875700; --ps-warn-line: #ecd3a3; --ps-warn-soft: #fbf2e1;\n --ps-bad: #b3261e; --ps-bad-line: #edbdb9; --ps-bad-soft: #fceceb;\n --ps-mono: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n\n color-scheme: light;\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n font-size: 15px;\n line-height: 1.5;\n color: var(--ps-ink);\n background: var(--ps-paper);\n display: flex;\n flex-direction: column;\n height: 100%;\n min-height: 34rem;\n}\n\n/* Title bar ------------------------------------------------------------ */\n.polly-studio .ps-bar {\n display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1.75rem;\n padding: .8rem 1.25rem;\n background: var(--ps-surface);\n border-bottom: 1px solid var(--ps-line);\n}\n.polly-studio .ps-brand { display: flex; align-items: baseline; flex-wrap: wrap; gap: .1rem .75rem; }\n.polly-studio h1 { margin: 0; font-size: 1.0625rem; font-weight: 650; letter-spacing: -.015em; }\n.polly-studio .ps-tagline { margin: 0; font-size: .8125rem; color: var(--ps-ink-soft); }\n.polly-studio .banner {\n flex: 1 1 26rem; min-width: 16rem; margin: 0;\n padding: .45rem .7rem;\n font-size: .78rem; line-height: 1.45;\n background: var(--ps-warn-soft);\n border: 1px solid var(--ps-warn-line); border-left-width: 3px;\n border-radius: 0 7px 7px 0;\n}\n.polly-studio code {\n font-family: var(--ps-mono); font-size: .92em;\n background: rgba(17,23,34,.05); border-radius: 4px; padding: 0 .22em;\n}\n\n/* Panes ---------------------------------------------------------------- */\n.polly-studio .ps-panes {\n flex: 1; min-height: 0;\n display: grid; gap: .9rem;\n grid-template-columns: 17rem 24rem minmax(0, 1fr);\n padding: .9rem 1.25rem 1.25rem;\n}\n.polly-studio .ps-pane {\n display: flex; flex-direction: column; min-height: 0;\n border: 1px solid var(--ps-line); border-radius: 12px;\n background: var(--ps-surface);\n overflow: hidden;\n}\n/* The two browsers read as one surface you pick from; the editor reads as the page you\n work on. Same border, different floor \u2014 not three identical cards. */\n.polly-studio .ps-pane--list { background: var(--ps-sunken); }\n.polly-studio .ps-pane-title {\n margin: 0; padding: .5rem .75rem;\n font-size: .8125rem; font-weight: 600; color: var(--ps-ink-soft);\n background: var(--ps-surface);\n border-bottom: 1px solid var(--ps-line);\n}\n.polly-studio .ps-pane--editor .ps-pane-title { box-shadow: inset 3px 0 0 var(--ps-accent); }\n.polly-studio .ps-scroll { flex: 1; min-height: 0; overflow: auto; padding: .5rem; }\n.polly-studio .ps-pane--editor .ps-scroll { padding: .9rem 1rem 1.25rem; }\n\n/* Recording list ------------------------------------------------------- */\n.polly-studio ul { list-style: none; margin: 0; padding: 0; }\n.polly-studio .recordings, .polly-studio #entries ul { display: grid; gap: .3rem; }\n/* Each row is its own bordered card rather than a hover-only highlight: a recorded URL\n wraps to three or four lines often enough that without one, two rows read as one. */\n.polly-studio button.recording, .polly-studio button.entry {\n width: 100%; text-align: left; font: inherit;\n color: var(--ps-ink); background: var(--ps-surface);\n border: 1px solid var(--ps-line); border-radius: 8px;\n padding: .4rem .5rem; cursor: pointer;\n transition: background-color .12s ease, border-color .12s ease;\n}\n.polly-studio button.recording:hover, .polly-studio button.entry:hover {\n border-color: var(--ps-line-strong);\n}\n/* :focus, not :focus-visible \u2014 a click selects, and the selection has to be visible\n whether it came from the mouse or the keyboard. app.ts keeps no selected state. */\n.polly-studio button.recording:focus, .polly-studio button.entry:focus {\n outline: none; background: var(--ps-accent-soft); border-color: var(--ps-accent-line);\n}\n.polly-studio button.recording:focus-visible, .polly-studio button.entry:focus-visible {\n outline: 2px solid var(--ps-accent); outline-offset: 1px;\n}\n.polly-studio button.recording { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: .5rem; }\n.polly-studio .ps-rec-name { font-family: var(--ps-mono); font-size: .8125rem; overflow-wrap: anywhere; }\n.polly-studio .count {\n font-family: var(--ps-mono); font-size: .6875rem; color: var(--ps-ink-soft);\n background: var(--ps-paper); border: 1px solid var(--ps-line);\n border-radius: 999px; padding: 0 .42rem;\n}\n.polly-studio .ps-rec-note { margin: .1rem 0 .3rem .5rem; font-size: .75rem; line-height: 1.4; }\n\n/* Import form ------------------------------------------------------------ */\n.polly-studio .ps-import {\n display: grid; gap: .4rem;\n padding: .7rem .75rem; margin-top: .4rem;\n border-top: 1px solid var(--ps-line);\n}\n.polly-studio .ps-import label {\n font-size: .75rem; font-weight: 600; color: var(--ps-ink-soft);\n}\n.polly-studio .ps-import input {\n width: 100%; min-width: 0;\n font-family: var(--ps-mono); font-size: .8125rem; color: var(--ps-ink);\n background: var(--ps-surface);\n border: 1px solid var(--ps-line-strong); border-radius: 8px;\n padding: .4rem .55rem;\n transition: border-color .12s ease, box-shadow .12s ease;\n}\n.polly-studio .ps-import input:hover { border-color: #a9b5c6; }\n.polly-studio .ps-import input:focus {\n outline: none; border-color: var(--ps-accent); box-shadow: 0 0 0 3px var(--ps-accent-soft);\n}\n.polly-studio .ps-import input[type=\"file\"] { font-size: .75rem; padding: .3rem .4rem; }\n.polly-studio .ps-import button {\n font: inherit; font-size: .8125rem; font-weight: 600;\n justify-self: start;\n padding: .45rem .95rem; border-radius: 8px; cursor: pointer;\n border: 1px solid transparent;\n background: var(--ps-accent); color: #fff;\n transition: background-color .12s ease;\n}\n.polly-studio .ps-import button:hover { background: var(--ps-accent-deep); }\n.polly-studio .ps-import button:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }\n.polly-studio .ps-import-result {\n margin: .2rem 0 0; padding: .5rem .6rem; font-size: .78rem; line-height: 1.5;\n background: var(--ps-sunken); border: 1px solid var(--ps-line);\n border-left-width: 3px; border-radius: 0 8px 8px 0;\n}\n/* max-height + overflow-y: auto rather than relying on the pane's own scroll: the pane\n around this (.ps-pane) is overflow: hidden, so without its own scroll a long skip\n list would clip silently instead of growing the pane. */\n.polly-studio .ps-import-skips {\n display: grid; gap: .25rem; margin: .4rem 0 0;\n max-height: 10rem; overflow-y: auto;\n}\n.polly-studio .ps-import-skips li {\n padding-left: .5rem; border-left: 2px solid var(--ps-line-strong);\n font-size: .75rem; line-height: 1.4; color: var(--ps-ink-soft);\n}\n\n/* Request list --------------------------------------------------------- */\n.polly-studio button.entry {\n display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto;\n align-items: baseline; gap: .1rem .5rem;\n}\n.polly-studio .method {\n font-family: var(--ps-mono); font-size: .6875rem; font-weight: 700;\n letter-spacing: .02em; color: var(--ps-ink-soft); min-width: 2.6rem;\n}\n.polly-studio .status {\n font-family: var(--ps-mono); font-size: .6875rem; font-weight: 700;\n border: 1px solid; border-radius: 5px; padding: 0 .3rem;\n}\n.polly-studio .status.s2 { color: var(--ps-ok); background: var(--ps-ok-soft); border-color: var(--ps-ok-line); }\n.polly-studio .status.s3 { color: var(--ps-warn); background: var(--ps-warn-soft); border-color: var(--ps-warn-line); }\n.polly-studio .status.s4, .polly-studio .status.s5 { color: var(--ps-bad); background: var(--ps-bad-soft); border-color: var(--ps-bad-line); }\n.polly-studio .url { font-family: var(--ps-mono); font-size: .78rem; overflow-wrap: anywhere; }\n.polly-studio .order { font-family: var(--ps-mono); font-size: .6875rem; color: var(--ps-ink-faint); }\n\n/* Detail --------------------------------------------------------------- */\n.polly-studio .detail { display: grid; gap: .85rem; }\n.polly-studio .ps-block {\n border: 1px solid var(--ps-line); border-radius: 10px;\n padding: .85rem .95rem 1rem;\n}\n.polly-studio .ps-block--request { background: var(--ps-sunken); }\n.polly-studio .detail h2 {\n margin: 0 0 .45rem; font-size: .9375rem; font-weight: 650; letter-spacing: -.01em;\n display: flex; align-items: baseline; gap: .5rem;\n}\n.polly-studio .detail h2 small {\n font-size: .6875rem; font-weight: 500; color: var(--ps-ink-soft);\n background: var(--ps-paper); border: 1px solid var(--ps-line);\n border-radius: 999px; padding: .05rem .45rem;\n}\n.polly-studio .note { margin: 0 0 .75rem; font-size: .78rem; line-height: 1.5; color: var(--ps-ink-soft); max-width: 68ch; }\n.polly-studio dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .3rem .9rem; margin: 0; font-size: .8125rem; }\n.polly-studio dt { font-weight: 600; color: var(--ps-ink-soft); }\n.polly-studio dd { margin: 0; font-family: var(--ps-mono); overflow-wrap: anywhere; }\n.polly-studio .ps-group-title {\n margin: 1rem 0 .4rem; font-size: .75rem; font-weight: 600; color: var(--ps-ink-soft);\n}\n\n/* Fields --------------------------------------------------------------- */\n.polly-studio .ps-fields { display: grid; grid-template-columns: 7rem minmax(0, 1fr); gap: .55rem; }\n.polly-studio label.ps-field { display: grid; gap: .25rem; margin: 0; }\n.polly-studio .ps-field-label { font-size: .75rem; font-weight: 600; color: var(--ps-ink-soft); }\n.polly-studio .ps-input, .polly-studio textarea {\n width: 100%; min-width: 0;\n font-family: var(--ps-mono); font-size: .8125rem; color: var(--ps-ink);\n background: var(--ps-surface);\n border: 1px solid var(--ps-line-strong); border-radius: 8px;\n padding: .4rem .55rem;\n transition: border-color .12s ease, box-shadow .12s ease;\n}\n.polly-studio .ps-input:hover, .polly-studio textarea:hover, .polly-studio .header-row:hover { border-color: #a9b5c6; }\n.polly-studio .ps-input:focus, .polly-studio textarea:focus {\n outline: none; border-color: var(--ps-accent); box-shadow: 0 0 0 3px var(--ps-accent-soft);\n}\n.polly-studio textarea { display: block; line-height: 1.55; resize: vertical; min-height: 11rem; tab-size: 2; }\n\n/* A header is one name:value pair, so it is one control split by a hairline, not two\n inputs that happen to sit side by side. */\n.polly-studio .headers { display: grid; gap: .35rem; }\n.polly-studio .header-row {\n display: grid; grid-template-columns: minmax(6rem, 13rem) minmax(0, 1fr);\n background: var(--ps-surface);\n border: 1px solid var(--ps-line-strong); border-radius: 8px;\n overflow: hidden;\n transition: border-color .12s ease, box-shadow .12s ease;\n}\n.polly-studio .header-row:focus-within { border-color: var(--ps-accent); box-shadow: 0 0 0 3px var(--ps-accent-soft); }\n.polly-studio .header-row input {\n width: 100%; min-width: 0;\n font-family: var(--ps-mono); font-size: .8125rem; color: var(--ps-ink);\n background: transparent; border: 0; padding: .38rem .55rem;\n}\n.polly-studio .header-row input:focus { outline: none; }\n.polly-studio .header-row input[name^=\"headerName\"] {\n color: var(--ps-accent-deep); font-weight: 600;\n background: var(--ps-sunken); border-right: 1px solid var(--ps-line);\n}\n\n/* Actions -------------------------------------------------------------- */\n.polly-studio .actions {\n display: flex; flex-wrap: wrap; gap: .5rem;\n margin-top: 1rem; padding-top: .9rem;\n border-top: 1px solid var(--ps-line);\n}\n.polly-studio .actions button {\n font: inherit; font-size: .8125rem; font-weight: 600;\n padding: .45rem .95rem; border-radius: 8px; cursor: pointer;\n border: 1px solid transparent;\n transition: background-color .12s ease, border-color .12s ease;\n}\n.polly-studio .actions button[name=\"save\"] { background: var(--ps-accent); color: #fff; }\n.polly-studio .actions button[name=\"save\"]:hover { background: var(--ps-accent-deep); }\n.polly-studio .actions button.danger { background: var(--ps-surface); color: var(--ps-bad); border-color: var(--ps-bad-line); }\n.polly-studio .actions button.danger:hover { background: var(--ps-bad-soft); }\n.polly-studio .actions button:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }\n\n/* Notices and empty states --------------------------------------------- */\n.polly-studio .warn { color: var(--ps-warn); }\n.polly-studio .problem { color: var(--ps-bad); }\n.polly-studio .ps-notice {\n margin: 0; padding: .55rem .7rem; font-size: .8125rem; line-height: 1.5;\n background: var(--ps-warn-soft); border: 1px solid var(--ps-warn-line);\n border-left-width: 3px; border-radius: 0 8px 8px 0;\n}\n/* report() prepends a bare <p class=\"problem\"> here; it is the only alert the page has. */\n.polly-studio #detail > .problem {\n margin: 0 0 .85rem; padding: .55rem .7rem; font-size: .8125rem;\n background: var(--ps-bad-soft); border: 1px solid var(--ps-bad-line);\n border-left-width: 3px; border-radius: 0 8px 8px 0;\n}\n.polly-studio section.problem { margin: 0; }\n.polly-studio li.problem { padding: .4rem .5rem; font-size: .8125rem; }\n.polly-studio .empty {\n margin: 0; padding: 1.5rem .75rem; text-align: center;\n font-size: .8125rem; line-height: 1.55; color: var(--ps-ink-faint);\n}\n/* The same class inside the editor is a field standing in for a control, not a pane with\n nothing in it \u2014 centring it there would read as a broken row between two left-aligned\n groups. */\n.polly-studio .ps-block .empty { padding: .3rem 0 .1rem; text-align: left; }\n/* The two panes app.ts blanks rather than filling with a placeholder. ::before keeps the\n hint out of the markup, so nothing here has to be re-added after every innerHTML wipe. */\n.polly-studio #entries:empty::before, .polly-studio #detail:empty::before {\n display: block; padding: 1.75rem .75rem; text-align: center;\n font-size: .8125rem; color: var(--ps-ink-faint);\n}\n.polly-studio #entries:empty::before { content: \"Pick a recording to see the requests it captured.\"; }\n.polly-studio #detail:empty::before { content: \"Pick a request to edit the response it replays.\"; }\n\n@media (max-width: 72rem) {\n .polly-studio { height: auto; }\n .polly-studio .ps-panes { grid-template-columns: minmax(0, 1fr); }\n .polly-studio .ps-pane--list .ps-scroll { max-height: 18rem; }\n}\n@media (max-width: 34rem) {\n .polly-studio .ps-fields { grid-template-columns: minmax(0, 1fr); }\n .polly-studio .header-row { grid-template-columns: minmax(0, 1fr); }\n .polly-studio .header-row input[name^=\"headerName\"] { border-right: 0; border-bottom: 1px solid var(--ps-line); }\n}\n@media (prefers-reduced-motion: reduce) {\n .polly-studio * { transition: none !important; }\n}\n";
|
package/dist/studio/ui/page.js
CHANGED
|
@@ -17,6 +17,13 @@ export const STUDIO_MARKUP = `
|
|
|
17
17
|
<section class="ps-pane ps-pane--list">
|
|
18
18
|
<h2 class="ps-pane-title">Recordings</h2>
|
|
19
19
|
<nav id="recordings" class="ps-scroll">Loading…</nav>
|
|
20
|
+
<form id="import" class="ps-import">
|
|
21
|
+
<label for="import-name">Import a HAR from the swagger-recorder extension</label>
|
|
22
|
+
<input id="import-name" name="recordingName" placeholder="recording name, e.g. my-app" />
|
|
23
|
+
<input id="import-file" name="har" type="file" accept=".har,application/json" />
|
|
24
|
+
<button type="submit">Import</button>
|
|
25
|
+
<div id="import-report" aria-live="polite"></div>
|
|
26
|
+
</form>
|
|
20
27
|
</section>
|
|
21
28
|
<section class="ps-pane ps-pane--list">
|
|
22
29
|
<h2 class="ps-pane-title">Requests</h2>
|
|
@@ -210,6 +217,55 @@ export const STUDIO_STYLE = `
|
|
|
210
217
|
}
|
|
211
218
|
.polly-studio .ps-rec-note { margin: .1rem 0 .3rem .5rem; font-size: .75rem; line-height: 1.4; }
|
|
212
219
|
|
|
220
|
+
/* Import form ------------------------------------------------------------ */
|
|
221
|
+
.polly-studio .ps-import {
|
|
222
|
+
display: grid; gap: .4rem;
|
|
223
|
+
padding: .7rem .75rem; margin-top: .4rem;
|
|
224
|
+
border-top: 1px solid var(--ps-line);
|
|
225
|
+
}
|
|
226
|
+
.polly-studio .ps-import label {
|
|
227
|
+
font-size: .75rem; font-weight: 600; color: var(--ps-ink-soft);
|
|
228
|
+
}
|
|
229
|
+
.polly-studio .ps-import input {
|
|
230
|
+
width: 100%; min-width: 0;
|
|
231
|
+
font-family: var(--ps-mono); font-size: .8125rem; color: var(--ps-ink);
|
|
232
|
+
background: var(--ps-surface);
|
|
233
|
+
border: 1px solid var(--ps-line-strong); border-radius: 8px;
|
|
234
|
+
padding: .4rem .55rem;
|
|
235
|
+
transition: border-color .12s ease, box-shadow .12s ease;
|
|
236
|
+
}
|
|
237
|
+
.polly-studio .ps-import input:hover { border-color: #a9b5c6; }
|
|
238
|
+
.polly-studio .ps-import input:focus {
|
|
239
|
+
outline: none; border-color: var(--ps-accent); box-shadow: 0 0 0 3px var(--ps-accent-soft);
|
|
240
|
+
}
|
|
241
|
+
.polly-studio .ps-import input[type="file"] { font-size: .75rem; padding: .3rem .4rem; }
|
|
242
|
+
.polly-studio .ps-import button {
|
|
243
|
+
font: inherit; font-size: .8125rem; font-weight: 600;
|
|
244
|
+
justify-self: start;
|
|
245
|
+
padding: .45rem .95rem; border-radius: 8px; cursor: pointer;
|
|
246
|
+
border: 1px solid transparent;
|
|
247
|
+
background: var(--ps-accent); color: #fff;
|
|
248
|
+
transition: background-color .12s ease;
|
|
249
|
+
}
|
|
250
|
+
.polly-studio .ps-import button:hover { background: var(--ps-accent-deep); }
|
|
251
|
+
.polly-studio .ps-import button:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }
|
|
252
|
+
.polly-studio .ps-import-result {
|
|
253
|
+
margin: .2rem 0 0; padding: .5rem .6rem; font-size: .78rem; line-height: 1.5;
|
|
254
|
+
background: var(--ps-sunken); border: 1px solid var(--ps-line);
|
|
255
|
+
border-left-width: 3px; border-radius: 0 8px 8px 0;
|
|
256
|
+
}
|
|
257
|
+
/* max-height + overflow-y: auto rather than relying on the pane's own scroll: the pane
|
|
258
|
+
around this (.ps-pane) is overflow: hidden, so without its own scroll a long skip
|
|
259
|
+
list would clip silently instead of growing the pane. */
|
|
260
|
+
.polly-studio .ps-import-skips {
|
|
261
|
+
display: grid; gap: .25rem; margin: .4rem 0 0;
|
|
262
|
+
max-height: 10rem; overflow-y: auto;
|
|
263
|
+
}
|
|
264
|
+
.polly-studio .ps-import-skips li {
|
|
265
|
+
padding-left: .5rem; border-left: 2px solid var(--ps-line-strong);
|
|
266
|
+
font-size: .75rem; line-height: 1.4; color: var(--ps-ink-soft);
|
|
267
|
+
}
|
|
268
|
+
|
|
213
269
|
/* Request list --------------------------------------------------------- */
|
|
214
270
|
.polly-studio button.entry {
|
|
215
271
|
display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RecordingSummary } from '../stores/types.js';
|
|
1
|
+
import type { ImportResult, RecordingSummary } from '../stores/types.js';
|
|
2
2
|
import type { HarEntry } from '../types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Rendering is string-in, string-out and lives apart from the DOM so it can be tested
|
|
@@ -21,5 +21,19 @@ import type { HarEntry } from '../types.js';
|
|
|
21
21
|
*/
|
|
22
22
|
export declare function escapeHtml(value: string | number | boolean | null | undefined): string;
|
|
23
23
|
export declare function renderRecordingList(recordings: RecordingSummary[]): string;
|
|
24
|
+
/**
|
|
25
|
+
* What an import did, stated rather than implied.
|
|
26
|
+
*
|
|
27
|
+
* `replaced` is the line that has to be here: new-wins means an import can
|
|
28
|
+
* overwrite a response edited in this very UI, and the only thing standing
|
|
29
|
+
* between that and a silent loss is saying so.
|
|
30
|
+
*
|
|
31
|
+
* `pollyWillReject` is the other half: `ImportResult extends ReadResult`, so a
|
|
32
|
+
* successful import can still land in a file Polly's own `findRecording` will
|
|
33
|
+
* refuse wholesale — a hand-copied plain HAR whose `creator.name` is still
|
|
34
|
+
* `swagger-recorder`, say. Without this, the report says "Created" and nothing
|
|
35
|
+
* else, which reads as success right up until Polly disagrees.
|
|
36
|
+
*/
|
|
37
|
+
export declare function renderImportResult(result: ImportResult): string;
|
|
24
38
|
export declare function renderEntryRow(entry: HarEntry): string;
|
|
25
39
|
export declare function renderEntryDetail(entry: HarEntry): string;
|
package/dist/studio/ui/render.js
CHANGED
|
@@ -66,6 +66,34 @@ export function renderRecordingList(recordings) {
|
|
|
66
66
|
.join('');
|
|
67
67
|
return `<ul class="recordings">${rows}</ul>`;
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* What an import did, stated rather than implied.
|
|
71
|
+
*
|
|
72
|
+
* `replaced` is the line that has to be here: new-wins means an import can
|
|
73
|
+
* overwrite a response edited in this very UI, and the only thing standing
|
|
74
|
+
* between that and a silent loss is saying so.
|
|
75
|
+
*
|
|
76
|
+
* `pollyWillReject` is the other half: `ImportResult extends ReadResult`, so a
|
|
77
|
+
* successful import can still land in a file Polly's own `findRecording` will
|
|
78
|
+
* refuse wholesale — a hand-copied plain HAR whose `creator.name` is still
|
|
79
|
+
* `swagger-recorder`, say. Without this, the report says "Created" and nothing
|
|
80
|
+
* else, which reads as success right up until Polly disagrees.
|
|
81
|
+
*/
|
|
82
|
+
export function renderImportResult(result) {
|
|
83
|
+
const verb = result.created ? 'Created' : 'Updated';
|
|
84
|
+
const counts = [`added ${result.added}`, `replaced ${result.replaced}`];
|
|
85
|
+
if (result.skipped.length > 0)
|
|
86
|
+
counts.push(`skipped ${result.skipped.length}`);
|
|
87
|
+
const skips = result.skipped.length === 0
|
|
88
|
+
? ''
|
|
89
|
+
: `<ul class="ps-import-skips">${result.skipped
|
|
90
|
+
.map((skip) => `<li><code>${escapeHtml(skip.url)}</code> — ${escapeHtml(skip.reason)}</li>`)
|
|
91
|
+
.join('')}</ul>`;
|
|
92
|
+
const rejectWarning = result.pollyWillReject === null
|
|
93
|
+
? ''
|
|
94
|
+
: `<p class="ps-notice warn">Polly will reject this: ${escapeHtml(result.pollyWillReject)}</p>`;
|
|
95
|
+
return `<p class="ps-import-result">${verb} <code>${escapeHtml(result.id)}</code> — ${escapeHtml(counts.join(', '))}.</p>${skips}${rejectWarning}`;
|
|
96
|
+
}
|
|
69
97
|
/**
|
|
70
98
|
* The other half of the same problem `escapeHtml`'s `String(value)` closes, and the half
|
|
71
99
|
* stringifying a leaf cannot reach. `isHarShaped` checks only that `log.entries` is an
|
package/package.json
CHANGED