fastbrowser_cli 1.0.33 → 1.0.37
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/README.md +26 -5
- package/dist/fastbrowser_mcp/fastbrowser_mcp.d.ts.map +1 -1
- package/dist/fastbrowser_mcp/fastbrowser_mcp.js +46 -10
- package/dist/fastbrowser_mcp/fastbrowser_mcp.js.map +1 -1
- package/dist/fastbrowser_mcp/libs/playwright_a11y_helper.d.ts +1 -0
- package/dist/fastbrowser_mcp/libs/playwright_a11y_helper.d.ts.map +1 -1
- package/dist/fastbrowser_mcp/libs/playwright_a11y_helper.js +33 -1
- package/dist/fastbrowser_mcp/libs/playwright_a11y_helper.js.map +1 -1
- package/examples/linkedin_cli/linkedin_dm.sh +36 -0
- package/examples/whatsapp/whatapp.a11y.txt +1521 -0
- package/examples/whatsapp/whatsapp.sh +10 -0
- package/listitem +3 -0
- package/package.json +3 -2
- package/skills/fastbrowser/SKILL.md +89 -10
- package/src/fastbrowser_mcp/fastbrowser_mcp.ts +50 -10
- package/src/fastbrowser_mcp/libs/playwright_a11y_helper.ts +33 -1
- package/examples/linkedin_cli/linked_dm.sh +0 -16
- /package/examples/linkedin_cli/{linked_post.sh → linkedin_post.sh} +0 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/bin/env bash
|
|
2
|
+
|
|
3
|
+
# restart the server
|
|
4
|
+
NODE_OPTIONS='' NPM_CONFIG_LOGLEVEL=silent npm run dev:cli -- server restart
|
|
5
|
+
|
|
6
|
+
# navigate to whatsapp web
|
|
7
|
+
NODE_OPTIONS='' NPM_CONFIG_LOGLEVEL=silent npm run dev:cli -- navigate_page --url https://web.whatsapp.com/
|
|
8
|
+
|
|
9
|
+
# List all conversations in the chat list
|
|
10
|
+
NODE_OPTIONS='' NPM_CONFIG_LOGLEVEL=silent npm run dev:cli -- query_selectors -s 'grid[name="Chat list"] > row' -a
|
package/listitem
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastbrowser_cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.37",
|
|
4
4
|
"description": "A CLI tool for FastBrowser, providing commands to interact with the FastBrowser MCP (Model Context Protocol) server and perform various browser automation tasks.",
|
|
5
5
|
"main": "dist/fastbrowser_cli/fastbrowser_cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"typescript": "^6.0.3",
|
|
31
31
|
"zod": "^4.3.6",
|
|
32
32
|
"zod-from-json-schema": "^0.5.2",
|
|
33
|
-
"a11y_parse": "^1.0.
|
|
33
|
+
"a11y_parse": "^1.0.7"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@types/express": "^4.17.21",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"prepublish:check": "test -z \"$(git status --porcelain)\" || (echo 'Working tree dirty — commit or stash first' && exit 1)",
|
|
52
52
|
"version:bump": "npm version patch --no-git-tag-version && git add package.json && git commit -m \"feat: bump version in fastbrowser_cli package.json\"",
|
|
53
53
|
"publish:all": "pnpm run prepublish:check && pnpm run build && pnpm run version:bump && pnpm publish --access public",
|
|
54
|
+
"clean:output:log": "rm -f outputs/*.log",
|
|
54
55
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
55
56
|
"test": "npx tsx --test 'tests/**/*.test.ts'"
|
|
56
57
|
}
|
|
@@ -45,6 +45,31 @@ npx fastbrowser_cli close_page --page-id 1
|
|
|
45
45
|
|
|
46
46
|
# Navigate the current page to a URL
|
|
47
47
|
npx fastbrowser_cli navigate_page --url https://example.com
|
|
48
|
+
|
|
49
|
+
# Restart the daemon - run this if pages opened by the bridge were closed manually
|
|
50
|
+
# and the MCP connection has broken
|
|
51
|
+
npx fastbrowser_cli server restart
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
Global flags accepted by every command:
|
|
58
|
+
|
|
59
|
+
| Flag / env | Purpose | Default |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `--server <url>` / `FASTBROWSER_SERVER` | URL of the `fastbrowser_httpd` daemon | `http://localhost:8787` |
|
|
62
|
+
| `--autostart` / `--no-autostart` | Auto-start the daemon if it is not already running | `--autostart` |
|
|
63
|
+
| `--mcp-target <target>` / `FASTBROWSER_MCP_TARGET` | Browser backend: `playwright` or `chrome_devtools` | `playwright` |
|
|
64
|
+
|
|
65
|
+
The daemon binds to one backend at startup. If it is already running with a different backend, the CLI refuses the request and prints the exact restart command to switch.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Use chrome-devtools-mcp for one command
|
|
69
|
+
npx fastbrowser_cli --mcp-target chrome_devtools list_pages
|
|
70
|
+
|
|
71
|
+
# Switch the running daemon to a different backend
|
|
72
|
+
npx fastbrowser_cli --mcp-target chrome_devtools server restart
|
|
48
73
|
```
|
|
49
74
|
|
|
50
75
|
|
|
@@ -59,7 +84,7 @@ Matches nodes by their accessibility role.
|
|
|
59
84
|
```
|
|
60
85
|
button
|
|
61
86
|
link
|
|
62
|
-
|
|
87
|
+
combobox
|
|
63
88
|
searchbox
|
|
64
89
|
heading
|
|
65
90
|
WebArea
|
|
@@ -93,6 +118,7 @@ Attribute selectors match values inside `node.attributes`. The special virtual a
|
|
|
93
118
|
| `[attr^="prefix"]` | starts with |
|
|
94
119
|
| `[attr$="suffix"]` | ends with |
|
|
95
120
|
| `[attr*="sub"]` | contains substring |
|
|
121
|
+
| `[attr~="word"]` | contains `word` as a whole space-separated word |
|
|
96
122
|
|
|
97
123
|
```
|
|
98
124
|
link[href]
|
|
@@ -100,6 +126,7 @@ button[disabled="true"]
|
|
|
100
126
|
link[href^="https"]
|
|
101
127
|
link[href$=".com"]
|
|
102
128
|
link[href*="example"]
|
|
129
|
+
button[name~="Submit"]
|
|
103
130
|
heading[name="Welcome"]
|
|
104
131
|
link[name="Click \"here\""]
|
|
105
132
|
```
|
|
@@ -110,15 +137,56 @@ link[name="Click \"here\""]
|
|
|
110
137
|
|--------|-----------|
|
|
111
138
|
| `A B` | B is a descendant of A (any depth) |
|
|
112
139
|
| `A > B` | B is a direct child of A |
|
|
140
|
+
| `A + B` | B is the immediately following sibling of A |
|
|
141
|
+
| `A ~ B` | B is any following sibling of A |
|
|
113
142
|
| `A, B` | union — matches A or B |
|
|
114
143
|
|
|
115
144
|
```
|
|
116
145
|
WebArea link
|
|
117
146
|
main > button
|
|
147
|
+
label + textbox
|
|
148
|
+
link ~ link
|
|
118
149
|
heading, button
|
|
119
150
|
RootWebArea > link[href^="https"]
|
|
120
151
|
```
|
|
121
152
|
|
|
153
|
+
### Positional pseudo-classes
|
|
154
|
+
|
|
155
|
+
Narrow a match by position within the parent's children array. Indexing is 1-based; the root node never matches a positional pseudo-class.
|
|
156
|
+
|
|
157
|
+
| Syntax | Semantics |
|
|
158
|
+
|--------|-----------|
|
|
159
|
+
| `:first-child` | node is the first child of its parent |
|
|
160
|
+
| `:last-child` | node is the last child of its parent |
|
|
161
|
+
| `:nth-child(n)` | node is the nth child (1-based) |
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
link:first-child
|
|
165
|
+
button:last-child
|
|
166
|
+
menuitem:nth-child(2)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Functional pseudo-classes
|
|
170
|
+
|
|
171
|
+
Take a comma-separated selector list inside parentheses. The argument list itself supports the full selector language and may be nested.
|
|
172
|
+
|
|
173
|
+
| Syntax | Semantics |
|
|
174
|
+
|--------|-----------|
|
|
175
|
+
| `:is(s1, s2, …)` | node matches any selector in the list |
|
|
176
|
+
| `:where(s1, s2, …)` | alias of `:is()` (no specificity in this engine) |
|
|
177
|
+
| `:not(s1, s2, …)` | node matches none of the selectors |
|
|
178
|
+
| `:has(s1, s2, …)` | node has a descendant matching any selector |
|
|
179
|
+
|
|
180
|
+
`:has()` walks descendants of the candidate node (excluding the node itself). Relative leading combinators (e.g. `:has(> link)`) are not supported.
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
:is(heading, button)
|
|
184
|
+
link:not(:first-child)
|
|
185
|
+
*:has(button)
|
|
186
|
+
*:not(:has(link))
|
|
187
|
+
main > *:not(button)
|
|
188
|
+
```
|
|
189
|
+
|
|
122
190
|
### Examples
|
|
123
191
|
|
|
124
192
|
Sample accessibility tree:
|
|
@@ -136,38 +204,47 @@ uid=1 WebArea "Main Page"
|
|
|
136
204
|
|
|
137
205
|
Example queries on it:
|
|
138
206
|
- `link` matches all the links (uid=4, uid=7, uid=8)
|
|
139
|
-
-
|
|
207
|
+
- `navigation > link` matches only the links that are direct children of navigation (uid=7, uid=8)
|
|
140
208
|
- `link[href^="https"]` matches links with an external href (uid=4)
|
|
141
209
|
- `button[name="Submit"]` matches the submit button by name (uid=5)
|
|
142
210
|
- `*[disabled="true"]` matches any disabled element (uid=5)
|
|
143
211
|
- `heading, button` matches both headings and buttons in one query (uid=3, uid=5)
|
|
144
212
|
- `#7` matches a node by its UID (uid=7)
|
|
213
|
+
- `link:first-child` matches uid=4 and uid=7 (first child of `main` and `navigation`)
|
|
214
|
+
- `link + button` matches uid=5 (button immediately after a link)
|
|
215
|
+
- `:is(heading, button)` is equivalent to `heading, button` (uid=3, uid=5)
|
|
216
|
+
- `link:not([href^="https"])` matches the relative-href links (uid=7, uid=8)
|
|
217
|
+
- `*:has(button)` matches ancestors of a button (uid=1, uid=2)
|
|
145
218
|
|
|
146
219
|
## Inspection
|
|
147
220
|
|
|
148
221
|
- `query_selectors` is the most efficient way to get specific elements or data from the page. Use it instead of `take_snapshot` whenever possible.
|
|
149
222
|
- By default, `query_selectors` returns the first match per selector (cheaper, less output). Pass `-a, --all` when you need every match — pair it with `--limit` to cap results per selector.
|
|
223
|
+
- Use `--wa, --with-ancestors` to include each match's ancestor chain in the result, and `--wc, --with-children` to include the descendant subtree of each match.
|
|
150
224
|
|
|
151
225
|
```bash
|
|
152
226
|
# Query the accessibility tree returning the FIRST match per selector (--selector is repeatable)
|
|
153
227
|
npx fastbrowser_cli query_selectors --selector "button" --selector "link"
|
|
154
228
|
|
|
155
|
-
#
|
|
156
|
-
npx fastbrowser_cli query_selectors --selector 'heading[level="1"]' --
|
|
229
|
+
# Include ancestor nodes in the result
|
|
230
|
+
npx fastbrowser_cli query_selectors --selector 'heading[level="1"]' --with-ancestors
|
|
231
|
+
|
|
232
|
+
# Include the descendant subtree of each match
|
|
233
|
+
npx fastbrowser_cli query_selectors --selector 'main' --with-children
|
|
157
234
|
|
|
158
|
-
# Per-selector control over withAncestors via JSON
|
|
235
|
+
# Per-selector control over withAncestors / withChildren via JSON
|
|
159
236
|
npx fastbrowser_cli query_selectors \
|
|
160
|
-
--selectors-json '[{"selector":"button","withAncestors":true},{"selector":"link","
|
|
237
|
+
--selectors-json '[{"selector":"button","withAncestors":true},{"selector":"link","withChildren":true}]'
|
|
161
238
|
|
|
162
239
|
# Pass --all to return every match per selector; --limit caps results per selector (0 = unlimited)
|
|
163
240
|
npx fastbrowser_cli query_selectors --all --selector "button" --selector "link" --limit 5
|
|
164
241
|
|
|
165
|
-
#
|
|
166
|
-
npx fastbrowser_cli query_selectors --all --selector 'heading[level="1"]' --
|
|
242
|
+
# Include ancestor nodes in the result
|
|
243
|
+
npx fastbrowser_cli query_selectors --all --selector 'heading[level="1"]' --with-ancestors
|
|
167
244
|
|
|
168
|
-
# Per-selector control over limit / withAncestors via JSON (with --all)
|
|
245
|
+
# Per-selector control over limit / withAncestors / withChildren via JSON (with --all)
|
|
169
246
|
npx fastbrowser_cli query_selectors --all \
|
|
170
|
-
--selectors-json '[{"selector":"button","limit":3,"withAncestors":true},{"selector":"link","limit":0,"
|
|
247
|
+
--selectors-json '[{"selector":"button","limit":3,"withAncestors":true},{"selector":"link","limit":0,"withChildren":true}]'
|
|
171
248
|
|
|
172
249
|
# Take an accessibility-tree full page snapshot of the current page - very expensive, prefer targeted queries when possible
|
|
173
250
|
npx fastbrowser_cli take_snapshot
|
|
@@ -239,9 +316,11 @@ press_keys --keys "Tab, Enter"
|
|
|
239
316
|
| `fill_form` | Fill a form field by accessibility selector | `--selector` / `-s`, `--value` |
|
|
240
317
|
| `press_keys` | Press a comma-separated key sequence | `--keys` |
|
|
241
318
|
| `batch` | Run multiple commands from a file, piped stdin, or `--script` inline | one of: `<file>`, `--script`, or piped stdin |
|
|
319
|
+
| `install [skill-folder]` | Install bundled skills into `<skill-folder>/skills/` (default: `.`) | — |
|
|
242
320
|
| `server start` | Start the HTTP server daemon | — |
|
|
243
321
|
| `server status` | Report server running/stopped | — |
|
|
244
322
|
| `server stop` | Stop the HTTP server | — |
|
|
323
|
+
| `server restart` | Restart the HTTP server (re-establishes the MCP connection) | — |
|
|
245
324
|
|
|
246
325
|
## Output & Errors
|
|
247
326
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// node imports
|
|
4
|
-
import * as Assert from 'node:assert/strict';
|
|
5
4
|
import Fs from 'node:fs';
|
|
6
5
|
import Path from 'node:path';
|
|
7
6
|
|
|
@@ -52,21 +51,62 @@ const logger = Logger.fromMetaUrl(import.meta.url, {
|
|
|
52
51
|
///////////////////////////////////////////////////////////////////////////////
|
|
53
52
|
|
|
54
53
|
class MainHelper {
|
|
54
|
+
/**
|
|
55
|
+
* Multiple retry... not sure it is actually useful - https://github.com/jeromeetienne/skillmd_collection/issues/47
|
|
56
|
+
*
|
|
57
|
+
* @param mcpClient
|
|
58
|
+
* @returns
|
|
59
|
+
*/
|
|
55
60
|
private static async _getA11yText(mcpClient: McpMyClient): Promise<string> {
|
|
56
61
|
const mcpTarget = await mcpClient.getMcpTarget();
|
|
57
62
|
const toolConfig = await McpTargetHelper.targetToolTakeSnapshot(mcpTarget);
|
|
58
63
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
64
|
+
// `take_snapshot` is racy — for reasons we haven't traced (could be playwright's a11y serializer, the
|
|
65
|
+
// `@playwright/mcp` extension transport, or the chrome extension itself), back-to-back calls on an unchanged
|
|
66
|
+
// page sometimes return incomplete or empty trees. Stabilize by taking snapshots in pairs and only returning
|
|
67
|
+
// once two consecutive ones agree on node count within STABLE_TOLERANCE. On exhaustion, return best-effort
|
|
68
|
+
// rather than throw, so legitimately tiny pages don't break workflows.
|
|
69
|
+
const MAX_ATTEMPTS = 6;
|
|
70
|
+
const RETRY_DELAY_MS = 250;
|
|
71
|
+
const STABLE_TOLERANCE = 2;
|
|
72
|
+
|
|
73
|
+
let prev: { text: string; nodeCount: number } | undefined = undefined;
|
|
74
|
+
let last: { text: string; nodeCount: number } = { text: '', nodeCount: 0 };
|
|
75
|
+
|
|
76
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
77
|
+
const callToolResult = await mcpClient.callTool(toolConfig.toolName, toolConfig.toolArgs);
|
|
78
|
+
const text = await ResponseFormatter.formatTakeSnapshot(mcpTarget, callToolResult);
|
|
79
|
+
const nodeCount = MainHelper._countSnapshotNodes(text);
|
|
80
|
+
last = { text, nodeCount };
|
|
81
|
+
|
|
82
|
+
if (prev !== undefined && Math.abs(nodeCount - prev.nodeCount) <= STABLE_TOLERANCE) {
|
|
83
|
+
if (nodeCount === 0) {
|
|
84
|
+
logger.warn(`${mcpTarget}:take_snapshot: settled at empty after ${attempt} attempt(s) — returning empty snapshot`);
|
|
85
|
+
}
|
|
86
|
+
return text;
|
|
87
|
+
}
|
|
62
88
|
|
|
63
|
-
|
|
64
|
-
const snapshotText = await ResponseFormatter.formatTakeSnapshot(mcpTarget, callToolResult);
|
|
65
|
-
// sanity check
|
|
66
|
-
Assert.ok(snapshotText !== undefined, "Snapshot text is empty");
|
|
89
|
+
logger.warn(`${mcpTarget}:take_snapshot: attempt ${attempt}/${MAX_ATTEMPTS}: nodeCount=${nodeCount}, prev=${prev === undefined ? 'n/a' : prev.nodeCount} — not stable, retrying`);
|
|
67
90
|
|
|
68
|
-
|
|
69
|
-
|
|
91
|
+
prev = last;
|
|
92
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
93
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
logger.warn(`${mcpTarget}:take_snapshot: exhausted ${MAX_ATTEMPTS} attempts without stabilizing — returning best-effort (nodeCount=${last.nodeCount})`);
|
|
98
|
+
return last.text;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private static _countSnapshotNodes(snapshotText: string): number {
|
|
102
|
+
if (snapshotText.trim().length === 0) return 0;
|
|
103
|
+
let count = 0;
|
|
104
|
+
for (const line of snapshotText.split('\n')) {
|
|
105
|
+
if (line.trim().startsWith('uid=')) {
|
|
106
|
+
count += 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return count;
|
|
70
110
|
}
|
|
71
111
|
|
|
72
112
|
/**
|
|
@@ -115,7 +115,39 @@ export class PlaywrightA11yConverter {
|
|
|
115
115
|
stack.push(node);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
// Playwright's `browser_snapshot` response often interleaves the a11y tree with preamble lines like
|
|
119
|
+
// `- Page URL: ...`, `- Page Title: ...`, `- Console: 193 errors, 0 warnings`, plus trailing entries
|
|
120
|
+
// like `- New`. Each of those parses as a spurious top-level (indent=0) node. Downstream,
|
|
121
|
+
// `A11yParse.A11yTree.parse` silently overwrites `root` for every indent-0 line and returns only the
|
|
122
|
+
// LAST top-level subtree — so the real a11y page tree (usually a large `generic [active]` subtree
|
|
123
|
+
// in the middle) gets discarded and queries return zero hits even though the data is in the text.
|
|
124
|
+
// Pick the largest top-level subtree, which is reliably the actual page tree since the metadata
|
|
125
|
+
// subtrees are tiny (0–1 nodes).
|
|
126
|
+
const primary = PlaywrightA11yConverter._pickPrimarySubtree(allNodes);
|
|
127
|
+
return primary.map((node) => PlaywrightA11yConverter._stringifyNode(node)).join('\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private static _pickPrimarySubtree(allNodes: EmittedNode[]): EmittedNode[] {
|
|
131
|
+
if (allNodes.length === 0) return allNodes;
|
|
132
|
+
|
|
133
|
+
const topLevelIndices: number[] = [];
|
|
134
|
+
for (let i = 0; i < allNodes.length; i++) {
|
|
135
|
+
if (allNodes[i].indent === 0) topLevelIndices.push(i);
|
|
136
|
+
}
|
|
137
|
+
if (topLevelIndices.length <= 1) return allNodes;
|
|
138
|
+
|
|
139
|
+
let bestStart = topLevelIndices[0];
|
|
140
|
+
let bestSize = 0;
|
|
141
|
+
for (let k = 0; k < topLevelIndices.length; k++) {
|
|
142
|
+
const start = topLevelIndices[k];
|
|
143
|
+
const end = k + 1 < topLevelIndices.length ? topLevelIndices[k + 1] : allNodes.length;
|
|
144
|
+
const size = end - start;
|
|
145
|
+
if (size > bestSize) {
|
|
146
|
+
bestSize = size;
|
|
147
|
+
bestStart = start;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return allNodes.slice(bestStart, bestStart + bestSize);
|
|
119
151
|
}
|
|
120
152
|
|
|
121
153
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
|
|
3
|
-
# Goto linkedin messaging page using the CLI commands below:
|
|
4
|
-
npx fastbrowser_cli navigate_page --url https://www.linkedin.com/messaging/
|
|
5
|
-
|
|
6
|
-
# list[name="Conversation list"] > listitem > heading
|
|
7
|
-
npx fastbrowser_cli query_selectors -s 'list[name="Conversation List"] > listitem heading' -a
|
|
8
|
-
|
|
9
|
-
# Select the conversation with Eric Defiez
|
|
10
|
-
npx fastbrowser_cli click -s 'list[name="Conversation List"] > listitem heading[name^="Eric Defiez"]'
|
|
11
|
-
|
|
12
|
-
# Fill the message content
|
|
13
|
-
npx fastbrowser_cli fill_form -s 'textbox[name^="Write"]' -v "Hello"
|
|
14
|
-
|
|
15
|
-
# Click the "Send" button to send the message
|
|
16
|
-
npx fastbrowser_cli click -s 'button[name^="Send"]'
|
|
File without changes
|