@avivbenhamo/polly-kit 0.7.0 → 0.9.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 +12 -2
- package/dist/cli.d.ts +46 -0
- package/dist/cli.js +117 -3
- package/dist/studio/client.bundle.js +23 -0
- package/dist/studio/edit.d.ts +22 -0
- package/dist/studio/edit.js +35 -0
- package/dist/studio/server.js +8 -1
- package/dist/studio/stores/apply.d.ts +1 -0
- package/dist/studio/stores/apply.js +4 -1
- package/dist/studio/stores/fs-store.d.ts +1 -0
- package/dist/studio/stores/fs-store.js +4 -1
- package/dist/studio/stores/http-store.d.ts +2 -0
- package/dist/studio/stores/http-store.js +4 -0
- package/dist/studio/stores/local-storage-store.d.ts +1 -0
- package/dist/studio/stores/local-storage-store.js +4 -1
- package/dist/studio/stores/types.d.ts +7 -1
- package/dist/studio/ui/app.js +20 -0
- package/dist/studio/ui/page.d.ts +2 -2
- package/dist/studio/ui/page.js +47 -16
- package/dist/studio/ui/render.js +5 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -71,7 +71,17 @@ body, so a bare `startPolly()` call in `index.tsx` runs *after* `./App` and ever
|
|
|
71
71
|
imports — and any module that fires a request at import time would escape interception,
|
|
72
72
|
silently and only sometimes.
|
|
73
73
|
|
|
74
|
-
Add
|
|
74
|
+
Add the recordings directory to `.gitignore` — a HAR holds complete response bodies:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
npx polly-kit init # adds /recordings, or /apps/web/recordings
|
|
78
|
+
npx polly-kit init --dir fixtures/har # when that is not where you record
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Run it from the directory the app records in. The entry is anchored at the repository root
|
|
82
|
+
and written out in full — `/apps/web/recordings` from a package inside a monorepo, not
|
|
83
|
+
`/recordings`, which would match nothing there and look like it had. An entry already in the
|
|
84
|
+
file, in any spelling git honours, is left alone; running it twice adds one line.
|
|
75
85
|
|
|
76
86
|
## Running it
|
|
77
87
|
|
|
@@ -135,7 +145,7 @@ $env:POLLY_MODE='record'; npm run start:dev
|
|
|
135
145
|
```
|
|
136
146
|
|
|
137
147
|
No Vite plugin and no recording store: `persister-fs` writes the HAR straight to disk.
|
|
138
|
-
|
|
148
|
+
`npx polly-kit init` gitignores the directory.
|
|
139
149
|
|
|
140
150
|
**Use `{kind: 'only'}`, not `{kind: 'all'}`.** In a browser Polly sees the page's own
|
|
141
151
|
requests. In Node the adapters are process-wide: they also see your service talking to
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
export declare function main(argv: string[]): Promise<number>;
|
|
3
|
+
export interface IgnoreOptions {
|
|
4
|
+
/** Where the command was run. The repository is found by walking up from here. */
|
|
5
|
+
cwd?: string;
|
|
6
|
+
/** The recordings directory, as passed to `startPolly`. Default `recordings`. */
|
|
7
|
+
dir?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface IgnoreResult {
|
|
10
|
+
/**
|
|
11
|
+
* `added` — the entry was appended.
|
|
12
|
+
* `covered` — a rule already matching the directory was there; nothing written.
|
|
13
|
+
* `outside` — the directory is not under the repository, so no rule can name it.
|
|
14
|
+
* `root` — the directory *is* the repository, which is not something to ignore.
|
|
15
|
+
*/
|
|
16
|
+
action: 'added' | 'covered' | 'outside' | 'root';
|
|
17
|
+
/** The `.gitignore` written, or the one that would have been. */
|
|
18
|
+
path: string;
|
|
19
|
+
/** The entry, as written or as it would be written. Empty when `outside`. */
|
|
20
|
+
entry: string;
|
|
21
|
+
/** The repository root this was resolved against. */
|
|
22
|
+
root: string;
|
|
23
|
+
/** The recordings directory, absolute. */
|
|
24
|
+
recordingsDir: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Adds the recordings directory to the repository's `.gitignore`.
|
|
28
|
+
*
|
|
29
|
+
* Two things here are deliberate and were the reason for the command.
|
|
30
|
+
*
|
|
31
|
+
* The entry is anchored at the repository root and spelled out in full —
|
|
32
|
+
* `/apps/web/recordings`, not `/recordings` — because the recordings directory
|
|
33
|
+
* is configured in the app's own code and so lives wherever the app does. A bare
|
|
34
|
+
* `/recordings` at the root of a monorepo matches nothing in `apps/web`, and the
|
|
35
|
+
* failure is silent: the file looks like it handled them and the HARs get
|
|
36
|
+
* committed anyway.
|
|
37
|
+
*
|
|
38
|
+
* Coverage is checked against every spelling git would honour, because the entry
|
|
39
|
+
* has been written by hand from the README for longer than this command has
|
|
40
|
+
* existed, and this package's own docs have asked for it as both `/recordings`
|
|
41
|
+
* and `recordings/`. `recordings` and `recordings/` have no leading slash, so git
|
|
42
|
+
* matches them at any depth and they already cover the directory — appending a
|
|
43
|
+
* second rule under one of them is a duplicate somebody then has to delete.
|
|
44
|
+
*
|
|
45
|
+
* Negated (`!recordings`) and commented-out entries are not coverage. Reading
|
|
46
|
+
* either as "already handled" would leave the recordings committed.
|
|
47
|
+
*/
|
|
48
|
+
export declare function ignoreRecordings(options?: IgnoreOptions): IgnoreResult;
|
|
3
49
|
export interface StudioOptions {
|
|
4
50
|
recordingsDir: string;
|
|
5
51
|
port?: number;
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* The
|
|
3
|
+
* The two things that are done to a checkout rather than inside an app: serving
|
|
4
|
+
* the studio with no app running (`studio`), and keeping the recordings out of
|
|
5
|
+
* git (`init`).
|
|
6
|
+
*
|
|
7
|
+
* On `init`, see `ignoreRecordings`. The rest of this is the studio.
|
|
4
8
|
*
|
|
5
9
|
* The mounted form stays the default and is the better one when there *is* a dev
|
|
6
10
|
* server: `app.use('/__polly', pollyUi(...))` — or the Vite plugin, which does
|
|
@@ -18,8 +22,8 @@
|
|
|
18
22
|
* see those.
|
|
19
23
|
*/
|
|
20
24
|
import { createServer } from 'node:http';
|
|
21
|
-
import { existsSync } from 'node:fs';
|
|
22
|
-
import { isAbsolute, join, resolve } from 'node:path';
|
|
25
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
23
27
|
import { fileURLToPath } from 'node:url';
|
|
24
28
|
import { pollyUi } from './studio/server.js';
|
|
25
29
|
const DEFAULT_PORT = 7788;
|
|
@@ -33,11 +37,16 @@ const PORT_TRIES = 20;
|
|
|
33
37
|
const USAGE = `polly-kit — Polly recordings, in a browser
|
|
34
38
|
|
|
35
39
|
npx polly-kit studio [--dir DIR] [--port PORT]
|
|
40
|
+
npx polly-kit init [--dir DIR]
|
|
36
41
|
|
|
37
42
|
studio Serve the recording editor on 127.0.0.1. Reads and writes the HAR
|
|
38
43
|
files under DIR (default ${DEFAULT_DIR}), the same ones the mounted
|
|
39
44
|
editor edits.
|
|
40
45
|
|
|
46
|
+
init Add DIR to the repository's .gitignore. A HAR holds complete
|
|
47
|
+
request and response bodies, so recordings are not something to
|
|
48
|
+
commit by accident. Safe to run twice.
|
|
49
|
+
|
|
41
50
|
Mounted in a dev server instead — no second port, and it stops when the server
|
|
42
51
|
does — this is the Vite plugin, or app.use('/__polly', pollyUi({recordingsDir})).
|
|
43
52
|
Recordings held in a browser's localStorage are reachable only from the page
|
|
@@ -49,6 +58,24 @@ export async function main(argv) {
|
|
|
49
58
|
process.stdout.write(USAGE);
|
|
50
59
|
return 0;
|
|
51
60
|
}
|
|
61
|
+
if (command === 'init' || command === 'setup') {
|
|
62
|
+
const result = ignoreRecordings({ dir: flag(argv, 'dir') });
|
|
63
|
+
// Every outcome is exit 0. This runs from a README in some trees and from a
|
|
64
|
+
// postinstall in others, and "already done" is the steady state in both.
|
|
65
|
+
if (result.action === 'added') {
|
|
66
|
+
process.stdout.write(`added to ${result.path}: ${result.entry}\n`);
|
|
67
|
+
}
|
|
68
|
+
else if (result.action === 'covered') {
|
|
69
|
+
process.stdout.write(`${result.entry} is already ignored by ${result.path}\n`);
|
|
70
|
+
}
|
|
71
|
+
else if (result.action === 'root') {
|
|
72
|
+
process.stdout.write(`${result.root} is the repository root, not a recordings directory — pass --dir\n`);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
process.stdout.write(`${result.recordingsDir} is outside ${result.root} — nothing to ignore there\n`);
|
|
76
|
+
}
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
52
79
|
if (command !== 'studio') {
|
|
53
80
|
process.stderr.write(`unknown command: ${command}\n\n${USAGE}`);
|
|
54
81
|
return 2;
|
|
@@ -79,6 +106,93 @@ export async function main(argv) {
|
|
|
79
106
|
await studio.close();
|
|
80
107
|
return 0;
|
|
81
108
|
}
|
|
109
|
+
const IGNORE_COMMENT = '# @avivbenhamo/polly-kit: recorded traffic. A HAR holds complete request and\n# response bodies, including anything the responses carried.';
|
|
110
|
+
/**
|
|
111
|
+
* Adds the recordings directory to the repository's `.gitignore`.
|
|
112
|
+
*
|
|
113
|
+
* Two things here are deliberate and were the reason for the command.
|
|
114
|
+
*
|
|
115
|
+
* The entry is anchored at the repository root and spelled out in full —
|
|
116
|
+
* `/apps/web/recordings`, not `/recordings` — because the recordings directory
|
|
117
|
+
* is configured in the app's own code and so lives wherever the app does. A bare
|
|
118
|
+
* `/recordings` at the root of a monorepo matches nothing in `apps/web`, and the
|
|
119
|
+
* failure is silent: the file looks like it handled them and the HARs get
|
|
120
|
+
* committed anyway.
|
|
121
|
+
*
|
|
122
|
+
* Coverage is checked against every spelling git would honour, because the entry
|
|
123
|
+
* has been written by hand from the README for longer than this command has
|
|
124
|
+
* existed, and this package's own docs have asked for it as both `/recordings`
|
|
125
|
+
* and `recordings/`. `recordings` and `recordings/` have no leading slash, so git
|
|
126
|
+
* matches them at any depth and they already cover the directory — appending a
|
|
127
|
+
* second rule under one of them is a duplicate somebody then has to delete.
|
|
128
|
+
*
|
|
129
|
+
* Negated (`!recordings`) and commented-out entries are not coverage. Reading
|
|
130
|
+
* either as "already handled" would leave the recordings committed.
|
|
131
|
+
*/
|
|
132
|
+
export function ignoreRecordings(options = {}) {
|
|
133
|
+
const cwd = options.cwd ?? process.cwd();
|
|
134
|
+
const dir = options.dir ?? DEFAULT_DIR;
|
|
135
|
+
const recordingsDir = isAbsolute(dir) ? resolve(dir) : resolve(cwd, dir);
|
|
136
|
+
// No git command is run: `.git` is a directory in a normal checkout and a file
|
|
137
|
+
// in a worktree or submodule, and either one marks the root. Falling back to
|
|
138
|
+
// cwd covers the directory that is not a repository yet.
|
|
139
|
+
const root = gitRoot(cwd) ?? resolve(cwd);
|
|
140
|
+
const path = join(root, '.gitignore');
|
|
141
|
+
const within = relative(root, recordingsDir);
|
|
142
|
+
if (within === '') {
|
|
143
|
+
return { action: 'root', path, entry: '', root, recordingsDir };
|
|
144
|
+
}
|
|
145
|
+
if (within.startsWith('..') || isAbsolute(within)) {
|
|
146
|
+
return { action: 'outside', path, entry: '', root, recordingsDir };
|
|
147
|
+
}
|
|
148
|
+
const entry = `/${within.split(sep).join('/')}`;
|
|
149
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
150
|
+
if (covers(existing, entry)) {
|
|
151
|
+
return { action: 'covered', path, entry, root, recordingsDir };
|
|
152
|
+
}
|
|
153
|
+
// The file's own line ending, not the platform's: git on Windows checks out a
|
|
154
|
+
// CRLF `.gitignore`, and appending LF lines to it shows the whole file as
|
|
155
|
+
// changed in the diff.
|
|
156
|
+
const eol = existing.includes('\r\n') ? '\r\n' : '\n';
|
|
157
|
+
const lead = existing === '' || /(\r?\n){2}$/.test(existing)
|
|
158
|
+
? ''
|
|
159
|
+
: /\r?\n$/.test(existing)
|
|
160
|
+
? eol
|
|
161
|
+
: `${eol}${eol}`;
|
|
162
|
+
const block = `${IGNORE_COMMENT}\n${entry}\n`.replace(/\n/g, eol);
|
|
163
|
+
writeFileSync(path, `${existing}${lead}${block}`, 'utf8');
|
|
164
|
+
return { action: 'added', path, entry, root, recordingsDir };
|
|
165
|
+
}
|
|
166
|
+
/** Nearest ancestor of `from` holding a `.git`, itself included. */
|
|
167
|
+
function gitRoot(from) {
|
|
168
|
+
let at = resolve(from);
|
|
169
|
+
for (;;) {
|
|
170
|
+
if (existsSync(join(at, '.git')))
|
|
171
|
+
return at;
|
|
172
|
+
const up = dirname(at);
|
|
173
|
+
if (up === at)
|
|
174
|
+
return undefined;
|
|
175
|
+
at = up;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Does any rule in `contents` already ignore `entry` (`/a/b/recordings`)? */
|
|
179
|
+
function covers(contents, entry) {
|
|
180
|
+
const wanted = entry.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
181
|
+
const leaf = wanted.slice(wanted.lastIndexOf('/') + 1);
|
|
182
|
+
return contents.split(/\r?\n/).some((raw) => {
|
|
183
|
+
const line = raw.trim();
|
|
184
|
+
if (line === '' || line.startsWith('#') || line.startsWith('!'))
|
|
185
|
+
return false;
|
|
186
|
+
// A leading `**/` is git's explicit spelling of "at any depth", which is
|
|
187
|
+
// what a bare name means anyway.
|
|
188
|
+
const rule = line.replace(/^\*\*\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
189
|
+
// Anchored by a leading slash, or by a slash anywhere inside it: git matches
|
|
190
|
+
// those against the path from the .gitignore's own directory, so it has to be
|
|
191
|
+
// the same path. Otherwise the rule matches at every depth and the last
|
|
192
|
+
// segment of the path is enough.
|
|
193
|
+
return line.startsWith('/') || rule.includes('/') ? rule === wanted : rule === leaf;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
82
196
|
/**
|
|
83
197
|
* The server, without the process around it.
|
|
84
198
|
*
|
|
@@ -111,8 +111,13 @@ function entryDetail(entry) {
|
|
|
111
111
|
|
|
112
112
|
<div class="actions">
|
|
113
113
|
<button name="save">Save response</button>
|
|
114
|
+
<button name="duplicate" title="Append a copy to this request's group, replayed the next time the same request is made">Duplicate for next call</button>
|
|
114
115
|
<button name="delete" class="danger">Delete entry</button>
|
|
115
116
|
</div>
|
|
117
|
+
<p class="note">Duplicating adds a second answer to the <em>same</em> request:
|
|
118
|
+
Polly replays <code>_order 0</code> on the first call, this copy on the second.
|
|
119
|
+
It cannot become a different request \u2014 the <code>_id</code> is a hash of the
|
|
120
|
+
request, and an edited URL under the old hash would match nothing.</p>
|
|
116
121
|
</div>
|
|
117
122
|
</section>`;
|
|
118
123
|
}
|
|
@@ -199,6 +204,18 @@ function mountStudio(root, store) {
|
|
|
199
204
|
report(err);
|
|
200
205
|
}
|
|
201
206
|
}
|
|
207
|
+
async function duplicate(entryId, order) {
|
|
208
|
+
if (currentId === null || current === null)
|
|
209
|
+
return;
|
|
210
|
+
try {
|
|
211
|
+
const { har } = await store.duplicateEntry(currentId, entryId, order, current.etag);
|
|
212
|
+
const added = har.log.entries.filter((entry) => String(entry._id) === entryId).length - 1;
|
|
213
|
+
await openRecording(currentId);
|
|
214
|
+
openEntry(entryId, added);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
report(err);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
202
219
|
async function remove(entryId, order) {
|
|
203
220
|
if (currentId === null || current === null)
|
|
204
221
|
return;
|
|
@@ -242,6 +259,8 @@ function mountStudio(root, store) {
|
|
|
242
259
|
const order = Number(section.dataset["order"]);
|
|
243
260
|
if (target.getAttribute("name") === "save")
|
|
244
261
|
void save(entryId, order);
|
|
262
|
+
if (target.getAttribute("name") === "duplicate")
|
|
263
|
+
void duplicate(entryId, order);
|
|
245
264
|
if (target.getAttribute("name") === "delete")
|
|
246
265
|
void remove(entryId, order);
|
|
247
266
|
});
|
|
@@ -309,6 +328,10 @@ var HttpStore = class {
|
|
|
309
328
|
async patchEntry(id, entryId, order, etag, patch) {
|
|
310
329
|
return await this.json("PATCH", `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}`, { etag, patch });
|
|
311
330
|
}
|
|
331
|
+
/** POST to the entry being copied: this adds a member to its group. */
|
|
332
|
+
async duplicateEntry(id, entryId, order, etag) {
|
|
333
|
+
return await this.json("POST", `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`);
|
|
334
|
+
}
|
|
312
335
|
async deleteEntry(id, entryId, order, etag) {
|
|
313
336
|
return await this.json("DELETE", `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`);
|
|
314
337
|
}
|
package/dist/studio/edit.d.ts
CHANGED
|
@@ -29,6 +29,28 @@ export type EditResult = {
|
|
|
29
29
|
* wrong rather than merely stale.
|
|
30
30
|
*/
|
|
31
31
|
export declare function applyResponsePatch(har: Har, id: string, order: number, patch: ResponsePatch): EditResult;
|
|
32
|
+
/**
|
|
33
|
+
* Copies an entry to the end of its own `_id` group — "the *next* time this same
|
|
34
|
+
* request happens, reply with this".
|
|
35
|
+
*
|
|
36
|
+
* `_order` is a position within a group of identical requests, not a label:
|
|
37
|
+
* Polly derives it at replay by counting how many earlier requests produced the
|
|
38
|
+
* same `_id`, starting at 0. So a recording with two members of a group answers
|
|
39
|
+
* the first call with `_order: 0` and the second with `_order: 1`. Duplicating
|
|
40
|
+
* appends a member, which is the only honest meaning "duplicate" can have here.
|
|
41
|
+
*
|
|
42
|
+
* What it deliberately is **not** is a way to make a *different* request. The
|
|
43
|
+
* `_id` is an md5 over the request that this package never recomputes — see
|
|
44
|
+
* `import.ts` — so a copy with an edited URL would carry the original's id,
|
|
45
|
+
* match nothing at replay, and fail silently. The copy keeps the id, and the
|
|
46
|
+
* caller edits the response.
|
|
47
|
+
*
|
|
48
|
+
* The clone is inserted directly after the last member of its group rather than
|
|
49
|
+
* appended to the file, because the list is shown in file order and a copy that
|
|
50
|
+
* appeared at the bottom, pages away from its original, reads as a new request
|
|
51
|
+
* somebody else recorded.
|
|
52
|
+
*/
|
|
53
|
+
export declare function duplicateEntry(har: Har, id: string, order: number): EditResult;
|
|
32
54
|
export declare function notFound(id: string, order: number): string;
|
|
33
55
|
/**
|
|
34
56
|
* Removes one entry and closes the gap it leaves in its `_id` group.
|
package/dist/studio/edit.js
CHANGED
|
@@ -62,6 +62,41 @@ export function applyResponsePatch(har, id, order, patch) {
|
|
|
62
62
|
}
|
|
63
63
|
return { ok: true, har: next };
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Copies an entry to the end of its own `_id` group — "the *next* time this same
|
|
67
|
+
* request happens, reply with this".
|
|
68
|
+
*
|
|
69
|
+
* `_order` is a position within a group of identical requests, not a label:
|
|
70
|
+
* Polly derives it at replay by counting how many earlier requests produced the
|
|
71
|
+
* same `_id`, starting at 0. So a recording with two members of a group answers
|
|
72
|
+
* the first call with `_order: 0` and the second with `_order: 1`. Duplicating
|
|
73
|
+
* appends a member, which is the only honest meaning "duplicate" can have here.
|
|
74
|
+
*
|
|
75
|
+
* What it deliberately is **not** is a way to make a *different* request. The
|
|
76
|
+
* `_id` is an md5 over the request that this package never recomputes — see
|
|
77
|
+
* `import.ts` — so a copy with an edited URL would carry the original's id,
|
|
78
|
+
* match nothing at replay, and fail silently. The copy keeps the id, and the
|
|
79
|
+
* caller edits the response.
|
|
80
|
+
*
|
|
81
|
+
* The clone is inserted directly after the last member of its group rather than
|
|
82
|
+
* appended to the file, because the list is shown in file order and a copy that
|
|
83
|
+
* appeared at the bottom, pages away from its original, reads as a new request
|
|
84
|
+
* somebody else recorded.
|
|
85
|
+
*/
|
|
86
|
+
export function duplicateEntry(har, id, order) {
|
|
87
|
+
const source = findEntry(har, id, order);
|
|
88
|
+
if (source === undefined) {
|
|
89
|
+
return { ok: false, error: { code: 'entry-not-found', message: notFound(id, order) } };
|
|
90
|
+
}
|
|
91
|
+
const next = structuredClone(har);
|
|
92
|
+
const entries = next.log.entries;
|
|
93
|
+
const group = entries.filter((entry) => entry._id === id);
|
|
94
|
+
const last = group.reduce((at, entry) => Math.max(at, entries.indexOf(entry)), -1);
|
|
95
|
+
const copy = structuredClone(findEntry(next, id, order));
|
|
96
|
+
copy._order = group.length;
|
|
97
|
+
entries.splice(last + 1, 0, copy);
|
|
98
|
+
return { ok: true, har: next };
|
|
99
|
+
}
|
|
65
100
|
export function notFound(id, order) {
|
|
66
101
|
return `no entry with _id ${id} and _order ${order} — it may have been deleted since the page loaded`;
|
|
67
102
|
}
|
package/dist/studio/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { FsStore } from './stores/fs-store.js';
|
|
5
5
|
import { StoreError } from './stores/types.js';
|
|
6
|
-
import { applyResponsePatch, deleteEntry } from './edit.js';
|
|
6
|
+
import { applyResponsePatch, deleteEntry, duplicateEntry } from './edit.js';
|
|
7
7
|
import { renderPage } from './ui/page.js';
|
|
8
8
|
/**
|
|
9
9
|
* A mountable editor for Polly recordings, in the style of swagger-ui: the app serves
|
|
@@ -131,6 +131,13 @@ async function handle(req, res, ctx) {
|
|
|
131
131
|
await mutate(ctx, res, id, etag, (har) => deleteEntry(har, entryId, order));
|
|
132
132
|
return;
|
|
133
133
|
}
|
|
134
|
+
// POST, not PUT: this creates a member of the group rather than
|
|
135
|
+
// replacing one, and the URL it is posted to is the entry being copied.
|
|
136
|
+
if (req.method === 'POST') {
|
|
137
|
+
const etag = url.searchParams.get('etag') ?? '';
|
|
138
|
+
await mutate(ctx, res, id, etag, (har) => duplicateEntry(har, entryId, order));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
134
141
|
}
|
|
135
142
|
}
|
|
136
143
|
sendJson(res, 404, { error: 'not found' });
|
|
@@ -3,6 +3,7 @@ import type { Har, ResponsePatch } from '../types.js';
|
|
|
3
3
|
type Local = Pick<RecordingStore, 'read' | 'write'>;
|
|
4
4
|
export declare function patchEntryVia(store: Local, id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
|
|
5
5
|
export declare function deleteEntryVia(store: Local, id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
6
|
+
export declare function duplicateEntryVia(store: Local, id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
6
7
|
type Importable = Pick<RecordingStore, 'read' | 'write' | 'create'>;
|
|
7
8
|
/**
|
|
8
9
|
* The read-merge-write cycle, shared by every store that owns its own storage.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { applyResponsePatch, deleteEntry as deleteEntryIn } from '../edit.js';
|
|
1
|
+
import { applyResponsePatch, deleteEntry as deleteEntryIn, duplicateEntry as duplicateEntryIn, } from '../edit.js';
|
|
2
2
|
import { POLLY_CREATOR_NAME } from '../har.js';
|
|
3
3
|
import { mergeEntries, rejectImport } from '../import.js';
|
|
4
4
|
import { guidForRecording, rejectRecordingName } from '../recording-id.js';
|
|
@@ -9,6 +9,9 @@ export function patchEntryVia(store, id, entryId, order, etag, patch) {
|
|
|
9
9
|
export function deleteEntryVia(store, id, entryId, order, etag) {
|
|
10
10
|
return applyEntryEdit(store, id, etag, (har) => deleteEntryIn(har, entryId, order));
|
|
11
11
|
}
|
|
12
|
+
export function duplicateEntryVia(store, id, entryId, order, etag) {
|
|
13
|
+
return applyEntryEdit(store, id, etag, (har) => duplicateEntryIn(har, entryId, order));
|
|
14
|
+
}
|
|
12
15
|
async function applyEntryEdit(store, id, etag, apply) {
|
|
13
16
|
const read = await store.read(id);
|
|
14
17
|
if (read.etag !== etag) {
|
|
@@ -49,6 +49,7 @@ export declare class FsStore implements RecordingStore {
|
|
|
49
49
|
*/
|
|
50
50
|
delete(id: string): Promise<void>;
|
|
51
51
|
patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
|
|
52
|
+
duplicateEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
52
53
|
deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
53
54
|
/**
|
|
54
55
|
* `write` is read-compare-write and refuses a file that is not there, which is
|
|
@@ -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, importHarVia, patchEntryVia } from './apply.js';
|
|
7
|
+
import { deleteEntryVia, duplicateEntryVia, importHarVia, patchEntryVia } from './apply.js';
|
|
8
8
|
export { StoreError } from './types.js';
|
|
9
9
|
const FILENAME = 'recording.har';
|
|
10
10
|
/**
|
|
@@ -151,6 +151,9 @@ export class FsStore {
|
|
|
151
151
|
patchEntry(id, entryId, order, etag, patch) {
|
|
152
152
|
return patchEntryVia(this, id, entryId, order, etag, patch);
|
|
153
153
|
}
|
|
154
|
+
duplicateEntry(id, entryId, order, etag) {
|
|
155
|
+
return duplicateEntryVia(this, id, entryId, order, etag);
|
|
156
|
+
}
|
|
154
157
|
deleteEntry(id, entryId, order, etag) {
|
|
155
158
|
return deleteEntryVia(this, id, entryId, order, etag);
|
|
156
159
|
}
|
|
@@ -25,6 +25,8 @@ export declare class HttpStore implements RecordingStore {
|
|
|
25
25
|
write(id: string, har: Har, etag: string): Promise<void>;
|
|
26
26
|
delete(id: string): Promise<void>;
|
|
27
27
|
patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
|
|
28
|
+
/** POST to the entry being copied: this adds a member to its group. */
|
|
29
|
+
duplicateEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
28
30
|
deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
29
31
|
/**
|
|
30
32
|
* Not on the wire protocol: creation happens inside `/api/import`, server-side,
|
|
@@ -36,6 +36,10 @@ export class HttpStore {
|
|
|
36
36
|
async patchEntry(id, entryId, order, etag, patch) {
|
|
37
37
|
return (await this.json('PATCH', `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}`, { etag, patch }));
|
|
38
38
|
}
|
|
39
|
+
/** POST to the entry being copied: this adds a member to its group. */
|
|
40
|
+
async duplicateEntry(id, entryId, order, etag) {
|
|
41
|
+
return (await this.json('POST', `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`));
|
|
42
|
+
}
|
|
39
43
|
async deleteEntry(id, entryId, order, etag) {
|
|
40
44
|
return (await this.json('DELETE', `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`));
|
|
41
45
|
}
|
|
@@ -19,6 +19,7 @@ export declare class LocalStorageStore implements RecordingStore {
|
|
|
19
19
|
write(id: string, har: Har, etag: string): Promise<void>;
|
|
20
20
|
delete(id: string): Promise<void>;
|
|
21
21
|
patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
|
|
22
|
+
duplicateEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
22
23
|
deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
23
24
|
create(id: string, har: Har): Promise<void>;
|
|
24
25
|
importHar(recordingName: string, incoming: Har): Promise<ImportResult>;
|
|
@@ -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, importHarVia, patchEntryVia } from './apply.js';
|
|
4
|
+
import { deleteEntryVia, duplicateEntryVia, 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
|
/**
|
|
@@ -90,6 +90,9 @@ export class LocalStorageStore {
|
|
|
90
90
|
patchEntry(id, entryId, order, etag, patch) {
|
|
91
91
|
return patchEntryVia(this, id, entryId, order, etag, patch);
|
|
92
92
|
}
|
|
93
|
+
duplicateEntry(id, entryId, order, etag) {
|
|
94
|
+
return duplicateEntryVia(this, id, entryId, order, etag);
|
|
95
|
+
}
|
|
93
96
|
deleteEntry(id, entryId, order, etag) {
|
|
94
97
|
return deleteEntryVia(this, id, entryId, order, etag);
|
|
95
98
|
}
|
|
@@ -35,7 +35,7 @@ export interface RecordingStore {
|
|
|
35
35
|
write(id: string, har: Har, etag: string): Promise<void>;
|
|
36
36
|
delete(id: string): Promise<void>;
|
|
37
37
|
/**
|
|
38
|
-
* The
|
|
38
|
+
* The three edits the UI performs, as store operations rather than as "read, apply, and
|
|
39
39
|
* hand a whole HAR back".
|
|
40
40
|
*
|
|
41
41
|
* They are on the interface so `mountStudio` never has to ask which store it holds.
|
|
@@ -46,6 +46,12 @@ export interface RecordingStore {
|
|
|
46
46
|
*/
|
|
47
47
|
patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
|
|
48
48
|
deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
49
|
+
/**
|
|
50
|
+
* Copy an entry to the end of its own `_id` group: the next identical request
|
|
51
|
+
* replays this one instead. Never a way to make a *different* request — see
|
|
52
|
+
* `duplicateEntry` in edit.ts.
|
|
53
|
+
*/
|
|
54
|
+
duplicateEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
49
55
|
/**
|
|
50
56
|
* Write a recording that does not exist yet. Unlike `write` there is no etag to
|
|
51
57
|
* compare, because there is nothing to compare against — and it must fail rather
|
package/dist/studio/ui/app.js
CHANGED
|
@@ -131,6 +131,24 @@ export function mountStudio(root, store) {
|
|
|
131
131
|
report(err);
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* The copy lands at the end of its group, and that is the one we open: the
|
|
136
|
+
* entry you were looking at is unchanged, and the thing you are about to edit
|
|
137
|
+
* is the new answer, not the old one.
|
|
138
|
+
*/
|
|
139
|
+
async function duplicate(entryId, order) {
|
|
140
|
+
if (currentId === null || current === null)
|
|
141
|
+
return;
|
|
142
|
+
try {
|
|
143
|
+
const { har } = await store.duplicateEntry(currentId, entryId, order, current.etag);
|
|
144
|
+
const added = har.log.entries.filter((entry) => String(entry._id) === entryId).length - 1;
|
|
145
|
+
await openRecording(currentId);
|
|
146
|
+
openEntry(entryId, added);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
report(err);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
134
152
|
async function remove(entryId, order) {
|
|
135
153
|
if (currentId === null || current === null)
|
|
136
154
|
return;
|
|
@@ -194,6 +212,8 @@ export function mountStudio(root, store) {
|
|
|
194
212
|
const order = Number(section.dataset['order']);
|
|
195
213
|
if (target.getAttribute('name') === 'save')
|
|
196
214
|
void save(entryId, order);
|
|
215
|
+
if (target.getAttribute('name') === 'duplicate')
|
|
216
|
+
void duplicate(entryId, order);
|
|
197
217
|
if (target.getAttribute('name') === 'delete')
|
|
198
218
|
void remove(entryId, order);
|
|
199
219
|
});
|
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 <
|
|
5
|
+
export declare const STUDIO_MARKUP = "\n<div class=\"polly-studio\">\n<!--\n Import sits beside the title, not inside the recordings list.\n It makes a recording rather than picking one, so under the list it read as a\n footer of that list \u2014 something you do *to* the thing above it \u2014 and it\n pushed the list it was supposedly part of into a shorter and shorter box.\n-->\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 <form id=\"import\" class=\"ps-import\">\n <h2 class=\"ps-import-title\">Import</h2>\n <label for=\"import-name\">A HAR from the swagger-recorder extension</label>\n <div class=\"ps-import-row\">\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>\n <div id=\"import-report\" aria-live=\"polite\"></div>\n </form>\n</header>\n<p class=\"warn banner\">Polly's <code>keepUnusedRequests</code> defaults to <code>false</code>:\nthe next record run deletes every entry it did not exercise, including the ones you edit here.\nSet <code>persisterOptions.keepUnusedRequests: true</code> before editing.</p>\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>";
|
|
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/* 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";
|
|
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-lift: 0 1px 2px rgba(17,23,34,.05), 0 10px 22px -14px rgba(17,23,34,.35);\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/* The title on the left, import on the right, and the warning on its own line\n underneath: three things competing for one row left the import squeezed into\n whatever the banner had not taken. */\n.polly-studio .ps-bar {\n display: flex; flex-wrap: wrap; align-items: flex-start;\n justify-content: space-between; gap: .9rem 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 margin: .9rem 1.25rem 0; 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 /* Lifted off the paper, so where one region ends and the next begins is a\n shadow rather than a hairline somebody has to look for. */\n box-shadow: var(--ps-lift);\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/* A card of its own in the title bar. Bounded width so a long page does not\n stretch it across the screen, and wide enough that the three controls sit on\n one line at the sizes this is actually used at. */\n.polly-studio .ps-import {\n display: grid; gap: .35rem;\n flex: 0 1 34rem; min-width: 17rem;\n padding: .6rem .7rem;\n background: var(--ps-sunken);\n border: 1px solid var(--ps-line); border-radius: 12px;\n box-shadow: var(--ps-lift);\n}\n.polly-studio .ps-import-title {\n margin: 0; font-size: .8125rem; font-weight: 650; color: var(--ps-ink);\n}\n.polly-studio .ps-import-row {\n display: grid; gap: .4rem;\n grid-template-columns: minmax(7rem, 1fr) minmax(8rem, 1.2fr) auto;\n align-items: center;\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: stretch; white-space: nowrap;\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-import-row { grid-template-columns: minmax(0, 1fr); }\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
|
@@ -4,26 +4,35 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export const STUDIO_MARKUP = `
|
|
6
6
|
<div class="polly-studio">
|
|
7
|
+
<!--
|
|
8
|
+
Import sits beside the title, not inside the recordings list.
|
|
9
|
+
It makes a recording rather than picking one, so under the list it read as a
|
|
10
|
+
footer of that list — something you do *to* the thing above it — and it
|
|
11
|
+
pushed the list it was supposedly part of into a shorter and shorter box.
|
|
12
|
+
-->
|
|
7
13
|
<header class="ps-bar">
|
|
8
14
|
<div class="ps-brand">
|
|
9
15
|
<h1>polly-studio</h1>
|
|
10
16
|
<p class="ps-tagline">Change what a recorded request replies with, then save it back to the HAR.</p>
|
|
11
17
|
</div>
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
<form id="import" class="ps-import">
|
|
19
|
+
<h2 class="ps-import-title">Import</h2>
|
|
20
|
+
<label for="import-name">A HAR from the swagger-recorder extension</label>
|
|
21
|
+
<div class="ps-import-row">
|
|
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>
|
|
26
|
+
<div id="import-report" aria-live="polite"></div>
|
|
27
|
+
</form>
|
|
15
28
|
</header>
|
|
29
|
+
<p class="warn banner">Polly's <code>keepUnusedRequests</code> defaults to <code>false</code>:
|
|
30
|
+
the next record run deletes every entry it did not exercise, including the ones you edit here.
|
|
31
|
+
Set <code>persisterOptions.keepUnusedRequests: true</code> before editing.</p>
|
|
16
32
|
<main class="ps-panes">
|
|
17
33
|
<section class="ps-pane ps-pane--list">
|
|
18
34
|
<h2 class="ps-pane-title">Recordings</h2>
|
|
19
35
|
<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>
|
|
27
36
|
</section>
|
|
28
37
|
<section class="ps-pane ps-pane--list">
|
|
29
38
|
<h2 class="ps-pane-title">Requests</h2>
|
|
@@ -122,6 +131,7 @@ export const STUDIO_STYLE = `
|
|
|
122
131
|
--ps-ok: #0a6b3d; --ps-ok-line: #b6ddc6; --ps-ok-soft: #e8f4ed;
|
|
123
132
|
--ps-warn: #875700; --ps-warn-line: #ecd3a3; --ps-warn-soft: #fbf2e1;
|
|
124
133
|
--ps-bad: #b3261e; --ps-bad-line: #edbdb9; --ps-bad-soft: #fceceb;
|
|
134
|
+
--ps-lift: 0 1px 2px rgba(17,23,34,.05), 0 10px 22px -14px rgba(17,23,34,.35);
|
|
125
135
|
--ps-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
126
136
|
|
|
127
137
|
color-scheme: light;
|
|
@@ -137,8 +147,12 @@ export const STUDIO_STYLE = `
|
|
|
137
147
|
}
|
|
138
148
|
|
|
139
149
|
/* Title bar ------------------------------------------------------------ */
|
|
150
|
+
/* The title on the left, import on the right, and the warning on its own line
|
|
151
|
+
underneath: three things competing for one row left the import squeezed into
|
|
152
|
+
whatever the banner had not taken. */
|
|
140
153
|
.polly-studio .ps-bar {
|
|
141
|
-
display: flex; flex-wrap: wrap; align-items:
|
|
154
|
+
display: flex; flex-wrap: wrap; align-items: flex-start;
|
|
155
|
+
justify-content: space-between; gap: .9rem 1.75rem;
|
|
142
156
|
padding: .8rem 1.25rem;
|
|
143
157
|
background: var(--ps-surface);
|
|
144
158
|
border-bottom: 1px solid var(--ps-line);
|
|
@@ -147,8 +161,7 @@ export const STUDIO_STYLE = `
|
|
|
147
161
|
.polly-studio h1 { margin: 0; font-size: 1.0625rem; font-weight: 650; letter-spacing: -.015em; }
|
|
148
162
|
.polly-studio .ps-tagline { margin: 0; font-size: .8125rem; color: var(--ps-ink-soft); }
|
|
149
163
|
.polly-studio .banner {
|
|
150
|
-
|
|
151
|
-
padding: .45rem .7rem;
|
|
164
|
+
margin: .9rem 1.25rem 0; padding: .45rem .7rem;
|
|
152
165
|
font-size: .78rem; line-height: 1.45;
|
|
153
166
|
background: var(--ps-warn-soft);
|
|
154
167
|
border: 1px solid var(--ps-warn-line); border-left-width: 3px;
|
|
@@ -170,6 +183,9 @@ export const STUDIO_STYLE = `
|
|
|
170
183
|
display: flex; flex-direction: column; min-height: 0;
|
|
171
184
|
border: 1px solid var(--ps-line); border-radius: 12px;
|
|
172
185
|
background: var(--ps-surface);
|
|
186
|
+
/* Lifted off the paper, so where one region ends and the next begins is a
|
|
187
|
+
shadow rather than a hairline somebody has to look for. */
|
|
188
|
+
box-shadow: var(--ps-lift);
|
|
173
189
|
overflow: hidden;
|
|
174
190
|
}
|
|
175
191
|
/* The two browsers read as one surface you pick from; the editor reads as the page you
|
|
@@ -218,10 +234,24 @@ export const STUDIO_STYLE = `
|
|
|
218
234
|
.polly-studio .ps-rec-note { margin: .1rem 0 .3rem .5rem; font-size: .75rem; line-height: 1.4; }
|
|
219
235
|
|
|
220
236
|
/* Import form ------------------------------------------------------------ */
|
|
237
|
+
/* A card of its own in the title bar. Bounded width so a long page does not
|
|
238
|
+
stretch it across the screen, and wide enough that the three controls sit on
|
|
239
|
+
one line at the sizes this is actually used at. */
|
|
221
240
|
.polly-studio .ps-import {
|
|
241
|
+
display: grid; gap: .35rem;
|
|
242
|
+
flex: 0 1 34rem; min-width: 17rem;
|
|
243
|
+
padding: .6rem .7rem;
|
|
244
|
+
background: var(--ps-sunken);
|
|
245
|
+
border: 1px solid var(--ps-line); border-radius: 12px;
|
|
246
|
+
box-shadow: var(--ps-lift);
|
|
247
|
+
}
|
|
248
|
+
.polly-studio .ps-import-title {
|
|
249
|
+
margin: 0; font-size: .8125rem; font-weight: 650; color: var(--ps-ink);
|
|
250
|
+
}
|
|
251
|
+
.polly-studio .ps-import-row {
|
|
222
252
|
display: grid; gap: .4rem;
|
|
223
|
-
|
|
224
|
-
|
|
253
|
+
grid-template-columns: minmax(7rem, 1fr) minmax(8rem, 1.2fr) auto;
|
|
254
|
+
align-items: center;
|
|
225
255
|
}
|
|
226
256
|
.polly-studio .ps-import label {
|
|
227
257
|
font-size: .75rem; font-weight: 600; color: var(--ps-ink-soft);
|
|
@@ -241,7 +271,7 @@ export const STUDIO_STYLE = `
|
|
|
241
271
|
.polly-studio .ps-import input[type="file"] { font-size: .75rem; padding: .3rem .4rem; }
|
|
242
272
|
.polly-studio .ps-import button {
|
|
243
273
|
font: inherit; font-size: .8125rem; font-weight: 600;
|
|
244
|
-
justify-self:
|
|
274
|
+
justify-self: stretch; white-space: nowrap;
|
|
245
275
|
padding: .45rem .95rem; border-radius: 8px; cursor: pointer;
|
|
246
276
|
border: 1px solid transparent;
|
|
247
277
|
background: var(--ps-accent); color: #fff;
|
|
@@ -406,6 +436,7 @@ export const STUDIO_STYLE = `
|
|
|
406
436
|
.polly-studio .ps-pane--list .ps-scroll { max-height: 18rem; }
|
|
407
437
|
}
|
|
408
438
|
@media (max-width: 34rem) {
|
|
439
|
+
.polly-studio .ps-import-row { grid-template-columns: minmax(0, 1fr); }
|
|
409
440
|
.polly-studio .ps-fields { grid-template-columns: minmax(0, 1fr); }
|
|
410
441
|
.polly-studio .header-row { grid-template-columns: minmax(0, 1fr); }
|
|
411
442
|
.polly-studio .header-row input[name^="headerName"] { border-right: 0; border-bottom: 1px solid var(--ps-line); }
|
package/dist/studio/ui/render.js
CHANGED
|
@@ -192,8 +192,13 @@ function entryDetail(entry) {
|
|
|
192
192
|
|
|
193
193
|
<div class="actions">
|
|
194
194
|
<button name="save">Save response</button>
|
|
195
|
+
<button name="duplicate" title="Append a copy to this request's group, replayed the next time the same request is made">Duplicate for next call</button>
|
|
195
196
|
<button name="delete" class="danger">Delete entry</button>
|
|
196
197
|
</div>
|
|
198
|
+
<p class="note">Duplicating adds a second answer to the <em>same</em> request:
|
|
199
|
+
Polly replays <code>_order 0</code> on the first call, this copy on the second.
|
|
200
|
+
It cannot become a different request — the <code>_id</code> is a hash of the
|
|
201
|
+
request, and an edited URL under the old hash would match nothing.</p>
|
|
197
202
|
</div>
|
|
198
203
|
</section>`;
|
|
199
204
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avivbenhamo/polly-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"dist",
|
|
13
13
|
"README.md"
|
|
14
14
|
],
|
|
15
|
-
"//bin": "
|
|
15
|
+
"//bin": "`studio` on its own, for a checkout with no dev server to mount it in — the mounted form stays the default — and `init`, which gitignores the recordings directory.",
|
|
16
16
|
"bin": {
|
|
17
17
|
"polly-kit": "./dist/cli.js"
|
|
18
18
|
},
|