@avivbenhamo/polly-kit 0.1.0 → 0.3.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/LICENSE +21 -0
- package/README.md +148 -10
- package/dist/adapters/encoding.d.ts +7 -0
- package/dist/adapters/encoding.js +46 -0
- package/dist/adapters/undici.d.ts +36 -0
- package/dist/adapters/undici.js +231 -0
- package/dist/browser.js +5 -19
- package/dist/defaults.d.ts +40 -0
- package/dist/defaults.js +40 -0
- package/dist/node.d.ts +56 -0
- package/dist/node.js +108 -0
- package/dist/studio/body.d.ts +28 -0
- package/dist/studio/body.js +66 -0
- package/dist/studio/browser.d.ts +22 -0
- package/dist/studio/browser.js +24 -0
- package/dist/studio/client.bundle.js +290 -0
- package/dist/studio/edit.d.ts +42 -0
- package/dist/studio/edit.js +90 -0
- package/dist/studio/etag.d.ts +6 -0
- package/dist/studio/etag.js +14 -0
- package/dist/studio/har.d.ts +19 -0
- package/dist/studio/har.js +36 -0
- package/dist/studio/headers.d.ts +31 -0
- package/dist/studio/headers.js +59 -0
- package/dist/studio/index.d.ts +9 -0
- package/dist/studio/index.js +5 -0
- package/dist/studio/server.d.ts +35 -0
- package/dist/studio/server.js +262 -0
- package/dist/studio/stores/apply.d.ts +6 -0
- package/dist/studio/stores/apply.js +20 -0
- package/dist/studio/stores/fs-store.d.ts +70 -0
- package/dist/studio/stores/fs-store.js +186 -0
- package/dist/studio/stores/http-store.d.ts +30 -0
- package/dist/studio/stores/http-store.js +67 -0
- package/dist/studio/stores/local-storage-store.d.ts +25 -0
- package/dist/studio/stores/local-storage-store.js +109 -0
- package/dist/studio/stores/types.d.ts +45 -0
- package/dist/studio/stores/types.js +7 -0
- package/dist/studio/types.d.ts +92 -0
- package/dist/studio/types.js +7 -0
- package/dist/studio/ui/app.d.ts +12 -0
- package/dist/studio/ui/app.js +180 -0
- package/dist/studio/ui/client.d.ts +2 -0
- package/dist/studio/ui/client.js +6 -0
- package/dist/studio/ui/page.d.ts +53 -0
- package/dist/studio/ui/page.js +360 -0
- package/dist/studio/ui/render.d.ts +25 -0
- package/dist/studio/ui/render.js +171 -0
- package/dist/vite.js +1 -1
- package/package.json +49 -7
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { renderEntryDetail, renderEntryRow, renderRecordingList } from './render.js';
|
|
2
|
+
import { StoreError } from '../stores/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The page's behaviour, written against `RecordingStore` and nothing else. Under the
|
|
5
|
+
* Node mount the store is an `HttpStore`; in a browser-only app it is a
|
|
6
|
+
* `LocalStorageStore`. Nothing below can tell the difference — no branch on which store
|
|
7
|
+
* it holds, which is why `patchEntry` and `deleteEntry` are on the interface rather than
|
|
8
|
+
* being assembled here out of `read` and `write`.
|
|
9
|
+
*
|
|
10
|
+
* Every mutation re-reads afterwards rather than trusting the local copy: the etag that
|
|
11
|
+
* the next save depends on comes from the store, never from here.
|
|
12
|
+
*/
|
|
13
|
+
export function mountStudio(root, store) {
|
|
14
|
+
const recordings = required(root, '#recordings');
|
|
15
|
+
const entries = required(root, '#entries');
|
|
16
|
+
const detail = required(root, '#detail');
|
|
17
|
+
let currentId = null;
|
|
18
|
+
let current = null;
|
|
19
|
+
// `.catch(report)` rather than a bare `void`: an unawaited rejection here would be an
|
|
20
|
+
// unhandled rejection the page never shows, and the user would be left staring at
|
|
21
|
+
// whatever placeholder markup the page shipped with — see Task 11 review, Finding 1.
|
|
22
|
+
refreshRecordings().catch(report);
|
|
23
|
+
async function refreshRecordings() {
|
|
24
|
+
recordings.innerHTML = renderRecordingList(await store.list());
|
|
25
|
+
}
|
|
26
|
+
async function openRecording(id) {
|
|
27
|
+
// `currentId` moves only once the read has actually succeeded. Setting it before
|
|
28
|
+
// the `await` would leave a failed open pointing `currentId` at a recording whose
|
|
29
|
+
// `har`/`etag` were never fetched, while `current` still held the previous one — a
|
|
30
|
+
// state no other code path expects, and one a subsequent Save could act on.
|
|
31
|
+
const read = await store.read(id);
|
|
32
|
+
currentId = id;
|
|
33
|
+
current = { har: read.har, etag: read.etag };
|
|
34
|
+
entries.innerHTML = `<ul>${read.har.log.entries.map(renderEntryRow).join('')}</ul>`;
|
|
35
|
+
detail.innerHTML = '';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* `entryId` always arrives as a string, because it came off `dataset`. `e._id` is
|
|
39
|
+
* whatever the file says — `isHarShaped` (`src/har.ts`) checks only that `log.entries`
|
|
40
|
+
* is an array — so a HAR with a numeric `_id` renders a row, emits `data-id="12345"`,
|
|
41
|
+
* and then fails `12345 === '12345'`. Comparing the string forms makes the click
|
|
42
|
+
* resolve; reporting the miss means the cases that still do not resolve say so instead
|
|
43
|
+
* of blanking the pane, which is indistinguishable from a dead button.
|
|
44
|
+
*/
|
|
45
|
+
function openEntry(entryId, order) {
|
|
46
|
+
const entry = current?.har.log.entries.find((e) => String(e._id) === entryId && e._order === order);
|
|
47
|
+
if (entry === undefined) {
|
|
48
|
+
detail.innerHTML = '';
|
|
49
|
+
report(new Error(`no entry with _id ${entryId} and _order ${order} in this recording`));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
detail.innerHTML = renderEntryDetail(entry);
|
|
53
|
+
}
|
|
54
|
+
async function save(entryId, order) {
|
|
55
|
+
if (currentId === null || current === null)
|
|
56
|
+
return;
|
|
57
|
+
try {
|
|
58
|
+
// Reading the form back can itself fail — an out-of-range status, say — so it
|
|
59
|
+
// shares this try/catch rather than running ahead of it unguarded.
|
|
60
|
+
const patch = readPatch(detail, entryId, order, current.har);
|
|
61
|
+
await store.patchEntry(currentId, entryId, order, current.etag, patch);
|
|
62
|
+
await openRecording(currentId);
|
|
63
|
+
openEntry(entryId, order);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
report(err);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function remove(entryId, order) {
|
|
70
|
+
if (currentId === null || current === null)
|
|
71
|
+
return;
|
|
72
|
+
try {
|
|
73
|
+
await store.deleteEntry(currentId, entryId, order, current.etag);
|
|
74
|
+
await openRecording(currentId);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
report(err);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function report(err) {
|
|
81
|
+
const message = err instanceof StoreError && err.code === 'conflict'
|
|
82
|
+
? 'the recording changed underneath — reload and retry'
|
|
83
|
+
: err instanceof Error
|
|
84
|
+
? err.message
|
|
85
|
+
: String(err);
|
|
86
|
+
// `document.createElement` would reach past `root` for a document `mountStudio`
|
|
87
|
+
// was never given — harmless today since the Node mount and the browser mount
|
|
88
|
+
// both happen to run in one document, but `root` exists precisely so this
|
|
89
|
+
// function need not assume that. `detail`'s own document is always the right one.
|
|
90
|
+
const banner = detail.ownerDocument.createElement('p');
|
|
91
|
+
banner.className = 'problem';
|
|
92
|
+
banner.textContent = message;
|
|
93
|
+
detail.prepend(banner);
|
|
94
|
+
}
|
|
95
|
+
root.addEventListener('click', (event) => {
|
|
96
|
+
const target = event.target?.closest('button');
|
|
97
|
+
if (target === null || target === undefined)
|
|
98
|
+
return;
|
|
99
|
+
if (target.classList.contains('recording')) {
|
|
100
|
+
openRecording(target.dataset['id']).catch(report);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (target.classList.contains('entry')) {
|
|
104
|
+
// The one synchronous call in this listener, and so the one that `.catch(report)`
|
|
105
|
+
// cannot cover. Without this it was also the only unguarded one: a throw out of
|
|
106
|
+
// `renderEntryDetail` became an uncaught exception with no banner and a detail
|
|
107
|
+
// pane still showing the previously opened entry — the reader has no way to tell
|
|
108
|
+
// the click did nothing from the click having worked. `renderEntryDetail` now
|
|
109
|
+
// guards itself per entry (`src/ui/render.ts`), which makes this the second line
|
|
110
|
+
// of defence rather than the only one; it stays because "every failure in this
|
|
111
|
+
// listener reports" should not depend on which function happens to be total.
|
|
112
|
+
try {
|
|
113
|
+
openEntry(target.dataset['id'], Number(target.dataset['order']));
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
report(err);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const section = target.closest('.detail');
|
|
121
|
+
if (section === null)
|
|
122
|
+
return;
|
|
123
|
+
const entryId = section.dataset['id'];
|
|
124
|
+
const order = Number(section.dataset['order']);
|
|
125
|
+
if (target.getAttribute('name') === 'save')
|
|
126
|
+
void save(entryId, order);
|
|
127
|
+
if (target.getAttribute('name') === 'delete')
|
|
128
|
+
void remove(entryId, order);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/** `root.querySelector` returns `Element | null`; this is the "or throw" version. */
|
|
132
|
+
function required(root, selector) {
|
|
133
|
+
const el = root.querySelector(selector);
|
|
134
|
+
if (el === null) {
|
|
135
|
+
throw new Error(`polly-studio: mountStudio could not find "${selector}" in the given root`);
|
|
136
|
+
}
|
|
137
|
+
return el;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Reads the form back into a patch. `bodyText` is omitted for a binary entry — there is
|
|
141
|
+
* no textarea for one — so a save on a binary body changes status and headers and
|
|
142
|
+
* leaves the recorded bytes alone.
|
|
143
|
+
*
|
|
144
|
+
* A cleared `status` input reports `.value === ''`, not `undefined` — `Number('')` is
|
|
145
|
+
* `0`, and nothing downstream (`applyResponsePatch` in `edit.ts`) range checks it, so an
|
|
146
|
+
* unguarded read would silently persist `response.status = 0`. `''` is treated as "no
|
|
147
|
+
* status change requested", the same as the field being absent; a value that parses but
|
|
148
|
+
* falls outside 100–599 is treated as a bad edit and reported the way a rejected save
|
|
149
|
+
* already is, through `report`, via the throw `save` catches.
|
|
150
|
+
*/
|
|
151
|
+
function readPatch(root, entryId, order, har) {
|
|
152
|
+
const value = (name) => root.querySelector(`[name="${name}"]`)?.value;
|
|
153
|
+
const headers = [];
|
|
154
|
+
for (let index = 0;; index++) {
|
|
155
|
+
const name = value(`headerName${index}`);
|
|
156
|
+
if (name === undefined)
|
|
157
|
+
break;
|
|
158
|
+
const existing = har.log.entries
|
|
159
|
+
// String-compared for the same reason as `openEntry`: `entryId` came off `dataset`.
|
|
160
|
+
.find((e) => String(e._id) === entryId && e._order === order)
|
|
161
|
+
?.response.headers[index];
|
|
162
|
+
headers.push({
|
|
163
|
+
...(existing?._fromType === 'array' ? { _fromType: 'array' } : {}),
|
|
164
|
+
name,
|
|
165
|
+
value: value(`headerValue${index}`) ?? '',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const rawStatus = value('status');
|
|
169
|
+
const status = rawStatus === undefined || rawStatus === '' ? undefined : Number(rawStatus);
|
|
170
|
+
if (status !== undefined && (!Number.isFinite(status) || status < 100 || status > 599)) {
|
|
171
|
+
throw new Error(`status must be a number between 100 and 599, got "${rawStatus}"`);
|
|
172
|
+
}
|
|
173
|
+
const bodyText = value('bodyText');
|
|
174
|
+
return {
|
|
175
|
+
...(status === undefined ? {} : { status }),
|
|
176
|
+
...(value('statusText') === undefined ? {} : { statusText: value('statusText') }),
|
|
177
|
+
headers,
|
|
178
|
+
...(bodyText === undefined ? {} : { bodyText }),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The body, shared with the browser-only mount in Task 12 so the two cannot drift into
|
|
3
|
+
* two different editors.
|
|
4
|
+
*/
|
|
5
|
+
export declare const STUDIO_MARKUP = "\n<div class=\"polly-studio\">\n<header class=\"ps-bar\">\n <div class=\"ps-brand\">\n <h1>polly-studio</h1>\n <p class=\"ps-tagline\">Change what a recorded request replies with, then save it back to the HAR.</p>\n </div>\n <p class=\"warn banner\">Polly's <code>keepUnusedRequests</code> defaults to <code>false</code>:\n the next record run deletes every entry it did not exercise, including the ones you edit here.\n Set <code>persisterOptions.keepUnusedRequests: true</code> before editing.</p>\n</header>\n<main class=\"ps-panes\">\n <section class=\"ps-pane ps-pane--list\">\n <h2 class=\"ps-pane-title\">Recordings</h2>\n <nav id=\"recordings\" class=\"ps-scroll\">Loading\u2026</nav>\n </section>\n <section class=\"ps-pane ps-pane--list\">\n <h2 class=\"ps-pane-title\">Requests</h2>\n <div id=\"entries\" class=\"ps-scroll\"></div>\n </section>\n <section class=\"ps-pane ps-pane--editor\">\n <h2 class=\"ps-pane-title\">Recorded exchange</h2>\n <div id=\"detail\" class=\"ps-scroll\"></div>\n </section>\n</main>\n</div>";
|
|
6
|
+
/**
|
|
7
|
+
* The HTML shell.
|
|
8
|
+
*
|
|
9
|
+
* `clientScript` is the bundled `client.ts`, inlined rather than linked. The handler has
|
|
10
|
+
* no way to know what URL a browser would have to request for it: mounted with
|
|
11
|
+
* `app.use('/__polly', ...)`, Express strips the prefix before the handler sees the
|
|
12
|
+
* request, so nothing on the server side still knows the page is at `/__polly`.
|
|
13
|
+
*
|
|
14
|
+
* `location.pathname` does know, and it is evaluated in the browser, so the API base is
|
|
15
|
+
* derived there. The same expression is correct when the handler is mounted at the root,
|
|
16
|
+
* behind a prefix, or reached with a trailing slash.
|
|
17
|
+
*
|
|
18
|
+
* `clientScript` is split on a literal `</script`, not because a recording can reach
|
|
19
|
+
* this string — it can't; this is build-time bundle text, not runtime HAR data — but
|
|
20
|
+
* because Task 12's browser mount imports `STUDIO_MARKUP`/`STUDIO_STYLE` from this same
|
|
21
|
+
* file, which means `page.ts` itself can end up bundled into the very `clientScript`
|
|
22
|
+
* this function inlines. HTML's script-end-tag detection is lexical, not JS-aware, so an
|
|
23
|
+
* un-split `</script` anywhere in that text — in code, a string, or a comment — would
|
|
24
|
+
* truncate the tag regardless of a bundler's own escaping. The replacement is lossless:
|
|
25
|
+
* `<\/script` is identical JavaScript to `</script`, just not a tag boundary to the HTML
|
|
26
|
+
* parser.
|
|
27
|
+
*
|
|
28
|
+
* The trailer's regex is written `/\\/+$/` — doubled backslash — and must stay that way.
|
|
29
|
+
* It sits inside a template literal, so the literal consumes one backslash and the page
|
|
30
|
+
* receives `/\/+$/`, the escaped forward slash the browser needs. Written the way it
|
|
31
|
+
* reads as code, `/\/+$/`, the emitted text is `//+$/`: `//` opens a line comment, the
|
|
32
|
+
* closing paren is swallowed, and the browser reports `SyntaxError: Unexpected end of
|
|
33
|
+
* input` before anything runs. That is the bug this doubling fixes, and it was
|
|
34
|
+
* introduced by checking the expression as code rather than as template text. The
|
|
35
|
+
* `vm.Script` test in `test/render.test.ts` pins the emitted form.
|
|
36
|
+
*/
|
|
37
|
+
export declare function renderPage(clientScript: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Rules that only make sense when this package owns the whole document, which is true of
|
|
40
|
+
* the served page and false of `mountPollyStudio` (`src/browser.ts`), where the same
|
|
41
|
+
* `STUDIO_STYLE` is injected into somebody else's app. Splitting them is what lets
|
|
42
|
+
* `STUDIO_STYLE` below be entirely scoped: a host page keeps its own body background,
|
|
43
|
+
* type and box-sizing, and the editor still fills the viewport when it is the page.
|
|
44
|
+
*/
|
|
45
|
+
export declare const PAGE_STYLE = "\nhtml { color-scheme: light; }\nhtml, body { height: 100%; }\nbody { margin: 0; background: #eaeef5; }\n";
|
|
46
|
+
/**
|
|
47
|
+
* Every selector is scoped under `.polly-studio`, the wrapper `STUDIO_MARKUP` opens with.
|
|
48
|
+
* `src/browser.ts` injects this stylesheet with `innerHTML` into an arbitrary element of
|
|
49
|
+
* a host application, where it is document-global regardless — the previous `:root`/`body`
|
|
50
|
+
* rules restyled the host app's own type and margins as a side effect of mounting an
|
|
51
|
+
* editor. Scoping is the only thing preventing that, so new rules need the prefix too.
|
|
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";
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The body, shared with the browser-only mount in Task 12 so the two cannot drift into
|
|
3
|
+
* two different editors.
|
|
4
|
+
*/
|
|
5
|
+
export const STUDIO_MARKUP = `
|
|
6
|
+
<div class="polly-studio">
|
|
7
|
+
<header class="ps-bar">
|
|
8
|
+
<div class="ps-brand">
|
|
9
|
+
<h1>polly-studio</h1>
|
|
10
|
+
<p class="ps-tagline">Change what a recorded request replies with, then save it back to the HAR.</p>
|
|
11
|
+
</div>
|
|
12
|
+
<p class="warn banner">Polly's <code>keepUnusedRequests</code> defaults to <code>false</code>:
|
|
13
|
+
the next record run deletes every entry it did not exercise, including the ones you edit here.
|
|
14
|
+
Set <code>persisterOptions.keepUnusedRequests: true</code> before editing.</p>
|
|
15
|
+
</header>
|
|
16
|
+
<main class="ps-panes">
|
|
17
|
+
<section class="ps-pane ps-pane--list">
|
|
18
|
+
<h2 class="ps-pane-title">Recordings</h2>
|
|
19
|
+
<nav id="recordings" class="ps-scroll">Loading…</nav>
|
|
20
|
+
</section>
|
|
21
|
+
<section class="ps-pane ps-pane--list">
|
|
22
|
+
<h2 class="ps-pane-title">Requests</h2>
|
|
23
|
+
<div id="entries" class="ps-scroll"></div>
|
|
24
|
+
</section>
|
|
25
|
+
<section class="ps-pane ps-pane--editor">
|
|
26
|
+
<h2 class="ps-pane-title">Recorded exchange</h2>
|
|
27
|
+
<div id="detail" class="ps-scroll"></div>
|
|
28
|
+
</section>
|
|
29
|
+
</main>
|
|
30
|
+
</div>`;
|
|
31
|
+
/**
|
|
32
|
+
* The HTML shell.
|
|
33
|
+
*
|
|
34
|
+
* `clientScript` is the bundled `client.ts`, inlined rather than linked. The handler has
|
|
35
|
+
* no way to know what URL a browser would have to request for it: mounted with
|
|
36
|
+
* `app.use('/__polly', ...)`, Express strips the prefix before the handler sees the
|
|
37
|
+
* request, so nothing on the server side still knows the page is at `/__polly`.
|
|
38
|
+
*
|
|
39
|
+
* `location.pathname` does know, and it is evaluated in the browser, so the API base is
|
|
40
|
+
* derived there. The same expression is correct when the handler is mounted at the root,
|
|
41
|
+
* behind a prefix, or reached with a trailing slash.
|
|
42
|
+
*
|
|
43
|
+
* `clientScript` is split on a literal `</script`, not because a recording can reach
|
|
44
|
+
* this string — it can't; this is build-time bundle text, not runtime HAR data — but
|
|
45
|
+
* because Task 12's browser mount imports `STUDIO_MARKUP`/`STUDIO_STYLE` from this same
|
|
46
|
+
* file, which means `page.ts` itself can end up bundled into the very `clientScript`
|
|
47
|
+
* this function inlines. HTML's script-end-tag detection is lexical, not JS-aware, so an
|
|
48
|
+
* un-split `</script` anywhere in that text — in code, a string, or a comment — would
|
|
49
|
+
* truncate the tag regardless of a bundler's own escaping. The replacement is lossless:
|
|
50
|
+
* `<\/script` is identical JavaScript to `</script`, just not a tag boundary to the HTML
|
|
51
|
+
* parser.
|
|
52
|
+
*
|
|
53
|
+
* The trailer's regex is written `/\\/+$/` — doubled backslash — and must stay that way.
|
|
54
|
+
* It sits inside a template literal, so the literal consumes one backslash and the page
|
|
55
|
+
* receives `/\/+$/`, the escaped forward slash the browser needs. Written the way it
|
|
56
|
+
* reads as code, `/\/+$/`, the emitted text is `//+$/`: `//` opens a line comment, the
|
|
57
|
+
* closing paren is swallowed, and the browser reports `SyntaxError: Unexpected end of
|
|
58
|
+
* input` before anything runs. That is the bug this doubling fixes, and it was
|
|
59
|
+
* introduced by checking the expression as code rather than as template text. The
|
|
60
|
+
* `vm.Script` test in `test/render.test.ts` pins the emitted form.
|
|
61
|
+
*/
|
|
62
|
+
export function renderPage(clientScript) {
|
|
63
|
+
const safeScript = clientScript.replaceAll('</script', '<\\/script');
|
|
64
|
+
return `<!doctype html>
|
|
65
|
+
<html lang="en">
|
|
66
|
+
<head>
|
|
67
|
+
<meta charset="utf-8">
|
|
68
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
69
|
+
<title>polly-studio</title>
|
|
70
|
+
<style>${PAGE_STYLE}${STUDIO_STYLE}</style>
|
|
71
|
+
</head>
|
|
72
|
+
<body>
|
|
73
|
+
${STUDIO_MARKUP}
|
|
74
|
+
<script type="module">
|
|
75
|
+
${safeScript}
|
|
76
|
+
start(location.pathname.replace(/\\/+$/, ''))
|
|
77
|
+
</script>
|
|
78
|
+
</body>
|
|
79
|
+
</html>`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Rules that only make sense when this package owns the whole document, which is true of
|
|
83
|
+
* the served page and false of `mountPollyStudio` (`src/browser.ts`), where the same
|
|
84
|
+
* `STUDIO_STYLE` is injected into somebody else's app. Splitting them is what lets
|
|
85
|
+
* `STUDIO_STYLE` below be entirely scoped: a host page keeps its own body background,
|
|
86
|
+
* type and box-sizing, and the editor still fills the viewport when it is the page.
|
|
87
|
+
*/
|
|
88
|
+
export const PAGE_STYLE = `
|
|
89
|
+
html { color-scheme: light; }
|
|
90
|
+
html, body { height: 100%; }
|
|
91
|
+
body { margin: 0; background: #eaeef5; }
|
|
92
|
+
`;
|
|
93
|
+
/**
|
|
94
|
+
* Every selector is scoped under `.polly-studio`, the wrapper `STUDIO_MARKUP` opens with.
|
|
95
|
+
* `src/browser.ts` injects this stylesheet with `innerHTML` into an arbitrary element of
|
|
96
|
+
* a host application, where it is document-global regardless — the previous `:root`/`body`
|
|
97
|
+
* rules restyled the host app's own type and margins as a side effect of mounting an
|
|
98
|
+
* editor. Scoping is the only thing preventing that, so new rules need the prefix too.
|
|
99
|
+
*/
|
|
100
|
+
export const STUDIO_STYLE = `
|
|
101
|
+
.polly-studio, .polly-studio *, .polly-studio *::before, .polly-studio *::after { box-sizing: border-box; }
|
|
102
|
+
.polly-studio {
|
|
103
|
+
--ps-paper: #eaeef5;
|
|
104
|
+
--ps-surface: #ffffff;
|
|
105
|
+
--ps-sunken: #f1f5fb;
|
|
106
|
+
--ps-ink: #111722;
|
|
107
|
+
--ps-ink-soft: #4d5867;
|
|
108
|
+
--ps-ink-faint: #838e9d;
|
|
109
|
+
--ps-line: #dce3ed;
|
|
110
|
+
--ps-line-strong: #c3ccda;
|
|
111
|
+
--ps-accent: #3d2fbd;
|
|
112
|
+
--ps-accent-deep: #2e2298;
|
|
113
|
+
--ps-accent-line: #c8c2f0;
|
|
114
|
+
--ps-accent-soft: #eeecfb;
|
|
115
|
+
--ps-ok: #0a6b3d; --ps-ok-line: #b6ddc6; --ps-ok-soft: #e8f4ed;
|
|
116
|
+
--ps-warn: #875700; --ps-warn-line: #ecd3a3; --ps-warn-soft: #fbf2e1;
|
|
117
|
+
--ps-bad: #b3261e; --ps-bad-line: #edbdb9; --ps-bad-soft: #fceceb;
|
|
118
|
+
--ps-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
119
|
+
|
|
120
|
+
color-scheme: light;
|
|
121
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
122
|
+
font-size: 15px;
|
|
123
|
+
line-height: 1.5;
|
|
124
|
+
color: var(--ps-ink);
|
|
125
|
+
background: var(--ps-paper);
|
|
126
|
+
display: flex;
|
|
127
|
+
flex-direction: column;
|
|
128
|
+
height: 100%;
|
|
129
|
+
min-height: 34rem;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Title bar ------------------------------------------------------------ */
|
|
133
|
+
.polly-studio .ps-bar {
|
|
134
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1.75rem;
|
|
135
|
+
padding: .8rem 1.25rem;
|
|
136
|
+
background: var(--ps-surface);
|
|
137
|
+
border-bottom: 1px solid var(--ps-line);
|
|
138
|
+
}
|
|
139
|
+
.polly-studio .ps-brand { display: flex; align-items: baseline; flex-wrap: wrap; gap: .1rem .75rem; }
|
|
140
|
+
.polly-studio h1 { margin: 0; font-size: 1.0625rem; font-weight: 650; letter-spacing: -.015em; }
|
|
141
|
+
.polly-studio .ps-tagline { margin: 0; font-size: .8125rem; color: var(--ps-ink-soft); }
|
|
142
|
+
.polly-studio .banner {
|
|
143
|
+
flex: 1 1 26rem; min-width: 16rem; margin: 0;
|
|
144
|
+
padding: .45rem .7rem;
|
|
145
|
+
font-size: .78rem; line-height: 1.45;
|
|
146
|
+
background: var(--ps-warn-soft);
|
|
147
|
+
border: 1px solid var(--ps-warn-line); border-left-width: 3px;
|
|
148
|
+
border-radius: 0 7px 7px 0;
|
|
149
|
+
}
|
|
150
|
+
.polly-studio code {
|
|
151
|
+
font-family: var(--ps-mono); font-size: .92em;
|
|
152
|
+
background: rgba(17,23,34,.05); border-radius: 4px; padding: 0 .22em;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* Panes ---------------------------------------------------------------- */
|
|
156
|
+
.polly-studio .ps-panes {
|
|
157
|
+
flex: 1; min-height: 0;
|
|
158
|
+
display: grid; gap: .9rem;
|
|
159
|
+
grid-template-columns: 17rem 24rem minmax(0, 1fr);
|
|
160
|
+
padding: .9rem 1.25rem 1.25rem;
|
|
161
|
+
}
|
|
162
|
+
.polly-studio .ps-pane {
|
|
163
|
+
display: flex; flex-direction: column; min-height: 0;
|
|
164
|
+
border: 1px solid var(--ps-line); border-radius: 12px;
|
|
165
|
+
background: var(--ps-surface);
|
|
166
|
+
overflow: hidden;
|
|
167
|
+
}
|
|
168
|
+
/* The two browsers read as one surface you pick from; the editor reads as the page you
|
|
169
|
+
work on. Same border, different floor — not three identical cards. */
|
|
170
|
+
.polly-studio .ps-pane--list { background: var(--ps-sunken); }
|
|
171
|
+
.polly-studio .ps-pane-title {
|
|
172
|
+
margin: 0; padding: .5rem .75rem;
|
|
173
|
+
font-size: .8125rem; font-weight: 600; color: var(--ps-ink-soft);
|
|
174
|
+
background: var(--ps-surface);
|
|
175
|
+
border-bottom: 1px solid var(--ps-line);
|
|
176
|
+
}
|
|
177
|
+
.polly-studio .ps-pane--editor .ps-pane-title { box-shadow: inset 3px 0 0 var(--ps-accent); }
|
|
178
|
+
.polly-studio .ps-scroll { flex: 1; min-height: 0; overflow: auto; padding: .5rem; }
|
|
179
|
+
.polly-studio .ps-pane--editor .ps-scroll { padding: .9rem 1rem 1.25rem; }
|
|
180
|
+
|
|
181
|
+
/* Recording list ------------------------------------------------------- */
|
|
182
|
+
.polly-studio ul { list-style: none; margin: 0; padding: 0; }
|
|
183
|
+
.polly-studio .recordings, .polly-studio #entries ul { display: grid; gap: .3rem; }
|
|
184
|
+
/* Each row is its own bordered card rather than a hover-only highlight: a recorded URL
|
|
185
|
+
wraps to three or four lines often enough that without one, two rows read as one. */
|
|
186
|
+
.polly-studio button.recording, .polly-studio button.entry {
|
|
187
|
+
width: 100%; text-align: left; font: inherit;
|
|
188
|
+
color: var(--ps-ink); background: var(--ps-surface);
|
|
189
|
+
border: 1px solid var(--ps-line); border-radius: 8px;
|
|
190
|
+
padding: .4rem .5rem; cursor: pointer;
|
|
191
|
+
transition: background-color .12s ease, border-color .12s ease;
|
|
192
|
+
}
|
|
193
|
+
.polly-studio button.recording:hover, .polly-studio button.entry:hover {
|
|
194
|
+
border-color: var(--ps-line-strong);
|
|
195
|
+
}
|
|
196
|
+
/* :focus, not :focus-visible — a click selects, and the selection has to be visible
|
|
197
|
+
whether it came from the mouse or the keyboard. app.ts keeps no selected state. */
|
|
198
|
+
.polly-studio button.recording:focus, .polly-studio button.entry:focus {
|
|
199
|
+
outline: none; background: var(--ps-accent-soft); border-color: var(--ps-accent-line);
|
|
200
|
+
}
|
|
201
|
+
.polly-studio button.recording:focus-visible, .polly-studio button.entry:focus-visible {
|
|
202
|
+
outline: 2px solid var(--ps-accent); outline-offset: 1px;
|
|
203
|
+
}
|
|
204
|
+
.polly-studio button.recording { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: .5rem; }
|
|
205
|
+
.polly-studio .ps-rec-name { font-family: var(--ps-mono); font-size: .8125rem; overflow-wrap: anywhere; }
|
|
206
|
+
.polly-studio .count {
|
|
207
|
+
font-family: var(--ps-mono); font-size: .6875rem; color: var(--ps-ink-soft);
|
|
208
|
+
background: var(--ps-paper); border: 1px solid var(--ps-line);
|
|
209
|
+
border-radius: 999px; padding: 0 .42rem;
|
|
210
|
+
}
|
|
211
|
+
.polly-studio .ps-rec-note { margin: .1rem 0 .3rem .5rem; font-size: .75rem; line-height: 1.4; }
|
|
212
|
+
|
|
213
|
+
/* Request list --------------------------------------------------------- */
|
|
214
|
+
.polly-studio button.entry {
|
|
215
|
+
display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto;
|
|
216
|
+
align-items: baseline; gap: .1rem .5rem;
|
|
217
|
+
}
|
|
218
|
+
.polly-studio .method {
|
|
219
|
+
font-family: var(--ps-mono); font-size: .6875rem; font-weight: 700;
|
|
220
|
+
letter-spacing: .02em; color: var(--ps-ink-soft); min-width: 2.6rem;
|
|
221
|
+
}
|
|
222
|
+
.polly-studio .status {
|
|
223
|
+
font-family: var(--ps-mono); font-size: .6875rem; font-weight: 700;
|
|
224
|
+
border: 1px solid; border-radius: 5px; padding: 0 .3rem;
|
|
225
|
+
}
|
|
226
|
+
.polly-studio .status.s2 { color: var(--ps-ok); background: var(--ps-ok-soft); border-color: var(--ps-ok-line); }
|
|
227
|
+
.polly-studio .status.s3 { color: var(--ps-warn); background: var(--ps-warn-soft); border-color: var(--ps-warn-line); }
|
|
228
|
+
.polly-studio .status.s4, .polly-studio .status.s5 { color: var(--ps-bad); background: var(--ps-bad-soft); border-color: var(--ps-bad-line); }
|
|
229
|
+
.polly-studio .url { font-family: var(--ps-mono); font-size: .78rem; overflow-wrap: anywhere; }
|
|
230
|
+
.polly-studio .order { font-family: var(--ps-mono); font-size: .6875rem; color: var(--ps-ink-faint); }
|
|
231
|
+
|
|
232
|
+
/* Detail --------------------------------------------------------------- */
|
|
233
|
+
.polly-studio .detail { display: grid; gap: .85rem; }
|
|
234
|
+
.polly-studio .ps-block {
|
|
235
|
+
border: 1px solid var(--ps-line); border-radius: 10px;
|
|
236
|
+
padding: .85rem .95rem 1rem;
|
|
237
|
+
}
|
|
238
|
+
.polly-studio .ps-block--request { background: var(--ps-sunken); }
|
|
239
|
+
.polly-studio .detail h2 {
|
|
240
|
+
margin: 0 0 .45rem; font-size: .9375rem; font-weight: 650; letter-spacing: -.01em;
|
|
241
|
+
display: flex; align-items: baseline; gap: .5rem;
|
|
242
|
+
}
|
|
243
|
+
.polly-studio .detail h2 small {
|
|
244
|
+
font-size: .6875rem; font-weight: 500; color: var(--ps-ink-soft);
|
|
245
|
+
background: var(--ps-paper); border: 1px solid var(--ps-line);
|
|
246
|
+
border-radius: 999px; padding: .05rem .45rem;
|
|
247
|
+
}
|
|
248
|
+
.polly-studio .note { margin: 0 0 .75rem; font-size: .78rem; line-height: 1.5; color: var(--ps-ink-soft); max-width: 68ch; }
|
|
249
|
+
.polly-studio dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .3rem .9rem; margin: 0; font-size: .8125rem; }
|
|
250
|
+
.polly-studio dt { font-weight: 600; color: var(--ps-ink-soft); }
|
|
251
|
+
.polly-studio dd { margin: 0; font-family: var(--ps-mono); overflow-wrap: anywhere; }
|
|
252
|
+
.polly-studio .ps-group-title {
|
|
253
|
+
margin: 1rem 0 .4rem; font-size: .75rem; font-weight: 600; color: var(--ps-ink-soft);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* Fields --------------------------------------------------------------- */
|
|
257
|
+
.polly-studio .ps-fields { display: grid; grid-template-columns: 7rem minmax(0, 1fr); gap: .55rem; }
|
|
258
|
+
.polly-studio label.ps-field { display: grid; gap: .25rem; margin: 0; }
|
|
259
|
+
.polly-studio .ps-field-label { font-size: .75rem; font-weight: 600; color: var(--ps-ink-soft); }
|
|
260
|
+
.polly-studio .ps-input, .polly-studio textarea {
|
|
261
|
+
width: 100%; min-width: 0;
|
|
262
|
+
font-family: var(--ps-mono); font-size: .8125rem; color: var(--ps-ink);
|
|
263
|
+
background: var(--ps-surface);
|
|
264
|
+
border: 1px solid var(--ps-line-strong); border-radius: 8px;
|
|
265
|
+
padding: .4rem .55rem;
|
|
266
|
+
transition: border-color .12s ease, box-shadow .12s ease;
|
|
267
|
+
}
|
|
268
|
+
.polly-studio .ps-input:hover, .polly-studio textarea:hover, .polly-studio .header-row:hover { border-color: #a9b5c6; }
|
|
269
|
+
.polly-studio .ps-input:focus, .polly-studio textarea:focus {
|
|
270
|
+
outline: none; border-color: var(--ps-accent); box-shadow: 0 0 0 3px var(--ps-accent-soft);
|
|
271
|
+
}
|
|
272
|
+
.polly-studio textarea { display: block; line-height: 1.55; resize: vertical; min-height: 11rem; tab-size: 2; }
|
|
273
|
+
|
|
274
|
+
/* A header is one name:value pair, so it is one control split by a hairline, not two
|
|
275
|
+
inputs that happen to sit side by side. */
|
|
276
|
+
.polly-studio .headers { display: grid; gap: .35rem; }
|
|
277
|
+
.polly-studio .header-row {
|
|
278
|
+
display: grid; grid-template-columns: minmax(6rem, 13rem) minmax(0, 1fr);
|
|
279
|
+
background: var(--ps-surface);
|
|
280
|
+
border: 1px solid var(--ps-line-strong); border-radius: 8px;
|
|
281
|
+
overflow: hidden;
|
|
282
|
+
transition: border-color .12s ease, box-shadow .12s ease;
|
|
283
|
+
}
|
|
284
|
+
.polly-studio .header-row:focus-within { border-color: var(--ps-accent); box-shadow: 0 0 0 3px var(--ps-accent-soft); }
|
|
285
|
+
.polly-studio .header-row input {
|
|
286
|
+
width: 100%; min-width: 0;
|
|
287
|
+
font-family: var(--ps-mono); font-size: .8125rem; color: var(--ps-ink);
|
|
288
|
+
background: transparent; border: 0; padding: .38rem .55rem;
|
|
289
|
+
}
|
|
290
|
+
.polly-studio .header-row input:focus { outline: none; }
|
|
291
|
+
.polly-studio .header-row input[name^="headerName"] {
|
|
292
|
+
color: var(--ps-accent-deep); font-weight: 600;
|
|
293
|
+
background: var(--ps-sunken); border-right: 1px solid var(--ps-line);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* Actions -------------------------------------------------------------- */
|
|
297
|
+
.polly-studio .actions {
|
|
298
|
+
display: flex; flex-wrap: wrap; gap: .5rem;
|
|
299
|
+
margin-top: 1rem; padding-top: .9rem;
|
|
300
|
+
border-top: 1px solid var(--ps-line);
|
|
301
|
+
}
|
|
302
|
+
.polly-studio .actions button {
|
|
303
|
+
font: inherit; font-size: .8125rem; font-weight: 600;
|
|
304
|
+
padding: .45rem .95rem; border-radius: 8px; cursor: pointer;
|
|
305
|
+
border: 1px solid transparent;
|
|
306
|
+
transition: background-color .12s ease, border-color .12s ease;
|
|
307
|
+
}
|
|
308
|
+
.polly-studio .actions button[name="save"] { background: var(--ps-accent); color: #fff; }
|
|
309
|
+
.polly-studio .actions button[name="save"]:hover { background: var(--ps-accent-deep); }
|
|
310
|
+
.polly-studio .actions button.danger { background: var(--ps-surface); color: var(--ps-bad); border-color: var(--ps-bad-line); }
|
|
311
|
+
.polly-studio .actions button.danger:hover { background: var(--ps-bad-soft); }
|
|
312
|
+
.polly-studio .actions button:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }
|
|
313
|
+
|
|
314
|
+
/* Notices and empty states --------------------------------------------- */
|
|
315
|
+
.polly-studio .warn { color: var(--ps-warn); }
|
|
316
|
+
.polly-studio .problem { color: var(--ps-bad); }
|
|
317
|
+
.polly-studio .ps-notice {
|
|
318
|
+
margin: 0; padding: .55rem .7rem; font-size: .8125rem; line-height: 1.5;
|
|
319
|
+
background: var(--ps-warn-soft); border: 1px solid var(--ps-warn-line);
|
|
320
|
+
border-left-width: 3px; border-radius: 0 8px 8px 0;
|
|
321
|
+
}
|
|
322
|
+
/* report() prepends a bare <p class="problem"> here; it is the only alert the page has. */
|
|
323
|
+
.polly-studio #detail > .problem {
|
|
324
|
+
margin: 0 0 .85rem; padding: .55rem .7rem; font-size: .8125rem;
|
|
325
|
+
background: var(--ps-bad-soft); border: 1px solid var(--ps-bad-line);
|
|
326
|
+
border-left-width: 3px; border-radius: 0 8px 8px 0;
|
|
327
|
+
}
|
|
328
|
+
.polly-studio section.problem { margin: 0; }
|
|
329
|
+
.polly-studio li.problem { padding: .4rem .5rem; font-size: .8125rem; }
|
|
330
|
+
.polly-studio .empty {
|
|
331
|
+
margin: 0; padding: 1.5rem .75rem; text-align: center;
|
|
332
|
+
font-size: .8125rem; line-height: 1.55; color: var(--ps-ink-faint);
|
|
333
|
+
}
|
|
334
|
+
/* The same class inside the editor is a field standing in for a control, not a pane with
|
|
335
|
+
nothing in it — centring it there would read as a broken row between two left-aligned
|
|
336
|
+
groups. */
|
|
337
|
+
.polly-studio .ps-block .empty { padding: .3rem 0 .1rem; text-align: left; }
|
|
338
|
+
/* The two panes app.ts blanks rather than filling with a placeholder. ::before keeps the
|
|
339
|
+
hint out of the markup, so nothing here has to be re-added after every innerHTML wipe. */
|
|
340
|
+
.polly-studio #entries:empty::before, .polly-studio #detail:empty::before {
|
|
341
|
+
display: block; padding: 1.75rem .75rem; text-align: center;
|
|
342
|
+
font-size: .8125rem; color: var(--ps-ink-faint);
|
|
343
|
+
}
|
|
344
|
+
.polly-studio #entries:empty::before { content: "Pick a recording to see the requests it captured."; }
|
|
345
|
+
.polly-studio #detail:empty::before { content: "Pick a request to edit the response it replays."; }
|
|
346
|
+
|
|
347
|
+
@media (max-width: 72rem) {
|
|
348
|
+
.polly-studio { height: auto; }
|
|
349
|
+
.polly-studio .ps-panes { grid-template-columns: minmax(0, 1fr); }
|
|
350
|
+
.polly-studio .ps-pane--list .ps-scroll { max-height: 18rem; }
|
|
351
|
+
}
|
|
352
|
+
@media (max-width: 34rem) {
|
|
353
|
+
.polly-studio .ps-fields { grid-template-columns: minmax(0, 1fr); }
|
|
354
|
+
.polly-studio .header-row { grid-template-columns: minmax(0, 1fr); }
|
|
355
|
+
.polly-studio .header-row input[name^="headerName"] { border-right: 0; border-bottom: 1px solid var(--ps-line); }
|
|
356
|
+
}
|
|
357
|
+
@media (prefers-reduced-motion: reduce) {
|
|
358
|
+
.polly-studio * { transition: none !important; }
|
|
359
|
+
}
|
|
360
|
+
`;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RecordingSummary } from '../stores/types.js';
|
|
2
|
+
import type { HarEntry } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Rendering is string-in, string-out and lives apart from the DOM so it can be tested
|
|
5
|
+
* without a browser. Recorded bodies are attacker-shaped by nature — they came off the
|
|
6
|
+
* network — so everything interpolated goes through `escapeHtml`.
|
|
7
|
+
*
|
|
8
|
+
* The parameter is not `string`, and the body opens with `String(value)`. `parseHar`'s
|
|
9
|
+
* `isHarShaped` (`src/har.ts`) is a shape check, not a schema validator: it confirms
|
|
10
|
+
* `log.entries` is an array and casts the rest of the parsed JSON to `Har`, so a field
|
|
11
|
+
* this module's types call `string` can be a number, `null` or `undefined` on disk — and
|
|
12
|
+
* `.replaceAll` does not exist on any of those. Design spec §7 requires the UI to open
|
|
13
|
+
* exactly the recordings Polly would reject, so that is an expected input.
|
|
14
|
+
*
|
|
15
|
+
* It is a union of the JSON primitives rather than `unknown`, which is the narrowest
|
|
16
|
+
* type that accepts everything a malformed HAR can put in a text field while still
|
|
17
|
+
* failing the build on `escapeHtml(someObject)` — a call that is always a mistake, and
|
|
18
|
+
* that `unknown` would have accepted silently as `[object Object]`. Callers holding an
|
|
19
|
+
* `unknown` say `String(value)` themselves, which makes the coercion visible at the site
|
|
20
|
+
* that chose it.
|
|
21
|
+
*/
|
|
22
|
+
export declare function escapeHtml(value: string | number | boolean | null | undefined): string;
|
|
23
|
+
export declare function renderRecordingList(recordings: RecordingSummary[]): string;
|
|
24
|
+
export declare function renderEntryRow(entry: HarEntry): string;
|
|
25
|
+
export declare function renderEntryDetail(entry: HarEntry): string;
|