testronaut 2.0.1 → 2.0.2

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.
@@ -0,0 +1,34 @@
1
+ name: Unit Tests
2
+
3
+ on:
4
+ pull_request:
5
+ workflow_dispatch:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ concurrency:
11
+ group: unit-tests-${{ github.event.pull_request.number || github.ref }}
12
+ cancel-in-progress: true
13
+
14
+ jobs:
15
+ test:
16
+ name: Unit Tests
17
+ runs-on: ubuntu-latest
18
+ timeout-minutes: 10
19
+
20
+ steps:
21
+ - name: Checkout
22
+ uses: actions/checkout@v4
23
+
24
+ - name: Set up Node.js
25
+ uses: actions/setup-node@v4
26
+ with:
27
+ node-version: 24
28
+ cache: npm
29
+
30
+ - name: Install dependencies
31
+ run: npm ci
32
+
33
+ - name: Run unit tests
34
+ run: npm test
package/TODO.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  ## Browser Automation Follow-Ups
13
13
 
14
- - [ ] Fix `click_text` so it prioritizes exact-text actionable elements (`button`, `a`, and elements with interactive roles) instead of clicking the first container whose descendant text includes the target. Define an explicit fallback for partial matches and add regression tests covering nested containers, duplicate labels, hidden elements, and the Add, Logout, and Report failures observed in the examples run.
14
+ - [x] Make `click_text` match exact text, prefer the innermost visible element, and fail quickly instead of clicking an ancestor or partial match.
15
15
 
16
16
  ## Session and Reporting Follow-Ups
17
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "bin/cli.js"
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ChromeBrowser } from '../../tools/chromeBrowser.js';
3
+
4
+ describe('ChromeBrowser.click_text', () => {
5
+ it('clicks the first visible innermost exact-text match with a short timeout', async () => {
6
+ const hidden = {
7
+ isVisible: vi.fn().mockResolvedValue(false),
8
+ };
9
+ const visible = {
10
+ isVisible: vi.fn().mockResolvedValue(true),
11
+ scrollIntoViewIfNeeded: vi.fn().mockResolvedValue(undefined),
12
+ click: vi.fn().mockResolvedValue(undefined),
13
+ };
14
+ const matches = {
15
+ count: vi.fn().mockResolvedValue(2),
16
+ nth: vi.fn(index => [hidden, visible][index]),
17
+ };
18
+ const browser = Object.create(ChromeBrowser.prototype);
19
+ browser.page = {
20
+ getByText: vi.fn().mockReturnValue(matches),
21
+ waitForTimeout: vi.fn().mockResolvedValue(undefined),
22
+ };
23
+
24
+ await expect(browser.click_text({ text: 'Report' })).resolves.toBe('clicked text "Report"');
25
+ expect(browser.page.getByText).toHaveBeenCalledWith('Report', { exact: true });
26
+ expect(visible.click).toHaveBeenCalledWith({ timeout: 3000 });
27
+ expect(browser.page.waitForTimeout).toHaveBeenCalledWith(500);
28
+ });
29
+
30
+ it('does not fall back to a partial or hidden match', async () => {
31
+ const matches = {
32
+ count: vi.fn().mockResolvedValue(1),
33
+ nth: vi.fn().mockReturnValue({ isVisible: vi.fn().mockResolvedValue(false) }),
34
+ };
35
+ const browser = Object.create(ChromeBrowser.prototype);
36
+ browser.page = {
37
+ getByText: vi.fn().mockReturnValue(matches),
38
+ waitForTimeout: vi.fn(),
39
+ };
40
+
41
+ await expect(browser.click_text({ text: 'Add' })).rejects.toThrow('Exact visible text "Add" not found');
42
+ expect(browser.page.getByText).toHaveBeenCalledWith('Add', { exact: true });
43
+ });
44
+ });
@@ -35,12 +35,13 @@ describe('domControl helpers', () => {
35
35
  </head>
36
36
  <body data-theme="dark">
37
37
  <!-- comment -->
38
- <div id="main" data-testid="main" onclick="alert('x')">
38
+ <div id="main" data-testid="main" role="region" data-state="open"
39
+ aria-expanded="true" aria-selected="false" onclick="alert('x')">
39
40
  <iframe src="frame.html"></iframe>
40
41
  <canvas></canvas>
41
42
  <svg></svg>
42
43
  <a href="/home" rel="noopener" target="_blank">Home</a>
43
- <input id="email" name="email" type="email" data-tracking="abc" />
44
+ <input id="email" name="email" type="email" disabled data-tracking="abc" />
44
45
  </div>
45
46
  </body>
46
47
  </html>
@@ -66,13 +67,18 @@ describe('domControl helpers', () => {
66
67
  // Whitelisted attrs preserved
67
68
  const main = $('#main');
68
69
  expect(main.attr('id')).toBe('main');
69
- expect(main.attr('data-testid')).toBeUndefined();
70
+ expect(main.attr('data-testid')).toBe('main');
71
+ expect(main.attr('role')).toBe('region');
72
+ expect(main.attr('data-state')).toBe('open');
73
+ expect(main.attr('aria-expanded')).toBe('true');
74
+ expect(main.attr('aria-selected')).toBe('false');
70
75
  expect(main.attr('onclick')).toBeUndefined();
71
76
 
72
77
  const input = $('#email');
73
78
  expect(input.attr('id')).toBe('email');
74
79
  expect(input.attr('name')).toBe('email');
75
80
  expect(input.attr('type')).toBe('email');
81
+ expect(input.attr('disabled')).toBeDefined();
76
82
  expect(input.attr('data-tracking')).toBeUndefined();
77
83
 
78
84
  const link = $('a[href="/home"]');
@@ -1025,9 +1025,8 @@ export class ChromeBrowser {
1025
1025
  $ = await removeNonContentTags($);
1026
1026
  $ = await reduceRepeatedDivs($, listLimit);
1027
1027
  reduceTableRows($, listLimit);
1028
- // If you later re-enable:
1029
- // $ = await removeObfuscatedClassNames($);
1030
- // $ = await removeAutoGeneratedClasses($);
1028
+ $ = removeObfuscatedClassNames($);
1029
+ $ = removeAutoGeneratedClasses($);
1031
1030
  }
1032
1031
 
1033
1032
  // Minify whitespace *after* structural cleanup
@@ -1140,21 +1139,24 @@ export class ChromeBrowser {
1140
1139
  return html.includes(text) ? `FOUND: ${text}` : `NOT FOUND: ${text}`;
1141
1140
  }
1142
1141
 
1143
- async click_text({ text }) {
1142
+ async click_text({ text, timeout = 3000 }) {
1144
1143
  console.log("attempting to click text with normal click:", text);
1145
1144
 
1146
- const elements = await this.page.$$('button, a, span, div, li, p'); // expand as needed
1147
- for (const el of elements) {
1148
- const innerText = await el.innerText().catch(() => '');
1149
- if (innerText.includes(text)) {
1150
- await el.scrollIntoViewIfNeeded().catch(() => {});
1151
- await el.click();
1152
- await this.page.waitForTimeout(5000);
1153
- return `clicked text "${text}"`;
1154
-
1155
- }
1145
+ // Playwright's exact text engine normalizes whitespace and resolves to the
1146
+ // innermost matching elements, avoiding ancestor containers that merely
1147
+ // contain the requested label.
1148
+ const matches = this.page.getByText(text, { exact: true });
1149
+ const count = await matches.count();
1150
+ for (let index = 0; index < count; index++) {
1151
+ const match = matches.nth(index);
1152
+ if (!await match.isVisible().catch(() => false)) continue;
1153
+
1154
+ await match.scrollIntoViewIfNeeded().catch(() => {});
1155
+ await match.click({ timeout });
1156
+ await this.page.waitForTimeout(500);
1157
+ return `clicked text "${text}"`;
1156
1158
  }
1157
- throw new Error(`Element with text "${text}" not found`);
1159
+ throw new Error(`Exact visible text "${text}" not found`);
1158
1160
  }
1159
1161
 
1160
1162
  async click_text_xpath({ text, delayMs = 1000 }) {
@@ -37,7 +37,8 @@ export const removeNonContentTags = async ($) => {
37
37
  const $el = $(el);
38
38
  const allowedAttrs = [
39
39
  'href', 'src', 'type', 'alt', 'title',
40
- 'placeholder', 'name', 'id', 'class', 'aria-label', 'value',
40
+ 'placeholder', 'name', 'id', 'class', 'value',
41
+ 'data-testid', 'disabled', 'role', 'data-state',
41
42
  // keep key data-* attributes that help identify rows/items without bloating too much
42
43
  'data-type', 'data-id', 'data-title', 'data-parentid', 'data-order'
43
44
  ];
@@ -51,7 +52,7 @@ export const removeNonContentTags = async ($) => {
51
52
  // }
52
53
 
53
54
  for (const attr in attrs) {
54
- if (!allowedAttrs.includes(attr)) {
55
+ if (!allowedAttrs.includes(attr) && !attr.startsWith('aria-')) {
55
56
  $el.removeAttr(attr);
56
57
  }
57
58
  }