browser-commander 0.3.0 → 0.5.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/CHANGELOG.md +62 -0
- package/package.json +1 -1
- package/scripts/validate-changeset.mjs +11 -2
- package/scripts/version-and-commit.mjs +3 -1
- package/src/browser/launcher.js +7 -2
- package/src/core/engine-adapter.js +14 -2
- package/tests/helpers/mocks.js +5 -18
- package/tests/unit/browser/launcher.test.js +126 -0
- package/tests/unit/core/engine-adapter.test.js +83 -0
- package/.github/workflows/release.yml +0 -296
- package/LICENSE +0 -24
- package/README.md +0 -320
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 87224ee: Fix package.json path in version-and-commit.mjs for monorepo structure
|
|
8
|
+
|
|
9
|
+
The git show command uses repository root paths, not the workflow's working directory. Since this is a monorepo with js/ and rust/ folders, the path must be js/package.json instead of just package.json.
|
|
10
|
+
|
|
11
|
+
This was causing "Unexpected end of JSON input" errors when the script tried to read package.json from the repository root (which doesn't exist) instead of js/package.json.
|
|
12
|
+
|
|
13
|
+
## 0.5.1
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 2b22f43: Fix PlaywrightAdapter.evaluateOnPage() to spread multiple arguments correctly
|
|
18
|
+
|
|
19
|
+
When using `evaluateOnPage()` with multiple arguments, the arguments are now properly spread to the function in the browser context, matching Puppeteer's behavior.
|
|
20
|
+
|
|
21
|
+
Previously, the function would receive the entire array as its first parameter instead of spread arguments, causing issues like invalid selectors when passing selector + array combinations.
|
|
22
|
+
|
|
23
|
+
## 0.5.0
|
|
24
|
+
|
|
25
|
+
### Minor Changes
|
|
26
|
+
|
|
27
|
+
- adfccde: Add Rust implementation with parallel JavaScript codebase reorganization
|
|
28
|
+
|
|
29
|
+
This introduces a complete Rust translation of the browser-commander library alongside the existing JavaScript implementation. The codebase is now organized into two parallel structures:
|
|
30
|
+
- `js/` - JavaScript implementation (all existing functionality preserved)
|
|
31
|
+
- `rust/` - New Rust implementation with the same modular architecture
|
|
32
|
+
|
|
33
|
+
Key features of the Rust implementation:
|
|
34
|
+
- Unified API across multiple browser engines (chromiumoxide, fantoccini)
|
|
35
|
+
- Core types and traits (constants, engine adapter, logger)
|
|
36
|
+
- Element operations (selectors, visibility, content)
|
|
37
|
+
- User interactions (click, scroll, fill)
|
|
38
|
+
- Browser management (launcher, navigation)
|
|
39
|
+
- General utilities (URL handling, wait operations)
|
|
40
|
+
- High-level DRY utilities
|
|
41
|
+
- Comprehensive test coverage with 106 tests
|
|
42
|
+
|
|
43
|
+
## 0.4.0
|
|
44
|
+
|
|
45
|
+
### Minor Changes
|
|
46
|
+
|
|
47
|
+
- 5af2479: Add support for custom Chrome args in launchBrowser
|
|
48
|
+
|
|
49
|
+
Adds a new `args` option to the `launchBrowser` function that allows passing custom Chrome arguments to append to the default `CHROME_ARGS`. This is useful for headless server environments (Docker, CI/CD) that require additional flags like `--no-sandbox`, `--disable-setuid-sandbox`, or `--disable-dev-shm-usage`.
|
|
50
|
+
|
|
51
|
+
Usage example:
|
|
52
|
+
|
|
53
|
+
```javascript
|
|
54
|
+
import { launchBrowser } from 'browser-commander';
|
|
55
|
+
|
|
56
|
+
const { browser, page } = await launchBrowser({
|
|
57
|
+
engine: 'puppeteer',
|
|
58
|
+
headless: true,
|
|
59
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Fixes #11
|
|
64
|
+
|
|
3
65
|
## 0.3.0
|
|
4
66
|
|
|
5
67
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -15,7 +15,10 @@ import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
|
15
15
|
import { join } from 'path';
|
|
16
16
|
|
|
17
17
|
const PACKAGE_NAME = 'browser-commander';
|
|
18
|
+
// When running from js/ directory, the changeset dir is local
|
|
18
19
|
const CHANGESET_DIR = '.changeset';
|
|
20
|
+
// But git diff output uses repository-root relative paths
|
|
21
|
+
const GIT_CHANGESET_PATH = 'js/.changeset';
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* Ensure a git commit is available locally, fetching if necessary
|
|
@@ -46,13 +49,19 @@ function parseAddedChangesets(diffOutput) {
|
|
|
46
49
|
continue;
|
|
47
50
|
}
|
|
48
51
|
const [status, filePath] = line.split('\t');
|
|
52
|
+
// Check both local (.changeset/) and repo-root (js/.changeset/) paths
|
|
53
|
+
const isChangeset =
|
|
54
|
+
filePath.startsWith(`${CHANGESET_DIR}/`) ||
|
|
55
|
+
filePath.startsWith(`${GIT_CHANGESET_PATH}/`);
|
|
49
56
|
if (
|
|
50
57
|
status === 'A' &&
|
|
51
|
-
|
|
58
|
+
isChangeset &&
|
|
52
59
|
filePath.endsWith('.md') &&
|
|
53
60
|
!filePath.endsWith('README.md')
|
|
54
61
|
) {
|
|
55
|
-
|
|
62
|
+
// Extract just the filename
|
|
63
|
+
const fileName = filePath.split('/').pop();
|
|
64
|
+
addedChangesets.push(fileName);
|
|
56
65
|
}
|
|
57
66
|
}
|
|
58
67
|
return addedChangesets;
|
|
@@ -126,7 +126,9 @@ function countChangesets() {
|
|
|
126
126
|
*/
|
|
127
127
|
async function getVersion(source = 'local') {
|
|
128
128
|
if (source === 'remote') {
|
|
129
|
-
|
|
129
|
+
// Use js/package.json for monorepo structure
|
|
130
|
+
// The workflow runs with working-directory: js, but git show uses repo root paths
|
|
131
|
+
const result = await $`git show origin/main:js/package.json`.run({
|
|
130
132
|
capture: true,
|
|
131
133
|
});
|
|
132
134
|
return JSON.parse(result.stdout).version;
|
package/src/browser/launcher.js
CHANGED
|
@@ -11,6 +11,7 @@ import { disableTranslateInPreferences } from '../core/preferences.js';
|
|
|
11
11
|
* @param {boolean} options.headless - Run in headless mode (default: false)
|
|
12
12
|
* @param {number} options.slowMo - Slow down operations by ms (default: 150 for Playwright, 0 for Puppeteer)
|
|
13
13
|
* @param {boolean} options.verbose - Enable verbose logging (default: false)
|
|
14
|
+
* @param {string[]} options.args - Custom Chrome arguments to append to the default CHROME_ARGS
|
|
14
15
|
* @returns {Promise<Object>} - Object with browser and page
|
|
15
16
|
*/
|
|
16
17
|
export async function launchBrowser(options = {}) {
|
|
@@ -20,8 +21,12 @@ export async function launchBrowser(options = {}) {
|
|
|
20
21
|
headless = false,
|
|
21
22
|
slowMo = engine === 'playwright' ? 150 : 0,
|
|
22
23
|
verbose = false,
|
|
24
|
+
args = [],
|
|
23
25
|
} = options;
|
|
24
26
|
|
|
27
|
+
// Combine default CHROME_ARGS with custom args
|
|
28
|
+
const chromeArgs = [...CHROME_ARGS, ...args];
|
|
29
|
+
|
|
25
30
|
if (!['playwright', 'puppeteer'].includes(engine)) {
|
|
26
31
|
throw new Error(
|
|
27
32
|
`Invalid engine: ${engine}. Expected 'playwright' or 'puppeteer'`
|
|
@@ -50,7 +55,7 @@ export async function launchBrowser(options = {}) {
|
|
|
50
55
|
slowMo,
|
|
51
56
|
chromiumSandbox: true,
|
|
52
57
|
viewport: null,
|
|
53
|
-
args:
|
|
58
|
+
args: chromeArgs,
|
|
54
59
|
ignoreDefaultArgs: ['--enable-automation'],
|
|
55
60
|
});
|
|
56
61
|
page = browser.pages()[0];
|
|
@@ -59,7 +64,7 @@ export async function launchBrowser(options = {}) {
|
|
|
59
64
|
browser = await puppeteer.default.launch({
|
|
60
65
|
headless,
|
|
61
66
|
defaultViewport: null,
|
|
62
|
-
args: ['--start-maximized', ...
|
|
67
|
+
args: ['--start-maximized', ...chromeArgs],
|
|
63
68
|
userDataDir,
|
|
64
69
|
});
|
|
65
70
|
const pages = await browser.pages();
|
|
@@ -304,13 +304,25 @@ export class PlaywrightAdapter extends EngineAdapter {
|
|
|
304
304
|
|
|
305
305
|
async evaluateOnPage(fn, args = []) {
|
|
306
306
|
// Playwright only accepts a single argument (can be array/object)
|
|
307
|
+
// To match Puppeteer's behavior where args are spread, we wrap the function
|
|
308
|
+
// and pass all args as a single array, then apply them in the browser context
|
|
307
309
|
if (args.length === 0) {
|
|
308
310
|
return await this.page.evaluate(fn);
|
|
309
311
|
} else if (args.length === 1) {
|
|
310
312
|
return await this.page.evaluate(fn, args[0]);
|
|
311
313
|
} else {
|
|
312
|
-
// Multiple args -
|
|
313
|
-
|
|
314
|
+
// Multiple args - wrap function to accept array and spread them
|
|
315
|
+
// This makes Playwright behave like Puppeteer's spread behavior
|
|
316
|
+
// We pass the function string and args array, then reconstruct and call in browser
|
|
317
|
+
const fnString = fn.toString();
|
|
318
|
+
return await this.page.evaluate(
|
|
319
|
+
({ fnStr, argsArray }) => {
|
|
320
|
+
// Reconstruct the function in browser context and call with spread args
|
|
321
|
+
const reconstructedFn = new Function(`return (${fnStr})`)();
|
|
322
|
+
return reconstructedFn(...argsArray);
|
|
323
|
+
},
|
|
324
|
+
{ fnStr: fnString, argsArray: args }
|
|
325
|
+
);
|
|
314
326
|
}
|
|
315
327
|
}
|
|
316
328
|
|
package/tests/helpers/mocks.js
CHANGED
|
@@ -105,28 +105,15 @@ export function createMockPlaywrightPage(options = {}) {
|
|
|
105
105
|
return locs.map((l) => l.evaluate(fn, ...args));
|
|
106
106
|
},
|
|
107
107
|
locator: locatorMock,
|
|
108
|
-
evaluate
|
|
108
|
+
// Playwright's page.evaluate() only accepts a single argument (not spread)
|
|
109
|
+
// This is the key difference from Puppeteer
|
|
110
|
+
evaluate: async (fn, arg) => {
|
|
109
111
|
if (evaluateResult !== null) {
|
|
110
112
|
return evaluateResult;
|
|
111
113
|
}
|
|
112
|
-
// Create mock window/document context
|
|
113
|
-
const mockContext = {
|
|
114
|
-
innerHeight: 800,
|
|
115
|
-
innerWidth: 1200,
|
|
116
|
-
sessionStorage: {
|
|
117
|
-
_data: {},
|
|
118
|
-
getItem: (key) => mockContext.sessionStorage._data[key] || null,
|
|
119
|
-
setItem: (key, val) => {
|
|
120
|
-
mockContext.sessionStorage._data[key] = val;
|
|
121
|
-
},
|
|
122
|
-
removeItem: (key) => {
|
|
123
|
-
delete mockContext.sessionStorage._data[key];
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
|
-
querySelectorAll: () => [],
|
|
127
|
-
};
|
|
128
114
|
try {
|
|
129
|
-
|
|
115
|
+
// Playwright passes exactly one argument (can be object/array)
|
|
116
|
+
return fn(arg);
|
|
130
117
|
} catch {
|
|
131
118
|
return fn;
|
|
132
119
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, mock, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
|
|
4
|
+
describe('launcher', () => {
|
|
5
|
+
describe('launchBrowser args option', () => {
|
|
6
|
+
let originalEnv;
|
|
7
|
+
let mockChromium;
|
|
8
|
+
let mockPuppeteer;
|
|
9
|
+
let launchBrowser;
|
|
10
|
+
let capturedPlaywrightArgs;
|
|
11
|
+
let capturedPuppeteerArgs;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
// Save original environment
|
|
15
|
+
originalEnv = { ...process.env };
|
|
16
|
+
|
|
17
|
+
// Reset captured args
|
|
18
|
+
capturedPlaywrightArgs = null;
|
|
19
|
+
capturedPuppeteerArgs = null;
|
|
20
|
+
|
|
21
|
+
// Create mock browser context for Playwright
|
|
22
|
+
const mockBrowserContext = {
|
|
23
|
+
pages: () => [
|
|
24
|
+
{
|
|
25
|
+
bringToFront: async () => {},
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
close: async () => {},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Create mock browser for Puppeteer
|
|
32
|
+
const mockBrowser = {
|
|
33
|
+
pages: async () => [
|
|
34
|
+
{
|
|
35
|
+
bringToFront: async () => {},
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
close: async () => {},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Mock Playwright's chromium
|
|
42
|
+
mockChromium = {
|
|
43
|
+
launchPersistentContext: async (userDataDir, options) => {
|
|
44
|
+
capturedPlaywrightArgs = options.args;
|
|
45
|
+
return mockBrowserContext;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Mock Puppeteer
|
|
50
|
+
mockPuppeteer = {
|
|
51
|
+
default: {
|
|
52
|
+
launch: async (options) => {
|
|
53
|
+
capturedPuppeteerArgs = options.args;
|
|
54
|
+
return mockBrowser;
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Use mock.module to mock the dynamic imports
|
|
60
|
+
// Since we can't easily mock dynamic imports in Node.js test runner,
|
|
61
|
+
// we'll test the CHROME_ARGS constant usage directly
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
// Restore original environment
|
|
66
|
+
process.env = originalEnv;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should export CHROME_ARGS constant', async () => {
|
|
70
|
+
const { CHROME_ARGS } = await import('../../../src/core/constants.js');
|
|
71
|
+
assert.ok(Array.isArray(CHROME_ARGS));
|
|
72
|
+
assert.ok(CHROME_ARGS.length > 0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should include expected default Chrome args', async () => {
|
|
76
|
+
const { CHROME_ARGS } = await import('../../../src/core/constants.js');
|
|
77
|
+
assert.ok(CHROME_ARGS.includes('--disable-session-crashed-bubble'));
|
|
78
|
+
assert.ok(CHROME_ARGS.includes('--no-first-run'));
|
|
79
|
+
assert.ok(CHROME_ARGS.includes('--no-default-browser-check'));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('launchBrowser function should accept args option', async () => {
|
|
83
|
+
// We can verify the function signature by checking that it
|
|
84
|
+
// destructures args from options without throwing
|
|
85
|
+
const { launchBrowser } =
|
|
86
|
+
await import('../../../src/browser/launcher.js');
|
|
87
|
+
assert.ok(typeof launchBrowser === 'function');
|
|
88
|
+
|
|
89
|
+
// The function should accept options object with args array
|
|
90
|
+
// We can't fully test the launch without actual browsers,
|
|
91
|
+
// but we can verify the function exists and is callable
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('launchBrowser should throw for invalid engine', async () => {
|
|
95
|
+
const { launchBrowser } =
|
|
96
|
+
await import('../../../src/browser/launcher.js');
|
|
97
|
+
|
|
98
|
+
await assert.rejects(
|
|
99
|
+
() => launchBrowser({ engine: 'invalid-engine' }),
|
|
100
|
+
(error) => {
|
|
101
|
+
assert.ok(error.message.includes('Invalid engine'));
|
|
102
|
+
assert.ok(error.message.includes('invalid-engine'));
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('launchBrowser should accept args in options', async () => {
|
|
109
|
+
const { launchBrowser } =
|
|
110
|
+
await import('../../../src/browser/launcher.js');
|
|
111
|
+
|
|
112
|
+
// Verify the function signature accepts args
|
|
113
|
+
// This test validates that the args parameter is correctly destructured
|
|
114
|
+
// by attempting to call with an invalid engine (which fails before browser launch)
|
|
115
|
+
// but proves args is accepted without error during options parsing
|
|
116
|
+
const customArgs = ['--no-sandbox', '--disable-setuid-sandbox'];
|
|
117
|
+
|
|
118
|
+
await assert.rejects(
|
|
119
|
+
() => launchBrowser({ engine: 'invalid', args: customArgs }),
|
|
120
|
+
/Invalid engine/
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// If we got here, the args option was accepted without error
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -78,6 +78,53 @@ describe('engine-adapter', () => {
|
|
|
78
78
|
const count = await adapter.count('button');
|
|
79
79
|
assert.strictEqual(count, 5);
|
|
80
80
|
});
|
|
81
|
+
|
|
82
|
+
describe('evaluateOnPage', () => {
|
|
83
|
+
it('should handle zero arguments', async () => {
|
|
84
|
+
page = createMockPlaywrightPage();
|
|
85
|
+
adapter = new PlaywrightAdapter(page);
|
|
86
|
+
const result = await adapter.evaluateOnPage(() => 42);
|
|
87
|
+
assert.strictEqual(result, 42);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should handle single argument', async () => {
|
|
91
|
+
page = createMockPlaywrightPage();
|
|
92
|
+
adapter = new PlaywrightAdapter(page);
|
|
93
|
+
const result = await adapter.evaluateOnPage((x) => x * 2, [5]);
|
|
94
|
+
assert.strictEqual(result, 10);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should handle multiple arguments by spreading them', async () => {
|
|
98
|
+
page = createMockPlaywrightPage();
|
|
99
|
+
adapter = new PlaywrightAdapter(page);
|
|
100
|
+
// This is the bug case - multiple args should be spread, not passed as array
|
|
101
|
+
const result = await adapter.evaluateOnPage((a, b) => a + b, [3, 7]);
|
|
102
|
+
assert.strictEqual(result, 10);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should handle selector + array arguments (real-world bug case)', async () => {
|
|
106
|
+
page = createMockPlaywrightPage();
|
|
107
|
+
adapter = new PlaywrightAdapter(page);
|
|
108
|
+
// This reproduces the original bug: selector and processedIds
|
|
109
|
+
const result = await adapter.evaluateOnPage(
|
|
110
|
+
(selector, processedIds) =>
|
|
111
|
+
`Selector: ${selector}, Count: ${processedIds.length}`,
|
|
112
|
+
['[data-qa="test"]', ['id1', 'id2']]
|
|
113
|
+
);
|
|
114
|
+
assert.ok(result.includes('Selector: [data-qa="test"]'));
|
|
115
|
+
assert.ok(result.includes('Count: 2'));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should handle multiple arguments of different types', async () => {
|
|
119
|
+
page = createMockPlaywrightPage();
|
|
120
|
+
adapter = new PlaywrightAdapter(page);
|
|
121
|
+
const result = await adapter.evaluateOnPage(
|
|
122
|
+
(str, num, obj) => `${str}-${num}-${obj.key}`,
|
|
123
|
+
['hello', 42, { key: 'world' }]
|
|
124
|
+
);
|
|
125
|
+
assert.strictEqual(result, 'hello-42-world');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
81
128
|
});
|
|
82
129
|
|
|
83
130
|
describe('PuppeteerAdapter', () => {
|
|
@@ -130,6 +177,42 @@ describe('engine-adapter', () => {
|
|
|
130
177
|
const count = await adapter.count('button');
|
|
131
178
|
assert.strictEqual(count, 5);
|
|
132
179
|
});
|
|
180
|
+
|
|
181
|
+
describe('evaluateOnPage', () => {
|
|
182
|
+
it('should handle zero arguments', async () => {
|
|
183
|
+
page = createMockPuppeteerPage();
|
|
184
|
+
adapter = new PuppeteerAdapter(page);
|
|
185
|
+
const result = await adapter.evaluateOnPage(() => 42);
|
|
186
|
+
assert.strictEqual(result, 42);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should handle single argument', async () => {
|
|
190
|
+
page = createMockPuppeteerPage();
|
|
191
|
+
adapter = new PuppeteerAdapter(page);
|
|
192
|
+
const result = await adapter.evaluateOnPage((x) => x * 2, [5]);
|
|
193
|
+
assert.strictEqual(result, 10);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should handle multiple arguments by spreading them', async () => {
|
|
197
|
+
page = createMockPuppeteerPage();
|
|
198
|
+
adapter = new PuppeteerAdapter(page);
|
|
199
|
+
// Puppeteer natively spreads args - ensure same behavior as Playwright fix
|
|
200
|
+
const result = await adapter.evaluateOnPage((a, b) => a + b, [3, 7]);
|
|
201
|
+
assert.strictEqual(result, 10);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should handle selector + array arguments (parity with PlaywrightAdapter)', async () => {
|
|
205
|
+
page = createMockPuppeteerPage();
|
|
206
|
+
adapter = new PuppeteerAdapter(page);
|
|
207
|
+
const result = await adapter.evaluateOnPage(
|
|
208
|
+
(selector, processedIds) =>
|
|
209
|
+
`Selector: ${selector}, Count: ${processedIds.length}`,
|
|
210
|
+
['[data-qa="test"]', ['id1', 'id2']]
|
|
211
|
+
);
|
|
212
|
+
assert.ok(result.includes('Selector: [data-qa="test"]'));
|
|
213
|
+
assert.ok(result.includes('Count: 2'));
|
|
214
|
+
});
|
|
215
|
+
});
|
|
133
216
|
});
|
|
134
217
|
|
|
135
218
|
describe('createEngineAdapter', () => {
|
|
@@ -1,296 +0,0 @@
|
|
|
1
|
-
name: Checks and release
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches:
|
|
6
|
-
- main
|
|
7
|
-
pull_request:
|
|
8
|
-
types: [opened, synchronize, reopened]
|
|
9
|
-
# Manual release support - consolidated here to work with npm trusted publishing
|
|
10
|
-
# npm only allows ONE workflow file as trusted publisher, so all publishing
|
|
11
|
-
# must go through this workflow (release.yml)
|
|
12
|
-
workflow_dispatch:
|
|
13
|
-
inputs:
|
|
14
|
-
release_mode:
|
|
15
|
-
description: 'Manual release mode'
|
|
16
|
-
required: true
|
|
17
|
-
type: choice
|
|
18
|
-
default: 'instant'
|
|
19
|
-
options:
|
|
20
|
-
- instant
|
|
21
|
-
- changeset-pr
|
|
22
|
-
bump_type:
|
|
23
|
-
description: 'Manual release type'
|
|
24
|
-
required: true
|
|
25
|
-
type: choice
|
|
26
|
-
options:
|
|
27
|
-
- patch
|
|
28
|
-
- minor
|
|
29
|
-
- major
|
|
30
|
-
description:
|
|
31
|
-
description: 'Manual release description (optional)'
|
|
32
|
-
required: false
|
|
33
|
-
type: string
|
|
34
|
-
|
|
35
|
-
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
|
36
|
-
|
|
37
|
-
jobs:
|
|
38
|
-
# Changeset check - only runs on PRs
|
|
39
|
-
changeset-check:
|
|
40
|
-
name: Check for Changesets
|
|
41
|
-
runs-on: ubuntu-latest
|
|
42
|
-
if: github.event_name == 'pull_request'
|
|
43
|
-
steps:
|
|
44
|
-
- uses: actions/checkout@v4
|
|
45
|
-
with:
|
|
46
|
-
fetch-depth: 0
|
|
47
|
-
|
|
48
|
-
- name: Setup Node.js
|
|
49
|
-
uses: actions/setup-node@v4
|
|
50
|
-
with:
|
|
51
|
-
node-version: '20.x'
|
|
52
|
-
|
|
53
|
-
- name: Install dependencies
|
|
54
|
-
run: npm install
|
|
55
|
-
|
|
56
|
-
- name: Check for changesets
|
|
57
|
-
env:
|
|
58
|
-
# Pass PR context to the validation script
|
|
59
|
-
GITHUB_BASE_REF: ${{ github.base_ref }}
|
|
60
|
-
GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
61
|
-
GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
62
|
-
run: |
|
|
63
|
-
# Skip changeset check for automated version PRs
|
|
64
|
-
if [[ "${{ github.head_ref }}" == "changeset-release/"* ]]; then
|
|
65
|
-
echo "Skipping changeset check for automated release PR"
|
|
66
|
-
exit 0
|
|
67
|
-
fi
|
|
68
|
-
|
|
69
|
-
# Run changeset validation script
|
|
70
|
-
# This validates that exactly ONE changeset was ADDED by this PR
|
|
71
|
-
# Pre-existing changesets from other merged PRs are ignored
|
|
72
|
-
node scripts/validate-changeset.mjs
|
|
73
|
-
|
|
74
|
-
# Linting and formatting - runs after changeset check on PRs, immediately on main
|
|
75
|
-
lint:
|
|
76
|
-
name: Lint and Format Check
|
|
77
|
-
runs-on: ubuntu-latest
|
|
78
|
-
needs: [changeset-check]
|
|
79
|
-
if: always() && (github.event_name == 'push' || needs.changeset-check.result == 'success')
|
|
80
|
-
steps:
|
|
81
|
-
- uses: actions/checkout@v4
|
|
82
|
-
|
|
83
|
-
- name: Setup Node.js
|
|
84
|
-
uses: actions/setup-node@v4
|
|
85
|
-
with:
|
|
86
|
-
node-version: '20.x'
|
|
87
|
-
|
|
88
|
-
- name: Install dependencies
|
|
89
|
-
run: npm install
|
|
90
|
-
|
|
91
|
-
- name: Run ESLint
|
|
92
|
-
run: npm run lint
|
|
93
|
-
|
|
94
|
-
- name: Check formatting
|
|
95
|
-
run: npm run format:check
|
|
96
|
-
|
|
97
|
-
- name: Check code duplication
|
|
98
|
-
run: npm run check:duplication
|
|
99
|
-
|
|
100
|
-
# Test job - runs on Node.js only (browser automation requires specific setup)
|
|
101
|
-
test:
|
|
102
|
-
name: Test (Node.js on ${{ matrix.os }})
|
|
103
|
-
runs-on: ${{ matrix.os }}
|
|
104
|
-
needs: [changeset-check]
|
|
105
|
-
if: always() && (github.event_name == 'push' || needs.changeset-check.result == 'success')
|
|
106
|
-
strategy:
|
|
107
|
-
fail-fast: false
|
|
108
|
-
matrix:
|
|
109
|
-
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
110
|
-
steps:
|
|
111
|
-
- uses: actions/checkout@v4
|
|
112
|
-
|
|
113
|
-
- name: Setup Node.js
|
|
114
|
-
uses: actions/setup-node@v4
|
|
115
|
-
with:
|
|
116
|
-
node-version: '20.x'
|
|
117
|
-
|
|
118
|
-
- name: Install dependencies
|
|
119
|
-
run: npm install
|
|
120
|
-
|
|
121
|
-
- name: Run unit tests
|
|
122
|
-
run: npm test
|
|
123
|
-
|
|
124
|
-
# Release - only runs on main after tests pass (for push events)
|
|
125
|
-
release:
|
|
126
|
-
name: Release
|
|
127
|
-
needs: [lint, test]
|
|
128
|
-
# Use always() to ensure this job runs even if changeset-check was skipped
|
|
129
|
-
# This is needed because lint/test jobs have a transitive dependency on changeset-check
|
|
130
|
-
if: always() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success'
|
|
131
|
-
runs-on: ubuntu-latest
|
|
132
|
-
# Permissions required for npm OIDC trusted publishing
|
|
133
|
-
permissions:
|
|
134
|
-
contents: write
|
|
135
|
-
pull-requests: write
|
|
136
|
-
id-token: write
|
|
137
|
-
steps:
|
|
138
|
-
- uses: actions/checkout@v4
|
|
139
|
-
with:
|
|
140
|
-
fetch-depth: 0
|
|
141
|
-
|
|
142
|
-
- name: Setup Node.js
|
|
143
|
-
uses: actions/setup-node@v4
|
|
144
|
-
with:
|
|
145
|
-
node-version: '20.x'
|
|
146
|
-
registry-url: 'https://registry.npmjs.org'
|
|
147
|
-
|
|
148
|
-
- name: Install dependencies
|
|
149
|
-
run: npm install
|
|
150
|
-
|
|
151
|
-
- name: Update npm for OIDC trusted publishing
|
|
152
|
-
run: node scripts/setup-npm.mjs
|
|
153
|
-
|
|
154
|
-
- name: Check for changesets
|
|
155
|
-
id: check_changesets
|
|
156
|
-
run: |
|
|
157
|
-
# Count changeset files (excluding README.md and config.json)
|
|
158
|
-
CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" | wc -l)
|
|
159
|
-
echo "Found $CHANGESET_COUNT changeset file(s)"
|
|
160
|
-
echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT
|
|
161
|
-
echo "changeset_count=$CHANGESET_COUNT" >> $GITHUB_OUTPUT
|
|
162
|
-
|
|
163
|
-
- name: Merge multiple changesets
|
|
164
|
-
if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1
|
|
165
|
-
run: |
|
|
166
|
-
echo "Multiple changesets detected, merging..."
|
|
167
|
-
node scripts/merge-changesets.mjs
|
|
168
|
-
|
|
169
|
-
- name: Version packages and commit to main
|
|
170
|
-
if: steps.check_changesets.outputs.has_changesets == 'true'
|
|
171
|
-
id: version
|
|
172
|
-
run: node scripts/version-and-commit.mjs --mode changeset
|
|
173
|
-
|
|
174
|
-
- name: Publish to npm
|
|
175
|
-
# Run if version was committed OR if a previous attempt already committed (for re-runs)
|
|
176
|
-
if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
|
|
177
|
-
id: publish
|
|
178
|
-
run: node scripts/publish-to-npm.mjs --should-pull
|
|
179
|
-
|
|
180
|
-
- name: Create GitHub Release
|
|
181
|
-
if: steps.publish.outputs.published == 'true'
|
|
182
|
-
env:
|
|
183
|
-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
184
|
-
run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}"
|
|
185
|
-
|
|
186
|
-
- name: Format GitHub release notes
|
|
187
|
-
if: steps.publish.outputs.published == 'true'
|
|
188
|
-
env:
|
|
189
|
-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
190
|
-
run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}"
|
|
191
|
-
|
|
192
|
-
# Manual Instant Release - triggered via workflow_dispatch with instant mode
|
|
193
|
-
# This job is in release.yml because npm trusted publishing
|
|
194
|
-
# only allows one workflow file to be registered as a trusted publisher
|
|
195
|
-
instant-release:
|
|
196
|
-
name: Instant Release
|
|
197
|
-
if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant'
|
|
198
|
-
runs-on: ubuntu-latest
|
|
199
|
-
# Permissions required for npm OIDC trusted publishing
|
|
200
|
-
permissions:
|
|
201
|
-
contents: write
|
|
202
|
-
pull-requests: write
|
|
203
|
-
id-token: write
|
|
204
|
-
steps:
|
|
205
|
-
- uses: actions/checkout@v4
|
|
206
|
-
with:
|
|
207
|
-
fetch-depth: 0
|
|
208
|
-
|
|
209
|
-
- name: Setup Node.js
|
|
210
|
-
uses: actions/setup-node@v4
|
|
211
|
-
with:
|
|
212
|
-
node-version: '20.x'
|
|
213
|
-
registry-url: 'https://registry.npmjs.org'
|
|
214
|
-
|
|
215
|
-
- name: Install dependencies
|
|
216
|
-
run: npm install
|
|
217
|
-
|
|
218
|
-
- name: Update npm for OIDC trusted publishing
|
|
219
|
-
run: node scripts/setup-npm.mjs
|
|
220
|
-
|
|
221
|
-
- name: Version packages and commit to main
|
|
222
|
-
id: version
|
|
223
|
-
run: node scripts/version-and-commit.mjs --mode instant --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}"
|
|
224
|
-
|
|
225
|
-
- name: Publish to npm
|
|
226
|
-
# Run if version was committed OR if a previous attempt already committed (for re-runs)
|
|
227
|
-
if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
|
|
228
|
-
id: publish
|
|
229
|
-
run: node scripts/publish-to-npm.mjs
|
|
230
|
-
|
|
231
|
-
- name: Create GitHub Release
|
|
232
|
-
if: steps.publish.outputs.published == 'true'
|
|
233
|
-
env:
|
|
234
|
-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
235
|
-
run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}"
|
|
236
|
-
|
|
237
|
-
- name: Format GitHub release notes
|
|
238
|
-
if: steps.publish.outputs.published == 'true'
|
|
239
|
-
env:
|
|
240
|
-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
241
|
-
run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}"
|
|
242
|
-
|
|
243
|
-
# Manual Changeset PR - creates a pull request with the changeset for review
|
|
244
|
-
changeset-pr:
|
|
245
|
-
name: Create Changeset PR
|
|
246
|
-
if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr'
|
|
247
|
-
runs-on: ubuntu-latest
|
|
248
|
-
permissions:
|
|
249
|
-
contents: write
|
|
250
|
-
pull-requests: write
|
|
251
|
-
steps:
|
|
252
|
-
- uses: actions/checkout@v4
|
|
253
|
-
with:
|
|
254
|
-
fetch-depth: 0
|
|
255
|
-
|
|
256
|
-
- name: Setup Node.js
|
|
257
|
-
uses: actions/setup-node@v4
|
|
258
|
-
with:
|
|
259
|
-
node-version: '20.x'
|
|
260
|
-
|
|
261
|
-
- name: Install dependencies
|
|
262
|
-
run: npm install
|
|
263
|
-
|
|
264
|
-
- name: Create changeset file
|
|
265
|
-
run: node scripts/create-manual-changeset.mjs --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}"
|
|
266
|
-
|
|
267
|
-
- name: Format changeset with Prettier
|
|
268
|
-
run: |
|
|
269
|
-
# Run Prettier on the changeset file to ensure it matches project style
|
|
270
|
-
npx prettier --write ".changeset/*.md" || true
|
|
271
|
-
|
|
272
|
-
echo "Formatted changeset files"
|
|
273
|
-
|
|
274
|
-
- name: Create Pull Request
|
|
275
|
-
uses: peter-evans/create-pull-request@v7
|
|
276
|
-
with:
|
|
277
|
-
token: ${{ secrets.GITHUB_TOKEN }}
|
|
278
|
-
commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release'
|
|
279
|
-
branch: changeset-manual-release-${{ github.run_id }}
|
|
280
|
-
delete-branch: true
|
|
281
|
-
title: 'chore: manual ${{ github.event.inputs.bump_type }} release'
|
|
282
|
-
body: |
|
|
283
|
-
## Manual Release Request
|
|
284
|
-
|
|
285
|
-
This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release.
|
|
286
|
-
|
|
287
|
-
### Release Details
|
|
288
|
-
- **Type:** ${{ github.event.inputs.bump_type }}
|
|
289
|
-
- **Description:** ${{ github.event.inputs.description || 'Manual release' }}
|
|
290
|
-
- **Triggered by:** @${{ github.actor }}
|
|
291
|
-
|
|
292
|
-
### Next Steps
|
|
293
|
-
1. Review the changeset in this PR
|
|
294
|
-
2. Merge this PR to main
|
|
295
|
-
3. The automated release workflow will create a version PR
|
|
296
|
-
4. Merge the version PR to publish to npm and create a GitHub release
|
package/LICENSE
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
This is free and unencumbered software released into the public domain.
|
|
2
|
-
|
|
3
|
-
Anyone is free to copy, modify, publish, use, compile, sell, or
|
|
4
|
-
distribute this software, either in source code form or as a compiled
|
|
5
|
-
binary, for any purpose, commercial or non-commercial, and by any
|
|
6
|
-
means.
|
|
7
|
-
|
|
8
|
-
In jurisdictions that recognize copyright laws, the author or authors
|
|
9
|
-
of this software dedicate any and all copyright interest in the
|
|
10
|
-
software to the public domain. We make this dedication for the benefit
|
|
11
|
-
of the public at large and to the detriment of our heirs and
|
|
12
|
-
successors. We intend this dedication to be an overt act of
|
|
13
|
-
relinquishment in perpetuity of all present and future rights to this
|
|
14
|
-
software under copyright law.
|
|
15
|
-
|
|
16
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
-
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
-
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
19
|
-
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
20
|
-
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
21
|
-
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
22
|
-
OTHER DEALINGS IN THE SOFTWARE.
|
|
23
|
-
|
|
24
|
-
For more information, please refer to <https://unlicense.org>
|
package/README.md
DELETED
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
# Browser Commander
|
|
2
|
-
|
|
3
|
-
A universal browser automation library that supports both Playwright and Puppeteer with a unified API. The key focus is on **stoppable page triggers** - ensuring automation logic is properly mounted/unmounted during page navigation.
|
|
4
|
-
|
|
5
|
-
## Installation
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm install browser-commander
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
You'll also need either Playwright or Puppeteer:
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
# With Playwright
|
|
15
|
-
npm install playwright
|
|
16
|
-
|
|
17
|
-
# Or with Puppeteer
|
|
18
|
-
npm install puppeteer
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
## Core Concept: Page State Machine
|
|
22
|
-
|
|
23
|
-
Browser Commander manages the browser as a state machine with two states:
|
|
24
|
-
|
|
25
|
-
```
|
|
26
|
-
+------------------+ +------------------+
|
|
27
|
-
| | navigation start | |
|
|
28
|
-
| WORKING STATE | -------------------> | LOADING STATE |
|
|
29
|
-
| (action runs) | | (wait only) |
|
|
30
|
-
| | <----------------- | |
|
|
31
|
-
+------------------+ page ready +------------------+
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
**LOADING STATE**: Page is loading. Only waiting/tracking operations are allowed. No automation logic runs.
|
|
35
|
-
|
|
36
|
-
**WORKING STATE**: Page is fully loaded (30 seconds of network idle). Page triggers can safely interact with DOM.
|
|
37
|
-
|
|
38
|
-
## Quick Start
|
|
39
|
-
|
|
40
|
-
```javascript
|
|
41
|
-
import {
|
|
42
|
-
launchBrowser,
|
|
43
|
-
makeBrowserCommander,
|
|
44
|
-
makeUrlCondition,
|
|
45
|
-
} from 'browser-commander';
|
|
46
|
-
|
|
47
|
-
// 1. Launch browser
|
|
48
|
-
const { browser, page } = await launchBrowser({ engine: 'playwright' });
|
|
49
|
-
|
|
50
|
-
// 2. Create commander
|
|
51
|
-
const commander = makeBrowserCommander({ page, verbose: true });
|
|
52
|
-
|
|
53
|
-
// 3. Register page trigger with condition and action
|
|
54
|
-
commander.pageTrigger({
|
|
55
|
-
name: 'example-trigger',
|
|
56
|
-
condition: makeUrlCondition('*example.com*'), // matches URLs containing 'example.com'
|
|
57
|
-
action: async (ctx) => {
|
|
58
|
-
// ctx.commander has all methods, but they throw ActionStoppedError if navigation happens
|
|
59
|
-
// ctx.checkStopped() - call in loops to check if should stop
|
|
60
|
-
// ctx.abortSignal - use with fetch() for cancellation
|
|
61
|
-
// ctx.onCleanup(fn) - register cleanup when action stops
|
|
62
|
-
|
|
63
|
-
console.log(`Processing: ${ctx.url}`);
|
|
64
|
-
|
|
65
|
-
// Safe iteration - stops if navigation detected
|
|
66
|
-
await ctx.forEach(['item1', 'item2'], async (item) => {
|
|
67
|
-
await ctx.commander.clickButton({ selector: `[data-id="${item}"]` });
|
|
68
|
-
});
|
|
69
|
-
},
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// 4. Navigate - action auto-starts when page is ready
|
|
73
|
-
await commander.goto({ url: 'https://example.com' });
|
|
74
|
-
|
|
75
|
-
// 5. Cleanup
|
|
76
|
-
await commander.destroy();
|
|
77
|
-
await browser.close();
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
## URL Condition Helpers
|
|
81
|
-
|
|
82
|
-
The `makeUrlCondition` helper makes it easy to create URL matching conditions:
|
|
83
|
-
|
|
84
|
-
```javascript
|
|
85
|
-
import {
|
|
86
|
-
makeUrlCondition,
|
|
87
|
-
allConditions,
|
|
88
|
-
anyCondition,
|
|
89
|
-
notCondition,
|
|
90
|
-
} from 'browser-commander';
|
|
91
|
-
|
|
92
|
-
// Exact URL match
|
|
93
|
-
makeUrlCondition('https://example.com/page');
|
|
94
|
-
|
|
95
|
-
// Contains substring (use * wildcards)
|
|
96
|
-
makeUrlCondition('*checkout*'); // URL contains 'checkout'
|
|
97
|
-
makeUrlCondition('*example.com*'); // URL contains 'example.com'
|
|
98
|
-
|
|
99
|
-
// Starts with / ends with
|
|
100
|
-
makeUrlCondition('/api/*'); // starts with '/api/'
|
|
101
|
-
makeUrlCondition('*.json'); // ends with '.json'
|
|
102
|
-
|
|
103
|
-
// Express-style route patterns
|
|
104
|
-
makeUrlCondition('/vacancy/:id'); // matches /vacancy/123
|
|
105
|
-
makeUrlCondition('https://hh.ru/vacancy/:vacancyId'); // matches specific domain + path
|
|
106
|
-
makeUrlCondition('/user/:userId/profile'); // multiple segments
|
|
107
|
-
|
|
108
|
-
// RegExp
|
|
109
|
-
makeUrlCondition(/\/product\/\d+/);
|
|
110
|
-
|
|
111
|
-
// Custom function (receives full context)
|
|
112
|
-
makeUrlCondition((url, ctx) => {
|
|
113
|
-
const parsed = new URL(url);
|
|
114
|
-
return (
|
|
115
|
-
parsed.pathname.startsWith('/admin') && parsed.searchParams.has('edit')
|
|
116
|
-
);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// Combine conditions
|
|
120
|
-
allConditions(
|
|
121
|
-
makeUrlCondition('*example.com*'),
|
|
122
|
-
makeUrlCondition('*/checkout*')
|
|
123
|
-
); // Both must match
|
|
124
|
-
|
|
125
|
-
anyCondition(makeUrlCondition('*/cart*'), makeUrlCondition('*/checkout*')); // Either matches
|
|
126
|
-
|
|
127
|
-
notCondition(makeUrlCondition('*/admin*')); // Negation
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
## Page Trigger Lifecycle
|
|
131
|
-
|
|
132
|
-
### The Guarantee
|
|
133
|
-
|
|
134
|
-
When navigation is detected:
|
|
135
|
-
|
|
136
|
-
1. **Action is signaled to stop** (AbortController.abort())
|
|
137
|
-
2. **Wait for action to finish** (up to 10 seconds for graceful cleanup)
|
|
138
|
-
3. **Only then start waiting for page load**
|
|
139
|
-
|
|
140
|
-
This ensures:
|
|
141
|
-
|
|
142
|
-
- No DOM operations on stale/loading pages
|
|
143
|
-
- Actions can do proper cleanup (clear intervals, save state)
|
|
144
|
-
- No race conditions between action and navigation
|
|
145
|
-
|
|
146
|
-
## Action Context API
|
|
147
|
-
|
|
148
|
-
When your action is called, it receives a context object with these properties:
|
|
149
|
-
|
|
150
|
-
```javascript
|
|
151
|
-
commander.pageTrigger({
|
|
152
|
-
name: 'my-trigger',
|
|
153
|
-
condition: makeUrlCondition('*/checkout*'),
|
|
154
|
-
action: async (ctx) => {
|
|
155
|
-
// Current URL
|
|
156
|
-
ctx.url; // 'https://example.com/checkout'
|
|
157
|
-
|
|
158
|
-
// Trigger name (for debugging)
|
|
159
|
-
ctx.triggerName; // 'my-trigger'
|
|
160
|
-
|
|
161
|
-
// Check if action should stop
|
|
162
|
-
ctx.isStopped(); // Returns true if navigation detected
|
|
163
|
-
|
|
164
|
-
// Throw ActionStoppedError if stopped (use in manual loops)
|
|
165
|
-
ctx.checkStopped();
|
|
166
|
-
|
|
167
|
-
// AbortSignal - use with fetch() or other cancellable APIs
|
|
168
|
-
ctx.abortSignal;
|
|
169
|
-
|
|
170
|
-
// Safe wait (throws if stopped during wait)
|
|
171
|
-
await ctx.wait(1000);
|
|
172
|
-
|
|
173
|
-
// Safe iteration (checks stopped between items)
|
|
174
|
-
await ctx.forEach(items, async (item) => {
|
|
175
|
-
await ctx.commander.clickButton({ selector: item.selector });
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// Register cleanup (runs when action stops)
|
|
179
|
-
ctx.onCleanup(() => {
|
|
180
|
-
console.log('Cleaning up...');
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
// Commander with all methods wrapped to throw on stop
|
|
184
|
-
await ctx.commander.fillTextArea({ selector: 'input', text: 'hello' });
|
|
185
|
-
|
|
186
|
-
// Raw commander (use carefully - does not auto-throw)
|
|
187
|
-
ctx.rawCommander;
|
|
188
|
-
},
|
|
189
|
-
});
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
## API Reference
|
|
193
|
-
|
|
194
|
-
### makeBrowserCommander(options)
|
|
195
|
-
|
|
196
|
-
```javascript
|
|
197
|
-
const commander = makeBrowserCommander({
|
|
198
|
-
page, // Required: Playwright/Puppeteer page
|
|
199
|
-
verbose: false, // Enable debug logging
|
|
200
|
-
enableNetworkTracking: true, // Track HTTP requests
|
|
201
|
-
enableNavigationManager: true, // Enable navigation events
|
|
202
|
-
});
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
### commander.pageTrigger(config)
|
|
206
|
-
|
|
207
|
-
```javascript
|
|
208
|
-
const unregister = commander.pageTrigger({
|
|
209
|
-
name: 'trigger-name', // For debugging
|
|
210
|
-
condition: (ctx) => boolean, // When to run (receives {url, commander})
|
|
211
|
-
action: async (ctx) => void, // What to do
|
|
212
|
-
priority: 0, // Higher runs first
|
|
213
|
-
});
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
### commander.goto(options)
|
|
217
|
-
|
|
218
|
-
```javascript
|
|
219
|
-
await commander.goto({
|
|
220
|
-
url: 'https://example.com',
|
|
221
|
-
waitUntil: 'domcontentloaded', // Playwright/Puppeteer option
|
|
222
|
-
timeout: 60000,
|
|
223
|
-
});
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
### commander.clickButton(options)
|
|
227
|
-
|
|
228
|
-
```javascript
|
|
229
|
-
await commander.clickButton({
|
|
230
|
-
selector: 'button.submit',
|
|
231
|
-
scrollIntoView: true,
|
|
232
|
-
waitForNavigation: true,
|
|
233
|
-
});
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
### commander.fillTextArea(options)
|
|
237
|
-
|
|
238
|
-
```javascript
|
|
239
|
-
await commander.fillTextArea({
|
|
240
|
-
selector: 'textarea.message',
|
|
241
|
-
text: 'Hello world',
|
|
242
|
-
checkEmpty: true,
|
|
243
|
-
});
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
### commander.destroy()
|
|
247
|
-
|
|
248
|
-
```javascript
|
|
249
|
-
await commander.destroy(); // Stop actions, cleanup
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
## Best Practices
|
|
253
|
-
|
|
254
|
-
### 1. Use ctx.forEach for Loops
|
|
255
|
-
|
|
256
|
-
```javascript
|
|
257
|
-
// BAD: Won't stop on navigation
|
|
258
|
-
for (const item of items) {
|
|
259
|
-
await ctx.commander.click({ selector: item });
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// GOOD: Stops immediately on navigation
|
|
263
|
-
await ctx.forEach(items, async (item) => {
|
|
264
|
-
await ctx.commander.click({ selector: item });
|
|
265
|
-
});
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
### 2. Use ctx.checkStopped for Complex Logic
|
|
269
|
-
|
|
270
|
-
```javascript
|
|
271
|
-
action: async (ctx) => {
|
|
272
|
-
while (hasMorePages) {
|
|
273
|
-
ctx.checkStopped(); // Throws if navigation detected
|
|
274
|
-
|
|
275
|
-
await processPage(ctx);
|
|
276
|
-
hasMorePages = await ctx.commander.isVisible({ selector: '.next' });
|
|
277
|
-
}
|
|
278
|
-
};
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
### 3. Register Cleanup for Resources
|
|
282
|
-
|
|
283
|
-
```javascript
|
|
284
|
-
action: async (ctx) => {
|
|
285
|
-
const intervalId = setInterval(updateStatus, 1000);
|
|
286
|
-
|
|
287
|
-
ctx.onCleanup(() => {
|
|
288
|
-
clearInterval(intervalId);
|
|
289
|
-
console.log('Interval cleared');
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
// ... rest of action
|
|
293
|
-
};
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
### 4. Use ctx.abortSignal with Fetch
|
|
297
|
-
|
|
298
|
-
```javascript
|
|
299
|
-
action: async (ctx) => {
|
|
300
|
-
const response = await fetch(url, {
|
|
301
|
-
signal: ctx.abortSignal, // Cancels on navigation
|
|
302
|
-
});
|
|
303
|
-
};
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
## Debugging
|
|
307
|
-
|
|
308
|
-
Enable verbose mode for detailed logs:
|
|
309
|
-
|
|
310
|
-
```javascript
|
|
311
|
-
const commander = makeBrowserCommander({ page, verbose: true });
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
## Architecture
|
|
315
|
-
|
|
316
|
-
See [src/ARCHITECTURE.md](src/ARCHITECTURE.md) for detailed architecture documentation.
|
|
317
|
-
|
|
318
|
-
## License
|
|
319
|
-
|
|
320
|
-
[UNLICENSE](LICENSE)
|