pw-repl 0.5.0 → 0.6.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/lib/tabs.js ADDED
@@ -0,0 +1,237 @@
1
+ // Tabs: listing, selecting, opening and closing them, and navigating the selected one.
2
+ const { state } = require('./state');
3
+ const out = require('./output');
4
+ const { hints } = require('./util');
5
+ const { tabState } = require('./tabstate');
6
+ const { keepDrawing } = require('./cdp');
7
+ const { ensureDialogHandler } = require('./dialogs');
8
+ const { notLoaded, stayedPut } = require('./requestlog');
9
+ const { viewportText } = require('./emulation');
10
+
11
+ // A navigation that does not load is said as wait load says it, from its request, rather than as
12
+ // Playwright's net:: error and call log.
13
+ async function navigating(go) {
14
+ const started = Date.now();
15
+ try {
16
+ return await go();
17
+ } catch (error) {
18
+ if (!/net::ERR_/.test(error.message)) throw error;
19
+ const find = () => (tabState(state.page).log || []).filter(e => e.navigation && e.t >= started && e.failed && e.status !== 'pending').pop();
20
+ for (let waited = 0; !find() && waited < 1000; waited += 50) await new Promise(resolve => setTimeout(resolve, 50));
21
+ const navigation = find();
22
+ if (!navigation) throw error;
23
+ throw new Error(notLoaded(navigation));
24
+ }
25
+ }
26
+
27
+ // A page restored from the back/forward cache never fires its load events
28
+ // again, so back and forward wait only for the navigation, then briefly for
29
+ // the document to be parsed, which a restored one already is.
30
+ async function historyStep(go, direction) {
31
+ const before = state.page.url();
32
+ const response = await navigating(go);
33
+ if (response === null && state.page.url() === before) throw new Error(`No page to go ${direction} to`);
34
+ await state.page.waitForFunction(() => document.readyState !== 'loading', null, { timeout: 3000 }).catch(() => {});
35
+ }
36
+
37
+ // The tab the REPL opened for its start URL, which tab marks: in a kept profile it sits among restored tabs.
38
+ function markStartTab(p) {
39
+ tabState(p).start = true;
40
+ }
41
+
42
+ // A tab of the REPL's own: closing it can go back to the tab before. It opens
43
+ // in the background, so it does not take the front of the window from the
44
+ // person using the browser; Playwright's newPage would bring it to the front.
45
+ const OPEN_TIMEOUT = 10000;
46
+
47
+ // True while tab new opens a tab: watch on --next-tab waits for one someone
48
+ // else opens, not one a client of the REPL opens for itself.
49
+ let opening = false;
50
+
51
+ function isOpening() {
52
+ return opening;
53
+ }
54
+
55
+ async function openTab() {
56
+ opening = true;
57
+ try { return await openOwnTab(); } finally { opening = false; }
58
+ }
59
+
60
+ async function openOwnTab() {
61
+ const ctx = state.browser.contexts()[0];
62
+ let opened = null;
63
+ const session = await state.browser.newBrowserCDPSession().catch(() => null);
64
+ if (session) {
65
+ try {
66
+ const { targetId } = await session.send('Target.createTarget', { url: 'about:blank', background: true });
67
+ opened = await pageForTarget(ctx, targetId);
68
+ } catch {} finally {
69
+ await session.detach().catch(() => {});
70
+ }
71
+ }
72
+ // A browser that cannot open one in the background (e.g. some headless ones) opens it as usual.
73
+ if (!opened) opened = await ctx.newPage();
74
+ tabState(opened).opened = true;
75
+ tabState(opened).owner = state.client;
76
+ keepDrawing(opened);
77
+ return opened;
78
+ }
79
+
80
+ // A page's CDP target id, asked for once per page.
81
+ async function targetIdOf(ctx, p) {
82
+ if (!('targetId' in tabState(p))) {
83
+ const cdp = await ctx.newCDPSession(p).catch(() => null);
84
+ if (!cdp) return null;
85
+ const info = await cdp.send('Target.getTargetInfo').catch(() => null);
86
+ await cdp.detach().catch(() => {});
87
+ if (!info) return null;
88
+ tabState(p).targetId = info.targetInfo.targetId;
89
+ }
90
+ return tabState(p).targetId;
91
+ }
92
+
93
+ async function pageForTarget(ctx, targetId) {
94
+ const deadline = Date.now() + OPEN_TIMEOUT;
95
+ while (Date.now() < deadline) {
96
+ for (const p of ctx.pages()) {
97
+ if ('opened' in tabState(p)) continue;
98
+ if (await targetIdOf(ctx, p) === targetId) return p;
99
+ }
100
+ await new Promise(resolve => setTimeout(resolve, 50));
101
+ }
102
+ return null;
103
+ }
104
+
105
+ // Every tab, URL first, with * on the selected one. The numbers are what tab <index> uses.
106
+ async function listTabs() {
107
+ const all = state.browser.contexts().flatMap(c => c.pages());
108
+ if (state.page && !all.includes(state.page)) state.page = null;
109
+ state.tabListing = all.slice();
110
+ if (!all.length) { out.log('No tabs. Use tab new.'); return; }
111
+ for (const [i, p] of all.entries()) {
112
+ let title;
113
+ try { title = await p.title(); } catch { title = '[title unavailable]'; }
114
+ const marker = p === state.page ? '*' : ' ';
115
+ // URL first on its own line: it is what distinguishes otherwise
116
+ // identically titled tabs, and titles wrap less badly when indented.
117
+ if (i) out.log('');
118
+ const notes = ['start' in tabState(p) && 'the start URL', tabState(p).owner && `opened by ${tabState(p).owner}`].filter(Boolean);
119
+ out.log(`${marker} [${i}] ${p.url()}${notes.length ? ` (${notes.join(', ')})` : ''}`);
120
+ out.log(` ${JSON.stringify(title)}`);
121
+ }
122
+ }
123
+
124
+ const commands = {
125
+ async tab(args) {
126
+ const trimmed = (args || '').trim();
127
+ if (!trimmed) {
128
+ await listTabs();
129
+ if (!state.page) out.log('\nNo tab is selected.');
130
+ out.log(hints([
131
+ ['tab <index|url-part>', 'select a tab'],
132
+ ['tab new [url]', 'open a tab of your own and select it'],
133
+ ['tab close [url-part]', 'close the selected tab, or the one whose URL contains url-part'],
134
+ ]));
135
+ return;
136
+ }
137
+ const usage = 'Usage: tab [<index> | <url-part> | new [url] | close [<url-part>]]';
138
+ const parts = trimmed.split(/\s+/);
139
+ const subcommand = parts.shift();
140
+ const all = state.browser.contexts().flatMap(c => c.pages());
141
+ // Matching on the URL, not an index, so a stale listing cannot point at someone else's tab.
142
+ // Remembers where the REPL was, so closing a tab can go back there, but
143
+ // only to a tab this REPL opened: the previous one may be someone else's.
144
+ const select = p => {
145
+ if (p !== state.page) state.previousPage = state.page;
146
+ state.page = p;
147
+ ensureDialogHandler(p);
148
+ keepDrawing(p);
149
+ };
150
+ const byUrl = part => {
151
+ const matches = all.filter(p => p.url().includes(part));
152
+ if (matches.length === 1) return matches[0];
153
+ if (!matches.length) throw new Error(`No tab URL contains "${part}"`);
154
+ throw new Error(`${matches.length} tabs match "${part}"; use a longer part:\n${matches.map(p => ` ${p.url()}`).join('\n')}`);
155
+ };
156
+ if (/^\d+$/.test(subcommand)) {
157
+ if (parts.length) throw new Error(usage);
158
+ const target = state.tabListing[Number(subcommand)];
159
+ if (target && all.includes(target)) { select(target); return commands.info(); }
160
+ // Not read as part of a URL instead: that could land in someone else's tab without a word.
161
+ if (target) throw new Error(`Tab [${subcommand}] has closed since the latest listing. Run tab to list them again.`);
162
+ // A number that is not a tab in the listing may be part of a URL (a port).
163
+ const containing = all.filter(p => p.url().includes(subcommand)).length;
164
+ if (!containing) throw new Error(`No tab [${subcommand}] in the latest listing, and no tab URL contains ${subcommand}. Run tab to list them.`);
165
+ if (containing > 1) {
166
+ const listing = state.tabListing.length ? 'the latest listing' : 'a listing yet: each client has its own, from tab';
167
+ throw new Error(`No tab [${subcommand}] in ${listing}. As part of a URL, ${subcommand} is in ${containing} tabs. Run tab, then tab <index>.`);
168
+ }
169
+ }
170
+ if (subcommand === 'new') {
171
+ select(await openTab());
172
+ const url = parts.join(' ');
173
+ if (url) {
174
+ try { await commands.goto(url); }
175
+ catch (error) { throw new Error(`${error.message}\nThe new tab stays open and selected; tab close closes it.`); }
176
+ } else {
177
+ out.log('New tab created and selected');
178
+ }
179
+ return listTabs();
180
+ }
181
+ if (subcommand === 'close') {
182
+ const target = parts.length ? byUrl(parts.join(' ')) : state.page;
183
+ if (!target || target.isClosed() || !all.includes(target)) throw new Error('Selected tab is unavailable. Run tab again.');
184
+ if (all.length <= 1) throw new Error('Refusing to close the last tab');
185
+ const url = target.url();
186
+ const wasSelected = target === state.page;
187
+ await target.close();
188
+ if (wasSelected) {
189
+ const back = state.previousPage;
190
+ state.page = back && !back.isClosed() && 'opened' in tabState(back) ? back : null;
191
+ state.previousPage = null;
192
+ }
193
+ out.log(`Closed ${url}${wasSelected && !state.page ? '; no tab is selected now' : ''}`);
194
+ return listTabs();
195
+ }
196
+ select(byUrl(trimmed));
197
+ return commands.info();
198
+ },
199
+
200
+ async goto(args) {
201
+ if (!args) throw new Error('Usage: goto <url>');
202
+ let url = args;
203
+ // As a browser's address bar does: a local dev server is almost always plain http.
204
+ const local = /^(?:localhost|127(?:\.\d+){3}|\[::1\]|0\.0\.0\.0)(?:[:/?#]|$)/i.test(url);
205
+ if (!/^[a-z][a-z\d+.-]*:\/\//i.test(url) && !/^(about|data|file|javascript):/i.test(url)) url = `${local ? 'http' : 'https'}://${url}`;
206
+ await navigating(() => state.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }));
207
+ out.log(`${state.page.url()} — ${await state.page.title()}`);
208
+ },
209
+
210
+ async back() {
211
+ await historyStep(() => state.page.goBack({ waitUntil: 'commit', timeout: 10000 }), 'back');
212
+ out.log(`Back to: ${state.page.url()}`);
213
+ },
214
+
215
+ async forward() {
216
+ await historyStep(() => state.page.goForward({ waitUntil: 'commit', timeout: 10000 }), 'forward');
217
+ out.log(`Forward to: ${state.page.url()}`);
218
+ },
219
+
220
+ async reload() {
221
+ await navigating(() => state.page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }));
222
+ out.log(`Reloaded: ${state.page.url()}`);
223
+ },
224
+
225
+ async info() {
226
+ out.log(` URL: ${state.page.url()}`);
227
+ // Chrome's error page has a URL of its own; the one that failed is in the request log.
228
+ if (state.page.url().startsWith('chrome-error://')) {
229
+ const failed = (tabState(state.page).log || []).filter(e => e.navigation && e.failed && !stayedPut(e)).pop();
230
+ if (failed) out.log(` Error: Chrome's error page, for #${failed.id} ${failed.url} ${failed.status}`);
231
+ }
232
+ out.log(` Title: ${await state.page.title()}`);
233
+ out.log(` Viewport: ${await viewportText()}`);
234
+ },
235
+ };
236
+
237
+ module.exports = { commands, markStartTab, isOpening, openTab, listTabs };
@@ -0,0 +1,35 @@
1
+ // Everything the REPL keeps about a tab, in one record per page. A WeakMap, so a closed page's
2
+ // record goes with it: the shutdown cleanups walk the browser's pages instead of the records.
3
+ // dialogHandled its dialog handler is on
4
+ // session the CDP session kept for it (a promise)
5
+ // drawing it is kept drawing in the background
6
+ // targetId its CDP target id
7
+ // networkEnabled its CDP session has Network on
8
+ // network how its network is changed: { offline: true }, or slowed { latency, down, up }
9
+ // (ms, kbps), with the owner
10
+ // emulation what emulate changed: { device, scheme, locale, timezone }, each unset when off,
11
+ // with the owner
12
+ // routes Map(glob -> { status, body, handler, owner })
13
+ // log its recent requests (requests)
14
+ // consoleLog its recent console messages and page errors (console)
15
+ // loadedAt when its page last fired its load event
16
+ // bodiesDurable the browser keeps its response bodies after it navigates
17
+ // opened this REPL opened it with tab new
18
+ // owner the client that opened it (null: the prompt, or a sender without a name)
19
+ // start the REPL opened it for its start URL
20
+ // watch its watch: the steps recorded, and whether it is on
21
+ // watchStarting its watch on --next-tab is still starting: the page can load before it is ready
22
+ // watchBinding its binding's record function; the binding is added once per tab
23
+ const tabStates = new WeakMap();
24
+
25
+ function tabState(p) {
26
+ // No tab selected: an empty record, as a WeakMap's get finds nothing. It is not kept, so a
27
+ // write to it is lost (a WeakMap's set would throw): the runner keeps commands that need a tab
28
+ // from running without one, and nothing may rely on it holding anything.
29
+ if (!p) return {};
30
+ let record = tabStates.get(p);
31
+ if (!record) tabStates.set(p, record = {});
32
+ return record;
33
+ }
34
+
35
+ module.exports = { tabState };
package/lib/util.js ADDED
@@ -0,0 +1,49 @@
1
+ // Small helpers the commands share: output hints, clipping, times, patterns and number arguments.
2
+
3
+ const COMMAND_TIMEOUT = 15000;
4
+
5
+ const MAX_EVENT_TEXT = 4000;
6
+
7
+ // For a command that needs a tab, when none is selected.
8
+ const NO_TAB = 'No tab is selected; select one with tab <index|url-part>, or open one with tab new';
9
+
10
+ // What a command run on its own prints after its state: the commands that
11
+ // come next, set apart from the state by a blank line.
12
+ function hints(pairs) {
13
+ const width = Math.max(...pairs.map(([command]) => command.length));
14
+ return `\n${pairs.map(([command, what]) => ` ${command.padEnd(width)} ${what}`).join('\n')}`;
15
+ }
16
+
17
+ function clipText(text) {
18
+ return text.length > MAX_EVENT_TEXT ? `${text.slice(0, MAX_EVENT_TEXT)}…` : text;
19
+ }
20
+
21
+ function numbers(args, usage) {
22
+ const words = (args || '').trim().split(/\s+/).filter(Boolean);
23
+ if (words.some(w => !/^-?\d+(?:\.\d+)?$/.test(w))) throw new Error(`Usage: ${usage}, in numbers of CSS pixels`);
24
+ return words.map(Number);
25
+ }
26
+
27
+ // For --regex, as playwright-cli's find --regex and requests --filter take one.
28
+ function regexOf(pattern) {
29
+ try { return new RegExp(pattern); } catch (error) { throw new Error(`Not a regular expression: ${error.message}`); }
30
+ }
31
+
32
+ // A pattern without its quotes, and with \" inside them as ", as snapshot
33
+ // reads one: other backslashes are the regexp's own (\d).
34
+ function patternText(text) {
35
+ return text.replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
36
+ }
37
+
38
+ // Local time with its offset (18:16:15.721-06:00), so it reads against the
39
+ // user's clock and is still unambiguous.
40
+ function clock(t) {
41
+ const d = new Date(t);
42
+ const pad = (n, width = 2) => String(n).padStart(width, '0');
43
+ const offset = -d.getTimezoneOffset();
44
+ const sign = offset < 0 ? '-' : '+';
45
+ const zone = `${sign}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`;
46
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}${zone}`;
47
+ }
48
+
49
+ module.exports = { COMMAND_TIMEOUT, MAX_EVENT_TEXT, NO_TAB, hints, clipText, numbers, regexOf, patternText, clock };