@avivbenhamo/polly-kit 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/studio/client.bundle.js +81 -1
- package/dist/studio/edit.d.ts +22 -0
- package/dist/studio/edit.js +35 -0
- package/dist/studio/import.d.ts +34 -0
- package/dist/studio/import.js +93 -0
- package/dist/studio/index.d.ts +4 -0
- package/dist/studio/index.js +2 -0
- package/dist/studio/recording-id.d.ts +16 -0
- package/dist/studio/recording-id.js +35 -0
- package/dist/studio/server.js +48 -9
- package/dist/studio/stores/apply.d.ts +13 -2
- package/dist/studio/stores/apply.js +68 -1
- package/dist/studio/stores/fs-store.d.ts +11 -1
- package/dist/studio/stores/fs-store.js +26 -1
- package/dist/studio/stores/http-store.d.ts +10 -1
- package/dist/studio/stores/http-store.js +15 -0
- package/dist/studio/stores/local-storage-store.d.ts +4 -1
- package/dist/studio/stores/local-storage-store.js +16 -1
- package/dist/studio/stores/types.d.ts +32 -1
- package/dist/studio/types.d.ts +3 -1
- package/dist/studio/ui/app.js +93 -3
- package/dist/studio/ui/page.d.ts +2 -2
- package/dist/studio/ui/page.js +93 -6
- package/dist/studio/ui/render.d.ts +15 -1
- package/dist/studio/ui/render.js +33 -0
- package/package.json +1 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { applyResponsePatch, deleteEntry as deleteEntryIn } from '../edit.js';
|
|
1
|
+
import { applyResponsePatch, deleteEntry as deleteEntryIn, duplicateEntry as duplicateEntryIn, } 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));
|
|
@@ -6,6 +9,9 @@ export function patchEntryVia(store, id, entryId, order, etag, patch) {
|
|
|
6
9
|
export function deleteEntryVia(store, id, entryId, order, etag) {
|
|
7
10
|
return applyEntryEdit(store, id, etag, (har) => deleteEntryIn(har, entryId, order));
|
|
8
11
|
}
|
|
12
|
+
export function duplicateEntryVia(store, id, entryId, order, etag) {
|
|
13
|
+
return applyEntryEdit(store, id, etag, (har) => duplicateEntryIn(har, entryId, order));
|
|
14
|
+
}
|
|
9
15
|
async function applyEntryEdit(store, id, etag, apply) {
|
|
10
16
|
const read = await store.read(id);
|
|
11
17
|
if (read.etag !== etag) {
|
|
@@ -18,3 +24,64 @@ async function applyEntryEdit(store, id, etag, apply) {
|
|
|
18
24
|
await store.write(id, result.har, read.etag);
|
|
19
25
|
return store.read(id);
|
|
20
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The read-merge-write cycle, shared by every store that owns its own storage.
|
|
29
|
+
*
|
|
30
|
+
* A corrupt existing file is refused rather than overwritten. `read` reports
|
|
31
|
+
* unparseable JSON as `not-found`, which is indistinguishable from "absent" at
|
|
32
|
+
* this layer, so absence is established by `list` instead — the one call that
|
|
33
|
+
* tells a recording that cannot be parsed apart from one that was never written.
|
|
34
|
+
*/
|
|
35
|
+
export async function importHarVia(store, recordingName, incoming) {
|
|
36
|
+
const badName = rejectRecordingName(recordingName);
|
|
37
|
+
if (badName !== null)
|
|
38
|
+
throw new StoreError('unprocessable', badName);
|
|
39
|
+
// Trimmed once, here, rather than left for `guidForRecording` to trim on its own:
|
|
40
|
+
// without this, a padded name would hash to the correct (trimmed) directory but
|
|
41
|
+
// `newRecording` below would still write the untrimmed name into `_recordingName`
|
|
42
|
+
// — a file that disagrees with its own location, and one that would then round-trip
|
|
43
|
+
// the padding straight back into the import form's prefill (`openRecording`, I-2b).
|
|
44
|
+
const name = recordingName.trim();
|
|
45
|
+
const rejection = rejectImport(incoming);
|
|
46
|
+
if (rejection !== null)
|
|
47
|
+
throw new StoreError('unprocessable', rejection);
|
|
48
|
+
const id = guidForRecording(name);
|
|
49
|
+
const summary = (await store.list()).find((candidate) => candidate.id === id);
|
|
50
|
+
if (summary !== undefined && summary.problem !== null) {
|
|
51
|
+
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`);
|
|
52
|
+
}
|
|
53
|
+
const existing = summary === undefined ? null : await store.read(id);
|
|
54
|
+
const outcome = mergeEntries(existing?.har.log.entries ?? [], incoming.log.entries);
|
|
55
|
+
if (existing === null) {
|
|
56
|
+
await store.create(id, newRecording(name, outcome.entries));
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
await store.write(id, { ...existing.har, log: { ...existing.har.log, entries: outcome.entries } }, existing.etag);
|
|
60
|
+
}
|
|
61
|
+
const after = await store.read(id);
|
|
62
|
+
return {
|
|
63
|
+
...after,
|
|
64
|
+
id,
|
|
65
|
+
added: outcome.added,
|
|
66
|
+
replaced: outcome.replaced,
|
|
67
|
+
skipped: outcome.skipped,
|
|
68
|
+
created: existing === null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The log shell real Polly writes, minus `browser` and `pages`: Polly emits them,
|
|
73
|
+
* nothing reads them, and swagger-recorder's Polly flavour already proves a
|
|
74
|
+
* recording without them replays.
|
|
75
|
+
*/
|
|
76
|
+
function newRecording(recordingName, entries) {
|
|
77
|
+
return {
|
|
78
|
+
log: {
|
|
79
|
+
_recordingName: recordingName,
|
|
80
|
+
version: '1.2',
|
|
81
|
+
// Asserted on by `Persister.findRecording`. Anything else and Polly asks
|
|
82
|
+
// the user to delete the file.
|
|
83
|
+
creator: { name: POLLY_CREATOR_NAME, version: '6.0.6', comment: 'persister:rest' },
|
|
84
|
+
entries,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -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
|
|
@@ -48,7 +49,16 @@ export declare class FsStore implements RecordingStore {
|
|
|
48
49
|
*/
|
|
49
50
|
delete(id: string): Promise<void>;
|
|
50
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>;
|
|
51
53
|
deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
|
|
54
|
+
/**
|
|
55
|
+
* `write` is read-compare-write and refuses a file that is not there, which is
|
|
56
|
+
* right for an edit and wrong for a creation. `wx` is what makes this a creation
|
|
57
|
+
* rather than a truncation: if the file appeared since `importHarVia` looked, the
|
|
58
|
+
* open fails instead of discarding whatever landed.
|
|
59
|
+
*/
|
|
60
|
+
create(id: string, har: Har): Promise<void>;
|
|
61
|
+
importHar(recordingName: string, incoming: Har): Promise<ImportResult>;
|
|
52
62
|
/**
|
|
53
63
|
* Polly's own node-server does `path.join(recordingsDir, recording, 'recording.har')`
|
|
54
64
|
* 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, duplicateEntryVia, importHarVia, patchEntryVia } from './apply.js';
|
|
8
8
|
export { StoreError } from './types.js';
|
|
9
9
|
const FILENAME = 'recording.har';
|
|
10
10
|
/**
|
|
@@ -151,9 +151,34 @@ 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
|
}
|
|
160
|
+
/**
|
|
161
|
+
* `write` is read-compare-write and refuses a file that is not there, which is
|
|
162
|
+
* right for an edit and wrong for a creation. `wx` is what makes this a creation
|
|
163
|
+
* rather than a truncation: if the file appeared since `importHarVia` looked, the
|
|
164
|
+
* open fails instead of discarding whatever landed.
|
|
165
|
+
*/
|
|
166
|
+
async create(id, har) {
|
|
167
|
+
const path = this.pathFor(id);
|
|
168
|
+
await mkdir(join(path, '..'), { recursive: true });
|
|
169
|
+
try {
|
|
170
|
+
await writeFile(path, serializeHar(har), { encoding: 'utf8', flag: 'wx' });
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
if (err.code === 'EEXIST') {
|
|
174
|
+
throw new StoreError('conflict', `recording ${id} appeared while it was being created — reload and retry`);
|
|
175
|
+
}
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
importHar(recordingName, incoming) {
|
|
180
|
+
return importHarVia(this, recordingName, incoming);
|
|
181
|
+
}
|
|
157
182
|
/**
|
|
158
183
|
* Polly's own node-server does `path.join(recordingsDir, recording, 'recording.har')`
|
|
159
184
|
* 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
|
|
@@ -25,6 +25,15 @@ 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>;
|
|
31
|
+
/**
|
|
32
|
+
* Not on the wire protocol: creation happens inside `/api/import`, server-side,
|
|
33
|
+
* the same way `write` happens inside PATCH. Declared so a caller holding a
|
|
34
|
+
* concrete HttpStore still type-checks against the interface's arity.
|
|
35
|
+
*/
|
|
36
|
+
create(id: string, har: Har): Promise<void>;
|
|
37
|
+
importHar(recordingName: string, incoming: Har): Promise<ImportResult>;
|
|
29
38
|
private json;
|
|
30
39
|
}
|
|
@@ -36,9 +36,24 @@ 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
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Not on the wire protocol: creation happens inside `/api/import`, server-side,
|
|
48
|
+
* the same way `write` happens inside PATCH. Declared so a caller holding a
|
|
49
|
+
* concrete HttpStore still type-checks against the interface's arity.
|
|
50
|
+
*/
|
|
51
|
+
async create(id, har) {
|
|
52
|
+
throw new StoreError('forbidden', 'the HTTP API creates recordings through import — use importHar');
|
|
53
|
+
}
|
|
54
|
+
async importHar(recordingName, incoming) {
|
|
55
|
+
return (await this.json('POST', '/api/import', { recordingName, har: incoming }));
|
|
56
|
+
}
|
|
42
57
|
async json(method, path, body) {
|
|
43
58
|
const res = await fetch(`${this.basePath}${path}`, {
|
|
44
59
|
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;
|
|
@@ -19,7 +19,10 @@ 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>;
|
|
24
|
+
create(id: string, har: Har): Promise<void>;
|
|
25
|
+
importHar(recordingName: string, incoming: Har): Promise<ImportResult>;
|
|
23
26
|
/** `null` means the key holds something that is not JSON, which `list` reports. */
|
|
24
27
|
private db;
|
|
25
28
|
}
|
|
@@ -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, 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,9 +90,24 @@ 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
|
}
|
|
99
|
+
async create(id, har) {
|
|
100
|
+
const db = this.db();
|
|
101
|
+
if (db === null)
|
|
102
|
+
throw new StoreError('unprocessable', `localStorage[${this.key}] is not valid JSON`);
|
|
103
|
+
if (id in db) {
|
|
104
|
+
throw new StoreError('conflict', `recording ${id} appeared while it was being created — reload and retry`);
|
|
105
|
+
}
|
|
106
|
+
this.storage.setItem(this.key, JSON.stringify({ ...db, [id]: har }));
|
|
107
|
+
}
|
|
108
|
+
importHar(recordingName, incoming) {
|
|
109
|
+
return importHarVia(this, recordingName, incoming);
|
|
110
|
+
}
|
|
96
111
|
/** `null` means the key holds something that is not JSON, which `list` reports. */
|
|
97
112
|
db() {
|
|
98
113
|
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,
|
|
@@ -26,7 +35,7 @@ export interface RecordingStore {
|
|
|
26
35
|
write(id: string, har: Har, etag: string): Promise<void>;
|
|
27
36
|
delete(id: string): Promise<void>;
|
|
28
37
|
/**
|
|
29
|
-
* The
|
|
38
|
+
* The three edits the UI performs, as store operations rather than as "read, apply, and
|
|
30
39
|
* hand a whole HAR back".
|
|
31
40
|
*
|
|
32
41
|
* They are on the interface so `mountStudio` never has to ask which store it holds.
|
|
@@ -37,6 +46,28 @@ 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
|
+
* 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>;
|
|
55
|
+
/**
|
|
56
|
+
* Write a recording that does not exist yet. Unlike `write` there is no etag to
|
|
57
|
+
* compare, because there is nothing to compare against — and it must fail rather
|
|
58
|
+
* than truncate if the file appeared since the caller last looked.
|
|
59
|
+
*/
|
|
60
|
+
create(id: string, har: Har): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Import a HAR from the swagger-recorder extension, creating the recording or
|
|
63
|
+
* merging into it.
|
|
64
|
+
*
|
|
65
|
+
* Takes a NAME, not an id: the name is what the user knows and what the app's
|
|
66
|
+
* Polly config holds, while the id is a hash the UI should never compute. On the
|
|
67
|
+
* interface for the same reason `patchEntry` and `deleteEntry` are — the
|
|
68
|
+
* read-merge-write cycle belongs on the server side of the network.
|
|
69
|
+
*/
|
|
70
|
+
importHar(recordingName: string, incoming: Har): Promise<ImportResult>;
|
|
40
71
|
}
|
|
41
72
|
export type StoreErrorCode = 'not-found' | 'conflict' | 'forbidden' | 'unprocessable';
|
|
42
73
|
export declare class StoreError extends Error {
|
package/dist/studio/types.d.ts
CHANGED
|
@@ -71,7 +71,9 @@ export interface HarLog {
|
|
|
71
71
|
};
|
|
72
72
|
_recordingName?: string;
|
|
73
73
|
entries: HarEntry[];
|
|
74
|
-
|
|
74
|
+
/** Optional: real Polly emits it, nothing in this package reads it, and the shell
|
|
75
|
+
* `newRecording` (`stores/apply.ts`) writes on import omits it entirely. */
|
|
76
|
+
pages?: unknown[];
|
|
75
77
|
browser?: {
|
|
76
78
|
name: string;
|
|
77
79
|
version: string;
|
package/dist/studio/ui/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { renderEntryDetail, renderEntryRow, renderRecordingList } from './render.js';
|
|
1
|
+
import { renderEntryDetail, renderEntryRow, renderImportResult, renderRecordingList, } from './render.js';
|
|
2
2
|
import { StoreError } from '../stores/types.js';
|
|
3
3
|
/**
|
|
4
4
|
* The page's behaviour, written against `RecordingStore` and nothing else. Under the
|
|
@@ -14,6 +14,58 @@ export function mountStudio(root, store) {
|
|
|
14
14
|
const recordings = required(root, '#recordings');
|
|
15
15
|
const entries = required(root, '#entries');
|
|
16
16
|
const detail = required(root, '#detail');
|
|
17
|
+
const importForm = root.querySelector('#import');
|
|
18
|
+
const importReport = root.querySelector('#import-report');
|
|
19
|
+
// Optional: the Node mount (`page.ts`'s `STUDIO_MARKUP`) and the browser-only mount
|
|
20
|
+
// (`browser.ts`, which injects that same `STUDIO_MARKUP`) both ship this form, so
|
|
21
|
+
// `#import` is present and `LocalStorageStore.importHar` reachable in either. The only
|
|
22
|
+
// root that lacks it is a test fake that predates this markup, and a missing form must
|
|
23
|
+
// not throw the whole page away before a single recording has been listed.
|
|
24
|
+
importForm?.addEventListener('submit', (event) => {
|
|
25
|
+
event.preventDefault();
|
|
26
|
+
void runImport();
|
|
27
|
+
});
|
|
28
|
+
async function runImport() {
|
|
29
|
+
if (importForm === null || importReport === null)
|
|
30
|
+
return;
|
|
31
|
+
// Cleared before the attempt, not just on success: a failed import must not leave
|
|
32
|
+
// the previous import's success line sitting next to a form that just failed — see
|
|
33
|
+
// Final whole-branch review, Finding I-4.
|
|
34
|
+
importReport.textContent = '';
|
|
35
|
+
try {
|
|
36
|
+
// The field reads live inside the try along with everything else: a missing
|
|
37
|
+
// `#import-name` or `#import-file` must surface the same way any other import
|
|
38
|
+
// failure does, not as an unhandled rejection — see Finding M-2.
|
|
39
|
+
const name = importForm.querySelector('#import-name').value;
|
|
40
|
+
const file = importForm.querySelector('#import-file').files?.[0];
|
|
41
|
+
if (file === undefined) {
|
|
42
|
+
throw new Error('choose a .har file to import');
|
|
43
|
+
}
|
|
44
|
+
// Parsed here rather than posted raw so a file that is not JSON fails with
|
|
45
|
+
// a message naming the file, before a 50 MB upload.
|
|
46
|
+
const har = JSON.parse(await file.text());
|
|
47
|
+
const result = await store.importHar(name, har);
|
|
48
|
+
importReport.innerHTML = renderImportResult(result);
|
|
49
|
+
try {
|
|
50
|
+
// A separate try: the import itself already succeeded and `importReport`
|
|
51
|
+
// already shows that. A failure refreshing the list or opening the result is
|
|
52
|
+
// a different problem — reported the normal way, rather than the outer catch
|
|
53
|
+
// overwriting the success line with what would read as the import having failed.
|
|
54
|
+
await refreshRecordings();
|
|
55
|
+
await openRecording(result.id);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
report(err);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
// Into `#import-report`, right next to the form that failed, rather than the
|
|
63
|
+
// distant `detail` banner `report()` uses for everything else — see Finding I-4.
|
|
64
|
+
// `textContent`, never `innerHTML`: this is an error message, not markup `render.ts`
|
|
65
|
+
// already escaped.
|
|
66
|
+
importReport.textContent = errorMessage(err);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
17
69
|
let currentId = null;
|
|
18
70
|
let current = null;
|
|
19
71
|
// `.catch(report)` rather than a bare `void`: an unawaited rejection here would be an
|
|
@@ -33,6 +85,19 @@ export function mountStudio(root, store) {
|
|
|
33
85
|
current = { har: read.har, etag: read.etag };
|
|
34
86
|
entries.innerHTML = `<ul>${read.har.log.entries.map(renderEntryRow).join('')}</ul>`;
|
|
35
87
|
detail.innerHTML = '';
|
|
88
|
+
// Spec §5.5: the import name prefills from the opened recording's `_recordingName`,
|
|
89
|
+
// so merging is one gesture and creating is one gesture plus typing a name. This is
|
|
90
|
+
// also the spec's own ergonomic defence against Finding I-2 (a padded name hashing
|
|
91
|
+
// to a different, identical-looking directory) — prefilling from a name Polly itself
|
|
92
|
+
// already wrote means fewer names get typed by hand at all.
|
|
93
|
+
const recordingName = read.har.log._recordingName;
|
|
94
|
+
// `importForm?.querySelector(...)` is `undefined`, not `null`, when `importForm`
|
|
95
|
+
// itself is absent — `=== null` alone would miss that and then dereference
|
|
96
|
+
// `undefined` below, so the guard checks `importForm` directly.
|
|
97
|
+
const nameField = importForm === null ? null : importForm.querySelector('#import-name');
|
|
98
|
+
if (nameField !== null && typeof recordingName === 'string' && recordingName !== '') {
|
|
99
|
+
nameField.value = recordingName;
|
|
100
|
+
}
|
|
36
101
|
}
|
|
37
102
|
/**
|
|
38
103
|
* `entryId` always arrives as a string, because it came off `dataset`. `e._id` is
|
|
@@ -66,6 +131,24 @@ export function mountStudio(root, store) {
|
|
|
66
131
|
report(err);
|
|
67
132
|
}
|
|
68
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
|
+
}
|
|
69
152
|
async function remove(entryId, order) {
|
|
70
153
|
if (currentId === null || current === null)
|
|
71
154
|
return;
|
|
@@ -77,12 +160,17 @@ export function mountStudio(root, store) {
|
|
|
77
160
|
report(err);
|
|
78
161
|
}
|
|
79
162
|
}
|
|
80
|
-
|
|
81
|
-
|
|
163
|
+
// Shared by `report` (the `detail`-pane banner) and `runImport`'s catch (`#import-report`):
|
|
164
|
+
// both want the same friendlier wording for a conflict, neither wants a raw stack string.
|
|
165
|
+
function errorMessage(err) {
|
|
166
|
+
return err instanceof StoreError && err.code === 'conflict'
|
|
82
167
|
? 'the recording changed underneath — reload and retry'
|
|
83
168
|
: err instanceof Error
|
|
84
169
|
? err.message
|
|
85
170
|
: String(err);
|
|
171
|
+
}
|
|
172
|
+
function report(err) {
|
|
173
|
+
const message = errorMessage(err);
|
|
86
174
|
// `document.createElement` would reach past `root` for a document `mountStudio`
|
|
87
175
|
// was never given — harmless today since the Node mount and the browser mount
|
|
88
176
|
// both happen to run in one document, but `root` exists precisely so this
|
|
@@ -124,6 +212,8 @@ export function mountStudio(root, store) {
|
|
|
124
212
|
const order = Number(section.dataset['order']);
|
|
125
213
|
if (target.getAttribute('name') === 'save')
|
|
126
214
|
void save(entryId, order);
|
|
215
|
+
if (target.getAttribute('name') === 'duplicate')
|
|
216
|
+
void duplicate(entryId, order);
|
|
127
217
|
if (target.getAttribute('name') === 'delete')
|
|
128
218
|
void remove(entryId, order);
|
|
129
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 <p class=\"warn banner\">Polly's <code>keepUnusedRequests</code> defaults to <code>false</code>:\
|
|
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/* 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";
|