pw-repl 0.4.2 → 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/AGENTS.md +34 -8
- package/README.md +90 -101
- package/bin/pw-repl.js +12 -2
- package/lib/background.js +4 -3
- package/lib/capture.js +139 -0
- package/lib/cdp.js +31 -0
- package/lib/cli-names.js +1 -3
- package/lib/client.js +6 -1
- package/lib/commands.js +34 -2285
- package/lib/dialogs.js +58 -0
- package/lib/elements.js +360 -0
- package/lib/emulation.js +180 -0
- package/lib/help.js +298 -39
- package/lib/inspect.js +460 -0
- package/lib/modes.js +115 -0
- package/lib/network.js +59 -0
- package/lib/output.js +12 -2
- package/lib/requestlog.js +367 -0
- package/lib/routes.js +179 -0
- package/lib/runner.js +7 -3
- package/lib/send.js +9 -0
- package/lib/server.js +9 -2
- package/lib/skill.js +29 -0
- package/lib/start.js +10 -5
- package/lib/state.js +5 -0
- package/lib/tabs.js +237 -0
- package/lib/tabstate.js +35 -0
- package/lib/util.js +49 -0
- package/lib/watch.js +530 -0
- package/package.json +1 -1
- package/skill/SKILL.md +35 -12
package/lib/skill.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The agent skill, stamped as it is printed with the version it came from and a hash of its text,
|
|
2
|
+
// so a saved copy can be told apart from the skill of the pw-repl being run.
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const FILE = path.join(__dirname, '..', 'skill', 'SKILL.md');
|
|
8
|
+
const STAMP = '<!-- pw-repl skill stamp -->';
|
|
9
|
+
|
|
10
|
+
// Only the skill's own text counts: not the stamp line, nor the Custom rules a person adds to a
|
|
11
|
+
// saved copy. Line endings are made the same first, so a Windows checkout gets the same hash.
|
|
12
|
+
function hashOf(text) {
|
|
13
|
+
const own = text.replace(/\r\n?/g, '\n').split('\n## Custom rules\n')[0]
|
|
14
|
+
.split('\n').filter(line => line !== STAMP && !/^This skill is from pw-repl /.test(line)).join('\n').trimEnd();
|
|
15
|
+
return crypto.createHash('sha256').update(own).digest('hex').slice(0, 8);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function current() {
|
|
19
|
+
const text = fs.readFileSync(FILE, 'utf8');
|
|
20
|
+
return { text, hash: hashOf(text), version: require('../package.json').version };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// What `pw-repl skill` prints.
|
|
24
|
+
function stamped() {
|
|
25
|
+
const { text, hash, version } = current();
|
|
26
|
+
return text.replace(STAMP, `This skill is from pw-repl ${version} (skill ${hash}).`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { STAMP, hashOf, current, stamped };
|
package/lib/start.js
CHANGED
|
@@ -3,7 +3,8 @@ const { chromium } = require('playwright-core');
|
|
|
3
3
|
const readline = require('readline');
|
|
4
4
|
const { state, withTimeout, shutdown, onBeforeExit, beforeExit } = require('./state');
|
|
5
5
|
const out = require('./output');
|
|
6
|
-
const {
|
|
6
|
+
const { watchPage, complete } = require('./commands');
|
|
7
|
+
const { listTabs, openTab, markStartTab } = require('./tabs');
|
|
7
8
|
const runner = require('./runner');
|
|
8
9
|
|
|
9
10
|
// A browser --launch starts has its own address instead.
|
|
@@ -29,7 +30,9 @@ Or let pw-repl start a private one for this REPL: pw-repl run --launch (or serve
|
|
|
29
30
|
async function start(options) {
|
|
30
31
|
const START_URL = options.startUrl || process.env.PW_START_URL || null;
|
|
31
32
|
if (options.pidFile) require('./background').markStopInLog();
|
|
33
|
+
const serving = options.serve ? await require('./server').serve(options.endpoint) : null;
|
|
32
34
|
if (options.launch) {
|
|
35
|
+
state.startingStep = 'launching a Chromium of its own';
|
|
33
36
|
const launched = await require('./launch').launch({ headed: options.headed, extraArgs: options.chromeArgs });
|
|
34
37
|
onBeforeExit(launched.stop);
|
|
35
38
|
CDP_URL = launched.url;
|
|
@@ -41,6 +44,7 @@ async function start(options) {
|
|
|
41
44
|
state.cdpUrl = CDP_URL;
|
|
42
45
|
state.launched = !!options.launch;
|
|
43
46
|
out.log(`Connecting to ${CDP_URL}...`);
|
|
47
|
+
state.startingStep = 'connecting to the browser';
|
|
44
48
|
try { state.browser = await chromium.connectOverCDP(CDP_URL, { timeout: CONNECT_TIMEOUT }); }
|
|
45
49
|
catch (error) { throw new Error(connectHelp(error)); }
|
|
46
50
|
if (state.stopping) {
|
|
@@ -67,13 +71,14 @@ async function start(options) {
|
|
|
67
71
|
|
|
68
72
|
// Hooks first, so the start URL's own requests, logs and dialogs are recorded.
|
|
69
73
|
for (const ctx of contexts) {
|
|
70
|
-
ctx.on('page', watchPage);
|
|
71
|
-
ctx.pages().forEach(watchPage);
|
|
74
|
+
ctx.on('page', p => watchPage(p, true));
|
|
75
|
+
ctx.pages().forEach(p => watchPage(p));
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
if (START_URL) {
|
|
79
|
+
state.startingStep = 'opening the start URL';
|
|
75
80
|
state.page = await openTab();
|
|
76
|
-
|
|
81
|
+
markStartTab(state.page);
|
|
77
82
|
await state.page.goto(START_URL, { waitUntil: 'networkidle', timeout: 15000 });
|
|
78
83
|
out.log(`Opened ${state.page.url()} in a new tab`);
|
|
79
84
|
}
|
|
@@ -82,7 +87,7 @@ async function start(options) {
|
|
|
82
87
|
await listTabs();
|
|
83
88
|
out.log('');
|
|
84
89
|
if (!state.page) out.log('No tab is selected: tab new [url] opens one of your own, tab <index|url-part> selects one.');
|
|
85
|
-
if (
|
|
90
|
+
if (serving) serving();
|
|
86
91
|
// In the background there is no prompt: commands come only through the server.
|
|
87
92
|
if (options.pidFile) {
|
|
88
93
|
require('./background').claimPidFile(options.pidFile);
|
package/lib/state.js
CHANGED
|
@@ -14,6 +14,9 @@ const state = {
|
|
|
14
14
|
// The browser the REPL is connected to, and whether --launch started it.
|
|
15
15
|
cdpUrl: null,
|
|
16
16
|
launched: false,
|
|
17
|
+
// The command server takes commands; before, it answers that the REPL is starting, and what it is doing.
|
|
18
|
+
serving: false,
|
|
19
|
+
startingStep: 'starting',
|
|
17
20
|
stopping: false,
|
|
18
21
|
connectionLost: false,
|
|
19
22
|
shutdownFailed: false,
|
|
@@ -26,6 +29,8 @@ const state = {
|
|
|
26
29
|
const PER_CLIENT = { page: null, previousPage: null, tabListing: [], previousCommandAt: 0, currentCommandAt: 0 };
|
|
27
30
|
const clients = new Map();
|
|
28
31
|
|
|
32
|
+
out.onSelectedTab(() => state.page);
|
|
33
|
+
|
|
29
34
|
function clientRecord(name = state.client) {
|
|
30
35
|
if (!clients.has(name)) clients.set(name, { ...PER_CLIENT, tabListing: [] });
|
|
31
36
|
return clients.get(name);
|
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 };
|
package/lib/tabstate.js
ADDED
|
@@ -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 };
|