textweb 0.1.2 → 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.page.waitForLoadState('networkidle').catch(() => {});
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.page.waitForLoadState('networkidle').catch(() => {});
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() {
@@ -159,6 +356,56 @@ class AgentBrowser {
159
356
  }
160
357
  }
161
358
 
359
+ /**
360
+ * Get the current page URL
361
+ */
362
+ getCurrentUrl() {
363
+ return this.page ? this.page.url() : null;
364
+ }
365
+
366
+ /**
367
+ * Find elements matching a CSS selector
368
+ * Returns array of {tag, text, selector, visible} objects
369
+ */
370
+ async query(selector) {
371
+ if (!this.page) throw new Error('No page open. Call navigate() first.');
372
+ return await this.page.evaluate((sel) => {
373
+ const els = document.querySelectorAll(sel);
374
+ return Array.from(els).map((el, i) => ({
375
+ tag: el.tagName.toLowerCase(),
376
+ text: (el.textContent || '').trim().substring(0, 200),
377
+ selector: `${sel}:nth-child(${i + 1})`,
378
+ visible: el.offsetParent !== null,
379
+ href: el.href || null,
380
+ value: el.value || null,
381
+ }));
382
+ }, selector);
383
+ }
384
+
385
+ /**
386
+ * Take a screenshot (for debugging)
387
+ * @param {object} options - Playwright screenshot options (path, fullPage, type, etc.)
388
+ */
389
+ async screenshot(options = {}) {
390
+ if (!this.page) throw new Error('No page open. Call navigate() first.');
391
+ return await this.page.screenshot({
392
+ fullPage: true,
393
+ type: 'png',
394
+ ...options,
395
+ });
396
+ }
397
+
398
+ /**
399
+ * Wait for page to settle after an interaction.
400
+ * Races networkidle against a short timeout to avoid hanging on SPAs.
401
+ */
402
+ async _settle() {
403
+ await Promise.race([
404
+ this.page.waitForLoadState('networkidle').catch(() => {}),
405
+ new Promise(r => setTimeout(r, 3000)),
406
+ ]);
407
+ }
408
+
162
409
  _getElement(ref) {
163
410
  if (!this.lastResult) throw new Error('No snapshot. Navigate first.');
164
411
  const el = this.lastResult.elements[ref];
package/src/cli.js CHANGED
@@ -17,7 +17,6 @@ function parseArgs() {
17
17
  json: false,
18
18
  serve: false,
19
19
  cols: 100,
20
- rows: 30,
21
20
  port: 3000,
22
21
  help: false
23
22
  };
@@ -48,7 +47,9 @@ function parseArgs() {
48
47
 
49
48
  case '--rows':
50
49
  case '-r':
51
- options.rows = parseInt(args[++i]) || 30;
50
+ // Deprecated: height is dynamic (grows to fit content). Ignored.
51
+ console.error('Warning: --rows is deprecated. Height is dynamic (grows to fit content).');
52
+ args[++i]; // consume the value
52
53
  break;
53
54
 
54
55
  case '--port':
@@ -85,7 +86,7 @@ USAGE:
85
86
 
86
87
  OPTIONS:
87
88
  --cols, -c <number> Grid width in characters (default: 100)
88
- --rows, -r <number> Grid height in characters (default: 30)
89
+ --rows, -r <number> (deprecated, height is dynamic)
89
90
  --port, -p <number> Server port (default: 3000)
90
91
  --interactive, -i Interactive REPL mode
91
92
  --json, -j JSON output format
@@ -117,7 +118,7 @@ INTERACTIVE COMMANDS:
117
118
  async function render(url, options) {
118
119
  const browser = new AgentBrowser({
119
120
  cols: options.cols,
120
- rows: options.rows,
121
+
121
122
  headless: true
122
123
  });
123
124
 
@@ -156,7 +157,7 @@ async function render(url, options) {
156
157
  async function interactive(url, options) {
157
158
  const browser = new AgentBrowser({
158
159
  cols: options.cols,
159
- rows: options.rows,
160
+
160
161
  headless: true
161
162
  });
162
163
 
@@ -367,7 +368,7 @@ async function serve(options) {
367
368
 
368
369
  const server = createServer({
369
370
  cols: options.cols,
370
- rows: options.rows
371
+
371
372
  });
372
373
 
373
374
  server.listen(options.port, () => {
package/src/renderer.js CHANGED
@@ -7,8 +7,8 @@
7
7
  * Key design decisions:
8
8
  * - Overflow > truncation (never lose information)
9
9
  * - Measure actual font metrics from the page
10
- * - Table-aware layout
11
- * - Z-index compositing (back to front)
10
+ * - Row-grouping layout (elements grouped by Y position)
11
+ * - Dynamic height (grows to fit all content)
12
12
  */
13
13
 
14
14
  /**
@@ -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);
@@ -277,44 +311,6 @@ async function extractElements(page) {
277
311
  });
278
312
  }
279
313
 
280
- /**
281
- * Place text onto the grid, allowing overflow (never truncate).
282
- * Text wraps to the next line at grid edge, continuing at the same start column.
283
- */
284
- function placeText(grid, zGrid, z, row, col, text, cols, rows) {
285
- let r = row;
286
- let c = col;
287
-
288
- for (let i = 0; i < text.length; i++) {
289
- // Grow grid vertically if needed (overflow — don't lose data)
290
- while (r >= grid.length) {
291
- grid.push(Array(cols).fill(' '));
292
- zGrid.push(Array(cols).fill(-1));
293
- }
294
-
295
- if (c >= cols) {
296
- // Wrap to next line at original column position
297
- r++;
298
- c = col;
299
- while (r >= grid.length) {
300
- grid.push(Array(cols).fill(' '));
301
- zGrid.push(Array(cols).fill(-1));
302
- }
303
- }
304
-
305
- const ch = text[i];
306
- if (ch === '\n') { r++; c = col; continue; }
307
-
308
- if (c >= 0 && c < cols && z >= zGrid[r][c]) {
309
- grid[r][c] = ch;
310
- zGrid[r][c] = z;
311
- }
312
- c++;
313
- }
314
-
315
- return r; // Return last row written to (useful for tracking grid growth)
316
- }
317
-
318
314
  /**
319
315
  * Detect row boundaries — groups of elements that share the same Y position
320
316
  * This prevents text from different elements on the same visual line from overlapping
@@ -464,6 +460,7 @@ function renderGrid(elements, cols, charW, charH, scrollY = 0) {
464
460
  */
465
461
  async function render(page, options = {}) {
466
462
  const { cols = 120, scrollY = 0 } = options;
463
+ const startMs = Date.now();
467
464
 
468
465
  // Measure actual font metrics from the page
469
466
  const metrics = await measureCharSize(page);
@@ -471,7 +468,16 @@ async function render(page, options = {}) {
471
468
  const charH = metrics.charH;
472
469
 
473
470
  const elements = await extractElements(page);
474
- 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;
475
481
  }
476
482
 
477
483
  module.exports = { render, extractElements, renderGrid, measureCharSize };
package/src/server.js CHANGED
@@ -10,7 +10,7 @@ class TextWebServer {
10
10
  constructor(options = {}) {
11
11
  this.options = {
12
12
  cols: options.cols || 100,
13
- rows: options.rows || 30,
13
+ // rows is deprecated — height is dynamic
14
14
  timeout: options.timeout || 30000,
15
15
  ...options
16
16
  };
@@ -33,7 +33,6 @@ class TextWebServer {
33
33
  if (!this.browser) {
34
34
  this.browser = new AgentBrowser({
35
35
  cols: this.options.cols,
36
- rows: this.options.rows,
37
36
  headless: true,
38
37
  timeout: this.options.timeout
39
38
  });