@avivbenhamo/polly-kit 0.5.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 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 = err instanceof StoreError && err.code === "conflict" ? "the recording changed underneath \u2014 reload and retry" : err instanceof Error ? err.message : String(err);
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
+ }
@@ -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';
@@ -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
+ }
@@ -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 unreachable while this handler calls only list/read/write, but
117
- // Task 11's HttpStore and any switch to `store.patchEntry` make it reachable — and
118
- // it would arrive as a binary-body refusal, which is a 422, not a 403.
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 MAX_BODY_BYTES = 4 * 1024 * 1024;
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 > MAX_BODY_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 ${MAX_BODY_BYTES} bytes` };
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 {
@@ -71,7 +71,9 @@ export interface HarLog {
71
71
  };
72
72
  _recordingName?: string;
73
73
  entries: HarEntry[];
74
- pages: unknown[];
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;
@@ -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
- function report(err) {
81
- const message = err instanceof StoreError && err.code === 'conflict'
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
@@ -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";
@@ -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;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avivbenhamo/polly-kit",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Drop-in record/replay of an app's HTTP traffic with Polly.JS, plus the polly-studio editor. Bring your own host policy.",
5
5
  "license": "MIT",
6
6
  "type": "module",