testronaut 2.0.1 → 2.0.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/.github/workflows/unit-tests.yml +34 -0
- package/TODO.md +2 -1
- package/package.json +1 -1
- package/tests/toolsTests/chromeBrowserClickText.test.js +72 -0
- package/tests/toolsTests/domControl.test.js +12 -4
- package/tools/chromeBrowser.js +25 -17
- package/tools/domControl.js +5 -3
- package/tools/toolSchema.js +1 -1
|
@@ -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,8 @@
|
|
|
11
11
|
|
|
12
12
|
## Browser Automation Follow-Ups
|
|
13
13
|
|
|
14
|
-
- [
|
|
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
|
+
- [ ] Replace framework/vendor-specific DOM class stripping with a framework-agnostic classifier that removes generated hashes, styling utilities, variants, and arbitrary selectors while preserving semantic component classes. Reconsider broad prefix rules such as `/^cl-/`, cap retained class count and length, and add representative fixtures for Tailwind, MUI, Ant Design, Chakra, Clerk, CSS Modules, and CSS-in-JS.
|
|
15
16
|
|
|
16
17
|
## Session and Reporting Follow-Ups
|
|
17
18
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('ChromeBrowser.check_text', () => {
|
|
47
|
+
it('reports text when at least one matching element is visible', async () => {
|
|
48
|
+
const matches = {
|
|
49
|
+
count: vi.fn().mockResolvedValue(2),
|
|
50
|
+
nth: vi.fn(index => [
|
|
51
|
+
{ isVisible: vi.fn().mockResolvedValue(false) },
|
|
52
|
+
{ isVisible: vi.fn().mockResolvedValue(true) },
|
|
53
|
+
][index]),
|
|
54
|
+
};
|
|
55
|
+
const browser = Object.create(ChromeBrowser.prototype);
|
|
56
|
+
browser.page = { getByText: vi.fn().mockReturnValue(matches) };
|
|
57
|
+
|
|
58
|
+
await expect(browser.check_text({ text: 'Signed out' })).resolves.toBe('FOUND: Signed out');
|
|
59
|
+
expect(browser.page.getByText).toHaveBeenCalledWith('Signed out');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('does not report text that only exists in hidden elements', async () => {
|
|
63
|
+
const matches = {
|
|
64
|
+
count: vi.fn().mockResolvedValue(1),
|
|
65
|
+
nth: vi.fn().mockReturnValue({ isVisible: vi.fn().mockResolvedValue(false) }),
|
|
66
|
+
};
|
|
67
|
+
const browser = Object.create(ChromeBrowser.prototype);
|
|
68
|
+
browser.page = { getByText: vi.fn().mockReturnValue(matches) };
|
|
69
|
+
|
|
70
|
+
await expect(browser.check_text({ text: 'Hidden message' })).resolves.toBe('NOT FOUND: Hidden message');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -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"
|
|
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')).
|
|
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"]');
|
|
@@ -279,7 +285,7 @@ describe('domControl helpers', () => {
|
|
|
279
285
|
const html = `
|
|
280
286
|
<button
|
|
281
287
|
id="btn"
|
|
282
|
-
class="text-sm bg-red-500 hover:bg-red-600 flex items-center btn-primary custom-class"
|
|
288
|
+
class="text-sm bg-red-500 hover:bg-red-600 disabled:opacity-50 [&_svg]:size-4 flex items-center btn-primary custom-class"
|
|
283
289
|
>
|
|
284
290
|
Click
|
|
285
291
|
</button>
|
|
@@ -295,6 +301,8 @@ describe('domControl helpers', () => {
|
|
|
295
301
|
expect(classes).not.toContain('text-sm');
|
|
296
302
|
expect(classes).not.toContain('bg-red-500');
|
|
297
303
|
expect(classes).not.toContain('hover:bg-red-600');
|
|
304
|
+
expect(classes).not.toContain('disabled:opacity-50');
|
|
305
|
+
expect(classes).not.toContain('[&_svg]:size-4');
|
|
298
306
|
expect(classes).not.toContain('flex');
|
|
299
307
|
expect(classes).not.toContain('items-center');
|
|
300
308
|
|
package/tools/chromeBrowser.js
CHANGED
|
@@ -1025,9 +1025,8 @@ export class ChromeBrowser {
|
|
|
1025
1025
|
$ = await removeNonContentTags($);
|
|
1026
1026
|
$ = await reduceRepeatedDivs($, listLimit);
|
|
1027
1027
|
reduceTableRows($, listLimit);
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
// $ = await removeAutoGeneratedClasses($);
|
|
1028
|
+
$ = removeObfuscatedClassNames($);
|
|
1029
|
+
$ = removeAutoGeneratedClasses($);
|
|
1031
1030
|
}
|
|
1032
1031
|
|
|
1033
1032
|
// Minify whitespace *after* structural cleanup
|
|
@@ -1136,25 +1135,34 @@ export class ChromeBrowser {
|
|
|
1136
1135
|
|
|
1137
1136
|
|
|
1138
1137
|
async check_text({ text }) {
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1138
|
+
const matches = this.page.getByText(text);
|
|
1139
|
+
const count = await matches.count();
|
|
1140
|
+
for (let index = 0; index < count; index++) {
|
|
1141
|
+
if (await matches.nth(index).isVisible().catch(() => false)) {
|
|
1142
|
+
return `FOUND: ${text}`;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return `NOT FOUND: ${text}`;
|
|
1141
1146
|
}
|
|
1142
1147
|
|
|
1143
|
-
async click_text({ text }) {
|
|
1148
|
+
async click_text({ text, timeout = 3000 }) {
|
|
1144
1149
|
console.log("attempting to click text with normal click:", text);
|
|
1145
1150
|
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
}
|
|
1151
|
+
// Playwright's exact text engine normalizes whitespace and resolves to the
|
|
1152
|
+
// innermost matching elements, avoiding ancestor containers that merely
|
|
1153
|
+
// contain the requested label.
|
|
1154
|
+
const matches = this.page.getByText(text, { exact: true });
|
|
1155
|
+
const count = await matches.count();
|
|
1156
|
+
for (let index = 0; index < count; index++) {
|
|
1157
|
+
const match = matches.nth(index);
|
|
1158
|
+
if (!await match.isVisible().catch(() => false)) continue;
|
|
1159
|
+
|
|
1160
|
+
await match.scrollIntoViewIfNeeded().catch(() => {});
|
|
1161
|
+
await match.click({ timeout });
|
|
1162
|
+
await this.page.waitForTimeout(500);
|
|
1163
|
+
return `clicked text "${text}"`;
|
|
1156
1164
|
}
|
|
1157
|
-
throw new Error(`
|
|
1165
|
+
throw new Error(`Exact visible text "${text}" not found`);
|
|
1158
1166
|
}
|
|
1159
1167
|
|
|
1160
1168
|
async click_text_xpath({ text, delayMs = 1000 }) {
|
package/tools/domControl.js
CHANGED
|
@@ -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', '
|
|
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
|
}
|
|
@@ -270,7 +271,8 @@ export function removeAutoGeneratedClasses($) {
|
|
|
270
271
|
const $el = $(el);
|
|
271
272
|
const classes = ($el.attr('class') || '').split(/\s+/);
|
|
272
273
|
const filtered = classes.filter(cls => {
|
|
273
|
-
|
|
274
|
+
const isVariantOrArbitraryUtility = cls.includes(':') || cls.includes('[');
|
|
275
|
+
return !isVariantOrArbitraryUtility && !utilityClassPatterns.some(pattern => pattern.test(cls));
|
|
274
276
|
});
|
|
275
277
|
if (filtered.length) {
|
|
276
278
|
$el.attr('class', filtered.join(' '));
|
package/tools/toolSchema.js
CHANGED