testronaut 2.0.0 → 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.
- package/.github/workflows/unit-tests.yml +34 -0
- package/TODO.md +1 -1
- package/bin/cli.js +37 -13
- package/package.json +3 -3
- package/tests/binTests/cli.helpers.test.js +34 -0
- package/tests/dependencyCompatibility.test.js +11 -2
- package/tests/toolsTests/chromeBrowserClickText.test.js +44 -0
- package/tests/toolsTests/domControl.test.js +9 -3
- package/tools/chromeBrowser.js +17 -15
- package/tools/domControl.js +3 -2
|
@@ -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
|
-
- [
|
|
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/bin/cli.js
CHANGED
|
@@ -76,6 +76,18 @@ const CLI_VERSION = JSON.parse(
|
|
|
76
76
|
|
|
77
77
|
const DEFAULT_API_BASE = 'https://api.testronaut.app';
|
|
78
78
|
const DEV_API_BASE = 'https://staging.api.testronaut.app';
|
|
79
|
+
const PLAYWRIGHT_INSTALL_PROMPT = {
|
|
80
|
+
type: 'confirm',
|
|
81
|
+
name: 'doPw',
|
|
82
|
+
message: 'Install Playwright browsers now?',
|
|
83
|
+
default: true,
|
|
84
|
+
};
|
|
85
|
+
const API_KEY_PROMPT = {
|
|
86
|
+
type: 'password',
|
|
87
|
+
name: 'apiKey',
|
|
88
|
+
message: 'Enter your API key:',
|
|
89
|
+
mask: '*',
|
|
90
|
+
};
|
|
79
91
|
|
|
80
92
|
let args = process.argv.slice(2);
|
|
81
93
|
|
|
@@ -138,6 +150,14 @@ function isDirectInvocation(argv1 = process.argv[1], moduleUrl = import.meta.url
|
|
|
138
150
|
}
|
|
139
151
|
}
|
|
140
152
|
|
|
153
|
+
function isPromptExitError(error) {
|
|
154
|
+
return error instanceof Error && error.name === 'ExitPromptError';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatCliError(error) {
|
|
158
|
+
return error instanceof Error ? error.message : String(error);
|
|
159
|
+
}
|
|
160
|
+
|
|
141
161
|
const cliName = detectCliName();
|
|
142
162
|
|
|
143
163
|
// Look for --dev / --developer / --developer-mode / --staging
|
|
@@ -367,6 +387,8 @@ export const __test__ = {
|
|
|
367
387
|
parseOptimizationArgs,
|
|
368
388
|
detectCliName,
|
|
369
389
|
isDirectInvocation,
|
|
390
|
+
isPromptExitError,
|
|
391
|
+
formatCliError,
|
|
370
392
|
resolveMissionPath,
|
|
371
393
|
collectReportScreenshotNames,
|
|
372
394
|
collectReportScreenshotPaths,
|
|
@@ -374,6 +396,8 @@ export const __test__ = {
|
|
|
374
396
|
closestMissionMatch,
|
|
375
397
|
resolveReportFile,
|
|
376
398
|
buildEffectiveConfig,
|
|
399
|
+
PLAYWRIGHT_INSTALL_PROMPT,
|
|
400
|
+
API_KEY_PROMPT,
|
|
377
401
|
};
|
|
378
402
|
|
|
379
403
|
function resolveReportDir(config = {}, cwd = process.cwd()) {
|
|
@@ -893,9 +917,7 @@ if (args.includes('--init')) {
|
|
|
893
917
|
await createWelcomeMission();
|
|
894
918
|
|
|
895
919
|
const { default: inquirer } = await import('inquirer');
|
|
896
|
-
const { doPw } = await inquirer.prompt([
|
|
897
|
-
{ type: 'confirm', name: 'doPw', message: 'Install Playwright browsers now?', default: true }
|
|
898
|
-
]);
|
|
920
|
+
const { doPw } = await inquirer.prompt([PLAYWRIGHT_INSTALL_PROMPT]);
|
|
899
921
|
|
|
900
922
|
if (doPw) {
|
|
901
923
|
// ensure @playwright/test exists so the installer can pin to its version
|
|
@@ -1438,14 +1460,7 @@ async function handleLogin() {
|
|
|
1438
1460
|
|
|
1439
1461
|
// If API key is not provided, prompt the user for it
|
|
1440
1462
|
if (!apiKey) {
|
|
1441
|
-
const responses = await inquirer.prompt([
|
|
1442
|
-
{
|
|
1443
|
-
type: 'password',
|
|
1444
|
-
name: 'apiKey',
|
|
1445
|
-
message: 'Enter your API key:',
|
|
1446
|
-
mask: '*'
|
|
1447
|
-
}
|
|
1448
|
-
]);
|
|
1463
|
+
const responses = await inquirer.prompt([API_KEY_PROMPT]);
|
|
1449
1464
|
apiKey = responses.apiKey;
|
|
1450
1465
|
}
|
|
1451
1466
|
|
|
@@ -1487,7 +1502,7 @@ async function handleLogin() {
|
|
|
1487
1502
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
1488
1503
|
console.log('✅ Login successful! Session token saved to testronaut-config.json.');
|
|
1489
1504
|
} catch (error) {
|
|
1490
|
-
console.error('❌ Error during login:', error);
|
|
1505
|
+
console.error('❌ Error during login:', formatCliError(error));
|
|
1491
1506
|
process.exit(1);
|
|
1492
1507
|
}
|
|
1493
1508
|
}
|
|
@@ -1845,5 +1860,14 @@ async function serveLatestReport() {
|
|
|
1845
1860
|
}
|
|
1846
1861
|
|
|
1847
1862
|
if (isDirectInvocation()) {
|
|
1848
|
-
|
|
1863
|
+
try {
|
|
1864
|
+
await main();
|
|
1865
|
+
} catch (error) {
|
|
1866
|
+
if (isPromptExitError(error)) {
|
|
1867
|
+
console.error('Prompt cancelled.');
|
|
1868
|
+
process.exitCode = 130;
|
|
1869
|
+
} else {
|
|
1870
|
+
throw error;
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1849
1873
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testronaut",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"main": "./runner/testronaut.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"testronaut": "bin/cli.js"
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"type": "module",
|
|
14
14
|
"engines": {
|
|
15
|
-
"node": ">=20"
|
|
15
|
+
"node": ">=20.17.0"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
18
|
"benchmark:optimization": "node ./experiments/optimizationBenchmark.js",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"cheerio": "^1.1.0",
|
|
38
38
|
"dotenv": "^17.2.0",
|
|
39
39
|
"fast-glob": "^3.3.3",
|
|
40
|
-
"inquirer": "^
|
|
40
|
+
"inquirer": "^14.2.2",
|
|
41
41
|
"jiti": "^2.7.0",
|
|
42
42
|
"node-fetch": "^3.3.2",
|
|
43
43
|
"openai": "^6.39.0",
|
|
@@ -22,6 +22,40 @@ describe('cli helpers', () => {
|
|
|
22
22
|
expect(__test__.DEFAULT_API_BASE).toBe('https://api.testronaut.app');
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
it('keeps interactive prompt contracts compatible with Inquirer 14', () => {
|
|
26
|
+
expect(__test__.PLAYWRIGHT_INSTALL_PROMPT).toEqual({
|
|
27
|
+
type: 'confirm',
|
|
28
|
+
name: 'doPw',
|
|
29
|
+
message: 'Install Playwright browsers now?',
|
|
30
|
+
default: true,
|
|
31
|
+
});
|
|
32
|
+
expect(__test__.API_KEY_PROMPT).toEqual({
|
|
33
|
+
type: 'password',
|
|
34
|
+
name: 'apiKey',
|
|
35
|
+
message: 'Enter your API key:',
|
|
36
|
+
mask: '*',
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('recognizes Inquirer prompt cancellation without swallowing other failures', () => {
|
|
41
|
+
const cancelled = new Error('User force closed the prompt with SIGINT');
|
|
42
|
+
cancelled.name = 'ExitPromptError';
|
|
43
|
+
|
|
44
|
+
expect(__test__.isPromptExitError(cancelled)).toBe(true);
|
|
45
|
+
expect(__test__.isPromptExitError(new Error('network failed'))).toBe(false);
|
|
46
|
+
expect(__test__.isPromptExitError({ name: 'ExitPromptError' })).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('formats CLI errors without printing stack traces', () => {
|
|
50
|
+
const error = new Error('login 401 Unauthorized: {"error":"Invalid API key"}');
|
|
51
|
+
|
|
52
|
+
expect(__test__.formatCliError(error)).toBe(
|
|
53
|
+
'login 401 Unauthorized: {"error":"Invalid API key"}',
|
|
54
|
+
);
|
|
55
|
+
expect(__test__.formatCliError(error)).not.toContain(' at ');
|
|
56
|
+
expect(__test__.formatCliError('request failed')).toBe('request failed');
|
|
57
|
+
});
|
|
58
|
+
|
|
25
59
|
it('guessMimeType returns expected types', () => {
|
|
26
60
|
const { guessMimeType } = __test__;
|
|
27
61
|
expect(guessMimeType('file.html')).toContain('text/html');
|
|
@@ -7,9 +7,8 @@ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
|
|
|
7
7
|
const lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8'));
|
|
8
8
|
|
|
9
9
|
describe('runtime dependency compatibility', () => {
|
|
10
|
-
it('
|
|
10
|
+
it('records the Node runtime required by the installed Playwright version', () => {
|
|
11
11
|
const playwright = lock.packages['node_modules/playwright'];
|
|
12
|
-
expect(pkg.engines.node).toBe('>=20');
|
|
13
12
|
expect(playwright.engines.node).toBe('>=20');
|
|
14
13
|
});
|
|
15
14
|
|
|
@@ -19,4 +18,14 @@ describe('runtime dependency compatibility', () => {
|
|
|
19
18
|
expect(lock.packages['node_modules/playwright'].version).toBe('1.63.0');
|
|
20
19
|
expect(lock.packages['node_modules/playwright-core'].version).toBe('1.63.0');
|
|
21
20
|
});
|
|
21
|
+
|
|
22
|
+
it('declares the minimum Node runtime required by Inquirer 14', () => {
|
|
23
|
+
const inquirer = lock.packages['node_modules/inquirer'];
|
|
24
|
+
expect(pkg.engines.node).toBe('>=20.17.0');
|
|
25
|
+
expect(lock.packages[''].engines.node).toBe(pkg.engines.node);
|
|
26
|
+
expect(pkg.dependencies.inquirer).toBe('^14.2.2');
|
|
27
|
+
expect(lock.packages[''].dependencies.inquirer).toBe(pkg.dependencies.inquirer);
|
|
28
|
+
expect(inquirer.version).toBe('14.2.2');
|
|
29
|
+
expect(inquirer.engines.node).toContain('^20.17.0');
|
|
30
|
+
});
|
|
22
31
|
});
|
|
@@ -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"
|
|
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"]');
|
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
|
|
@@ -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
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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(`
|
|
1159
|
+
throw new Error(`Exact visible text "${text}" not found`);
|
|
1158
1160
|
}
|
|
1159
1161
|
|
|
1160
1162
|
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
|
}
|