Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11eab471f1 | ||
|
|
aa256e30c7 | ||
|
|
c0e2b80f8c | ||
|
|
f319195974 | ||
|
|
77f2caa1bc | ||
|
|
6f1dd39121 | ||
|
|
85d18799a4 | ||
|
|
43e781a8d3 | ||
|
|
25e8719e51 | ||
|
|
ec011f46ff | ||
|
|
aef8fcc038 | ||
|
|
96582b79fd |
@@ -142,8 +142,8 @@ jobs:
|
||||
needs: build-binaries
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
published: ${{ steps.changesets.outputs.published }}
|
||||
publishedPackages: ${{ steps.changesets.outputs.publishedPackages }}
|
||||
published: ${{ steps.publish_metadata.outputs.published }}
|
||||
publishedPackages: ${{ steps.publish_metadata.outputs.publishedPackages }}
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
@@ -160,7 +160,6 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -215,12 +214,51 @@ jobs:
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm ci:version
|
||||
publish: pnpm ci:publish
|
||||
title: 'chore: version packages'
|
||||
commit: 'chore: version packages'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if publish is needed
|
||||
id: publish_check
|
||||
if: steps.changesets.outputs.hasChangesets == 'false'
|
||||
run: |
|
||||
LOCAL_VERSION=$(node -p "require('./package.json').version")
|
||||
REMOTE_VERSION=$(npm view agent-browser-stealth version 2>/dev/null || echo "")
|
||||
echo "local_version=$LOCAL_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "remote_version=$REMOTE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
if [ "$LOCAL_VERSION" != "$REMOTE_VERSION" ]; then
|
||||
echo "needs_publish=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "needs_publish=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "Local: $LOCAL_VERSION"
|
||||
echo "Remote: ${REMOTE_VERSION:-<none>}"
|
||||
|
||||
- name: Publish to npm (trusted publishing)
|
||||
id: publish_npm
|
||||
if: steps.changesets.outputs.hasChangesets == 'false' && steps.publish_check.outputs.needs_publish == 'true'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ""
|
||||
NPM_CONFIG_USERCONFIG: /home/runner/work/_temp/trusted-npmrc
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
run: |
|
||||
npm install -g npm@^11
|
||||
npm --version
|
||||
printf "registry=https://registry.npmjs.org/\n" > "$NPM_CONFIG_USERCONFIG"
|
||||
pnpm ci:publish
|
||||
|
||||
- name: Set release outputs
|
||||
id: publish_metadata
|
||||
run: |
|
||||
if [ "${{ steps.publish_npm.outcome }}" = "success" ]; then
|
||||
echo "published=true" >> "$GITHUB_OUTPUT"
|
||||
echo "publishedPackages=[{\"name\":\"agent-browser-stealth\",\"version\":\"${{ steps.publish_check.outputs.local_version }}\"}]" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "published=false" >> "$GITHUB_OUTPUT"
|
||||
echo "publishedPackages=[]" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Create GitHub release with binaries after npm publish
|
||||
github-release:
|
||||
name: Create GitHub Release
|
||||
|
||||
@@ -27,10 +27,15 @@ npm-debug.log*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
||||
# Test artifacts
|
||||
*.png
|
||||
*.jpeg
|
||||
*.jpg
|
||||
*.webm
|
||||
test/e2e/.dogfood-output/
|
||||
|
||||
# Package manager
|
||||
package-lock.json
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
if [ "${SKIP_CLAWHUB_SYNC:-0}" = "1" ]; then
|
||||
echo "Skipping ClawHub sync (SKIP_CLAWHUB_SYNC=1)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pnpm run clawhub:sync || {
|
||||
echo "ClawHub sync failed. Push continues. Run 'pnpm run clawhub:sync' manually after fixing login/network."
|
||||
}
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.14.0-fork.3"
|
||||
version = "0.14.0-fork.4"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"dirs",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.14.0-fork.3"
|
||||
version = "0.14.0-fork.4"
|
||||
edition = "2021"
|
||||
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+125
-6
@@ -369,12 +369,48 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
|
||||
// === Scroll ===
|
||||
"scroll" => {
|
||||
let dir = rest.first().unwrap_or(&"down");
|
||||
let amount = rest
|
||||
.get(1)
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.unwrap_or(300);
|
||||
Ok(json!({ "id": id, "action": "scroll", "direction": dir, "amount": amount }))
|
||||
let mut cmd = json!({ "id": id, "action": "scroll" });
|
||||
let obj = cmd.as_object_mut().unwrap();
|
||||
let mut positional_index = 0;
|
||||
let mut i = 0;
|
||||
while i < rest.len() {
|
||||
match rest[i] {
|
||||
"-s" | "--selector" => {
|
||||
if let Some(s) = rest.get(i + 1) {
|
||||
obj.insert("selector".to_string(), json!(s));
|
||||
i += 1;
|
||||
} else {
|
||||
return Err(ParseError::MissingArguments {
|
||||
context: "scroll --selector".to_string(),
|
||||
usage: "scroll [direction] [amount] [--selector <sel>]",
|
||||
});
|
||||
}
|
||||
}
|
||||
arg if arg.starts_with('-') => {}
|
||||
_ => {
|
||||
match positional_index {
|
||||
0 => {
|
||||
obj.insert("direction".to_string(), json!(rest[i]));
|
||||
}
|
||||
1 => {
|
||||
if let Ok(n) = rest[i].parse::<i32>() {
|
||||
obj.insert("amount".to_string(), json!(n));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
positional_index += 1;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if !obj.contains_key("direction") {
|
||||
obj.insert("direction".to_string(), json!("down"));
|
||||
}
|
||||
if !obj.contains_key("amount") {
|
||||
obj.insert("amount".to_string(), json!(300));
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
"scrollintoview" | "scrollinto" => {
|
||||
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
@@ -1988,8 +2024,10 @@ mod tests {
|
||||
cli_proxy_bypass: false,
|
||||
cli_allow_file_access: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
annotate: false,
|
||||
color_scheme: None,
|
||||
download_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3565,4 +3603,85 @@ mod tests {
|
||||
ParseError::MissingArguments { .. }
|
||||
));
|
||||
}
|
||||
|
||||
// === Scroll Tests ===
|
||||
|
||||
#[test]
|
||||
fn test_scroll_defaults() {
|
||||
let cmd = parse_command(&args("scroll"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "down");
|
||||
assert_eq!(cmd["amount"], 300);
|
||||
assert!(cmd.get("selector").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_direction_and_amount() {
|
||||
let cmd = parse_command(&args("scroll up 200"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "up");
|
||||
assert_eq!(cmd["amount"], 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_with_selector() {
|
||||
let cmd = parse_command(
|
||||
&args("scroll down 500 --selector div.scroll-container"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "down");
|
||||
assert_eq!(cmd["amount"], 500);
|
||||
assert_eq!(cmd["selector"], "div.scroll-container");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_with_selector_short_flag() {
|
||||
let cmd = parse_command(
|
||||
&args("scroll left 100 -s .sidebar"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "left");
|
||||
assert_eq!(cmd["amount"], 100);
|
||||
assert_eq!(cmd["selector"], ".sidebar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_selector_before_positional() {
|
||||
let cmd = parse_command(
|
||||
&args("scroll --selector .panel down 400"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "down");
|
||||
assert_eq!(cmd["amount"], 400);
|
||||
assert_eq!(cmd["selector"], ".panel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_selector_only() {
|
||||
let cmd = parse_command(
|
||||
&args("scroll --selector .content"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "down");
|
||||
assert_eq!(cmd["amount"], 300);
|
||||
assert_eq!(cmd["selector"], ".content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_selector_missing_value() {
|
||||
let result = parse_command(&args("scroll down 500 --selector"), &default_flags());
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
ParseError::MissingArguments { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +220,7 @@ pub fn ensure_daemon(
|
||||
device: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
debug: bool,
|
||||
download_path: Option<&str>,
|
||||
) -> Result<DaemonResult, String> {
|
||||
// Check if daemon is running AND responsive
|
||||
if is_daemon_running(session) && daemon_ready(session) {
|
||||
@@ -364,6 +365,9 @@ pub fn ensure_daemon(
|
||||
if debug {
|
||||
cmd.env("AGENT_BROWSER_DEBUG", "1");
|
||||
}
|
||||
if let Some(dp) = download_path {
|
||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||
}
|
||||
|
||||
// Create new process group and session to fully detach
|
||||
unsafe {
|
||||
@@ -448,6 +452,9 @@ pub fn ensure_daemon(
|
||||
if debug {
|
||||
cmd.env("AGENT_BROWSER_DEBUG", "1");
|
||||
}
|
||||
if let Some(dp) = download_path {
|
||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||
}
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
|
||||
@@ -33,6 +33,7 @@ pub struct Config {
|
||||
pub headers: Option<String>,
|
||||
pub annotate: Option<bool>,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -66,6 +67,7 @@ impl Config {
|
||||
headers: other.headers.or(self.headers),
|
||||
annotate: other.annotate.or(self.annotate),
|
||||
color_scheme: other.color_scheme.or(self.color_scheme),
|
||||
download_path: other.download_path.or(self.download_path),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +133,7 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
"--session-name",
|
||||
"--color-scheme",
|
||||
"--channel",
|
||||
"--download-path",
|
||||
];
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -200,6 +203,7 @@ pub struct Flags {
|
||||
pub session_name: Option<String>,
|
||||
pub annotate: bool,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
|
||||
// Track which launch-time options were explicitly passed via CLI
|
||||
// (as opposed to being set only via environment variables)
|
||||
@@ -212,6 +216,7 @@ pub struct Flags {
|
||||
pub cli_proxy_bypass: bool,
|
||||
pub cli_allow_file_access: bool,
|
||||
pub cli_annotate: bool,
|
||||
pub cli_download_path: bool,
|
||||
}
|
||||
|
||||
pub fn parse_flags(args: &[String]) -> Flags {
|
||||
@@ -278,6 +283,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME")
|
||||
.ok()
|
||||
.or(config.color_scheme),
|
||||
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok()
|
||||
.or(config.download_path),
|
||||
cli_executable_path: false,
|
||||
cli_extensions: false,
|
||||
cli_state: false,
|
||||
@@ -287,6 +294,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
cli_proxy_bypass: false,
|
||||
cli_allow_file_access: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
};
|
||||
|
||||
let mut i = 0;
|
||||
@@ -441,6 +449,13 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--download-path" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.download_path = Some(s.clone());
|
||||
flags.cli_download_path = true;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--config" => {
|
||||
// Already handled by load_config(); skip the value
|
||||
i += 1;
|
||||
@@ -484,6 +499,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--device",
|
||||
"--session-name",
|
||||
"--color-scheme",
|
||||
"--download-path",
|
||||
"--config",
|
||||
];
|
||||
|
||||
@@ -668,6 +684,19 @@ mod tests {
|
||||
assert!(!flags.cli_annotate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_download_path_tracking() {
|
||||
let flags = parse_flags(&args("--download-path /tmp/dl snapshot"));
|
||||
assert!(flags.cli_download_path);
|
||||
assert_eq!(flags.download_path, Some("/tmp/dl".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_download_path_not_set_without_flag() {
|
||||
let flags = parse_flags(&args("snapshot"));
|
||||
assert!(!flags.cli_download_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_multiple_flags_tracking() {
|
||||
let flags = parse_flags(&args(
|
||||
|
||||
+31
-23
@@ -267,6 +267,7 @@ fn main() {
|
||||
flags.device.as_deref(),
|
||||
flags.session_name.as_deref(),
|
||||
flags.debug,
|
||||
flags.download_path.as_deref(),
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
@@ -317,6 +318,7 @@ fn main() {
|
||||
},
|
||||
flags.ignore_https_errors.then_some("--ignore-https-errors"),
|
||||
flags.cli_allow_file_access.then_some("--allow-file-access"),
|
||||
flags.cli_download_path.then_some("--download-path"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -398,6 +400,10 @@ fn main() {
|
||||
launch_cmd["colorScheme"] = json!(cs);
|
||||
}
|
||||
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
|
||||
let err = match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) if resp.success => None,
|
||||
Ok(resp) => Some(
|
||||
@@ -484,29 +490,26 @@ fn main() {
|
||||
launch_cmd["colorScheme"] = json!(cs);
|
||||
}
|
||||
|
||||
match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) => {
|
||||
if !resp.success {
|
||||
let msg = resp
|
||||
.error
|
||||
.unwrap_or_else(|| "CDP connection failed".to_string());
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||
} else {
|
||||
eprintln!("{} {}", color::error_indicator(), msg);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
}
|
||||
Err(e) => {
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
||||
} else {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
}
|
||||
exit(1);
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
|
||||
let err = match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) if resp.success => None,
|
||||
Ok(resp) => Some(
|
||||
resp.error
|
||||
.unwrap_or_else(|| "CDP connection failed".to_string()),
|
||||
),
|
||||
Err(e) => Some(e.to_string()),
|
||||
};
|
||||
|
||||
if let Some(msg) = err {
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||
} else {
|
||||
eprintln!("{} {}", color::error_indicator(), msg);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,7 +602,8 @@ fn main() {
|
||||
|| flags.ignore_https_errors
|
||||
|| flags.allow_file_access
|
||||
|| flags.debug
|
||||
|| flags.color_scheme.is_some())
|
||||
|| flags.color_scheme.is_some()
|
||||
|| flags.download_path.is_some())
|
||||
&& flags.cdp.is_none()
|
||||
&& flags.provider.is_none()
|
||||
&& !launched_via_default_cdp
|
||||
@@ -661,6 +665,10 @@ fn main() {
|
||||
launch_cmd["colorScheme"] = json!(cs);
|
||||
}
|
||||
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
|
||||
match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) => {
|
||||
if !resp.success {
|
||||
|
||||
+8
-2
@@ -995,14 +995,17 @@ Use Cases:
|
||||
r##"
|
||||
agent-browser scroll - Scroll the page
|
||||
|
||||
Usage: agent-browser scroll [direction] [amount]
|
||||
Usage: agent-browser scroll [direction] [amount] [options]
|
||||
|
||||
Scrolls the page in the specified direction.
|
||||
Scrolls the page or a specific element in the specified direction.
|
||||
|
||||
Arguments:
|
||||
direction up, down, left, right (default: down)
|
||||
amount Pixels to scroll (default: 300)
|
||||
|
||||
Options:
|
||||
-s, --selector <sel> CSS selector for a scrollable container
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
@@ -1012,6 +1015,7 @@ Examples:
|
||||
agent-browser scroll down 500
|
||||
agent-browser scroll up 200
|
||||
agent-browser scroll left 100
|
||||
agent-browser scroll down 500 --selector "div.scroll-container"
|
||||
"##
|
||||
}
|
||||
"scrollintoview" | "scrollinto" => {
|
||||
@@ -2130,6 +2134,7 @@ Options:
|
||||
--auto-connect Auto-discover and connect to running Chrome
|
||||
Project default: require existing browser at localhost:9333 (no auto local fallback)
|
||||
--color-scheme <scheme> Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME)
|
||||
--download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
|
||||
--session-name <name> Auto-save/restore session state (cookies, localStorage)
|
||||
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
|
||||
--debug Debug output
|
||||
@@ -2180,6 +2185,7 @@ Environment:
|
||||
AGENT_BROWSER_LOCALE Override auto-detected locale (e.g., zh-TW, ja-JP)
|
||||
AGENT_BROWSER_TIMEZONE Override auto-detected timezone (e.g., Asia/Taipei)
|
||||
AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
|
||||
AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads
|
||||
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
|
||||
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
|
||||
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30)
|
||||
|
||||
@@ -22,7 +22,7 @@ agent-browser focus <sel> # Focus element
|
||||
agent-browser select <sel> <val> # Select dropdown option
|
||||
agent-browser check <sel> # Check checkbox
|
||||
agent-browser uncheck <sel> # Uncheck checkbox
|
||||
agent-browser scroll <dir> [px] # Scroll (up/down/left/right)
|
||||
agent-browser scroll <dir> [px] # Scroll (up/down/left/right, --selector <sel>)
|
||||
agent-browser scrollintoview <sel> # Scroll element into view
|
||||
agent-browser drag <src> <dst> # Drag and drop
|
||||
agent-browser upload <sel> <files> # Upload files
|
||||
@@ -117,6 +117,8 @@ agent-browser download <sel> <path> # Click element to trigger download
|
||||
agent-browser wait --download [path] # Wait for any download to complete
|
||||
```
|
||||
|
||||
Use `--download-path <dir>` (or `AGENT_BROWSER_DOWNLOAD_PATH` env) to set a default download directory. Without it, downloads go to a temporary directory that is deleted when the browser closes.
|
||||
|
||||
## Mouse
|
||||
|
||||
```bash
|
||||
|
||||
@@ -73,6 +73,7 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
||||
<tr><td><code>cdp</code></td><td><code>--cdp</code></td><td>string</td></tr>
|
||||
<tr><td><code>autoConnect</code></td><td><code>--auto-connect</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>colorScheme</code></td><td><code>--color-scheme</code></td><td>string (<code>dark</code>, <code>light</code>, <code>no-preference</code>)</td></tr>
|
||||
<tr><td><code>downloadPath</code></td><td><code>--download-path</code></td><td>string</td></tr>
|
||||
<tr><td><code>headers</code></td><td><code>--headers</code></td><td>string (JSON)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -151,6 +152,7 @@ These environment variables configure additional daemon and runtime behavior:
|
||||
<tr><td><code>AGENT_BROWSER_AUTO_CONNECT</code></td><td>Auto-discover and connect to a running Chrome instance.</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ALLOW_FILE_ACCESS</code></td><td>Allow <code>file://</code> URLs to access local files.</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_COLOR_SCHEME</code></td><td>Color scheme preference (<code>dark</code>, <code>light</code>, <code>no-preference</code>).</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_DOWNLOAD_PATH</code></td><td>Default directory for browser downloads.</td><td>(temp directory)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_DEFAULT_TIMEOUT</code></td><td>Default Playwright timeout in ms. Keep below 30000 to avoid IPC timeouts.</td><td><code>25000</code></td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_SESSION_NAME</code></td><td>Auto-save/load state persistence name.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code></td><td>Auto-delete saved session states older than N days.</td><td><code>30</code></td></tr>
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.14.0-fork.3",
|
||||
"version": "0.14.0-fork.4",
|
||||
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
|
||||
"type": "module",
|
||||
"main": "dist/daemon.js",
|
||||
@@ -33,7 +33,9 @@
|
||||
"format:check": "prettier --check 'src/**/*.ts'",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e:dogfood": "vitest run test/e2e/dogfood.eval.ts",
|
||||
"postinstall": "node scripts/postinstall.js",
|
||||
"clawhub:sync": "bash scripts/clawhub-sync.sh",
|
||||
"sync:upstream": "bash scripts/sync-upstream.sh",
|
||||
"sync:upstream:push": "bash scripts/sync-upstream.sh --push",
|
||||
"changeset": "changeset",
|
||||
@@ -68,6 +70,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.52",
|
||||
"@changesets/cli": "^2.29.8",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
|
||||
Generated
+174
@@ -24,6 +24,9 @@ importers:
|
||||
specifier: ^3.22.4
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@anthropic-ai/claude-agent-sdk':
|
||||
specifier: ^0.2.52
|
||||
version: 0.2.52(zod@3.25.76)
|
||||
'@changesets/cli':
|
||||
specifier: ^2.29.8
|
||||
version: 2.29.8(@types/node@20.19.28)
|
||||
@@ -57,6 +60,12 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk@0.2.52':
|
||||
resolution: {integrity: sha512-rdTQUu/HjKlDNNxJuhtXY6LJDOLvzVBU7sXFuFIG6CEC/nFfcvYq035EyjVw4nzu7lLZim/m+g2yZ8uNIcbaFw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
zod: ^4.0.0
|
||||
|
||||
'@appium/logger@1.7.1':
|
||||
resolution: {integrity: sha512-9C2o9X/lBEDBUnKfAi3mRo9oG7Z03nmISLwsGkWxIWjMAvBdJD0RRSJMekWVKzfXN3byrI1WlCXTITzN4LAoLw==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0, npm: '>=8'}
|
||||
@@ -276,6 +285,95 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@inquirer/external-editor@1.0.3':
|
||||
resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1950,6 +2048,20 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk@0.2.52(zod@3.25.76)':
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.34.5
|
||||
'@img/sharp-darwin-x64': 0.34.5
|
||||
'@img/sharp-linux-arm': 0.34.5
|
||||
'@img/sharp-linux-arm64': 0.34.5
|
||||
'@img/sharp-linux-x64': 0.34.5
|
||||
'@img/sharp-linuxmusl-arm64': 0.34.5
|
||||
'@img/sharp-linuxmusl-x64': 0.34.5
|
||||
'@img/sharp-win32-arm64': 0.34.5
|
||||
'@img/sharp-win32-x64': 0.34.5
|
||||
|
||||
'@appium/logger@1.7.1':
|
||||
dependencies:
|
||||
console-control-strings: 1.1.0
|
||||
@@ -2181,6 +2293,68 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@inquirer/external-editor@1.0.3(@types/node@20.19.28)':
|
||||
dependencies:
|
||||
chardet: 2.1.1
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
SKILL_NAME="agent-browser-stealth"
|
||||
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
echo "pnpm is required for ClawHub sync"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "skills/${SKILL_NAME}/SKILL.md" ]; then
|
||||
echo "Missing skill file: skills/${SKILL_NAME}/SKILL.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Sync only this fork-owned skill to avoid permission errors on other skills.
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
mkdir -p "$TMP_DIR/skills"
|
||||
cp -R "skills/${SKILL_NAME}" "$TMP_DIR/skills/${SKILL_NAME}"
|
||||
|
||||
echo "Syncing local skill '${SKILL_NAME}' to ClawHub..."
|
||||
cd "$TMP_DIR"
|
||||
pnpm dlx clawhub@latest sync --all --root ./skills
|
||||
echo "ClawHub sync completed."
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: agent-browser-stealth
|
||||
description: Stealth-first browser automation for OpenClaw using agent-browser-stealth. Use when tasks involve bot-protected websites, anti-fingerprint evasion, captcha-prone flows, login persistence, region-sensitive targets (e.g., Shopee/TikTok/e-commerce), or any request to automate web actions with lower detection risk.
|
||||
homepage: https://github.com/leeguooooo/agent-browser
|
||||
---
|
||||
|
||||
# agent-browser-stealth for OpenClaw
|
||||
|
||||
Use this skill when the task needs web automation and anti-bot stability.
|
||||
|
||||
## What this skill prioritizes
|
||||
|
||||
- Use `agent-browser` CLI from `agent-browser-stealth` package
|
||||
- Prefer stealth-safe interaction patterns over brittle one-shot scripts
|
||||
- Keep command flow deterministic: `open -> snapshot -> act -> re-snapshot`
|
||||
- Minimize bot signals with humanized pacing and stable session reuse
|
||||
|
||||
## Install and baseline
|
||||
|
||||
```bash
|
||||
npm install -g agent-browser-stealth
|
||||
agent-browser install
|
||||
agent-browser --version
|
||||
```
|
||||
|
||||
If default CDP mode is used in your environment, ensure a browser is available at `localhost:9333`, or pass `--cdp` / `--auto-connect` explicitly.
|
||||
|
||||
## Standard execution workflow
|
||||
|
||||
```bash
|
||||
agent-browser open <url>
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser snapshot -i
|
||||
# choose refs (@e1, @e2, ...)
|
||||
agent-browser click @eN
|
||||
agent-browser fill @eM "..."
|
||||
agent-browser snapshot -i
|
||||
```
|
||||
|
||||
Use refs (`@e1`) from snapshot output whenever possible.
|
||||
|
||||
## Anti-bot operating rules
|
||||
|
||||
1. Prefer headed mode for sensitive targets:
|
||||
|
||||
```bash
|
||||
agent-browser --headed --session-name shop open https://example.com
|
||||
```
|
||||
|
||||
2. Reuse session state to avoid repeated cold-start fingerprints:
|
||||
|
||||
```bash
|
||||
agent-browser --session-name shop open https://example.com
|
||||
```
|
||||
|
||||
3. Keep interactions human-like:
|
||||
|
||||
```bash
|
||||
agent-browser type @e2 "query" --delay 120
|
||||
agent-browser wait 1200-2600
|
||||
```
|
||||
|
||||
4. For contenteditable editors, use keyboard mode:
|
||||
|
||||
```bash
|
||||
agent-browser click "[contenteditable='true']"
|
||||
agent-browser keyboard type "Hello world" --delay 90
|
||||
```
|
||||
|
||||
5. If text must literally include `--delay`, stop arg parsing with `--`:
|
||||
|
||||
```bash
|
||||
agent-browser type @e2 -- "--delay 120"
|
||||
agent-browser keyboard type -- "--delay 120"
|
||||
```
|
||||
|
||||
## Region-sensitive websites
|
||||
|
||||
For region-bound sites, open target domain directly and let locale/timezone alignment apply.
|
||||
|
||||
```bash
|
||||
agent-browser open https://shopee.tw
|
||||
```
|
||||
|
||||
Only override locale/timezone when explicitly required by the task.
|
||||
|
||||
## Recovery patterns
|
||||
|
||||
If blocked or unstable:
|
||||
|
||||
1. Retry with `--headed`.
|
||||
2. Reuse `--session-name`.
|
||||
3. Slow down action cadence (`wait`, `type --delay`).
|
||||
4. Re-open page and regenerate refs with `snapshot -i`.
|
||||
|
||||
## Minimal recipes
|
||||
|
||||
Login flow:
|
||||
|
||||
```bash
|
||||
agent-browser --session-name account open https://example.com/login
|
||||
agent-browser snapshot -i
|
||||
agent-browser fill @e1 "$USERNAME"
|
||||
agent-browser fill @e2 "$PASSWORD"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --url "**/dashboard"
|
||||
```
|
||||
|
||||
Search and capture:
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com
|
||||
agent-browser snapshot -i
|
||||
agent-browser type @e2 "iphone" --delay 120
|
||||
agent-browser press Enter
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser screenshot result.png
|
||||
```
|
||||
|
||||
## Output expectations for OpenClaw
|
||||
|
||||
When using this skill, return:
|
||||
|
||||
- Exact commands executed
|
||||
- Key page state changes (URL/title/important element text)
|
||||
- Any anti-bot signal encountered and mitigation used
|
||||
- Next safe action
|
||||
@@ -70,6 +70,7 @@ agent-browser press Enter # Press key
|
||||
agent-browser keyboard type "text" --delay 90 # Type at current focus (no selector)
|
||||
agent-browser keyboard inserttext "text" # Insert without key events
|
||||
agent-browser scroll down 500 # Scroll page
|
||||
agent-browser scroll down 500 --selector "div.content" # Scroll within a specific container
|
||||
|
||||
# Get information
|
||||
agent-browser get text @e1 # Get element text
|
||||
@@ -83,6 +84,11 @@ agent-browser wait --url "**/page" # Wait for URL pattern
|
||||
agent-browser wait 2000 # Wait milliseconds
|
||||
agent-browser wait 2000-5000 # Random wait between 2-5 seconds
|
||||
|
||||
# Downloads
|
||||
agent-browser download @e1 ./file.pdf # Click element to trigger download
|
||||
agent-browser wait --download ./output.zip # Wait for any download to complete
|
||||
agent-browser --download-path ./downloads open <url> # Set default download directory
|
||||
|
||||
# Capture
|
||||
agent-browser screenshot # Screenshot to temp dir
|
||||
agent-browser screenshot --full # Full page screenshot
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
---
|
||||
name: dogfood
|
||||
description: Systematically explore and test a web application to find bugs, UX issues, and other problems. Use when asked to "dogfood", "QA", "exploratory test", "find issues", "bug hunt", "test this app/site/platform", or review the quality of a web application. Produces a structured report with full reproduction evidence -- step-by-step screenshots, repro videos, and detailed repro steps for every issue -- so findings can be handed directly to the responsible teams.
|
||||
allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*)
|
||||
---
|
||||
|
||||
# Dogfood
|
||||
|
||||
Systematically explore a web application, find issues, and produce a report with full reproduction evidence for every finding.
|
||||
|
||||
## Setup
|
||||
|
||||
Only the **Target URL** is required. Everything else has sensible defaults -- use them unless the user explicitly provides an override.
|
||||
|
||||
| Parameter | Default | Example override |
|
||||
|-----------|---------|-----------------|
|
||||
| **Target URL** | _(required)_ | `vercel.com`, `http://localhost:3000` |
|
||||
| **Session name** | Slugified domain (e.g., `vercel.com` -> `vercel-com`) | `--session my-session` |
|
||||
| **Output directory** | `./dogfood-output/` | `Output directory: /tmp/qa` |
|
||||
| **Scope** | Full app | `Focus on the billing page` |
|
||||
| **Authentication** | None | `Sign in to user@example.com` |
|
||||
|
||||
If the user says something like "dogfood vercel.com", start immediately with defaults. Do not ask clarifying questions unless authentication is mentioned but credentials are missing.
|
||||
|
||||
Always use `agent-browser` directly -- never `npx agent-browser`. The direct binary uses the fast Rust client. `npx` routes through Node.js and is significantly slower.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. Initialize Set up session, output dirs, report file
|
||||
2. Authenticate Sign in if needed, save state
|
||||
3. Orient Navigate to starting point, take initial snapshot
|
||||
4. Explore Systematically visit pages and test features
|
||||
5. Document Screenshot + record each issue as found
|
||||
6. Wrap up Update summary counts, close session
|
||||
```
|
||||
|
||||
### 1. Initialize
|
||||
|
||||
```bash
|
||||
mkdir -p {OUTPUT_DIR}/screenshots {OUTPUT_DIR}/videos
|
||||
```
|
||||
|
||||
Copy the report template into the output directory and fill in the header fields:
|
||||
|
||||
```bash
|
||||
cp {SKILL_DIR}/templates/dogfood-report-template.md {OUTPUT_DIR}/report.md
|
||||
```
|
||||
|
||||
Start a named session:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} open {TARGET_URL}
|
||||
agent-browser --session {SESSION} wait --load networkidle
|
||||
```
|
||||
|
||||
### 2. Authenticate
|
||||
|
||||
If the app requires login:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} snapshot -i
|
||||
# Identify login form refs, fill credentials
|
||||
agent-browser --session {SESSION} fill @e1 "{EMAIL}"
|
||||
agent-browser --session {SESSION} fill @e2 "{PASSWORD}"
|
||||
agent-browser --session {SESSION} click @e3
|
||||
agent-browser --session {SESSION} wait --load networkidle
|
||||
```
|
||||
|
||||
For OTP/email codes: ask the user, wait for their response, then enter the code.
|
||||
|
||||
After successful login, save state for potential reuse:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} state save {OUTPUT_DIR}/auth-state.json
|
||||
```
|
||||
|
||||
### 3. Orient
|
||||
|
||||
Take an initial annotated screenshot and snapshot to understand the app structure:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/initial.png
|
||||
agent-browser --session {SESSION} snapshot -i
|
||||
```
|
||||
|
||||
Identify the main navigation elements and map out the sections to visit.
|
||||
|
||||
### 4. Explore
|
||||
|
||||
Read [references/issue-taxonomy.md](references/issue-taxonomy.md) for the full list of what to look for and the exploration checklist.
|
||||
|
||||
**Strategy -- work through the app systematically:**
|
||||
|
||||
- Start from the main navigation. Visit each top-level section.
|
||||
- Within each section, test interactive elements: click buttons, fill forms, open dropdowns/modals.
|
||||
- Check edge cases: empty states, error handling, boundary inputs.
|
||||
- Try realistic end-to-end workflows (create, edit, delete flows).
|
||||
- Check the browser console for errors periodically.
|
||||
|
||||
**At each page:**
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} snapshot -i
|
||||
agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/{page-name}.png
|
||||
agent-browser --session {SESSION} errors
|
||||
agent-browser --session {SESSION} console
|
||||
```
|
||||
|
||||
Use your judgment on how deep to go. Spend more time on core features and less on peripheral pages. If you find a cluster of issues in one area, investigate deeper.
|
||||
|
||||
### 5. Document Issues (Repro-First)
|
||||
|
||||
Steps 4 and 5 happen together -- explore and document in a single pass. When you find an issue, stop exploring and document it immediately before moving on. Do not explore the whole app first and document later.
|
||||
|
||||
Every issue must be reproducible. When you find something wrong, do not just note it -- prove it with evidence. The goal is that someone reading the report can see exactly what happened and replay it.
|
||||
|
||||
**Choose the right level of evidence for the issue:**
|
||||
|
||||
#### Interactive / behavioral issues (functional, ux, console errors on action)
|
||||
|
||||
These require user interaction to reproduce -- use full repro with video and step-by-step screenshots:
|
||||
|
||||
1. **Start a repro video** _before_ reproducing:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} record start {OUTPUT_DIR}/videos/issue-{NNN}-repro.webm
|
||||
```
|
||||
|
||||
2. **Walk through the steps at human pace.** Pause 1-2 seconds between actions so the video is watchable. Take a screenshot at each step:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} screenshot {OUTPUT_DIR}/screenshots/issue-{NNN}-step-1.png
|
||||
sleep 1
|
||||
# Perform action (click, fill, etc.)
|
||||
sleep 1
|
||||
agent-browser --session {SESSION} screenshot {OUTPUT_DIR}/screenshots/issue-{NNN}-step-2.png
|
||||
sleep 1
|
||||
# ...continue until the issue manifests
|
||||
```
|
||||
|
||||
3. **Capture the broken state.** Pause so the viewer can see it, then take an annotated screenshot:
|
||||
|
||||
```bash
|
||||
sleep 2
|
||||
agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/issue-{NNN}-result.png
|
||||
```
|
||||
|
||||
4. **Stop the video:**
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} record stop
|
||||
```
|
||||
|
||||
5. Write numbered repro steps in the report, each referencing its screenshot.
|
||||
|
||||
#### Static / visible-on-load issues (typos, placeholder text, clipped text, misalignment, console errors on load)
|
||||
|
||||
These are visible without interaction -- a single annotated screenshot is sufficient. No video, no multi-step repro:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/issue-{NNN}.png
|
||||
```
|
||||
|
||||
Write a brief description and reference the screenshot in the report. Set **Repro Video** to `N/A`.
|
||||
|
||||
---
|
||||
|
||||
**For all issues:**
|
||||
|
||||
1. **Append to the report immediately.** Do not batch issues for later. Write each one as you find it so nothing is lost if the session is interrupted.
|
||||
|
||||
2. **Increment the issue counter** (ISSUE-001, ISSUE-002, ...).
|
||||
|
||||
### 6. Wrap Up
|
||||
|
||||
Aim to find **5-10 well-documented issues**, then wrap up. Depth of evidence matters more than total count -- 5 issues with full repro beats 20 with vague descriptions.
|
||||
|
||||
After exploring:
|
||||
|
||||
1. Re-read the report and update the summary severity counts so they match the actual issues. Every `### ISSUE-` block must be reflected in the totals.
|
||||
2. Close the session:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} close
|
||||
```
|
||||
|
||||
3. Tell the user the report is ready and summarize findings: total issues, breakdown by severity, and the most critical items.
|
||||
|
||||
## Guidance
|
||||
|
||||
- **Repro is everything.** Every issue needs proof -- but match the evidence to the issue. Interactive bugs need video and step-by-step screenshots. Static bugs (typos, placeholder text, visual glitches visible on load) only need a single annotated screenshot.
|
||||
- **Don't record video for static issues.** A typo or clipped text doesn't benefit from a video. Save video for issues that involve user interaction, timing, or state changes.
|
||||
- **For interactive issues, screenshot each step.** Capture the before, the action, and the after -- so someone can see the full sequence.
|
||||
- **Write repro steps that map to screenshots.** Each numbered step in the report should reference its corresponding screenshot. A reader should be able to follow the steps visually without touching a browser.
|
||||
- **Be thorough but use judgment.** You are not following a test script -- you are exploring like a real user would. If something feels off, investigate.
|
||||
- **Write findings incrementally.** Append each issue to the report as you discover it. If the session is interrupted, findings are preserved. Never batch all issues for the end.
|
||||
- **Never delete output files.** Do not `rm` screenshots, videos, or the report mid-session. Do not close the session and restart. Work forward, not backward.
|
||||
- **Never read the target app's source code.** You are testing as a user, not auditing code. Do not read HTML, JS, or config files of the app under test. All findings must come from what you observe in the browser.
|
||||
- **Check the console.** Many issues are invisible in the UI but show up as JS errors or failed requests.
|
||||
- **Test like a user, not a robot.** Try common workflows end-to-end. Click things a real user would click. Enter realistic data.
|
||||
- **Type like a human.** When filling form fields during video recording, use `type` instead of `fill` -- it types character-by-character. Use `fill` only outside of video recording when speed matters.
|
||||
- **Pace repro videos for humans.** Add `sleep 1` between actions and `sleep 2` before the final result screenshot. Videos should be watchable at 1x speed -- a human reviewing the report needs to see what happened, not a blur of instant state changes.
|
||||
- **Be efficient with commands.** Batch multiple `agent-browser` commands in a single shell call when they are independent (e.g., `agent-browser ... screenshot ... && agent-browser ... console`). Use `agent-browser --session {SESSION} scroll down 300` for scrolling -- do not use `key` or `evaluate` to scroll.
|
||||
|
||||
## References
|
||||
|
||||
| Reference | When to Read |
|
||||
|-----------|--------------|
|
||||
| [references/issue-taxonomy.md](references/issue-taxonomy.md) | Start of session -- calibrate what to look for, severity levels, exploration checklist |
|
||||
|
||||
## Templates
|
||||
|
||||
| Template | Purpose |
|
||||
|----------|---------|
|
||||
| [templates/dogfood-report-template.md](templates/dogfood-report-template.md) | Copy into output directory as the report file |
|
||||
@@ -0,0 +1,109 @@
|
||||
# Issue Taxonomy
|
||||
|
||||
Reference for categorizing issues found during dogfooding. Read this at the start of a dogfood session to calibrate what to look for.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Severity Levels](#severity-levels)
|
||||
- [Categories](#categories)
|
||||
- [Exploration Checklist](#exploration-checklist)
|
||||
|
||||
## Severity Levels
|
||||
|
||||
| Severity | Definition |
|
||||
|----------|------------|
|
||||
| **critical** | Blocks a core workflow, causes data loss, or crashes the app |
|
||||
| **high** | Major feature broken or unusable, no workaround |
|
||||
| **medium** | Feature works but with noticeable problems, workaround exists |
|
||||
| **low** | Minor cosmetic or polish issue |
|
||||
|
||||
## Categories
|
||||
|
||||
### Visual / UI
|
||||
|
||||
- Layout broken or misaligned elements
|
||||
- Overlapping or clipped text
|
||||
- Inconsistent spacing, padding, or margins
|
||||
- Missing or broken icons/images
|
||||
- Dark mode / light mode rendering issues
|
||||
- Responsive layout problems (viewport sizes)
|
||||
- Z-index stacking issues (elements hidden behind others)
|
||||
- Font rendering issues (wrong font, size, weight)
|
||||
- Color contrast problems
|
||||
- Animation glitches or jank
|
||||
|
||||
### Functional
|
||||
|
||||
- Broken links (404, wrong destination)
|
||||
- Buttons or controls that do nothing on click
|
||||
- Form validation that rejects valid input or accepts invalid input
|
||||
- Incorrect redirects
|
||||
- Features that fail silently
|
||||
- State not persisted when expected (lost on refresh, navigation)
|
||||
- Race conditions (double-submit, stale data)
|
||||
- Broken search or filtering
|
||||
- Pagination issues
|
||||
- File upload/download failures
|
||||
|
||||
### UX
|
||||
|
||||
- Confusing or unclear navigation
|
||||
- Missing loading indicators or feedback after actions
|
||||
- Slow or unresponsive interactions (>300ms perceived delay)
|
||||
- Unclear error messages
|
||||
- Missing confirmation for destructive actions
|
||||
- Dead ends (no way to go back or proceed)
|
||||
- Inconsistent patterns across similar features
|
||||
- Missing keyboard shortcuts or focus management
|
||||
- Unintuitive defaults
|
||||
- Missing empty states or unhelpful empty states
|
||||
|
||||
### Content
|
||||
|
||||
- Typos or grammatical errors
|
||||
- Outdated or incorrect text
|
||||
- Placeholder or lorem ipsum content left in
|
||||
- Truncated text without tooltip or expansion
|
||||
- Missing or wrong labels
|
||||
- Inconsistent terminology
|
||||
|
||||
### Performance
|
||||
|
||||
- Slow page loads (>3s)
|
||||
- Janky scrolling or animations
|
||||
- Large layout shifts (content jumping)
|
||||
- Excessive network requests (check via console/network)
|
||||
- Memory leaks (page slows over time)
|
||||
- Unoptimized images (large file sizes)
|
||||
|
||||
### Console / Errors
|
||||
|
||||
- JavaScript exceptions in console
|
||||
- Failed network requests (4xx, 5xx)
|
||||
- Deprecation warnings
|
||||
- CORS errors
|
||||
- Mixed content warnings
|
||||
- Unhandled promise rejections
|
||||
|
||||
### Accessibility
|
||||
|
||||
- Missing alt text on images
|
||||
- Unlabeled form inputs
|
||||
- Poor keyboard navigation (can't tab to elements)
|
||||
- Focus traps
|
||||
- Insufficient color contrast
|
||||
- Missing ARIA attributes on dynamic content
|
||||
- Screen reader incompatible patterns
|
||||
|
||||
## Exploration Checklist
|
||||
|
||||
Use this as a guide for what to test on each page/feature:
|
||||
|
||||
1. **Visual scan** -- Take an annotated screenshot. Look for layout, alignment, and rendering issues.
|
||||
2. **Interactive elements** -- Click every button, link, and control. Do they work? Is there feedback?
|
||||
3. **Forms** -- Fill and submit. Test empty submission, invalid input, and edge cases.
|
||||
4. **Navigation** -- Follow all navigation paths. Check breadcrumbs, back button, deep links.
|
||||
5. **States** -- Check empty states, loading states, error states, and full/overflow states.
|
||||
6. **Console** -- Check for JS errors, failed requests, and warnings.
|
||||
7. **Responsiveness** -- If relevant, test at different viewport sizes.
|
||||
8. **Auth boundaries** -- Test what happens when not logged in, with different roles if applicable.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Dogfood Report: {APP_NAME}
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Date** | {DATE} |
|
||||
| **App URL** | {URL} |
|
||||
| **Session** | {SESSION_NAME} |
|
||||
| **Scope** | {SCOPE} |
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| Critical | 0 |
|
||||
| High | 0 |
|
||||
| Medium | 0 |
|
||||
| Low | 0 |
|
||||
| **Total** | **0** |
|
||||
|
||||
## Issues
|
||||
|
||||
<!-- Copy this block for each issue found. Interactive issues need video + step-by-step screenshots. Static issues (typos, visual glitches) only need a single screenshot -- set Repro Video to N/A. -->
|
||||
|
||||
### ISSUE-001: {Short title}
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Severity** | critical / high / medium / low |
|
||||
| **Category** | visual / functional / ux / content / performance / console / accessibility |
|
||||
| **URL** | {page URL where issue was found} |
|
||||
| **Repro Video** | {path to video, or N/A for static issues} |
|
||||
|
||||
**Description**
|
||||
|
||||
{What is wrong, what was expected, and what actually happened.}
|
||||
|
||||
**Repro Steps**
|
||||
|
||||
<!-- Each step has a screenshot. A reader should be able to follow along visually. -->
|
||||
|
||||
1. Navigate to {URL}
|
||||

|
||||
|
||||
2. {Action -- e.g., click "Settings" in the sidebar}
|
||||

|
||||
|
||||
3. {Action -- e.g., type "test" in the search field and press Enter}
|
||||

|
||||
|
||||
4. **Observe:** {what goes wrong -- e.g., the page shows a blank white screen instead of search results}
|
||||

|
||||
|
||||
---
|
||||
+25
-25
@@ -998,41 +998,41 @@ async function handleWait(command: WaitCommand, browser: BrowserManager): Promis
|
||||
async function handleScroll(command: ScrollCommand, browser: BrowserManager): Promise<Response> {
|
||||
const page = browser.getPage();
|
||||
|
||||
let deltaX = command.x ?? 0;
|
||||
let deltaY = command.y ?? 0;
|
||||
const hasExplicitDelta = command.x !== undefined || command.y !== undefined;
|
||||
|
||||
if (command.direction) {
|
||||
const amount = command.amount ?? 100;
|
||||
switch (command.direction) {
|
||||
case 'up':
|
||||
deltaY = -amount;
|
||||
break;
|
||||
case 'down':
|
||||
deltaY = amount;
|
||||
break;
|
||||
case 'left':
|
||||
deltaX = -amount;
|
||||
break;
|
||||
case 'right':
|
||||
deltaX = amount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (command.selector) {
|
||||
const element = browser.getLocator(command.selector);
|
||||
await element.scrollIntoViewIfNeeded();
|
||||
|
||||
if (command.x !== undefined || command.y !== undefined) {
|
||||
if (hasExplicitDelta || deltaX !== 0 || deltaY !== 0) {
|
||||
await element.evaluate(
|
||||
(el, { x, y }) => {
|
||||
el.scrollBy(x ?? 0, y ?? 0);
|
||||
el.scrollBy(x, y);
|
||||
},
|
||||
{ x: command.x, y: command.y }
|
||||
{ x: deltaX, y: deltaY }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Scroll the page
|
||||
let deltaX = command.x ?? 0;
|
||||
let deltaY = command.y ?? 0;
|
||||
|
||||
if (command.direction) {
|
||||
const amount = command.amount ?? 100;
|
||||
switch (command.direction) {
|
||||
case 'up':
|
||||
deltaY = -amount;
|
||||
break;
|
||||
case 'down':
|
||||
deltaY = amount;
|
||||
break;
|
||||
case 'left':
|
||||
deltaX = -amount;
|
||||
break;
|
||||
case 'right':
|
||||
deltaX = amount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await page.evaluate(`window.scrollBy(${deltaX}, ${deltaY})`);
|
||||
}
|
||||
|
||||
|
||||
+9
-8
@@ -9,11 +9,11 @@ describe('BrowserManager', () => {
|
||||
beforeAll(async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({ headless: true });
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
describe('launch and close', () => {
|
||||
it('should report as launched', () => {
|
||||
@@ -56,7 +56,7 @@ describe('BrowserManager', () => {
|
||||
|
||||
it('should report local stealth policy capabilities', async () => {
|
||||
const testBrowser = new BrowserManager();
|
||||
await testBrowser.launch({ headless: true, stealth: true });
|
||||
await testBrowser.launch({ headless: true });
|
||||
|
||||
const status = testBrowser.getStealthStatus('chromium');
|
||||
expect(status.enabled).toBe(true);
|
||||
@@ -84,7 +84,7 @@ describe('BrowserManager', () => {
|
||||
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
|
||||
|
||||
const cdpBrowser = new BrowserManager();
|
||||
await cdpBrowser.launch({ cdpPort: 9222, stealth: true });
|
||||
await cdpBrowser.launch({ cdpPort: 9222 });
|
||||
|
||||
expect(addInitScript).toHaveBeenCalledTimes(1);
|
||||
const status = cdpBrowser.getStealthStatus();
|
||||
@@ -97,7 +97,7 @@ describe('BrowserManager', () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('should disable stealth capabilities when launch stealth is false in CDP mode', async () => {
|
||||
it('should ignore legacy stealth=false and keep CDP stealth capabilities enabled', async () => {
|
||||
const addInitScript = vi.fn().mockResolvedValue(undefined);
|
||||
const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false };
|
||||
const mockContext = {
|
||||
@@ -116,11 +116,12 @@ describe('BrowserManager', () => {
|
||||
const cdpBrowser = new BrowserManager();
|
||||
await cdpBrowser.launch({ cdpPort: 9222, stealth: false });
|
||||
|
||||
expect(addInitScript).not.toHaveBeenCalled();
|
||||
expect(addInitScript).toHaveBeenCalledTimes(1);
|
||||
const status = cdpBrowser.getStealthStatus();
|
||||
expect(status.enabled).toBe(false);
|
||||
expect(status.enabled).toBe(true);
|
||||
expect(status.connectionKind).toBe('cdp');
|
||||
expect(status.capabilities).toEqual([]);
|
||||
expect(status.capabilities).toContain('context-init-scripts');
|
||||
expect(status.capabilities).not.toContain('chromium-launch-args');
|
||||
|
||||
await cdpBrowser.close();
|
||||
spy.mockRestore();
|
||||
|
||||
+46
-3
@@ -16,7 +16,7 @@ import {
|
||||
} from 'playwright-core';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs';
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import type { LaunchCommand, TraceEvent } from './types.js';
|
||||
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
||||
@@ -160,6 +160,7 @@ export class BrowserManager {
|
||||
private contextTimezoneId: string | undefined = undefined;
|
||||
private contextHeaders: Record<string, string> | undefined = undefined;
|
||||
private contextUserAgent: string | undefined = undefined;
|
||||
private downloadPath: string | null = null;
|
||||
|
||||
/**
|
||||
* Set the persistent color scheme preference.
|
||||
@@ -445,7 +446,10 @@ export class BrowserManager {
|
||||
): Promise<void> {
|
||||
const policy = this.getStealthPolicy();
|
||||
if (!policy.applyInitScripts) return;
|
||||
await applyStealthScripts(context, options);
|
||||
await applyStealthScripts(context, {
|
||||
...options,
|
||||
userAgent: this.contextUserAgent,
|
||||
});
|
||||
this.logStealthPolicy('init-script applied');
|
||||
}
|
||||
|
||||
@@ -1564,6 +1568,17 @@ export class BrowserManager {
|
||||
}
|
||||
this.logStealthPolicy('launch policy', options.browser ?? 'chromium');
|
||||
|
||||
if (options.downloadPath) {
|
||||
this.downloadPath = options.downloadPath;
|
||||
}
|
||||
|
||||
if (this.downloadPath && (cdpEndpoint || options.autoConnect)) {
|
||||
const warning =
|
||||
"--download-path is ignored when connecting via CDP or auto-connect (downloads use the remote browser's configuration)";
|
||||
this.launchWarnings.push(warning);
|
||||
console.error(`[WARN] ${warning}`);
|
||||
}
|
||||
|
||||
if (cdpEndpoint) {
|
||||
await this.connectViaCDP(cdpEndpoint);
|
||||
return;
|
||||
@@ -1575,6 +1590,13 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
// Cloud browser providers require explicit opt-in via -p flag or AGENT_BROWSER_PROVIDER env var
|
||||
// -p flag takes precedence over AGENT_BROWSER_PROVIDER.
|
||||
if (this.downloadPath && provider) {
|
||||
const warning =
|
||||
"--download-path is ignored when using a cloud provider (downloads use the remote browser's configuration)";
|
||||
this.launchWarnings.push(warning);
|
||||
console.error(`[WARN] ${warning}`);
|
||||
}
|
||||
if (provider === 'browserbase') {
|
||||
await this.connectToBrowserbase();
|
||||
return;
|
||||
@@ -1590,6 +1612,23 @@ export class BrowserManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.downloadPath) {
|
||||
const resolved = path.resolve(this.downloadPath);
|
||||
const stat = statSync(resolved, { throwIfNoEntry: false });
|
||||
if (stat && !stat.isDirectory()) {
|
||||
throw new Error(`Download path is not a directory: ${resolved}`);
|
||||
}
|
||||
if (!stat) {
|
||||
try {
|
||||
mkdirSync(resolved, { recursive: true });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(`Cannot create download directory '${resolved}': ${msg}`);
|
||||
}
|
||||
}
|
||||
this.downloadPath = resolved;
|
||||
}
|
||||
|
||||
const browserType = options.browser ?? 'chromium';
|
||||
if (hasExtensions && browserType !== 'chromium') {
|
||||
throw new Error('Extensions are only supported in Chromium');
|
||||
@@ -1679,6 +1718,7 @@ export class BrowserManager {
|
||||
...(options.proxy && { proxy: options.proxy }),
|
||||
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
||||
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
||||
...(this.downloadPath && { downloadsPath: this.downloadPath }),
|
||||
}
|
||||
);
|
||||
this.isPersistentContext = true;
|
||||
@@ -1689,11 +1729,14 @@ export class BrowserManager {
|
||||
executablePath: options.executablePath,
|
||||
...(chromeChannel && { channel: chromeChannel }),
|
||||
args: baseArgs,
|
||||
...(this.downloadPath && { downloadsPath: this.downloadPath }),
|
||||
});
|
||||
this.cdpEndpoint = null;
|
||||
|
||||
if (stealthPolicy.enabled && browserType === 'chromium') {
|
||||
await applyBrowserLevelStealth(this.browser);
|
||||
await applyBrowserLevelStealth(this.browser, {
|
||||
userAgent: contextUserAgent,
|
||||
});
|
||||
}
|
||||
|
||||
if (!options.userAgent && stealthPolicy.enabled && browserType === 'chromium') {
|
||||
|
||||
@@ -6,14 +6,14 @@ const cmd = (obj: object) => JSON.stringify(obj);
|
||||
|
||||
describe('parseCommand', () => {
|
||||
describe('launch', () => {
|
||||
it('should parse launch command with stealth flag', () => {
|
||||
it('should parse launch command and ignore legacy stealth flag', () => {
|
||||
const result = parseCommand(
|
||||
cmd({ id: '1', action: 'launch', headless: false, stealth: true })
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.command.action).toBe('launch');
|
||||
expect(result.command.stealth).toBe(true);
|
||||
expect((result.command as any).stealth).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ const launchSchema = baseCommandSchema.extend({
|
||||
ignoreHTTPSErrors: z.boolean().optional(),
|
||||
allowFileAccess: z.boolean().optional(),
|
||||
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(),
|
||||
downloadPath: z.string().optional(),
|
||||
storageState: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
+21
-9
@@ -10,6 +10,8 @@ import type { Browser, BrowserContext, Page } from 'playwright-core';
|
||||
|
||||
export interface StealthScriptOptions {
|
||||
locale?: string;
|
||||
userAgent?: string;
|
||||
acceptLanguage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,25 +37,30 @@ export async function applyStealthScripts(
|
||||
// Apply CDP-level User-Agent override so Workers also get the patched UA.
|
||||
// This must be done per-page since CDP sessions are page-scoped.
|
||||
for (const page of context.pages()) {
|
||||
await applyCDPStealthToPage(page);
|
||||
await applyCDPStealthToPage(page, options);
|
||||
}
|
||||
context.on('page', (page: Page) => applyCDPStealthToPage(page));
|
||||
context.on('page', (page: Page) => applyCDPStealthToPage(page, options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply browser-level CDP overrides that affect all targets (including Workers).
|
||||
* Call this right after browser.launch() and before creating pages.
|
||||
*/
|
||||
export async function applyBrowserLevelStealth(browser: Browser): Promise<void> {
|
||||
export async function applyBrowserLevelStealth(
|
||||
browser: Browser,
|
||||
options: StealthScriptOptions = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cdp = await (browser as any).newBrowserCDPSession();
|
||||
const version = await cdp.send('Browser.getVersion');
|
||||
const rawUA = version?.userAgent ?? '';
|
||||
if (!rawUA.includes('HeadlessChrome')) {
|
||||
const explicitUA = options.userAgent?.trim();
|
||||
if (!explicitUA && !rawUA.includes('HeadlessChrome')) {
|
||||
await cdp.detach();
|
||||
return;
|
||||
}
|
||||
const patchedUA = rawUA.replace(/HeadlessChrome/g, 'Chrome');
|
||||
const patchedUA = explicitUA || rawUA.replace(/HeadlessChrome/g, 'Chrome');
|
||||
const acceptLanguage = options.acceptLanguage ?? 'en-US,en;q=0.9';
|
||||
const metadata = buildUserAgentMetadata(patchedUA);
|
||||
|
||||
// Override on all existing targets
|
||||
@@ -66,7 +73,7 @@ export async function applyBrowserLevelStealth(browser: Browser): Promise<void>
|
||||
});
|
||||
await cdp.send('Emulation.setUserAgentOverride', {
|
||||
userAgent: patchedUA,
|
||||
acceptLanguage: 'en-US,en;q=0.9',
|
||||
acceptLanguage,
|
||||
platform: getPlatformString(),
|
||||
userAgentMetadata: metadata,
|
||||
});
|
||||
@@ -81,17 +88,22 @@ export async function applyBrowserLevelStealth(browser: Browser): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
async function applyCDPStealthToPage(page: Page): Promise<void> {
|
||||
async function applyCDPStealthToPage(
|
||||
page: Page,
|
||||
options: StealthScriptOptions = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const ua = await cdp.send('Browser.getVersion').catch(() => null);
|
||||
const rawUA = ua?.userAgent ?? '';
|
||||
const patchedUA = rawUA.replace(/HeadlessChrome/g, 'Chrome');
|
||||
const explicitUA = options.userAgent?.trim();
|
||||
const patchedUA = explicitUA || rawUA.replace(/HeadlessChrome/g, 'Chrome');
|
||||
const acceptLanguage = options.acceptLanguage ?? 'en-US,en;q=0.9';
|
||||
const metadata = buildUserAgentMetadata(patchedUA);
|
||||
|
||||
await cdp.send('Emulation.setUserAgentOverride', {
|
||||
userAgent: patchedUA,
|
||||
acceptLanguage: 'en-US,en;q=0.9',
|
||||
acceptLanguage,
|
||||
platform: getPlatformString(),
|
||||
userAgentMetadata: metadata,
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface LaunchCommand extends BaseCommand {
|
||||
ignoreHTTPSErrors?: boolean;
|
||||
allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests
|
||||
colorScheme?: 'light' | 'dark' | 'no-preference'; // Persistent color scheme override
|
||||
downloadPath?: string; // Directory for browser downloads (Playwright's downloadsPath)
|
||||
// Auto-load state file for session persistence
|
||||
autoStateFilePath?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { SDKMessage, SDKResultMessage } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { mkdirSync, readFileSync, writeFileSync, appendFileSync, existsSync, readdirSync, rmSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const AI_GATEWAY_URL =
|
||||
process.env.ANTHROPIC_BASE_URL || 'https://ai-gateway.vercel.sh';
|
||||
const API_KEY = process.env.AI_GATEWAY_API_KEY;
|
||||
const MODEL = process.env.DOGFOOD_MODEL || 'anthropic/claude-haiku-4.5';
|
||||
const CUSTOM_URL = process.env.DOGFOOD_URL;
|
||||
|
||||
const FIXTURE_PATH = path.resolve('test/e2e/fixtures/buggy-app.html');
|
||||
const SKILL_PATH = path.resolve('skills/dogfood/SKILL.md');
|
||||
const TARGET_URL = CUSTOM_URL || `file://${FIXTURE_PATH}`;
|
||||
const IS_FIXTURE = !CUSTOM_URL;
|
||||
|
||||
const OUTPUT_DIR = path.resolve('test/e2e/.dogfood-output');
|
||||
const EVAL_TIMEOUT = 10 * 60 * 1000;
|
||||
|
||||
async function runDogfood(outputDir: string): Promise<{
|
||||
result: SDKResultMessage | null;
|
||||
messages: SDKMessage[];
|
||||
toolsUsed: Set<string>;
|
||||
}> {
|
||||
const instruction = [
|
||||
`Read the dogfood skill at ${SKILL_PATH} and follow its workflow.`,
|
||||
`Dogfood ${TARGET_URL}`,
|
||||
`Output directory: ${outputDir}`,
|
||||
].join(' ');
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
const toolsUsed = new Set<string>();
|
||||
let result: SDKResultMessage | null = null;
|
||||
|
||||
const conversation = query({
|
||||
prompt: instruction,
|
||||
options: {
|
||||
model: MODEL,
|
||||
cwd: process.cwd(),
|
||||
allowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep'],
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
maxTurns: 80,
|
||||
maxBudgetUsd: 2,
|
||||
settingSources: ['project'],
|
||||
persistSession: false,
|
||||
env: {
|
||||
...process.env,
|
||||
ANTHROPIC_BASE_URL: AI_GATEWAY_URL,
|
||||
ANTHROPIC_API_KEY: API_KEY,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const verbose = process.env.DOGFOOD_VERBOSE !== '0';
|
||||
const log = verbose ? (msg: string) => process.stderr.write(` [dogfood] ${msg}\n`) : () => {};
|
||||
|
||||
const chatLogPath = path.join(outputDir, 'chat-log.jsonl');
|
||||
writeFileSync(chatLogPath, '');
|
||||
|
||||
function appendToLog(entry: Record<string, unknown>) {
|
||||
appendFileSync(chatLogPath, JSON.stringify(entry) + '\n');
|
||||
}
|
||||
|
||||
for await (const message of conversation) {
|
||||
messages.push(message);
|
||||
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
log(`session started (model: ${message.model})`);
|
||||
appendToLog({ type: 'system', subtype: 'init', model: message.model });
|
||||
}
|
||||
|
||||
if (message.type === 'assistant' && message.message?.content) {
|
||||
const logParts: Record<string, unknown>[] = [];
|
||||
for (const block of message.message.content) {
|
||||
if ('type' in block && block.type === 'tool_use') {
|
||||
toolsUsed.add(block.name);
|
||||
const input = block.input as Record<string, unknown>;
|
||||
let preview: string;
|
||||
if (block.name === 'Bash') {
|
||||
const cmd = String(input.command ?? '');
|
||||
const firstLine = cmd.split('\n').find(l => l.trim() && !l.trim().startsWith('#')) ?? cmd.split('\n')[0];
|
||||
preview = firstLine.trim().slice(0, 200);
|
||||
} else if (block.name === 'Write') {
|
||||
preview = String(input.file_path ?? input.path ?? '');
|
||||
} else if (block.name === 'Read') {
|
||||
preview = String(input.file_path ?? input.path ?? '');
|
||||
} else if (block.name === 'Edit') {
|
||||
preview = String(input.file_path ?? input.path ?? '');
|
||||
} else {
|
||||
preview = JSON.stringify(input).slice(0, 120);
|
||||
}
|
||||
log(`${block.name}: ${preview}`);
|
||||
logParts.push({ tool: block.name, input: block.input });
|
||||
}
|
||||
if ('type' in block && block.type === 'text' && block.text) {
|
||||
const line = block.text.split('\n')[0].slice(0, 120);
|
||||
if (line.trim()) log(line);
|
||||
logParts.push({ text: block.text });
|
||||
}
|
||||
}
|
||||
appendToLog({ type: 'assistant', content: logParts });
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
result = message;
|
||||
const cost = `$${message.total_cost_usd.toFixed(4)}`;
|
||||
const usage = message.usage;
|
||||
const cacheRead = usage.cache_read_input_tokens ?? 0;
|
||||
const cacheCreate = usage.cache_creation_input_tokens ?? 0;
|
||||
const inputTokens = usage.input_tokens ?? 0;
|
||||
const cacheInfo = cacheRead > 0
|
||||
? ` | cache: ${cacheRead} read, ${cacheCreate} created, ${inputTokens} uncached`
|
||||
: '';
|
||||
if (message.subtype === 'success') {
|
||||
log(`done (${message.num_turns} turns, ${cost}${cacheInfo})`);
|
||||
} else {
|
||||
log(`stopped: ${message.subtype} (${message.num_turns} turns, ${cost}${cacheInfo})`);
|
||||
}
|
||||
appendToLog({ type: 'result', subtype: message.subtype, num_turns: message.num_turns, cost: message.total_cost_usd });
|
||||
}
|
||||
}
|
||||
|
||||
log(`chat log: ${chatLogPath}`);
|
||||
|
||||
return { result, messages, toolsUsed };
|
||||
}
|
||||
|
||||
function findFiles(dir: string, ext: string): string[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir, { recursive: true })
|
||||
.map(String)
|
||||
.filter((f) => f.endsWith(ext));
|
||||
}
|
||||
|
||||
describe.skipIf(!API_KEY)('Dogfood e2e eval (Agent SDK)', () => {
|
||||
const outputDir = OUTPUT_DIR;
|
||||
let evalResult: Awaited<ReturnType<typeof runDogfood>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (existsSync(outputDir)) {
|
||||
rmSync(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
evalResult = await runDogfood(outputDir);
|
||||
}, EVAL_TIMEOUT);
|
||||
|
||||
it('completes without hard failure', () => {
|
||||
expect(evalResult.result, 'No result message received').toBeTruthy();
|
||||
const acceptable = ['success', 'error_max_turns', 'error_max_budget_usd'];
|
||||
expect(
|
||||
acceptable,
|
||||
`Agent failed unexpectedly: ${evalResult.result!.subtype}`
|
||||
).toContain(evalResult.result!.subtype);
|
||||
});
|
||||
|
||||
it('used agent-browser via Bash tool', () => {
|
||||
expect(
|
||||
evalResult.toolsUsed.has('Bash'),
|
||||
'Agent never used Bash (needed for agent-browser commands)'
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('produced a report file', () => {
|
||||
const reportPath = path.join(outputDir, 'report.md');
|
||||
expect(existsSync(reportPath), 'report.md not found in output dir').toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('found a minimum number of issues', () => {
|
||||
const reportPath = path.join(outputDir, 'report.md');
|
||||
if (!existsSync(reportPath)) return;
|
||||
const report = readFileSync(reportPath, 'utf-8');
|
||||
|
||||
const issueBlocks = report.match(/###\s+ISSUE-\d+/g) || [];
|
||||
if (IS_FIXTURE) {
|
||||
expect(
|
||||
issueBlocks.length,
|
||||
`Expected >=2 issues from fixture, found ${issueBlocks.length}`
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
} else {
|
||||
expect(issueBlocks.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('each issue has required fields and repro evidence', () => {
|
||||
const reportPath = path.join(outputDir, 'report.md');
|
||||
if (!existsSync(reportPath)) return;
|
||||
const report = readFileSync(reportPath, 'utf-8');
|
||||
|
||||
const issueSections = report.split(/(?=###\s+ISSUE-\d+)/).slice(1);
|
||||
for (const section of issueSections) {
|
||||
const issueId = section.match(/ISSUE-\d+/)?.[0] ?? 'unknown';
|
||||
|
||||
expect(section, `${issueId}: missing Severity`).toMatch(
|
||||
/\*\*Severity\*\*/i
|
||||
);
|
||||
|
||||
const sevMatch = section.match(
|
||||
/\*\*Severity\*\*\s*\|?\s*(critical|high|medium|low)/i
|
||||
);
|
||||
expect(sevMatch, `${issueId}: invalid severity value`).toBeTruthy();
|
||||
|
||||
expect(section, `${issueId}: missing Category`).toMatch(
|
||||
/\*\*Category\*\*/i
|
||||
);
|
||||
|
||||
expect(section, `${issueId}: missing URL`).toMatch(/\*\*URL\*\*/i);
|
||||
|
||||
expect(section, `${issueId}: missing Repro Video field`).toMatch(
|
||||
/\*\*Repro Video\*\*/i
|
||||
);
|
||||
|
||||
const hasScreenshot = /!\[.*?\]\(.*?\)/.test(section);
|
||||
const hasReproSteps = /\*\*Repro Steps\*\*/i.test(section);
|
||||
expect(
|
||||
hasScreenshot || hasReproSteps,
|
||||
`${issueId}: needs either screenshot refs or repro steps`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('has a summary table with non-zero total', () => {
|
||||
const reportPath = path.join(outputDir, 'report.md');
|
||||
if (!existsSync(reportPath)) return;
|
||||
const report = readFileSync(reportPath, 'utf-8');
|
||||
|
||||
expect(report, 'Missing Summary section').toContain('## Summary');
|
||||
const totalMatch = report.match(/\*\*Total\*\*\s*\|?\s*\*\*(\d+)\*\*/);
|
||||
expect(totalMatch, 'Summary Total not found').toBeTruthy();
|
||||
if (totalMatch) {
|
||||
const total = parseInt(totalMatch[1], 10);
|
||||
expect(total, 'Summary Total should be > 0').toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('produced screenshot files', () => {
|
||||
const screenshotsDir = path.join(outputDir, 'screenshots');
|
||||
const screenshots = findFiles(screenshotsDir, '.png');
|
||||
expect(
|
||||
screenshots.length,
|
||||
'No screenshot files found in output'
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('produced video files for interactive issues', () => {
|
||||
const reportPath = path.join(outputDir, 'report.md');
|
||||
if (!existsSync(reportPath)) return;
|
||||
const report = readFileSync(reportPath, 'utf-8');
|
||||
const hasVideoRefs = /videos\/issue-\d+/.test(report);
|
||||
if (!hasVideoRefs) return;
|
||||
const videosDir = path.join(outputDir, 'videos');
|
||||
const videos = findFiles(videosDir, '.webm');
|
||||
expect(
|
||||
videos.length,
|
||||
'Report references videos but none were found'
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKILL_DIR = path.resolve('skills/dogfood');
|
||||
const SKILL_MD = path.join(SKILL_DIR, 'SKILL.md');
|
||||
const TAXONOMY_MD = path.join(SKILL_DIR, 'references', 'issue-taxonomy.md');
|
||||
const TEMPLATE_MD = path.join(SKILL_DIR, 'templates', 'dogfood-report-template.md');
|
||||
|
||||
function readSkillFile(filePath: string): string {
|
||||
return readFileSync(filePath, 'utf-8');
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): Record<string, string> {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return {};
|
||||
const fields: Record<string, string> = {};
|
||||
for (const line of match[1].split('\n')) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
fields[line.slice(0, colonIdx).trim()] = line.slice(colonIdx + 1).trim();
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
describe('Dogfood skill: file structure', () => {
|
||||
it('SKILL.md exists', () => {
|
||||
expect(existsSync(SKILL_MD)).toBe(true);
|
||||
});
|
||||
|
||||
it('references/issue-taxonomy.md exists', () => {
|
||||
expect(existsSync(TAXONOMY_MD)).toBe(true);
|
||||
});
|
||||
|
||||
it('templates/dogfood-report-template.md exists', () => {
|
||||
expect(existsSync(TEMPLATE_MD)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dogfood skill: SKILL.md frontmatter', () => {
|
||||
const content = readSkillFile(SKILL_MD);
|
||||
const frontmatter = parseFrontmatter(content);
|
||||
|
||||
it('has name field', () => {
|
||||
expect(frontmatter.name).toBe('dogfood');
|
||||
});
|
||||
|
||||
it('has description field', () => {
|
||||
expect(frontmatter.description).toBeTruthy();
|
||||
expect(frontmatter.description!.length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('has allowed-tools field', () => {
|
||||
expect(frontmatter['allowed-tools']).toBeTruthy();
|
||||
expect(frontmatter['allowed-tools']).toContain('agent-browser');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dogfood skill: SKILL.md body references', () => {
|
||||
const content = readSkillFile(SKILL_MD);
|
||||
|
||||
it('references issue-taxonomy.md', () => {
|
||||
expect(content).toContain('references/issue-taxonomy.md');
|
||||
});
|
||||
|
||||
it('references dogfood-report-template.md', () => {
|
||||
expect(content).toContain('templates/dogfood-report-template.md');
|
||||
});
|
||||
|
||||
it('referenced files exist on disk', () => {
|
||||
const refPattern = /\[.*?\]\((references\/.*?\.md|templates\/.*?\.md)\)/g;
|
||||
const refs = [...content.matchAll(refPattern)].map((m) => m[1]);
|
||||
expect(refs.length).toBeGreaterThan(0);
|
||||
for (const ref of refs) {
|
||||
const fullPath = path.join(SKILL_DIR, ref);
|
||||
expect(existsSync(fullPath), `Missing: ${ref}`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dogfood skill: report template', () => {
|
||||
const template = readSkillFile(TEMPLATE_MD);
|
||||
|
||||
it('has ISSUE- prefix in issue blocks', () => {
|
||||
expect(template).toContain('ISSUE-');
|
||||
});
|
||||
|
||||
it('has Severity field', () => {
|
||||
expect(template).toContain('**Severity**');
|
||||
});
|
||||
|
||||
it('has Category field', () => {
|
||||
expect(template).toContain('**Category**');
|
||||
});
|
||||
|
||||
it('has URL field', () => {
|
||||
expect(template).toContain('**URL**');
|
||||
});
|
||||
|
||||
it('has Repro Video field', () => {
|
||||
expect(template).toContain('**Repro Video**');
|
||||
});
|
||||
|
||||
it('has Repro Steps section', () => {
|
||||
expect(template).toContain('**Repro Steps**');
|
||||
});
|
||||
|
||||
it('has screenshot image references in repro steps', () => {
|
||||
expect(template).toMatch(/!\[.*?\]\(screenshots\//);
|
||||
});
|
||||
|
||||
it('lists all valid severity values', () => {
|
||||
expect(template).toMatch(/critical\s*\/\s*high\s*\/\s*medium\s*\/\s*low/);
|
||||
});
|
||||
|
||||
it('lists all valid category values', () => {
|
||||
const categoryLine = template
|
||||
.split('\n')
|
||||
.find((l) => l.includes('**Category**'));
|
||||
expect(categoryLine).toBeTruthy();
|
||||
for (const cat of [
|
||||
'visual',
|
||||
'functional',
|
||||
'ux',
|
||||
'content',
|
||||
'performance',
|
||||
'console',
|
||||
'accessibility',
|
||||
]) {
|
||||
expect(categoryLine!.toLowerCase()).toContain(cat);
|
||||
}
|
||||
});
|
||||
|
||||
it('has Summary table with severity counts', () => {
|
||||
expect(template).toContain('## Summary');
|
||||
for (const sev of ['Critical', 'High', 'Medium', 'Low', 'Total']) {
|
||||
expect(template).toContain(sev);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dogfood skill: issue taxonomy', () => {
|
||||
const taxonomy = readSkillFile(TAXONOMY_MD);
|
||||
|
||||
it('has severity level definitions', () => {
|
||||
expect(taxonomy).toContain('## Severity Levels');
|
||||
for (const sev of ['critical', 'high', 'medium', 'low']) {
|
||||
expect(taxonomy.toLowerCase()).toContain(`**${sev}**`);
|
||||
}
|
||||
});
|
||||
|
||||
it('has all 7 category sections', () => {
|
||||
const expectedCategories = [
|
||||
'Visual',
|
||||
'Functional',
|
||||
'UX',
|
||||
'Content',
|
||||
'Performance',
|
||||
'Console',
|
||||
'Accessibility',
|
||||
];
|
||||
for (const cat of expectedCategories) {
|
||||
expect(taxonomy).toMatch(new RegExp(`###\\s+.*${cat}`, 'i'));
|
||||
}
|
||||
});
|
||||
|
||||
it('has exploration checklist', () => {
|
||||
expect(taxonomy).toContain('## Exploration Checklist');
|
||||
});
|
||||
|
||||
it('checklist has numbered items', () => {
|
||||
const checklistSection = taxonomy.split('## Exploration Checklist')[1];
|
||||
expect(checklistSection).toBeTruthy();
|
||||
const numberedItems = checklistSection!.match(/^\d+\./gm);
|
||||
expect(numberedItems!.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dogfood skill: cross-consistency', () => {
|
||||
const template = readSkillFile(TEMPLATE_MD);
|
||||
const taxonomy = readSkillFile(TAXONOMY_MD);
|
||||
|
||||
it('every category in template exists in taxonomy', () => {
|
||||
const categoryLine = template
|
||||
.split('\n')
|
||||
.find((l) => l.includes('**Category**'));
|
||||
expect(categoryLine).toBeTruthy();
|
||||
|
||||
const categories = categoryLine!
|
||||
.split('|')
|
||||
.pop()!
|
||||
.split('/')
|
||||
.map((c) => c.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const cat of categories) {
|
||||
expect(
|
||||
taxonomy.toLowerCase(),
|
||||
`Category "${cat}" from template not found in taxonomy`
|
||||
).toMatch(new RegExp(`###\\s+.*${cat}`));
|
||||
}
|
||||
});
|
||||
|
||||
it('every severity in template exists in taxonomy', () => {
|
||||
for (const sev of ['critical', 'high', 'medium', 'low']) {
|
||||
expect(taxonomy.toLowerCase()).toContain(`**${sev}**`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Buggy App - Dogfood Test Fixture</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, sans-serif; color: #333; background: #f9f9f9; }
|
||||
header { background: #1a1a2e; color: #fff; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; }
|
||||
header h1 { font-size: 20px; }
|
||||
nav { display: flex; gap: 16px; }
|
||||
nav a { color: #ccc; text-decoration: none; }
|
||||
nav a:hover { color: #fff; }
|
||||
main { max-width: 960px; margin: 0 auto; padding: 32px 24px; }
|
||||
.card { background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 24px; margin-bottom: 24px; }
|
||||
.card h2 { margin-bottom: 12px; }
|
||||
.btn { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||||
.btn-primary { background: #3b82f6; color: #fff; }
|
||||
.btn-danger { background: #ef4444; color: #fff; }
|
||||
input, textarea { padding: 8px 12px; border: 1px solid #d0d0d0; border-radius: 4px; font-size: 14px; width: 100%; margin-bottom: 12px; }
|
||||
label { display: block; margin-bottom: 4px; font-weight: 500; }
|
||||
footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
|
||||
|
||||
/* BUG: Visual - clipped text via overflow: hidden on a short container */
|
||||
.clipped-container {
|
||||
overflow: hidden;
|
||||
height: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 4px 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* BUG: Visual - misaligned element */
|
||||
.misaligned {
|
||||
display: flex;
|
||||
align-items: flex-start; /* should be center */
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f0f4ff;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.misaligned .icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 14px; /* intentionally off */
|
||||
}
|
||||
.misaligned .label {
|
||||
font-size: 16px;
|
||||
line-height: 40px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>Buggy App</h1>
|
||||
<nav>
|
||||
<a href="#dashboard">Dashboard</a>
|
||||
<a href="#settings">Settings</a>
|
||||
<!-- BUG: Functional - broken link to nonexistent page -->
|
||||
<a href="#/this-page-does-not-exist">Reports</a>
|
||||
<a href="#help">Help</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- BUG: Content - typo "Welocme" -->
|
||||
<h2 style="margin-bottom: 24px;">Welocme to the Dashboard</h2>
|
||||
|
||||
<!-- Card 1: Functional bug - button throws JS error -->
|
||||
<div class="card">
|
||||
<h2>Quick Actions</h2>
|
||||
<p>Perform common tasks from here.</p>
|
||||
<div style="margin-top: 12px; display: flex; gap: 8px;">
|
||||
<!-- BUG: Functional - button throws JS error on click -->
|
||||
<button class="btn btn-primary" onclick="processAction()">Run Analysis</button>
|
||||
<button class="btn btn-danger" onclick="deleteAllData()">Delete All Data</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: Visual bugs - clipped text and misaligned element -->
|
||||
<div class="card">
|
||||
<h2>System Status</h2>
|
||||
<!-- BUG: Visual - text is clipped because container is too short -->
|
||||
<div class="clipped-container">
|
||||
The system is currently operating normally. All services are online and responding within expected latency thresholds. Last health check completed at 14:32 UTC.
|
||||
</div>
|
||||
<!-- BUG: Visual - icon and label are misaligned -->
|
||||
<div class="misaligned">
|
||||
<div class="icon"></div>
|
||||
<span class="label">All systems operational</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 3: Content bug - placeholder text -->
|
||||
<div class="card">
|
||||
<h2>Recent Activity</h2>
|
||||
<!-- BUG: Content - lorem ipsum placeholder left in -->
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.</p>
|
||||
</div>
|
||||
|
||||
<!-- Card 4: UX bug - form with no feedback on submit -->
|
||||
<div class="card">
|
||||
<h2>Contact Support</h2>
|
||||
<form id="support-form">
|
||||
<label for="subject">Subject</label>
|
||||
<input type="text" id="subject" placeholder="Enter subject">
|
||||
<label for="message">Message</label>
|
||||
<textarea id="message" rows="3" placeholder="Describe your issue"></textarea>
|
||||
<!-- BUG: UX - submit does nothing, no feedback -->
|
||||
<button type="submit" class="btn btn-primary">Send Message</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Card 5: UX bug - empty state with no message -->
|
||||
<div class="card">
|
||||
<h2>Notifications</h2>
|
||||
<!-- BUG: UX - empty container with no empty state message -->
|
||||
<div id="notifications-list" style="min-height: 60px;">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
© 2025 Buggy App Inc. All rights reserved.
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// BUG: Console - error on page load
|
||||
console.error("Failed to initialize analytics: endpoint not configured");
|
||||
|
||||
// BUG: Console - failed fetch on page load
|
||||
fetch("https://api.nonexistent-endpoint.invalid/v1/health")
|
||||
.catch(function() {});
|
||||
|
||||
// BUG: Functional - function referenced by button is broken
|
||||
function processAction() {
|
||||
// Throws because undefinedService is not defined
|
||||
undefinedService.runAnalysis();
|
||||
}
|
||||
|
||||
// No confirmation for destructive action
|
||||
function deleteAllData() {
|
||||
alert("All data deleted!");
|
||||
}
|
||||
|
||||
// Form submit does nothing
|
||||
document.getElementById("support-form").addEventListener("submit", function(e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -146,9 +146,9 @@ describe('File Access (Issue #345)', () => {
|
||||
const content = await page.locator('h1').textContent();
|
||||
expect(content).toBe('Test File Access');
|
||||
|
||||
// Verify webdriver is hidden (from custom arg)
|
||||
// Verify webdriver is hidden under stealth defaults
|
||||
const webdriver = await page.evaluate(() => navigator.webdriver);
|
||||
expect(webdriver).toBe(false);
|
||||
expect(webdriver).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('Launch Options', () => {
|
||||
});
|
||||
|
||||
describe('browser args', () => {
|
||||
it('should launch with custom args to disable webdriver detection', async () => {
|
||||
it('should keep webdriver undefined with custom args under stealth defaults', async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({
|
||||
headless: true,
|
||||
@@ -21,9 +21,9 @@ describe('Launch Options', () => {
|
||||
const page = browser.getPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
// Check that navigator.webdriver is false
|
||||
// Under stealth defaults, webdriver is hidden (undefined)
|
||||
const webdriver = await page.evaluate(() => navigator.webdriver);
|
||||
expect(webdriver).toBe(false);
|
||||
expect(webdriver).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should launch with multiple args', async () => {
|
||||
@@ -39,7 +39,7 @@ describe('Launch Options', () => {
|
||||
expect(browser.isLaunched()).toBe(true);
|
||||
});
|
||||
|
||||
it('should launch without args (default behavior)', async () => {
|
||||
it('should launch without args and keep webdriver hidden by default', async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({
|
||||
headless: true,
|
||||
@@ -48,9 +48,9 @@ describe('Launch Options', () => {
|
||||
const page = browser.getPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
// Default Playwright behavior - webdriver is true
|
||||
// Stealth default behavior - webdriver is hidden
|
||||
const webdriver = await page.evaluate(() => navigator.webdriver);
|
||||
expect(webdriver).toBe(true);
|
||||
expect(webdriver).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('Launch Options', () => {
|
||||
|
||||
// Verify webdriver is hidden
|
||||
const webdriver = await page.evaluate(() => navigator.webdriver);
|
||||
expect(webdriver).toBe(false);
|
||||
expect(webdriver).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts', 'test/**/*.eval.ts'],
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user