textweb 0.2.0 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -0
- package/logo.svg +30 -0
- package/mcp/index.js +220 -26
- package/package.json +13 -2
- package/src/browser.js +230 -33
- package/src/cli.js +4 -1
- package/src/ensure-browser.js +92 -0
- package/src/renderer.js +50 -6
- package/tools/tool_definitions.json +299 -24
- package/canvas/dashboard.html +0 -153
- package/docs/index.html +0 -761
package/src/browser.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
const { chromium } = require('playwright');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_VIEWPORT = { width: 1280, height: 800 };
|
|
8
|
+
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
9
|
+
|
|
6
10
|
const { render } = require('./renderer');
|
|
7
11
|
|
|
8
12
|
class AgentBrowser {
|
|
@@ -15,32 +19,75 @@ class AgentBrowser {
|
|
|
15
19
|
this.lastResult = null;
|
|
16
20
|
this.headless = options.headless !== false;
|
|
17
21
|
this.charH = 16; // default, updated after first render
|
|
22
|
+
this.defaultTimeout = options.timeout || 30000;
|
|
23
|
+
this.defaultRetries = options.retries ?? 2;
|
|
24
|
+
this.defaultRetryDelayMs = options.retryDelayMs ?? 250;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
|
-
async
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
async _withRetries(actionName, fn, options = {}) {
|
|
28
|
+
const retries = options.retries ?? this.defaultRetries;
|
|
29
|
+
const retryDelayMs = options.retryDelayMs ?? this.defaultRetryDelayMs;
|
|
30
|
+
|
|
31
|
+
let lastError = null;
|
|
32
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
33
|
+
try {
|
|
34
|
+
return await fn();
|
|
35
|
+
} catch (err) {
|
|
36
|
+
lastError = err;
|
|
37
|
+
if (attempt >= retries) break;
|
|
38
|
+
await new Promise(r => setTimeout(r, retryDelayMs));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw new Error(`${actionName} failed after ${retries + 1} attempt(s): ${lastError?.message || 'unknown error'}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_contextOptions(storageStatePath = null) {
|
|
46
|
+
const opts = {
|
|
47
|
+
viewport: DEFAULT_VIEWPORT,
|
|
48
|
+
userAgent: DEFAULT_USER_AGENT,
|
|
49
|
+
};
|
|
50
|
+
if (storageStatePath) {
|
|
51
|
+
opts.storageState = storageStatePath;
|
|
52
|
+
}
|
|
53
|
+
return opts;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async _createContext(storageStatePath = null) {
|
|
57
|
+
this.context = await this.browser.newContext(this._contextOptions(storageStatePath));
|
|
29
58
|
this.page = await this.context.newPage();
|
|
30
|
-
this.page.setDefaultTimeout(
|
|
59
|
+
this.page.setDefaultTimeout(this.defaultTimeout);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async launch(options = {}) {
|
|
63
|
+
if (!this.browser) {
|
|
64
|
+
this.browser = await chromium.launch({
|
|
65
|
+
headless: this.headless,
|
|
66
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!this.context) {
|
|
71
|
+
await this._createContext(options.storageStatePath || null);
|
|
72
|
+
}
|
|
73
|
+
|
|
31
74
|
return this;
|
|
32
75
|
}
|
|
33
76
|
|
|
34
|
-
async navigate(url) {
|
|
77
|
+
async navigate(url, options = {}) {
|
|
35
78
|
if (!this.page) await this.launch();
|
|
36
79
|
this.scrollY = 0;
|
|
37
|
-
|
|
38
|
-
await this.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
80
|
+
|
|
81
|
+
await this._withRetries('navigate', async () => {
|
|
82
|
+
// Use domcontentloaded + a short settle, not networkidle (SPAs never go idle)
|
|
83
|
+
await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: options.timeoutMs || this.defaultTimeout });
|
|
84
|
+
// Wait for network to settle or 3s max — whichever comes first
|
|
85
|
+
await Promise.race([
|
|
86
|
+
this.page.waitForLoadState('networkidle').catch(() => {}),
|
|
87
|
+
new Promise(r => setTimeout(r, 3000)),
|
|
88
|
+
]);
|
|
89
|
+
}, options);
|
|
90
|
+
|
|
44
91
|
return await this.snapshot();
|
|
45
92
|
}
|
|
46
93
|
|
|
@@ -57,17 +104,21 @@ class AgentBrowser {
|
|
|
57
104
|
return this.lastResult;
|
|
58
105
|
}
|
|
59
106
|
|
|
60
|
-
async click(ref) {
|
|
107
|
+
async click(ref, options = {}) {
|
|
61
108
|
const el = this._getElement(ref);
|
|
62
|
-
await this.
|
|
63
|
-
|
|
109
|
+
await this._withRetries(`click ref=${ref}`, async () => {
|
|
110
|
+
await this.page.click(el.selector);
|
|
111
|
+
await this._settle();
|
|
112
|
+
}, options);
|
|
64
113
|
return await this.snapshot();
|
|
65
114
|
}
|
|
66
115
|
|
|
67
|
-
async type(ref, text) {
|
|
116
|
+
async type(ref, text, options = {}) {
|
|
68
117
|
const el = this._getElement(ref);
|
|
69
|
-
await this.
|
|
70
|
-
|
|
118
|
+
await this._withRetries(`type ref=${ref}`, async () => {
|
|
119
|
+
await this.page.click(el.selector);
|
|
120
|
+
await this.page.fill(el.selector, text);
|
|
121
|
+
}, options);
|
|
71
122
|
return await this.snapshot();
|
|
72
123
|
}
|
|
73
124
|
|
|
@@ -101,22 +152,28 @@ class AgentBrowser {
|
|
|
101
152
|
await this.page.setInputFiles(selector, paths);
|
|
102
153
|
}
|
|
103
154
|
|
|
104
|
-
async press(key) {
|
|
105
|
-
await this.
|
|
106
|
-
|
|
155
|
+
async press(key, options = {}) {
|
|
156
|
+
await this._withRetries(`press key=${key}`, async () => {
|
|
157
|
+
await this.page.keyboard.press(key);
|
|
158
|
+
await this._settle();
|
|
159
|
+
}, options);
|
|
107
160
|
return await this.snapshot();
|
|
108
161
|
}
|
|
109
162
|
|
|
110
|
-
async upload(ref, filePaths) {
|
|
163
|
+
async upload(ref, filePaths, options = {}) {
|
|
111
164
|
const el = this._getElement(ref);
|
|
112
165
|
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
|
113
|
-
await this.
|
|
166
|
+
await this._withRetries(`upload ref=${ref}`, async () => {
|
|
167
|
+
await this.page.setInputFiles(el.selector, paths);
|
|
168
|
+
}, options);
|
|
114
169
|
return await this.snapshot();
|
|
115
170
|
}
|
|
116
171
|
|
|
117
|
-
async select(ref, value) {
|
|
172
|
+
async select(ref, value, options = {}) {
|
|
118
173
|
const el = this._getElement(ref);
|
|
119
|
-
await this.
|
|
174
|
+
await this._withRetries(`select ref=${ref}`, async () => {
|
|
175
|
+
await this.page.selectOption(el.selector, value);
|
|
176
|
+
}, options);
|
|
120
177
|
return await this.snapshot();
|
|
121
178
|
}
|
|
122
179
|
|
|
@@ -146,8 +203,148 @@ class AgentBrowser {
|
|
|
146
203
|
return region.join('\n');
|
|
147
204
|
}
|
|
148
205
|
|
|
149
|
-
async evaluate(fn) {
|
|
150
|
-
return await this.page.evaluate(fn);
|
|
206
|
+
async evaluate(fn, arg) {
|
|
207
|
+
return await this.page.evaluate(fn, arg);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Save cookies/localStorage/sessionStorage state to disk
|
|
212
|
+
*/
|
|
213
|
+
async saveStorageState(path) {
|
|
214
|
+
if (!this.context) throw new Error('No browser context open.');
|
|
215
|
+
await this.context.storageState({ path });
|
|
216
|
+
return { saved: true, path };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Load cookies/localStorage/sessionStorage state from disk into a fresh context
|
|
221
|
+
*/
|
|
222
|
+
async loadStorageState(path) {
|
|
223
|
+
if (!this.browser) {
|
|
224
|
+
await this.launch();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (this.context) {
|
|
228
|
+
await this.context.close();
|
|
229
|
+
this.context = null;
|
|
230
|
+
this.page = null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await this._createContext(path);
|
|
234
|
+
this.scrollY = 0;
|
|
235
|
+
this.lastResult = null;
|
|
236
|
+
return { loaded: true, path };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Wait until one or more conditions are true, then return a fresh snapshot.
|
|
241
|
+
* Supported conditions: selector, text, urlIncludes.
|
|
242
|
+
*/
|
|
243
|
+
async waitFor(options = {}) {
|
|
244
|
+
if (!this.page) throw new Error('No page open. Call navigate() first.');
|
|
245
|
+
|
|
246
|
+
const timeout = options.timeoutMs || this.defaultTimeout;
|
|
247
|
+
const pollMs = options.pollMs || 100;
|
|
248
|
+
|
|
249
|
+
await this._withRetries('waitFor', async () => {
|
|
250
|
+
const waits = [];
|
|
251
|
+
|
|
252
|
+
if (options.selector) {
|
|
253
|
+
waits.push(
|
|
254
|
+
this.page.waitForSelector(options.selector, {
|
|
255
|
+
state: options.state || 'visible',
|
|
256
|
+
timeout,
|
|
257
|
+
})
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (options.text) {
|
|
262
|
+
waits.push(
|
|
263
|
+
this.page.waitForFunction(
|
|
264
|
+
(text) => document.body && document.body.innerText.includes(text),
|
|
265
|
+
options.text,
|
|
266
|
+
{ timeout, polling: pollMs }
|
|
267
|
+
)
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (options.urlIncludes) {
|
|
272
|
+
waits.push(
|
|
273
|
+
this.page.waitForFunction(
|
|
274
|
+
(needle) => window.location.href.includes(needle),
|
|
275
|
+
options.urlIncludes,
|
|
276
|
+
{ timeout, polling: pollMs }
|
|
277
|
+
)
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!waits.length) {
|
|
282
|
+
await this.page.waitForTimeout(timeout);
|
|
283
|
+
} else {
|
|
284
|
+
await Promise.all(waits);
|
|
285
|
+
}
|
|
286
|
+
}, options);
|
|
287
|
+
|
|
288
|
+
await this._settle();
|
|
289
|
+
return await this.snapshot();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Assert a field's value/text by ref.
|
|
294
|
+
* comparator: equals | includes | regex | not_empty
|
|
295
|
+
*/
|
|
296
|
+
async assertField(ref, expected, options = {}) {
|
|
297
|
+
if (!this.page) throw new Error('No page open. Call navigate() first.');
|
|
298
|
+
const el = this._getElement(ref);
|
|
299
|
+
const comparator = options.comparator || 'equals';
|
|
300
|
+
const attribute = options.attribute || null;
|
|
301
|
+
|
|
302
|
+
const actual = await this.page.evaluate(({ selector, attributeName }) => {
|
|
303
|
+
const target = document.querySelector(selector);
|
|
304
|
+
if (!target) return null;
|
|
305
|
+
|
|
306
|
+
if (attributeName) {
|
|
307
|
+
return target.getAttribute(attributeName);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const tag = (target.tagName || '').toLowerCase();
|
|
311
|
+
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
|
|
312
|
+
return target.value ?? '';
|
|
313
|
+
}
|
|
314
|
+
return (target.textContent || '').trim();
|
|
315
|
+
}, { selector: el.selector, attributeName: attribute });
|
|
316
|
+
|
|
317
|
+
let pass = false;
|
|
318
|
+
const actualStr = actual == null ? '' : String(actual);
|
|
319
|
+
const expectedStr = expected == null ? '' : String(expected);
|
|
320
|
+
|
|
321
|
+
switch (comparator) {
|
|
322
|
+
case 'equals':
|
|
323
|
+
pass = actualStr === expectedStr;
|
|
324
|
+
break;
|
|
325
|
+
case 'includes':
|
|
326
|
+
pass = actualStr.includes(expectedStr);
|
|
327
|
+
break;
|
|
328
|
+
case 'regex': {
|
|
329
|
+
const re = new RegExp(expectedStr);
|
|
330
|
+
pass = re.test(actualStr);
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case 'not_empty':
|
|
334
|
+
pass = actualStr.trim().length > 0;
|
|
335
|
+
break;
|
|
336
|
+
default:
|
|
337
|
+
throw new Error(`Unknown comparator: ${comparator}`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
pass,
|
|
342
|
+
ref,
|
|
343
|
+
selector: el.selector,
|
|
344
|
+
comparator,
|
|
345
|
+
expected: expectedStr,
|
|
346
|
+
actual: actualStr,
|
|
347
|
+
};
|
|
151
348
|
}
|
|
152
349
|
|
|
153
350
|
async close() {
|
package/src/cli.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
const { AgentBrowser } = require('./browser');
|
|
8
8
|
const { createServer } = require('./server');
|
|
9
|
+
const { ensureBrowser } = require('./ensure-browser');
|
|
9
10
|
const readline = require('readline');
|
|
10
11
|
|
|
11
12
|
// Parse command line arguments
|
|
@@ -392,7 +393,9 @@ async function main() {
|
|
|
392
393
|
showHelp();
|
|
393
394
|
return;
|
|
394
395
|
}
|
|
395
|
-
|
|
396
|
+
|
|
397
|
+
await ensureBrowser();
|
|
398
|
+
|
|
396
399
|
if (options.serve) {
|
|
397
400
|
await serve(options);
|
|
398
401
|
} else if (options.interactive) {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ensure-browser.js
|
|
5
|
+
*
|
|
6
|
+
* Checks whether Playwright's Chromium is installed and, when running
|
|
7
|
+
* in an interactive terminal, offers to install it automatically.
|
|
8
|
+
*
|
|
9
|
+
* All output goes to stderr so it never pollutes stdout-based protocols
|
|
10
|
+
* (e.g. the MCP JSON-RPC transport).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const { spawnSync } = require('child_process');
|
|
15
|
+
const readline = require('readline');
|
|
16
|
+
|
|
17
|
+
function ask(prompt) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
20
|
+
rl.question(prompt, (answer) => {
|
|
21
|
+
rl.close();
|
|
22
|
+
resolve(answer.trim());
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function log(msg) {
|
|
28
|
+
process.stderr.write(msg + '\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isInstalled() {
|
|
32
|
+
try {
|
|
33
|
+
const { chromium } = require('playwright');
|
|
34
|
+
const execPath = chromium.executablePath();
|
|
35
|
+
return fs.existsSync(execPath);
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function install() {
|
|
42
|
+
// Prefer the local playwright CLI (bundled with the dep) over npx
|
|
43
|
+
let cliPath = null;
|
|
44
|
+
try {
|
|
45
|
+
cliPath = require.resolve('playwright/cli.js');
|
|
46
|
+
} catch { /* fall through to npx */ }
|
|
47
|
+
|
|
48
|
+
const result = cliPath
|
|
49
|
+
? spawnSync(process.execPath, [cliPath, 'install', 'chromium'], { stdio: 'inherit' })
|
|
50
|
+
: spawnSync('npx', ['playwright', 'install', 'chromium'], { stdio: 'inherit', shell: true });
|
|
51
|
+
|
|
52
|
+
return result.status === 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function ensureBrowser() {
|
|
56
|
+
if (isInstalled()) return;
|
|
57
|
+
|
|
58
|
+
log('');
|
|
59
|
+
log('⚠ Playwright Chromium is not installed — textweb needs it to browse the web.');
|
|
60
|
+
|
|
61
|
+
if (process.stdin.isTTY) {
|
|
62
|
+
const answer = await ask(' Install it now? (Y/n) ');
|
|
63
|
+
if (answer.toLowerCase() === 'n') {
|
|
64
|
+
log('');
|
|
65
|
+
log(' Run manually: npx playwright install chromium');
|
|
66
|
+
log('');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
log('');
|
|
71
|
+
log(' Run: npx playwright install chromium');
|
|
72
|
+
log('');
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
log('');
|
|
77
|
+
log(' Installing Chromium — this takes ~30 seconds the first time…');
|
|
78
|
+
log('');
|
|
79
|
+
|
|
80
|
+
if (!install()) {
|
|
81
|
+
log('');
|
|
82
|
+
log(' Installation failed. Run manually: npx playwright install chromium');
|
|
83
|
+
log('');
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
log('');
|
|
88
|
+
log('✓ Chromium installed successfully!');
|
|
89
|
+
log('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { ensureBrowser };
|
package/src/renderer.js
CHANGED
|
@@ -80,14 +80,48 @@ async function extractElements(page) {
|
|
|
80
80
|
|
|
81
81
|
function buildSelector(el) {
|
|
82
82
|
// Build a robust CSS selector for clicking
|
|
83
|
+
// Priority: id > data-testid > aria > role+name > name > positional
|
|
83
84
|
if (el.id) return '#' + CSS.escape(el.id);
|
|
84
85
|
|
|
85
|
-
// Try unique attributes
|
|
86
|
-
if (el.getAttribute('data-testid')) return `[data-testid="${el.getAttribute('data-testid')}"]`;
|
|
87
|
-
if (el.getAttribute('name')) return `${el.tagName.toLowerCase()}[name="${el.getAttribute('name')}"]`;
|
|
88
|
-
|
|
89
|
-
// Fallback: positional selector
|
|
90
86
|
const tag = el.tagName.toLowerCase();
|
|
87
|
+
|
|
88
|
+
// Stable test attributes (used by many frameworks)
|
|
89
|
+
for (const attr of ['data-testid', 'data-test', 'data-cy', 'data-test-id']) {
|
|
90
|
+
const val = el.getAttribute(attr);
|
|
91
|
+
if (val) return `[${attr}="${val}"]`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Aria-label (very stable, set by developers intentionally)
|
|
95
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
96
|
+
if (ariaLabel) {
|
|
97
|
+
const sel = `${tag}[aria-label="${CSS.escape(ariaLabel)}"]`;
|
|
98
|
+
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Role + name combination
|
|
102
|
+
const role = el.getAttribute('role');
|
|
103
|
+
if (role) {
|
|
104
|
+
const name = ariaLabel || el.textContent.trim().substring(0, 50);
|
|
105
|
+
if (name) {
|
|
106
|
+
const sel = `[role="${role}"]`;
|
|
107
|
+
// Only use if unique enough
|
|
108
|
+
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Name attribute (forms)
|
|
113
|
+
if (el.getAttribute('name')) return `${tag}[name="${el.getAttribute('name')}"]`;
|
|
114
|
+
|
|
115
|
+
// href for links (use partial match for stability)
|
|
116
|
+
if (tag === 'a' && el.href) {
|
|
117
|
+
const href = el.getAttribute('href');
|
|
118
|
+
if (href && !href.startsWith('javascript:') && href !== '#') {
|
|
119
|
+
const sel = `a[href="${CSS.escape(href)}"]`;
|
|
120
|
+
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Fallback: positional selector (least stable)
|
|
91
125
|
const parent = el.parentElement;
|
|
92
126
|
if (!parent) return tag;
|
|
93
127
|
const siblings = Array.from(parent.children);
|
|
@@ -426,6 +460,7 @@ function renderGrid(elements, cols, charW, charH, scrollY = 0) {
|
|
|
426
460
|
*/
|
|
427
461
|
async function render(page, options = {}) {
|
|
428
462
|
const { cols = 120, scrollY = 0 } = options;
|
|
463
|
+
const startMs = Date.now();
|
|
429
464
|
|
|
430
465
|
// Measure actual font metrics from the page
|
|
431
466
|
const metrics = await measureCharSize(page);
|
|
@@ -433,7 +468,16 @@ async function render(page, options = {}) {
|
|
|
433
468
|
const charH = metrics.charH;
|
|
434
469
|
|
|
435
470
|
const elements = await extractElements(page);
|
|
436
|
-
|
|
471
|
+
const result = renderGrid(elements, cols, charW, charH, scrollY);
|
|
472
|
+
|
|
473
|
+
// Add stats to meta
|
|
474
|
+
result.meta.stats = {
|
|
475
|
+
totalElements: elements.length,
|
|
476
|
+
interactiveElements: result.meta.totalRefs,
|
|
477
|
+
renderMs: Date.now() - startMs,
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
return result;
|
|
437
481
|
}
|
|
438
482
|
|
|
439
483
|
module.exports = { render, extractElements, renderGrid, measureCharSize };
|