textweb 0.2.0 → 0.2.3

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/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 launch() {
21
- this.browser = await chromium.launch({
22
- headless: this.headless,
23
- args: ['--no-sandbox', '--disable-setuid-sandbox'],
24
- });
25
- this.context = await this.browser.newContext({
26
- viewport: { width: 1280, height: 800 },
27
- userAgent: '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',
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(30000);
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
- // Use domcontentloaded + a short settle, not networkidle (SPAs never go idle)
38
- await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
39
- // Wait for network to settle or 3s max whichever comes first
40
- await Promise.race([
41
- this.page.waitForLoadState('networkidle').catch(() => {}),
42
- new Promise(r => setTimeout(r, 3000)),
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.page.click(el.selector);
63
- await this._settle();
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.page.click(el.selector);
70
- await this.page.fill(el.selector, text);
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.page.keyboard.press(key);
106
- await this._settle();
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.page.setInputFiles(el.selector, paths);
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.page.selectOption(el.selector, value);
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/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
- return renderGrid(elements, cols, charW, charH, scrollY);
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 };