mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat(chrome): hand snapshots to context mode
This commit is contained in:
@@ -35,7 +35,10 @@
|
||||
- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,并在权限扩展注册前将 `config/pi-permission-system.json` 同步为全局权威配置。
|
||||
- `extensions/lsp.ts`:把组合包内 TypeScript Language Server CLI 物化为绝对命令路径,部署 TypeScript/Kotlin/JDT LS 全局配置并加载 `pi-lsp`。
|
||||
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,在每轮开始前按激活工具追加简短路由规则,并提供 `/dump-system-prompt` 将扩展所见的有效提示词写入 `.pi-debug/effective-system-prompt.md`。
|
||||
- `pi-chrome@0.15.46`:直接加载上游 Chrome Profile Bridge,通过用户安装的 unpacked MV3 扩展与 `127.0.0.1:17318` loopback bridge 接管当前已登录 Chrome;控制默认锁定,每个 Pi session 必须显式 `/chrome authorize`。
|
||||
- `pi-chrome/`:从上游源码导入并由本仓库直接维护,通过用户安装的 unpacked MV3 扩展与 `127.0.0.1:17318` loopback bridge 接管当前已登录 Chrome;控制默认锁定,每个 Pi session 必须显式 `/chrome authorize`。
|
||||
- 上游来源:<https://github.com/tianrendong/pi-chrome>
|
||||
- 初始导入快照:`017ff4b9a639f0b8b213e58a3f30613fc38edcc8`(`0.15.46`,该 commit 无对应 tag)。
|
||||
- 该目录从明确 commit 导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`、`node_modules` 或构建产物。
|
||||
- `pi-tool-search/`:从上游 `v0.3.6` 源码导入并由本仓库直接维护;从完整工具定义生成并缓存经过校验的 Tool Cards/工作流分组,通过固定工具 + 有界动态组 LRU 加载当前 Pi 的原始 schema。
|
||||
- 上游来源:<https://github.com/tuansondinh/pi-tool-search>
|
||||
- 初始导入快照:`ddfb23646fd3957b791214de278e23aa393c9b13`(`v0.3.6`)。
|
||||
@@ -63,7 +66,7 @@
|
||||
- 初始导入快照:`f1d0efd24a7f4ae99d19e10c5f4c3770a3bdd845`(`@firstpick/pi-extension-codex-fast-mode@0.1.1`)。
|
||||
- 该目录从上游源码仓库导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
||||
- `pi-condense@2.9.1`:总结已完成的工具调用批次,以短 stub 替换历史原始输出,并通过 `context_tree_query` 按需恢复;组合包在用户尚未配置 `contextPrune.enabled` 时默认开启。
|
||||
- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-chrome@0.15.46`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`@tintinweb/pi-subagents`、`pi-ask-user`、`pi-minimal-footer`、`pi-ssh` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。
|
||||
- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`@tintinweb/pi-subagents`、`pi-ask-user`、`pi-chrome`、`pi-minimal-footer`、`pi-ssh` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。
|
||||
- `install.sh`:先安装根组合包,再交互检查 Hippo Memory CLI、Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Hippo 项目目录与 `hippo init` 始终由用户自行决定。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。
|
||||
- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。升级成功后只能调用可定位的更新后组合包 Node helper 检查 Pi agent `settings.json`:只有 `toolSearch` 完整精确匹配旧版 `3` 组 / `20` 工具默认快照时,才创建从生成起即为权限 `600` 的时间戳备份、以同目录临时文件原子迁移到 `5` / `28` 并写入 `bundleDefaultsVersion: 2`;任何自定义或无效配置必须保留,提交前检测到内容变化必须中止。无法从 `PI_PACKAGE_SOURCE` 定位更新后 helper 时必须保留配置并报告失败,不能回退到可能陈旧的脚本目录 helper。其他组件升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
|
||||
- `uninstall.sh`:先移除根组合包;若检测到 Hippo Memory CLI,再明确询问是否卸载确认属于 npm 全局安装的 `hippo-memory`,默认保留且始终不删除 `.hippo/` 或用户记忆数据。其他共享终端环境和机器工具不卸载。
|
||||
@@ -76,11 +79,12 @@
|
||||
- `readCompaction.enabled`、`sourceCodeFilteringEnabled` 和 `smartTruncate.enabled` 当前均默认 `false`,源码读取保持原样。未经用户明确决定,不因节省上下文而改变这些默认值。
|
||||
- 若以后开启 read 压缩,优先考虑 `readCompaction + smartTruncate`,源码过滤仍独立评估;必须保留精确 `offset/limit` 读取、短文件和行锚点的完整性。
|
||||
- Context Mode 负责避免批量读取、命令研究和网页原始内容直接撑大上下文;FFF 仍负责精确字面搜索,RTK 仍处理未走 Context Mode 的普通输出。
|
||||
- `chrome_snapshot`、`chrome_find` 以及带 `includeSnapshot=true` 的交互工具必须把完整结构化快照写入当前项目 `.pi/chrome-context/` 下的 Session 临时文件,工具结果只返回相对路径与有界摘要;模型必须立即用 `ctx_execute_file` 提取下一步所需信息,不得用 `read` / `cat` 把原始 JSON 重新塞入上下文。每个 Session/目标只保留最新快照,页面交互后旧快照视为失效,Session shutdown、撤销或授权过期时清理。
|
||||
- Hashline 默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 和 `replaceText` 均关闭;它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
|
||||
- 工具路由规则不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索按场景只先选一个服务:广泛发现、新闻和候选来源用 Tavily,官方/技术/论文和高确定性来源用 Exa,中文、站点限定和日期筛选用 Keenable;只有必要时才跨服务复核。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。
|
||||
- `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具仅支持交互式 TUI、顺序执行和最多 8 题;取消或 abort 必须返回 `cancelled: true`,不得伪造答案,且 `/qna` 不属于该扩展。其成功 `details.answers` 必须保留 `question` / `answer` 字段,以匹配 AutoReview 已有的可信结构化用户交互边界。
|
||||
- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` 与 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。首次创建完整默认 `toolSearch` 段时必须通过唯一同目录临时文件以权限 `600` 原子写入并记录 `bundleDefaultsVersion: 2`,已有自定义段只补缺失运行时字段且不得伪造迁移来源标记。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。
|
||||
- `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。Tool Search 重新激活 schema 不能替代上游每次执行时的授权校验。
|
||||
- `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。授权撤销或过期时必须停用全部 21 个工具,包括 `chrome_find` / `chrome_inspect`;Tool Search 重新激活 schema 不能替代每次执行时的授权校验。
|
||||
- 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
|
||||
- Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。
|
||||
- `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
|
||||
@@ -200,6 +204,16 @@
|
||||
- 默认只在 TUI `agent_settled` 后通知,非 TUI 输出完整性、自动 retry/compaction/follow-up 去重、总耗时、短任务阈值和多窗口点击聚焦都属于回归边界。
|
||||
- 根包必须通过 `file:./pi-notify` 提供依赖并直接加载源码,不得同时加载 npm `@smoose/pi-notify` 实现。
|
||||
|
||||
## `pi-chrome` 开发约定
|
||||
|
||||
- 扩展入口是 `pi-chrome/extensions/chrome-profile-bridge/index.ts`,Companion MV3 扩展位于其 `browser-extension/`,Session 快照存储位于 `pi-chrome/src/session-snapshot-store.ts`,测试保留在 `pi-chrome/test/` 与 `pi-chrome/test-suite/`,来源记录是 `pi-chrome/UPSTREAM.md`。
|
||||
- 同步上游必须从官方 `tianrendong/pi-chrome` 的明确 tag/commit 移植并保留 README、CHANGELOG、SECURITY、文档、测试、作者和 LICENSE;不得导入嵌套 `.git`、`node_modules` 或构建产物。
|
||||
- 根包必须通过 `file:./pi-chrome` 提供依赖并直接加载仓库源码,不得同时安装或加载 npm 预编译入口。
|
||||
- 完整 Chrome snapshot 不得进入普通工具文本或 `details`;只能原子写入当前项目 `.pi/chrome-context/<session>/<target>/snapshot.json`,使用哈希目录、POSIX `700` 目录 / `600` 文件,并只返回工作区相对路径和有界元数据。快照不得跨 Session 持久化,Session 启动/关闭、撤销和授权过期必须清理。
|
||||
- `chrome_snapshot` 和带 `includeSnapshot=true` 的动作必须引导模型立即使用 `ctx_execute_file`,不得使用 `read` / `cat`;任何可能改变页面或目标的成功操作必须使旧快照失效,`chrome_find` 只可额外返回有界匹配摘要。
|
||||
- 修改 bridge、Companion 扩展、授权 lifecycle、快照存储或加载入口后至少运行包内 `npm test`、根 Tool Routing/Tool Search/扩展联合加载测试、`npm pack --dry-run --json` 和实际 packed tarball 隔离安装验证;未经明确要求不得连接或控制真实 Chrome。
|
||||
|
||||
|
||||
## `pi-ssh` 开发约定
|
||||
|
||||
- 扩展入口是 `pi-ssh/index.ts`,`ssh2` transport 与 SFTP 实现在 `pi-ssh/src/ssh2-transport.ts`,有界自适应远程搜索位于 `pi-ssh/src/remote-search.ts`,AES-GCM vault 位于 `pi-ssh/src/vault.ts`,选择性导入入口是根 `ssh_config.sh` 与 `pi-ssh/scripts/ssh-config.mjs`,权限桥接位于 `pi-ssh/permission-integration.ts`,测试保留在 `pi-ssh/test/`,来源记录是 `pi-ssh/UPSTREAM.md`。
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。
|
||||
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`,用一个顺序 TUI 同时支持单题、多题、选择题、文本题、自定义回答与提交前复核。
|
||||
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。
|
||||
- `pi-chrome@0.15.46`:通过显式会话授权接入用户当前已登录的 Chrome Profile,并由 Tool Search 按导航观察、页面交互和调试上传三个工作流按需加载 21 个 `chrome_*` 工具。
|
||||
- 本仓库维护的 `pi-chrome`:从上游 `0.15.46` 源码导入,通过显式会话授权接入用户当前已登录的 Chrome Profile;大体积页面快照写入 Session 临时文件并交给 Context Mode 隔离分析,21 个 `chrome_*` 工具仍由 Tool Search 按工作流加载。
|
||||
- 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。
|
||||
- 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入,负责工具、路径、MCP、硬拒绝和兜底权限基线。
|
||||
- 本仓库维护的 `pi-subagents`:从 `@tintinweb/pi-subagents@0.18.0` 源码导入并默认加载,提供隔离子 Agent、后台执行、steering、resume、嵌套代理与自定义 Agent;通过 mandatory permission wrapper 接入权限 child lifecycle、根 authority `ask` 转发和根 transcript auto-review 身份桥接,其 3 个编排工具由 Tool Search 的 `subagents` 组按需加载。
|
||||
@@ -145,6 +145,8 @@ CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自
|
||||
|
||||
授权后注册的 21 个 `chrome_*` 工具不会常驻提示词;Tool Search 将它们分为 `chrome-navigation`、`chrome-interaction` 和 `chrome-debugging`。普通网页资料发现仍使用 Tavily/Exa/Keenable,只有需要读取登录态页面或实际操作当前 Chrome 时才加载 Chrome 组。
|
||||
|
||||
`chrome_snapshot`、`chrome_find` 和带 `includeSnapshot=true` 的交互不会再把完整页面结构直接塞进模型上下文:完整 JSON 只写入当前项目 `.pi/chrome-context/` 下按 Session/目标隔离的临时文件,工具结果仅返回相对路径和小型摘要。模型应立即用 `ctx_execute_file` 从该文件提取下一步所需的 uid、文本或状态,不能用 `read` / `cat` 重新读入全文。每个目标只保留最新快照;页面操作后旧快照失效,Session 结束、`/chrome revoke` 或授权过期时清理。
|
||||
|
||||
### 按需加载工具 schema
|
||||
|
||||
`pi-tool-search/` 从上游 <https://github.com/tuansondinh/pi-tool-search> 的 `v0.3.6`、commit `ddfb23646fd3957b791214de278e23aa393c9b13` 导入,并像 `pi-rtk-optimizer/` 一样由本仓库直接维护;它不是 submodule,不加载 npm 预编译入口,也不保留嵌套 `.git`、上游 `.pi` 状态或构建产物。来源、许可证说明和本地差异记录在 `pi-tool-search/UPSTREAM.md`。
|
||||
|
||||
@@ -98,6 +98,12 @@ export function buildToolRoutingSection(selectedTools: SelectedTools): string {
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTool(selectedTools, "chrome_snapshot") && hasTool(selectedTools, "ctx_execute_file")) {
|
||||
rules.push(
|
||||
"- After chrome_snapshot or a Chrome action with includeSnapshot=true, immediately analyze the returned workspace-relative capture path with ctx_execute_file; never use read or cat for the raw JSON. Print only the uids, text, or state needed for the next action, and take a fresh snapshot after any interaction because the previous capture may be stale.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTool(selectedTools, "read")) {
|
||||
rules.push(
|
||||
"- Use read only when exact source is needed or the file is small. Before editing, request the smallest useful offset/limit range; do not read a complete large file merely to copy, compare, hash, count, explore, or summarize it.",
|
||||
|
||||
Generated
+22
-17
@@ -20,7 +20,7 @@
|
||||
"context-mode": "1.0.169",
|
||||
"jiti": "2.7.0",
|
||||
"pi-ask-user": "file:./pi-ask-user",
|
||||
"pi-chrome": "0.15.46",
|
||||
"pi-chrome": "file:./pi-chrome",
|
||||
"pi-condense": "2.9.1",
|
||||
"pi-context-view": "0.4.2",
|
||||
"pi-hashline-edit": "0.8.3",
|
||||
@@ -7438,22 +7438,8 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/pi-chrome": {
|
||||
"version": "0.15.46",
|
||||
"resolved": "https://registry.npmjs.org/pi-chrome/-/pi-chrome-0.15.46.tgz",
|
||||
"integrity": "sha512-4ZG4h4F3OkAwb9Os2LAiMAFqCGdr6cgUZSna0d6L+cR8eUDvI537UMuqLrf4sepy7US5rk3W0dvqy19KGfK3IQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"typebox": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@earendil-works/pi-coding-agent": {
|
||||
"optional": true
|
||||
},
|
||||
"typebox": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
"resolved": "pi-chrome",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/pi-condense": {
|
||||
"version": "2.9.1",
|
||||
@@ -9047,6 +9033,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"pi-chrome": {
|
||||
"version": "0.15.46",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"jiti": "2.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"typebox": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@earendil-works/pi-coding-agent": {
|
||||
"optional": true
|
||||
},
|
||||
"typebox": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"pi-extension-codex-fast-mode": {
|
||||
"name": "@firstpick/pi-extension-codex-fast-mode",
|
||||
"version": "0.1.1",
|
||||
|
||||
+3
-2
@@ -12,6 +12,7 @@
|
||||
"extensions",
|
||||
"hippo-memory-pi",
|
||||
"pi-ask-user",
|
||||
"pi-chrome",
|
||||
"pi-extension-codex-fast-mode",
|
||||
"pi-rtk-optimizer",
|
||||
"pi-minimal-footer",
|
||||
@@ -67,7 +68,7 @@
|
||||
"./extensions/subagents.ts",
|
||||
"./pi-ssh/index.ts",
|
||||
"./pi-ask-user/index.ts",
|
||||
"./node_modules/pi-chrome/extensions/chrome-profile-bridge/index.ts",
|
||||
"./pi-chrome/extensions/chrome-profile-bridge/index.ts",
|
||||
"./extensions/tool-routing.ts",
|
||||
"./pi-tool-search/extensions/index.ts"
|
||||
],
|
||||
@@ -89,7 +90,7 @@
|
||||
"context-mode": "1.0.169",
|
||||
"jiti": "2.7.0",
|
||||
"pi-ask-user": "file:./pi-ask-user",
|
||||
"pi-chrome": "0.15.46",
|
||||
"pi-chrome": "file:./pi-chrome",
|
||||
"pi-condense": "2.9.1",
|
||||
"pi-context-view": "0.4.2",
|
||||
"pi-hashline-edit": "0.8.3",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.pi/
|
||||
*.log
|
||||
.DS_Store
|
||||
dist/
|
||||
build/
|
||||
@@ -0,0 +1,306 @@
|
||||
# Changelog
|
||||
|
||||
All notable user-facing changes to `pi-chrome`.
|
||||
|
||||
## Unreleased — my-pi maintained build
|
||||
|
||||
- **Context Mode snapshot handoff.** `chrome_snapshot`, `chrome_find`, and action results with `includeSnapshot=true` atomically save the full structured snapshot to a private workspace-local `.pi/chrome-context/<session>/<target>/snapshot.json` file and return only bounded metadata plus a path for `ctx_execute_file`. Each target keeps only its latest capture; interactions invalidate stale captures, and session shutdown/revoke/authorization expiry removes them.
|
||||
- **Compact find results.** `chrome_find` keeps its top 20 ranked uid/coordinate matches inline while the full page structure stays in the session-local capture.
|
||||
- **Complete authorization deactivation.** `chrome_find` and `chrome_inspect` are now included in the authoritative Chrome tool-name set so revoke/expiry deactivates all 21 registered tools.
|
||||
|
||||
## 0.15.40 — 2026-06-22
|
||||
|
||||
- **Automation targets reuse the session tab group.** When `chrome_navigate` / implicit page actions create a new pi-chrome automation tab, it is now created in this session's existing tab-group window when possible and joins that same group, avoiding duplicate same-title `Pi Session: ...` groups.
|
||||
|
||||
## 0.15.39 — 2026-06-07
|
||||
|
||||
- **Dedicated automation tab/window (no more hijacking your active tab).** When a chrome_* action runs without an explicit target, pi-chrome now opens and reuses a dedicated automation window it owns (falling back to a dedicated tab if a separate window can't be created) instead of navigating whatever tab you currently have open. Your existing tabs/windows are left untouched. Pass `targetId`/`urlIncludes`/`titleIncludes` to act on a specific existing tab.
|
||||
- **Per session.** Ownership is scoped to the calling Pi session, so concurrent sessions sharing one companion extension each get their own automation window instead of fighting over a tab.
|
||||
- **Survives `/reload` and Chrome service-worker restarts.** Ownership is tracked by id and mirrored to `chrome.storage.session`, so the window is reused rather than orphaned.
|
||||
- **Guarded tab management.** `chrome_tab` `activate`/`close`/`group`/`ungroup` with no target now act on the session's automation tab if present, otherwise error — they no longer fall back to (or spawn a throwaway tab to touch) your active tab.
|
||||
- **Safe, non-blocking cleanup.** The owned target is closed on `/chrome revoke` and on real session end (never on `/reload`); cleanup only ever closes the calling session's own window/tab — never user tabs/windows or another session's target — and is fire-and-forget so it never blocks `/quit`, `/reload`, or session end.
|
||||
- **One session, one tab group.** `chrome_tab new` now reuses this session's existing Pi tab group even when another Chrome window is focused, creating the tab in that group's window because Chrome tab groups cannot span windows. The old `group:false` / `groupTitle:""` opt-out is ignored; Pi-created tabs are never intentionally left ungrouped, and a grouping failure closes the just-created tab before returning an error.
|
||||
|
||||
## 0.15.38 — 2026-06-07
|
||||
|
||||
- **Overlay-safe click/fill fallbacks.** `chrome_click` and `chrome_fill` now fall back to DOM-dispatched click/value events when Chrome's debugger input path is blocked by another extension overlay (for example password-manager/autofill UI), unless `domFallback:false` is passed.
|
||||
|
||||
## 0.15.37 — 2026-06-07
|
||||
|
||||
- **Hardened Chrome input targeting.** `chrome_click`/`chrome_fill`/related input paths now fail fast with resolved tab/CDP target metadata when debugger attach hits a stale or protected target, instead of surfacing bare `chrome-extension://` errors or hanging until the bridge timeout.
|
||||
- **Internal timeouts and cleanup.** Companion extension commands, debugger attach, CDP commands, and script injection now have shorter internal timeouts with debugger cleanup, so stuck input dispatch returns actionable errors before the 30s bridge timeout.
|
||||
- **Clear stale uid errors.** Snapshot uids that no longer map to live elements now report `snapshot uid ... is stale; refresh chrome_snapshot`.
|
||||
- **`chrome_fill` fallback.** If real CDP input is blocked by another extension overlay (for example password-manager/autofill UI), `chrome_fill` falls back to setting the field value through the page DOM and dispatching `input`/`change` events, unless `domFallback:false` is passed.
|
||||
|
||||
## 0.15.36 — 2026-06-03
|
||||
|
||||
- **Richer page observation.** `chrome_snapshot` now returns a concise, agent-friendly observation — structural layout/context, page hints, visible actions, form fields, a page map, query matches, and a diff of changes since the previous snapshot — instead of a raw JSON dump. New `mode` (`auto`/`interactive`/`forms`/`pageMap`/`text`/`changes`/`full`), `query`, and `maxTextChars` parameters let the agent zoom in instead of dumping the whole page.
|
||||
- **New `chrome_find` tool.** Find elements, regions, or text by natural-language query (`'merge button'`, `'email error'`) and get ranked matches with stable uids and coordinates. Thin wrapper around `chrome_snapshot({ query })`.
|
||||
- **New `chrome_inspect` tool.** Inspect one snapshot uid/selector deeply: nearby text, nearby actions, form context, ancestors, and a suggested click target. Falls back to a focused snapshot if the loaded extension predates `page.inspect`.
|
||||
- **`includeSnapshot` now embeds the formatted snapshot.** `chrome_click`/`chrome_type`/`chrome_fill`/`chrome_key` with `includeSnapshot=true` append the fresh concise snapshot to the tool text so the agent can verify in one round trip.
|
||||
- **Snapshot logic moved to a packaged `snapshot_injected.js`.** The MAIN-world snapshot/inspect implementation ships as an eval-free packaged script, shared `window.__PI_CHROME_STATE__` (same `el-` uid scheme) with the existing input helpers.
|
||||
|
||||
## 0.15.34 — 2026-06-01
|
||||
|
||||
- **Every tab Pi uses now joins the session group.** Previously only `chrome_tab new`/`group` created/used the `Pi Session: <name-or-id>` group; tabs Pi drove via `page.*` actions (navigate, click, type, snapshot, screenshot, etc.) on the existing/active tab stayed ungrouped. Now any ungrouped tab Pi interacts with is pulled into this session's group, so the user can see exactly which tabs Pi is driving. Tabs already in a group (the user's or another session's) are left untouched.
|
||||
- **Fixed punctuation being dropped by `chrome_type`/`chrome_key`.** Printable punctuation derived its `code`/virtual-keyCode from `charCodeAt()`, so `.` mapped to keyCode 46 (VK_DELETE), `-` to 45 (VK_INSERT), shifted symbols to wrong codes, and `code` was the raw char (`"."`) instead of the physical key (`"Period"`). Apps with keydown handlers (e.g. Gmail's filter address field) saw a Delete/Insert key and silently rejected the character. Both the CDP and DOM-event input paths now resolve keys through a proper US-keyboard layout map.
|
||||
|
||||
## 0.15.33 — 2026-05-31
|
||||
|
||||
- **Background is now the default.** `chrome_*` tools run silently without focusing Chrome unless you opt in. Pass `background: false` per call, or run `/chrome background off`, to bring Chrome forward and watch. Tool/param descriptions and docs updated to match.
|
||||
|
||||
## 0.15.32 — 2026-05-31
|
||||
|
||||
- **Session tab-group naming tweak.** Per-session groups are now titled `Pi Session: <name-or-id>` (added a colon separator).
|
||||
|
||||
## 0.15.31 — 2026-05-31
|
||||
|
||||
Per-session tab groups.
|
||||
|
||||
- **Each Pi session gets its own tab group.** Auto-grouped tabs are now named `Pi Session <name>` using the session's display name, falling back to the session id when unnamed. Multiple Pi sessions driving the same Chrome no longer share one group — each collects its tabs separately. Pass an explicit `groupTitle` to override, or `group:false` / `groupTitle:""` on `action=new` to opt out.
|
||||
|
||||
## 0.15.30 — 2026-05-31
|
||||
|
||||
Tab grouping for `chrome_tab`.
|
||||
|
||||
- **Pi-opened tabs auto-group.** `action=new` now drops every tab into a shared `Pi` tab group per window by default (created once, then reused), so agent tabs stay visually separated from your own. Opt out per call with `groupTitle:""` or `group:false`.
|
||||
- **`chrome_tab` can group/ungroup tabs.** New `action=group` (and `action=ungroup`) plus `groupTitle`/`groupColor` params. Grouping reuses an existing same-title group in the window instead of spawning duplicates. Defaults: title `Pi`, color `blue`; color validated against Chrome's 9 group colors. Target an existing tab with `targetId`/`urlIncludes`/`titleIncludes`.
|
||||
- **Tab listings include group info.** `formatTab` now reports `groupId` and a `group` record (`title`, `color`, `collapsed`, `windowId`, `piGroup`), and `chrome_tab list` prefixes grouped tabs with `[Group Title]`.
|
||||
- Requires the new `tabGroups` extension permission — reload the companion extension after updating.
|
||||
|
||||
## 0.15.29 — 2026-05-31
|
||||
|
||||
Strict-CSP support: `chrome_evaluate`, `chrome_snapshot`, `chrome_wait_for`, and `chrome_navigate initScript` now work on pages that block `unsafe-eval`.
|
||||
|
||||
- **CDP-based evaluation bypasses page CSP.** `chrome_evaluate`/`chrome_snapshot` (and all snapshot-driven inspection) previously ran user code in the page MAIN world via the **Function constructor**, which is blocked by `script-src 'self'` without `'unsafe-eval'` — so they returned null/empty (or `EvalError`) on github.com and many bank/SaaS apps. They now evaluate through CDP `Runtime.evaluate`, a DevTools protocol command that is not subject to the page's Content-Security-Policy. Rich return values (undefined/function/symbol/bigint/Error markers, DOMRect expansion) and the expression/statement fallback are preserved.
|
||||
- **`chrome_wait_for` polls via CDP.** The selector/expression polling loop moved from in-page `new Function()` to service-worker-side CDP evaluation, so waits work under strict CSP too.
|
||||
- **`chrome_navigate initScript` injects via CDP.** Document-start init scripts now register with `Page.addScriptToEvaluateOnNewDocument` instead of `new Function()` on `webNavigation.onCommitted`, so seeding localStorage / stubbing `Date.now` works under strict CSP.
|
||||
- **Tests.** Added a Node unit harness (`test-suite/unit/csp-eval.test.mjs`, run via `npm test`) validating the evaluate/execute/waitFor refactor, and an in-browser regression challenge (42 `strict-csp-evaluate`) that reads a JS-only secret under strict CSP. Updated challenge 39's notes and docs (FAQ, EXAMPLES, COMPARISON, primer) which previously stated eval/snapshot fail under strict CSP.
|
||||
|
||||
## 0.15.28 — 2026-05-31
|
||||
|
||||
Low-risk reliability fixes from a long-session bug report.
|
||||
|
||||
- **Bridge self-heals when the owning session dies.** A Pi session running in shared-client mode used to fail every `chrome_*` call with a bare `fetch failed` once the session that owned `127.0.0.1:17318` exited. The client now detects the unreachable owner, takes over the bridge port, and re-runs the command locally instead of staying stuck.
|
||||
- **Actionable timeout messages.** A 30s timeout now says *why*: extension not polling (not installed/closed), polling but didn't pick up the command, or picked it up but never returned a result (long-running action / failed result post) — instead of one generic message.
|
||||
- **`chrome_type` / `chrome_fill` DOM path no longer throws `pressKeyInPage is not defined`.** The helper is now included in the injected MAIN-world helper set and its callers await it.
|
||||
- **`getBoundingClientRect()` / DOMRect now serialize in `chrome_evaluate`.** DOMRect-like values return `{x,y,width,height,top,right,bottom,left}` instead of `{}`.
|
||||
- **Clearer stale-target errors.** A missing `targetId` now lists the current tabs and suggests re-targeting via `chrome_tab list` or `urlIncludes`/`titleIncludes`, instead of a bare "No matching Chrome tab found".
|
||||
- **`pageMutated=false` no longer reads as failure.** Click/type/fill summaries explain it's a coarse heuristic that can miss real effects, and suggest verifying with `includeSnapshot`.
|
||||
|
||||
## 0.15.26 — 2026-05-16
|
||||
|
||||
- **Documentation accuracy.** README, FAQ, examples, comparison, and test-suite docs now describe the 41-challenge suite, gate buckets, strict-CSP fallback, and current human-vs-extension limitations.
|
||||
- **Published benchmark assets.** The npm package now includes `test-suite/` so the documented benchmark pages are available from installed packages, not only from the repository checkout.
|
||||
|
||||
## 0.15.25 — 2026-05-16
|
||||
|
||||
- **Reload after older installs.** `/reload` now recovers from stale singleton state left by pi-chrome 0.15.19 and earlier instead of skipping the freshly loaded extension.
|
||||
- **Test suite coverage.** Added gate buckets plus strict-CSP fallback, dynamic wait readiness, and explicit tab lifecycle challenges.
|
||||
|
||||
## 0.15.24 — 2026-05-16
|
||||
|
||||
- **Unload Chrome tools on lock.** `chrome_*` tools now deactivate when `/chrome revoke` runs or timed authorization expires, keeping the prompt/tool list small after Chrome control locks again.
|
||||
|
||||
## 0.15.23 — 2026-05-16
|
||||
|
||||
- **Attribution.** The 0.15.22 features below are pulled from Dani Bednarski's fork (`DaniBedz/pi-chrome`). Thank you, Dani.
|
||||
|
||||
## 0.15.22 — 2026-05-16
|
||||
|
||||
Features in this release are pulled from Dani Bednarski's fork (`DaniBedz/pi-chrome`). Thank you, Dani.
|
||||
|
||||
- **Earlier page-load capture.** Companion extension now injects console/network instrumentation at `document_start`, so initial React render errors and early API calls show up in `chrome_list_console_messages` / `chrome_list_network_requests`.
|
||||
- **Quieter locked state.** Startup no longer shows a persistent Chrome bridge notification/status item before authorization; status bar appears only when Chrome control is authorized.
|
||||
- **Lazy tool registration.** `chrome_*` tools and primer are registered only after `/chrome authorize`, reducing prompt/tool overhead while Chrome control is locked.
|
||||
|
||||
## 0.15.21 — 2026-05-16
|
||||
|
||||
### Reverted 0.16.x and 0.17.x lines
|
||||
|
||||
- Versions 0.16.0 through 0.17.2 were published to npm and subsequently unpublished. 0.17.3 was prepared locally but never published. The work introduced in those versions — mandatory pairing, signed-envelope auth, standalone bridge daemon, idempotent onboard, etc. — is reachable only via git tags (`v0.16.0` … `v0.17.3`) and is not in the current main branch.
|
||||
- This release is **tree-equivalent to 0.15.20** with a version-only bump so future patch releases can ship cleanly.
|
||||
|
||||
## 0.15.20 — 2026-05-15
|
||||
|
||||
- **Interruptible `chrome_*` tools.** All `chrome_*` tools now honor the agent harness `AbortSignal`, so pressing Esc aborts in-flight bridge calls (including the long-polling `chrome_wait_for`) immediately instead of waiting out the full `timeoutMs`.
|
||||
|
||||
## 0.15.19 — 2026-05-14
|
||||
|
||||
- **Simpler package description.** README hero and npm/pi.dev description now use the same concise authorization-focused sentence.
|
||||
|
||||
## 0.15.18 — 2026-05-14
|
||||
|
||||
- **Cleaner package description.** npm/pi.dev description now focuses on the existing Chrome profile and explicit authorization model, avoiding implementation details.
|
||||
|
||||
## 0.15.17 — 2026-05-14
|
||||
|
||||
- **Docs accuracy pass.** Updated README, FAQ, comparison, contributing notes, and package metadata for the current real-input-only, terminal-authorized tool surface.
|
||||
- **Input verification fix.** `includeSnapshot=true` now works for `chrome_click`, `chrome_type`, `chrome_fill`, and `chrome_key`, returning the Chrome-input result plus a fresh snapshot.
|
||||
|
||||
## 0.15.16 — 2026-05-14
|
||||
|
||||
- **Visible `/chrome` loading state.** Bare `/chrome` and `/chrome status` now immediately say “Checking Chrome connection…” before probing the companion extension, so a slow Chrome bridge no longer looks like the command did nothing.
|
||||
|
||||
## 0.15.15 — 2026-05-14
|
||||
|
||||
- **Terminal authorization restored.** `/chrome authorize` is back to terminal-based confirmation. Removed the browser-side Chrome consent page and companion-extension consent polling.
|
||||
|
||||
## 0.15.14 — 2026-05-14
|
||||
|
||||
- **Clearer consent wait state.** After the Chrome approval page opens, Pi now says “Approve or deny the Chrome approval page to continue” instead of looking stuck at the launch step.
|
||||
|
||||
## 0.15.13 — 2026-05-14
|
||||
|
||||
- **Fix Chrome-side consent hang.** `/chrome authorize` now launches the browser consent page as a short command, then polls for the decision. This avoids holding one long extension command open while the user reads/clicks the page, which could leave Pi stuck at “Opening Chrome approval page…”.
|
||||
|
||||
## 0.15.12 — 2026-05-14
|
||||
|
||||
- **Docs accuracy.** Clarified that the bundled Chrome extension currently polls `127.0.0.1:17318`; custom bridge ports are not supported without editing/reloading the extension source. Also softened the unpacked-extension rationale to avoid overstating Web Store limitations and fixed stale strict-CSP guidance for `chrome_evaluate`.
|
||||
|
||||
## 0.15.11 — 2026-05-14
|
||||
|
||||
- **README cleanup.** Removed the Playwright/CDP/Selenium comparison table and low-signal Composes with / Contributing sections from the package page because they are noisy and easy to drift.
|
||||
|
||||
## 0.15.10 — 2026-05-14
|
||||
|
||||
- **Browser-side Chrome consent.** `/chrome authorize` now opens a Pi Chrome Connector approval page inside Chrome showing duration, workspace, process id, and extension/package versions. Chrome control unlocks only after the user approves there; denying, closing the tab, or timeout leaves control locked.
|
||||
- **README cleanup.** Removed npm/download/license shield badges from the package page because they are noisy and easy to drift.
|
||||
|
||||
## 0.15.9 — 2026-05-14
|
||||
|
||||
- **Tighter `/chrome` menu.** Removed the redundant “Connection status” item from the interactive `/chrome` menu because connection/auth/background are already shown in the menu header. `/chrome status` remains available as a slash command.
|
||||
|
||||
## 0.15.8 — 2026-05-14
|
||||
|
||||
- **Simpler `/chrome` submenus.** Authorize menu now offers 15 minutes, 30 minutes, indefinite, and custom minutes only. Background menu now offers only foreground/background. Esc from a submenu returns to the main `/chrome` menu.
|
||||
|
||||
## 0.15.7 — 2026-05-14
|
||||
|
||||
- **Grouped `/chrome` menu.** Bare `/chrome` now opens a status dashboard with grouped actions: authorize, lock, status, doctor, background/watch mode, and onboard. Authorize/background open submenus instead of showing one flat command list.
|
||||
|
||||
## 0.15.6 — 2026-05-14
|
||||
|
||||
- **Bare `/chrome` is now a command menu.** Running `/chrome` shows interactive options for every `/chrome ...` command, including authorize/revoke/status/doctor/onboard/background variants.
|
||||
|
||||
## 0.15.5 — 2026-05-14
|
||||
|
||||
- **Chrome control authorization.** `chrome_*` tools are locked until the user runs `/chrome authorize` in the current Pi session. Grants can be one command, 15 minutes, 1 hour, or the session; `/chrome revoke` locks control again and `/chrome status` shows auth state.
|
||||
- **Browser-origin bridge hardening.** The loopback bridge no longer sends wildcard CORS headers. `/command` accepts only local-process requests, while extension polling/result endpoints reject non-extension browser origins, blocking ordinary web pages from driving or draining the bridge through `127.0.0.1`.
|
||||
|
||||
## 0.15.4 — 2026-05-14
|
||||
|
||||
- **Quiet renamed to background.** Public UX now uses `/chrome background [on|off|toggle|status]` and docs/status say “run in background” instead of “quiet mode”.
|
||||
|
||||
## 0.15.3 — 2026-05-14
|
||||
|
||||
- **Chrome real input only.** Public trusted/synthetic mode controls were removed. Interactive tools now always use Chrome's real input layer; `/chrome clicks` and public `trusted` parameters are gone.
|
||||
|
||||
## 0.15.2 — 2026-05-13
|
||||
|
||||
- **Recipe prompts rewritten in user-language.** Earlier recipes leaked tool names into the `You:` prompts ("Use `chrome_tab list` to find my GitHub notifications tab…"), implying users need to know the tool catalog before they can ask anything. Prompts now read as natural intent; the agent trace below each one still shows the `chrome_*` primitives the agent picked. Affects the 30-second try-this block, all 3 hero recipes (PR triage / Linear standup / Bug repro), and 3 of the 6 collapsed recipes (auth-only data pull, network forensics, file upload).
|
||||
|
||||
## 0.15.1 — 2026-05-13
|
||||
|
||||
- **Architecture diagram now renders on pi.dev.** Replaced Unicode box-drawing characters (`┌─┐│└┘┬▼`) with plain ASCII (`+ - | v`). Pi.dev's monospace font was dropping the horizontal `─` glyphs, leaving the diagram as floating vertical bars. ASCII renders everywhere.
|
||||
- **`author` switched to object form.** Was `"tianrendong (Earendil Inc.)"` — npm's author-string spec parses `(parens)` as the URL slot, so `"Earendil Inc."` ended up in `author.url`. Now `{ "name": "tianrendong", "company": "Earendil Inc." }`.
|
||||
|
||||
## 0.15.0 — 2026-05-13
|
||||
|
||||
- **README rewrite — top-3 recipes as terminal mockups.** PR triage, Linear standup, and Bug-repro-with-evidence each get a copy-pasteable prompt → tool trace → result block modeled on the hero example. The other six recipes (form auto-fill, admin cross-check, visual diff, auth-only data pull, network forensics, file upload) collapsed into a `<details>` block so the section sells before it catalogs.
|
||||
- **Comparison table rewritten.** Dropped the all-✅ "Works on strict-CSP pages" row (zero signal). New table leads with "Time from `pi install` → first useful action on your real account" (~60s vs. hours) and "Survives MFA / SSO without code" (✅ already logged in). Multi-session row reframed as the bolded "Multiple agents drive the same Chrome at once". Footnote ² rewritten to highlight mode-aware scoring + open invitation for competing tools to PR their scores.
|
||||
- **Section reorder: sells before catalogs.** New flow: hero → 60-second install → 30-second try-this → killer recipes → comparison → honest results → tool catalog → click/watch modes (with Diagnostics folded in) → architecture → benchmark suite → security model & why unpacked (combined) → composes-with → roadmap → contributing → license. Hero blockquote now precedes shields badges so pi.dev no longer scrapes a broken-image badge as the description. Package `description` shortened to 255 chars so pi.dev hero stops truncating mid-word. `author` set to `"tianrendong (Earendil Inc.)"`.
|
||||
|
||||
## 0.14.9
|
||||
|
||||
- Primer (agent system prompt) now teaches the **trusted-mode escape hatch** explicitly. Previously the bridge would hit a CSP-locked page (github.com, banks, many SaaS apps), `chrome_evaluate`/`chrome_snapshot` would throw `EvalError: 'unsafe-eval' is not an allowed source of script`, and the agent would conclude *"bridge can't drive this page"* and ask the user for a fallback. New primer makes three things self-discoverable: (1) `trusted: true` on click/type/key/fill/hover/drag/scroll dispatches through chrome.debugger / CDP and bypasses page CSP entirely, (2) the recipe for strict-CSP pages is `chrome_screenshot` + trusted input at viewport coordinates, (3) when synthetic input produces no `pageMutated` or you see a CSP/eval error, **escalate to `trusted: true` yourself instead of asking the user**. Also corrects the old claim that `chrome_evaluate` works without `'unsafe-eval'` (it does not — Function constructor is gated by `script-src`).
|
||||
- Add `scripts/sync-manifest-version.js` wired to npm's `version` + `prepublishOnly` lifecycle hooks. Bumping the package version with `npm version <bump>` now auto-syncs `extensions/chrome-profile-bridge/browser-extension/manifest.json` and stages it into the version commit — kills the recurring drift class (cf. 0.14.4, 0.14.8, this fix).
|
||||
|
||||
## 0.14.8
|
||||
|
||||
- Repo moved to its own home: https://github.com/tianrendong/pi-chrome. No code changes; updated `repository`, `homepage`, and `bugs` URLs in `package.json`.
|
||||
|
||||
## 0.14.7
|
||||
|
||||
- Replace "30+ challenges" hand-wave in README + COMPARISON.md with the accurate framing from chrome-benchmark: **38 primitive challenges + 4 hermetic BrowserGym-style long-horizon tasks**, scored by **expected-outcome-by-mode** (not raw PASS count). Explains why a synthetic-events tool isn't supposed to satisfy a clipboard user-activation gate — matching that expectation is the pass.
|
||||
|
||||
## 0.14.6
|
||||
|
||||
- Fix Browser Use license in `docs/COMPARISON.md`: MIT (not Apache-2.0). Confirmed against upstream LICENSE on GitHub.
|
||||
|
||||
## 0.14.5
|
||||
|
||||
- `docs/COMPARISON.md` rewritten with a three-axis landscape (drivers / agent frameworks / cloud providers). Adds Browser Use, Stagehand, Skyvern, Magnitude, Alumnium, OpenAI Operator, Project Mariner, Surfer 2, Anthropic Computer Use, Browserbase, Steel.dev, Hyperbrowser, Anchor, Browserless. Adds Interop section, public-benchmark cheat sheet (WebArena, WorkArena++, BrowseComp, Mind2Web 2, WebChoreArena, MiniWoB++, BrowserGym).
|
||||
- README gains a one-liner pointing at the new three-axis framing.
|
||||
- Sourced from `benchmark-search` session research. No code changes.
|
||||
|
||||
## 0.14.4
|
||||
|
||||
- Sync `manifest.json` version to match `package.json` (0.14.3 shipped with stale manifest, would trigger spurious `/chrome doctor` drift warnings). No code or behavior changes.
|
||||
|
||||
## 0.14.3
|
||||
|
||||
- Documentation & discoverability overhaul.
|
||||
- New README: hero, alternatives comparison table, 20-tool reference grouped by job, killer recipes, architecture diagram, honest-results explainer, benchmark suite plug.
|
||||
- `docs/COMPARISON.md` — deep comparison vs Playwright / Puppeteer / Stagehand / browser-use / Selenium.
|
||||
- `docs/EXAMPLES.md` — ready-to-paste agent prompts (PR triage, Linear standup, bug repro, network forensics, multi-tab admin cross-check, etc.).
|
||||
- `docs/FAQ.md` — covers Brave/Arc, incognito, detection, banner, multi-session, CSP, file uploads, common envelope causes.
|
||||
- `CHANGELOG.md`, `CONTRIBUTING.md`, `SECURITY.md`, `LICENSE` added.
|
||||
- `package.json`: `homepage`, `repository`, `bugs`, expanded keywords for npm search.
|
||||
- No code changes to the tools or extension.
|
||||
|
||||
## 0.14.2
|
||||
|
||||
- Recover from foreign-extension input overlays so input tools don't get hijacked by other Chrome extensions.
|
||||
|
||||
## 0.14.1
|
||||
|
||||
- Harder `attachDebugger` retry path so trusted clicks survive transient Chrome debugger contention.
|
||||
- New `trusted.debug` diagnostic surface.
|
||||
|
||||
## 0.14.0
|
||||
|
||||
- Bare `/chrome` opens a `SettingsList` dialog. `space` cycles values, `enter` saves.
|
||||
|
||||
## 0.13.0
|
||||
|
||||
- Flattened `/chrome` tree. Cycle-on-pick for `clicks` / `quiet`. Status header at the top of the picker.
|
||||
|
||||
## 0.12.1
|
||||
|
||||
- Fixed anti-automation regressions across 7 benchmark challenges (`07/13/21/27/28`).
|
||||
|
||||
## 0.12.0
|
||||
|
||||
- Unified all slash commands under `/chrome` (`/chrome doctor`, `/chrome onboard`, `/chrome clicks`, `/chrome quiet`).
|
||||
|
||||
## 0.11.x
|
||||
|
||||
- Plain-English audit pass across `/chrome doctor`, `/chrome onboard`, `/chrome quiet`, README.
|
||||
- `chrome_tap` (real CDP touch events).
|
||||
- Smoother `trustedScroll`; CDP debugger auto-recovers from detach.
|
||||
- Smart-auto trusted mode default. Extension renamed to **Pi Chrome Connector**.
|
||||
- Per-event scroll delta cap so IntersectionObserver thresholds land naturally.
|
||||
|
||||
## 0.10.x
|
||||
|
||||
- Trusted-input mode via `chrome.debugger` (CDP) — opt-in, indistinguishable-from-human clicks/keys.
|
||||
- `chrome_key` modifiers (Cmd+V, Ctrl+Shift+Tab, etc.) for trusted chords.
|
||||
- Interactive `/chrome-trusted` picker (later folded into `/chrome`).
|
||||
|
||||
## 0.9.x
|
||||
|
||||
- Humanized synthetic input (pointer paths, key cadence variance).
|
||||
- Anti-automation benchmark test suite landed in `test-suite/`.
|
||||
|
||||
## 0.8.0
|
||||
|
||||
- `chrome_evaluate` no longer returns `null` for valid expressions — dedicated MAIN-world `evaluateInTab` pipeline with statement-mode fallback and tagged envelope for `undefined`/errors/symbols/bigints.
|
||||
- Truthful action result envelopes: `isTrusted`, `defaultPrevented`, `elementVisible`, `occludedBy`, `valueMatches`, `pageMutated`.
|
||||
- Extended `/chrome doctor`: bridge mode/URL, extension version drift, MAIN-world helper injection, `navigator.webdriver` fingerprint, CDP availability probe.
|
||||
- Removed misleading `returnByValue` param. Implemented `chrome_screenshot.fullPage` via tile stitching.
|
||||
- Autoplay-gate heuristic for `chrome_click`.
|
||||
|
||||
## 0.7.0
|
||||
|
||||
- Initial public `pi-chrome` release. Companion Chrome extension + local bridge + first tool set.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Contributing to pi-chrome
|
||||
|
||||
Thanks for considering a contribution. pi-chrome aims to be the **de-facto browser-control toolkit for Pi agents** — that means a few non-negotiables.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
1. **No re-login.** Every change must keep working against the user's already-signed-in Chrome profile. Anything that requires a fresh profile or extra auth steps is out of scope.
|
||||
2. **Verifiable action results.** Input tools must return structured details and support `includeSnapshot` where verification matters. Agents need enough evidence to avoid blind retries.
|
||||
3. **Chrome real input.** Interactive controls use Chrome's input layer through `chrome.debugger`; do not re-expose synthetic/untrusted input as public UX.
|
||||
4. **Benchmarks gate features.** Add a page in `test-suite/` that fails before your change and passes after. We accept PRs faster when there's a green/red verdict to point at.
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
# Link from a checkout
|
||||
pi install ./pi-chrome
|
||||
|
||||
# Run the benchmark dashboard
|
||||
cd test-suite
|
||||
python3 -m http.server 8765
|
||||
# open http://127.0.0.1:8765/ in the Chrome window pi-chrome controls
|
||||
```
|
||||
|
||||
## Adding a new tool
|
||||
|
||||
1. Register in `extensions/chrome-profile-bridge/index.ts` (the `register*Tool` calls near line 840+).
|
||||
2. Implement the handler in `extensions/chrome-profile-bridge/browser-extension/service_worker.js`.
|
||||
3. Return structured details and support `includeSnapshot` for user-visible state changes when relevant.
|
||||
4. Add a benchmark page under `test-suite/challenges/` and a manifest entry.
|
||||
5. Update `README.md` "What an agent gets" table.
|
||||
6. Add a `CHANGELOG.md` entry.
|
||||
|
||||
## Filing a bug
|
||||
|
||||
Include:
|
||||
|
||||
- `/chrome doctor` output
|
||||
- `pi-chrome` version + extension version (the `doctor` output prints both)
|
||||
- The exact tool call + the result envelope you got
|
||||
- Page URL or a minimal repro page in `test-suite/`
|
||||
|
||||
## Releasing
|
||||
|
||||
- Bump `package.json` version.
|
||||
- Move `CHANGELOG.md` notes from the working section to the new version header.
|
||||
- `npm publish --access public`.
|
||||
|
||||
## Code of conduct
|
||||
|
||||
Be kind, be precise, ship things. PRs that break the "no re-login" promise will be closed with a note explaining which non-negotiable they hit.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2026 pi-chrome contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,155 @@
|
||||
# pi-chrome
|
||||
|
||||
> Let [Pi](https://pi.dev) use your existing signed-in Chrome profile after explicit authorization.
|
||||
|
||||
**MIT · 0 runtime deps · loopback-only bridge (`127.0.0.1:17318`) · inspectable unpacked Chrome extension.** Review [`extensions/chrome-profile-bridge/browser-extension/`](./extensions/chrome-profile-bridge/browser-extension) before loading. Verify setup with `/chrome doctor`.
|
||||
|
||||
```text
|
||||
You: "Find my open GitHub PR tab, summarize review state, and screenshot failing CI."
|
||||
Agent: chrome_tab(list) → chrome_snapshot(...) → ctx_execute_file(capture) → chrome_screenshot(...)
|
||||
✓ 3 reviewers, 1 change requested, CI red on iOS. Saved → .pi/chrome-screenshots/ci.png
|
||||
You: [keeps coding — agent never asked you to log in]
|
||||
```
|
||||
|
||||
`pi-chrome` runs through a small Chrome extension inside the Chrome profile **you already use** — including sites where you're already signed in. Agents can inspect or control Chrome only after you run `/chrome authorize` in current Pi session.
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pi install npm:pi-chrome
|
||||
```
|
||||
|
||||
In Pi:
|
||||
|
||||
```text
|
||||
/chrome onboard
|
||||
```
|
||||
|
||||
This opens `chrome://extensions` and copies bundled extension path. In Chrome Extensions:
|
||||
|
||||
1. Turn on **Developer mode**.
|
||||
2. Click **Load unpacked**.
|
||||
3. Open path field with **Cmd+Shift+G** on macOS or **Ctrl+L** on Windows/Linux.
|
||||
4. Paste copied path.
|
||||
5. Press Enter.
|
||||
|
||||
Reload Pi so installed package loads:
|
||||
|
||||
```text
|
||||
/reload
|
||||
```
|
||||
|
||||
Check bridge:
|
||||
|
||||
```text
|
||||
/chrome doctor
|
||||
```
|
||||
|
||||
You should see:
|
||||
|
||||
```text
|
||||
✓ Chrome is connected (...)
|
||||
```
|
||||
|
||||
Authorize current session:
|
||||
|
||||
```text
|
||||
/chrome authorize
|
||||
/chrome doctor
|
||||
```
|
||||
|
||||
Second doctor run should show all checks passing.
|
||||
|
||||
---
|
||||
|
||||
## What it can do
|
||||
|
||||
- Read and summarize pages you're already signed into.
|
||||
- Click, type, fill forms, scroll, drag, tap, and upload files.
|
||||
- Capture screenshots for bugs, PRs, and demos.
|
||||
- Inspect console logs and captured `fetch`/`XMLHttpRequest` responses.
|
||||
- Manage tabs without taking over your active window.
|
||||
|
||||
Tool parameters and gotchas are documented inline in Pi.
|
||||
|
||||
### Session-local snapshots and Context Mode
|
||||
|
||||
In this maintained build, `chrome_snapshot`, `chrome_find`, and actions with `includeSnapshot=true` keep the full structured page snapshot out of the normal tool result. They atomically save only the latest capture for each target under:
|
||||
|
||||
```text
|
||||
.pi/chrome-context/<session-hash>/<target-hash>/snapshot.json
|
||||
```
|
||||
|
||||
The tool returns a workspace-relative path plus bounded metadata. Analyze that path immediately with `ctx_execute_file` and print only the uids, text, or state needed for the next browser action. Do **not** use `read` or `cat` on the capture, because that would put the raw page back into model context.
|
||||
|
||||
Captures are deliberately ephemeral: a successful page interaction invalidates the old target capture, and session shutdown, `/chrome revoke`, or authorization expiry removes the session files. `chrome_find` still returns up to 20 compact ranked matches for direct use while storing the full capture privately.
|
||||
|
||||
---
|
||||
|
||||
## Safety model
|
||||
|
||||
Chrome control is locked by default. Authorize per Pi session:
|
||||
|
||||
```text
|
||||
/chrome authorize # 15 minutes
|
||||
/chrome authorize 30m # custom duration
|
||||
/chrome authorize indefinite
|
||||
/chrome revoke # lock again
|
||||
/chrome status
|
||||
```
|
||||
|
||||
Safety properties:
|
||||
|
||||
- Extension runs in your real Chrome profile and has broad tab/scripting permissions. Install only from trusted package source.
|
||||
- Pi side binds to `127.0.0.1:17318` only; no default network exposure.
|
||||
- Bridge rejects browser-origin command requests, so ordinary web pages cannot drive it through CORS.
|
||||
- Each Pi session gets its own automation target; user tabs/windows are not closed by cleanup.
|
||||
- `/chrome revoke` closes only calling session's automation target.
|
||||
|
||||
Security details: [`SECURITY.md`](./SECURITY.md). Architecture details: [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md).
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```text
|
||||
/chrome onboard # guided setup
|
||||
/chrome doctor # connectivity + version + eval checks
|
||||
/chrome status # connection + auth + background state
|
||||
/chrome authorize [duration]
|
||||
/chrome revoke
|
||||
/chrome background on # default: don't steal focus
|
||||
/chrome background off # foreground/watch mode
|
||||
/chrome background status
|
||||
```
|
||||
|
||||
If loaded extension is older than installed `pi-chrome`, `/chrome doctor` tells you to reload it from `chrome://extensions`.
|
||||
|
||||
---
|
||||
|
||||
## Limits
|
||||
|
||||
`pi-chrome` works best on web-page workflows exposed through DOM, screenshots, tabs, network, console, and Chrome input. It is not full OS automation.
|
||||
|
||||
Current limits include native Chrome/OS surfaces, print/save dialogs, permission bubbles, password-manager prompts, cross-origin iframe DOM access, CAPTCHA/bot challenges, passkeys/security keys/biometrics, rich multitouch/pinch/stylus gestures, and arbitrary desktop apps.
|
||||
|
||||
For strict-CSP pages, use screenshots + coordinate input when snapshot/evaluate paths are blocked.
|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
|
||||
- Examples: [`docs/EXAMPLES.md`](./docs/EXAMPLES.md)
|
||||
- FAQ: [`docs/FAQ.md`](./docs/FAQ.md)
|
||||
- Comparison: [`docs/COMPARISON.md`](./docs/COMPARISON.md)
|
||||
- Security: [`SECURITY.md`](./SECURITY.md)
|
||||
- Benchmark suite: [`test-suite/README.md`](./test-suite/README.md)
|
||||
- Architecture: [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](./LICENSE).
|
||||
@@ -0,0 +1,38 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Open a GitHub issue prefixed with `[security]` at https://github.com/tianrendong/pi-chrome/issues, or contact the maintainer directly if the issue is sensitive. Please do **not** include exploit details in a public issue without coordinating first.
|
||||
|
||||
## Threat model
|
||||
|
||||
`pi-chrome` is a developer tool you install knowingly. It is **not** designed to defend against:
|
||||
|
||||
- Hostile pages running in your Chrome trying to detect or escape automation. (Standard browser security boundaries still apply, but a hostile page that already runs in your tab can do anything that page can already do.)
|
||||
- Other processes on your local machine. The bridge binds to `127.0.0.1:17318` (loopback only) and chrome_* tools require `/chrome authorize` inside Pi, but the bridge does not authenticate arbitrary non-browser local callers. If your threat model includes hostile local processes running as you, run pi-chrome on a separate user account.
|
||||
|
||||
`pi-chrome` **is** designed to:
|
||||
|
||||
- Never exfiltrate page state to the network. All communication is loopback (`127.0.0.1`).
|
||||
- Surface every action with an honest result envelope so the agent can't silently do the wrong thing.
|
||||
- Keep Chrome control locked until the user explicitly runs `/chrome authorize` in the current Pi session.
|
||||
- Reject browser-origin command requests to the loopback bridge so ordinary web pages cannot use CORS to drive Chrome.
|
||||
|
||||
## The companion extension
|
||||
|
||||
The Chrome extension under `extensions/chrome-profile-bridge/browser-extension/` runs with broad permissions: `tabs`, `scripting`, `debugger`, `webNavigation`, etc. **Only install it from a package source you trust.** Read the source before loading. Pin a known-good commit if you're security-sensitive.
|
||||
|
||||
## Defaults
|
||||
|
||||
- Loopback bridge only. No remote port. No telemetry.
|
||||
- Chrome real input layer for interactive controls.
|
||||
- Chrome control locked by default; `/chrome authorize` unlocks current Pi session after terminal confirmation, `/chrome revoke` locks it again.
|
||||
- Run-in-background optional; tab/window focus is observable by default (the user can see Pi acting).
|
||||
|
||||
## Custom ports
|
||||
|
||||
The bundled Chrome extension currently polls `127.0.0.1:17318`. Custom bridge ports are not supported without editing the extension source and reloading it.
|
||||
|
||||
## Supported versions
|
||||
|
||||
The latest minor on npm is supported. Security patches will be released as soon as practical.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Upstream source
|
||||
|
||||
- Official repository: <https://github.com/tianrendong/pi-chrome>
|
||||
- Imported npm version: `pi-chrome@0.15.46`
|
||||
- Imported commit: `017ff4b9a639f0b8b213e58a3f30613fc38edcc8`
|
||||
- Commit date: `2026-07-07T01:30:54-04:00`
|
||||
- Import basis: the `gitHead` recorded in the official npm metadata for `pi-chrome@0.15.46`; no upstream tag points at this commit.
|
||||
- Initial local import: 2026-08-28
|
||||
|
||||
The tracked upstream source, tests, documentation, scripts, and license were imported from the official Git repository. Nested `.git`, dependency directories, coverage, and build output are not retained.
|
||||
|
||||
## Local maintenance
|
||||
|
||||
This directory is maintained directly by the `my-pi` bundle and is not a submodule. The root package loads `extensions/chrome-profile-bridge/index.ts` from this directory and depends on it through `file:./pi-chrome`; the npm prebuilt extension entry must not be loaded in parallel.
|
||||
|
||||
Local changes after the import:
|
||||
|
||||
- Chrome snapshots are written atomically to owner-only, session-local files under the active project's `.pi/chrome-context/` directory instead of returning raw page structure to model context.
|
||||
- Each session/target keeps only its latest snapshot; browser interactions invalidate stale captures and session shutdown removes the session directory.
|
||||
- `chrome_find` returns compact ranked matches while keeping the complete structured snapshot in the session-local capture for optional `ctx_execute_file` analysis.
|
||||
- `includeSnapshot=true` on click/type/fill/key saves the post-action snapshot and returns its capture path rather than embedding the snapshot in the tool result.
|
||||
|
||||
When syncing upstream, compare against the imported commit and port changes selectively. Do not overwrite these local lifecycle, privacy, and Context Mode integrations wholesale.
|
||||
@@ -0,0 +1,74 @@
|
||||
# pi-chrome architecture
|
||||
|
||||
`pi-chrome` connects Pi to your existing Chrome profile through a local-only bridge and an unpacked Chrome extension.
|
||||
|
||||
```text
|
||||
+----------------------+ +--------------------------+
|
||||
| Pi agent (terminal) | -- 127.0.0.1:17318 ->| Chrome extension |
|
||||
| chrome_* tools | | (your real profile) |
|
||||
+-----------+----------+ +-------------+------------+
|
||||
| same machine |
|
||||
v v
|
||||
Other Pi sessions Tabs you already have open
|
||||
share same bridge (GitHub, Linear, Stripe, etc.)
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
- **Pi extension** — exposes `chrome_*` tools and `/chrome` commands inside Pi.
|
||||
- **Loopback bridge** — listens on `127.0.0.1:17318`; no external network bind by default.
|
||||
- **Chrome companion extension** — loaded unpacked into your real Chrome profile.
|
||||
- **Chrome debugger / CDP** — drives input, screenshots, network/console observation, and evaluation.
|
||||
|
||||
## Session model
|
||||
|
||||
Multiple Pi sessions can use same Chrome companion extension. First session opens local bridge; later sessions detect it and pipe commands through.
|
||||
|
||||
Each Pi session owns its own automation target:
|
||||
|
||||
- First chrome action without explicit target opens dedicated automation window.
|
||||
- If separate window cannot be created, pi-chrome falls back to dedicated tab.
|
||||
- Target survives `/reload` and Chrome service-worker restarts.
|
||||
- Ownership is tracked by id and mirrored to `chrome.storage.session`.
|
||||
- Cleanup closes only calling session's own target, never user tabs/windows or other sessions' targets.
|
||||
|
||||
To point pi-chrome at an existing tab, pass `targetId`, `urlIncludes`, or `titleIncludes`.
|
||||
|
||||
## Tab management guards
|
||||
|
||||
`chrome_tab` management actions are guarded:
|
||||
|
||||
- `activate`, `close`, `group`, and `ungroup` without explicit target act on session automation tab if it exists.
|
||||
- If no automation tab exists, operation errors instead of touching your active tab.
|
||||
|
||||
## Background mode
|
||||
|
||||
By default, chrome calls run in background so Chrome does not steal focus.
|
||||
|
||||
```text
|
||||
/chrome background on # background mode
|
||||
/chrome background off # foreground/watch mode
|
||||
```
|
||||
|
||||
Per-call `background: false` brings Chrome forward for that action. Per-call `background: true` forces background.
|
||||
|
||||
## Authorization
|
||||
|
||||
Bridge connection alone is not enough. Chrome control stays locked until current Pi session runs:
|
||||
|
||||
```text
|
||||
/chrome authorize
|
||||
```
|
||||
|
||||
Authorization expires after configured duration, on `/chrome revoke`, or when Pi exits.
|
||||
|
||||
## Unpacked extension choice
|
||||
|
||||
`pi-chrome` ships browser extension source as an unpacked folder on purpose:
|
||||
|
||||
- easy to inspect before loading
|
||||
- no Web Store release delay
|
||||
- MIT-licensed source in repo
|
||||
- `/chrome doctor` can compare loaded extension version against installed package
|
||||
|
||||
Loaded extension has broad tab/scripting permissions inside profile where it is installed. Install only from trusted package source.
|
||||
@@ -0,0 +1,166 @@
|
||||
# pi-chrome vs. the rest of the browser-automation landscape
|
||||
|
||||
This is the honest "which tool when" page. The browser-automation space has three different layers and people often compare across them — let's not do that.
|
||||
|
||||
We benchmark in public — see [`../test-suite/`](../test-suite). Where exact scores matter (WebVoyager, WorkArena++, BrowseComp, Mind2Web 2), check the live leaderboards; they shift monthly.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
| You are… | Use… |
|
||||
| -------------------------------------------------------------- | ------------------------------- |
|
||||
| A Pi agent operator who wants the agent to use **your real Chrome** (logged-in tabs, cookies, extensions) | **pi-chrome** |
|
||||
| Building a Pi/LLM agent and want low-level browser primitives | **pi-chrome** |
|
||||
| Writing deterministic end-to-end tests in CI | Playwright / Cypress |
|
||||
| Building a hosted scraping/agent fleet on isolated profiles | Playwright / Puppeteer + Browserbase / Steel |
|
||||
| Want a turnkey "natural-language agent" with built-in loop | Browser Use / Stagehand / Skyvern |
|
||||
| Want a hosted, vendor-managed agent | OpenAI Operator / Project Mariner / Surfer |
|
||||
| Debugging your own app from your editor without leaving your real session | **pi-chrome** |
|
||||
|
||||
`pi-chrome` is **primitives**, not an opinionated agent loop. Think of it as **"Playwright for the Chrome you're already signed into"** — and pluggable under any agent framework above the line.
|
||||
|
||||
---
|
||||
|
||||
## The three axes you should compare on
|
||||
|
||||
| Axis | Examples | What it gives you |
|
||||
| --------------------------------- | --------------------------------------------------------------------- | ----------------------------- |
|
||||
| **1. Driver / transport** | Playwright, Puppeteer, Selenium, CDP raw, `puppeteer-stealth`, **pi-chrome** | low-level tools (click, type, navigate) |
|
||||
| **2. Agent framework** | Browser Use, Stagehand, Skyvern, Magnitude, Alumnium, LangGraph-with-Playwright, Operator, Mariner, Surfer | LLM loop, planning, NL API |
|
||||
| **3. Cloud browser provider** | Browserbase, Steel.dev, Hyperbrowser, Anchor, Browserless | managed Chromes, sessions, quotas |
|
||||
|
||||
`pi-chrome` is **axis 1**. You can wrap it with any axis-2 agent framework, or run it directly from a Pi agent's `chrome_*` tool calls. It explicitly does NOT compete with axis 3 — it runs locally inside *your* Chrome.
|
||||
|
||||
---
|
||||
|
||||
## Axis 1 — drivers (where pi-chrome lives)
|
||||
|
||||
| Tool | Transport | Profile | Browser input | Banner when controlling | Default detectable as bot |
|
||||
| --------------------------------- | ------------------------------------------ | ---------------------------------- | -------------------- | ----------------------------------- | ------------------------- |
|
||||
| Playwright | CDP (own driver) | throwaway by default | always | always ("controlled by test software") | yes (webdriver flag, automation flags) |
|
||||
| Puppeteer | CDP | throwaway by default | always | always | yes |
|
||||
| Selenium | WebDriver / BiDi | throwaway | partial (BiDi improves) | always | most detectable |
|
||||
| puppeteer-stealth / playwright-extra | CDP + patches | throwaway | always | always | medium (patches flags) |
|
||||
| Raw CDP | direct devtools protocol | either (needs `--remote-debugging-port`) | always | always | yes |
|
||||
| **pi-chrome** | **Chrome extension bridge → local loopback** | **your real Chrome profile, signed-in cookies, extensions, history** | **always for input tools** | **while Chrome input is attached** | **low (real profile + Chrome input)¹** |
|
||||
|
||||
¹ pi-chrome uses `chrome.debugger` for browser input and shows Chrome's banner like other CDP-based tools. The [`test-suite/`](../test-suite) grades browser-control behavior against common detection signals.
|
||||
|
||||
### What makes pi-chrome different on this axis
|
||||
|
||||
1. **Profile attach, not driver launch.** Every other driver fights cookie persistence, login walls, MFA, and extension state. pi-chrome inherits all of it because it *is* your Chrome.
|
||||
2. **Chrome input against your real profile.** Interactive tools use CDP input for reliability while still controlling the Chrome profile you already use.
|
||||
3. **Extension bridge transport.** No `--remote-debugging-port`, no throwaway Chromium. Survives Chrome auto-updates. Works alongside your normal Chrome usage.
|
||||
4. **Structured action results.** Input tools return target coordinates/tags and can include a fresh snapshot (`includeSnapshot`) so agents can verify state instead of blindly retrying.
|
||||
5. **Multi-session shared bridge.** Planner + worker + audit Pi sessions all drive the same Chrome concurrently.
|
||||
6. **Stable element uids.** `chrome_snapshot` returns deterministic uids you can pass to subsequent actions — similar to BrowserGym's `bid`, but built into the snapshot tool itself.
|
||||
|
||||
---
|
||||
|
||||
## Axis 2 — agent frameworks (built on top of axis 1)
|
||||
|
||||
These wrap a driver with an LLM loop. They are **higher-level than pi-chrome** and **complementary**, not competitors.
|
||||
|
||||
| Framework | Driver underneath | Approach | Open source |
|
||||
| ------------------------ | ------------------------------ | --------------------------------------------------------------------------------------------- | --------------- |
|
||||
| **Browser Use** | Playwright | DOM + a11y tree → LLM → action JSON. Open-source leader; widely cited on WebVoyager. | MIT (Python) |
|
||||
| **Stagehand** (Browserbase) | Playwright | Natural-language `.act()` / `.observe()` / `.extract()`; deterministic + AI mix. | MIT (TypeScript)|
|
||||
| **Skyvern** | Playwright + own DOM model | Vision-first + DOM; YAML workflows for form/workflow automation. | AGPL (Python) |
|
||||
| **Magnitude** | Playwright | NL test authoring; QA-focused. | open |
|
||||
| **Alumnium** | Selenium / Playwright | Test-author NL → agent. QA-focused. | open |
|
||||
| **LangGraph / AutoGen + Playwright** | Playwright | Generic agent graph + browser tools. | open |
|
||||
| **OpenAI Operator** | proprietary | OpenAI's own VLM + browser; ChatGPT-integrated. | closed, hosted |
|
||||
| **Project Mariner** (Google) | proprietary Chrome integration | Google's own VLM Chrome experiment. | closed |
|
||||
| **Surfer 2 / Surfer-H** (H Company) | proprietary | Hosted proprietary agent stack. | closed, hosted |
|
||||
| **Anthropic Computer Use** | OS-level screenshots + mouse/keyboard | Broader than browser; OS-level events. | closed (API) |
|
||||
|
||||
**Why pi-chrome is not on this list:** it's intentionally **not an agent**. There's no LLM loop, no `.act("click the blue button")`. Pi handles the loop; pi-chrome provides the primitives. This means:
|
||||
|
||||
- You can use pi-chrome under Browser Use, Stagehand, LangGraph, or any other agent framework (see [Interop](#interop)).
|
||||
- You don't pay for an opinion you don't want.
|
||||
- Agent improvements compound across all your workflows because the primitives stay stable.
|
||||
|
||||
---
|
||||
|
||||
## Axis 3 — cloud browser providers (orthogonal)
|
||||
|
||||
| Provider | What it sells |
|
||||
| ----------------- | -------------------------------------------------------- |
|
||||
| **Browserbase** | Managed browsers; pairs with Stagehand. |
|
||||
| **Steel.dev** | Managed browsers + public agent leaderboards. |
|
||||
| **Hyperbrowser** | Managed browsers, session APIs. |
|
||||
| **Anchor Browser**| Managed browsers. |
|
||||
| **Browserless** | Managed Chrome, scraping focus. |
|
||||
|
||||
**pi-chrome doesn't compete here.** It runs locally in *your* Chrome. The right framing if someone asks: *"no cloud cost, no session-handoff, no rate limits — and the agent runs against your real logged-in state."* If you need fleets of isolated Chromes in CI, you want one of these.
|
||||
|
||||
---
|
||||
|
||||
## Interop
|
||||
|
||||
`pi-chrome` exposes tools that any Pi agent can call. If you want to use it from outside Pi:
|
||||
|
||||
1. The local bridge speaks HTTP JSON over `127.0.0.1:17318` (default). The API is internal; use the Pi tool surface unless you are building an adapter.
|
||||
2. Tool surface mirrors Playwright closely (click/type/navigate/snapshot/screenshot/evaluate/wait_for) so adapter code is short.
|
||||
3. `includeSnapshot` on input tools lets agent harnesses verify state after actions.
|
||||
|
||||
If you want a first-class pi-chrome adapter for Browser Use / Stagehand / LangGraph, file an issue with your use case.
|
||||
|
||||
---
|
||||
|
||||
## "But Playwright has `storageState` / Puppeteer has user-data-dir"
|
||||
|
||||
Yes — you can export cookies and replay them, or point Playwright at your existing profile directory. In practice for agent workflows that breaks down fast:
|
||||
|
||||
1. **OAuth + SSO** providers (Okta, Google, GitHub) often pin sessions to TLS fingerprints, device IDs, and browser-extension state that doesn't survive replay or a parallel Chrome instance.
|
||||
2. **MFA** tokens expire mid-run.
|
||||
3. **Internal admin tools** hard-pin to your real device.
|
||||
4. **Pointing Playwright at your real `user-data-dir`** requires closing your normal Chrome (Chrome won't share the profile lock). pi-chrome doesn't fight you for the profile because it lives *inside* it.
|
||||
5. **Watching the agent work** in your real window is a different UX than a hidden parallel Chrome. Demos, pair-driving, and confidence-building all want axis-1-with-attach.
|
||||
|
||||
---
|
||||
|
||||
## "Is this safer than CDP?"
|
||||
|
||||
Different security boundary, not strictly safer.
|
||||
|
||||
- **CDP-based tools** require `chrome --remote-debugging-port=...`. That port is unauthenticated and exposes the whole browser to any local process. Easy to misconfigure.
|
||||
- **pi-chrome** runs through an extension you install yourself with broad permissions (tabs, scripting, debugger, webNavigation). The bridge listens on `127.0.0.1:17318` loopback only, rejects browser-origin command requests, and keeps chrome_* tools locked until `/chrome authorize` is run in the current Pi session. **Only install the bundled extension if you trust the source you got the npm package from.**
|
||||
|
||||
If your threat model excludes extensions with broad permissions, neither approach is a fit — you want a sandboxed CI runner.
|
||||
|
||||
---
|
||||
|
||||
## Public benchmarks worth knowing (for axis 2 / axis 3 comparison)
|
||||
|
||||
Pi-chrome itself ships a benchmark suite ([`../test-suite/`](../test-suite)) of **42 primitive challenges** plus **4 hermetic BrowserGym-style long-horizon tasks** covering trusted input, pointer humanization, keyboard fidelity, drag/drop, Shadow DOM, iframes, file uploads, strict-CSP screenshot fallback and CDP eval/snapshot bypass, dynamic waits, tab lifecycle, network observability, fingerprint leaks, and agent-safety honeypots. Scoring tracks expected outcomes per challenge instead of raw PASS count, with `core`, `conditional`, and `quality` gate buckets. That's **driver-level** grading.
|
||||
|
||||
For **agent-level** comparison (axis 2), the public benchmarks worth citing:
|
||||
|
||||
| Benchmark | What it measures | Notes |
|
||||
| ---------------- | ------------------------------------------------- | ------------------------------------------------ |
|
||||
| **WebArena** (CMU) | Hermetic, programmatic graders | Gold standard for reproducibility. |
|
||||
| **WorkArena++** (ServiceNow) | Enterprise SaaS workflows | Hardest realistic benchmark; <5% frontier. |
|
||||
| **BrowseComp** (OpenAI) | Hard info-retrieval | Not saturated. |
|
||||
| **Mind2Web 2** (NeurIPS '25) | Long-horizon, rubric-tree judge | New, well-designed. |
|
||||
| **WebChoreArena** | Tedious cross-page workflows | Reflects real ops work. |
|
||||
| **WebVoyager** | Live web tasks | **Saturated** — 90%+ scores common; cite only as smoke test. |
|
||||
| **VisualWebArena** | Multimodal | |
|
||||
| **MiniWoB++** | Classic unit-task suite | |
|
||||
| **BrowserGym + AgentLab** | Research harness covering the above | The de-facto research API; pi-chrome's snapshot uid is comparable to BrowserGym's `bid`. |
|
||||
|
||||
Cite live leaderboards rather than hard-coded numbers; agent scores shift monthly.
|
||||
|
||||
---
|
||||
|
||||
## Reproducing pi-chrome's driver-level claims
|
||||
|
||||
Run [`../test-suite/`](../test-suite) against any browser-control tool. Each challenge exposes `window.__verdict` / `window.__reason` / `window.__events`, so any tool (Playwright, Puppeteer, Selenium, Stagehand, pi-chrome) can grade itself deterministically. Headline release scoring should use the `core` gate; `conditional` depends on declared environment capabilities, and `quality` tracks adversarial/humanization regressions.
|
||||
|
||||
```bash
|
||||
cd test-suite && python3 -m http.server 8765
|
||||
# open http://127.0.0.1:8765/ in the Chrome window the tool controls
|
||||
```
|
||||
|
||||
If you build a competing tool, please open a PR with your scores.
|
||||
@@ -0,0 +1,166 @@
|
||||
# pi-chrome examples
|
||||
|
||||
Real, useful agent prompts. Drop any of these into Pi after running `/chrome onboard`, then `/chrome authorize`. Each one uses Chrome tabs and accounts you already have.
|
||||
|
||||
## Daily workflow
|
||||
|
||||
### PR triage
|
||||
|
||||
```text
|
||||
Use chrome_tab list to find my GitHub notifications tab.
|
||||
chrome_snapshot it. Group PRs by:
|
||||
- awaiting my review
|
||||
- blocked on me (changes requested back)
|
||||
- mergeable (approved + green CI)
|
||||
Output a 5-bullet ranked triage. Do not click anything.
|
||||
```
|
||||
|
||||
### Linear standup
|
||||
|
||||
```text
|
||||
Open my Linear current cycle in the active tab.
|
||||
chrome_snapshot, then write yesterday/today/blockers
|
||||
in the exact format my standup channel uses.
|
||||
```
|
||||
|
||||
### Slack catch-up
|
||||
|
||||
```text
|
||||
For each unread channel in my Slack tab, chrome_snapshot,
|
||||
extract the top 3 messages that mention me or my team,
|
||||
and summarize what I missed in <100 words total.
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Reproduce a customer bug with evidence
|
||||
|
||||
```text
|
||||
1. chrome_navigate to https://staging.acme.com/orders/<id>
|
||||
2. chrome_snapshot
|
||||
3. Click "Refund" with chrome_click
|
||||
4. Use chrome_list_network_requests to capture the API call
|
||||
5. chrome_get_network_request on the failing one — give me the response body
|
||||
6. chrome_screenshot the error state to ./repro/refund-bug.png
|
||||
```
|
||||
|
||||
### Visual diff local vs staging
|
||||
|
||||
```text
|
||||
chrome_screenshot http://localhost:3000/pricing → ./diff/local.png
|
||||
chrome_screenshot https://staging.acme.com/pricing → ./diff/staging.png
|
||||
Then read both, describe layout differences in plain English.
|
||||
```
|
||||
|
||||
### Console + network forensics
|
||||
|
||||
```text
|
||||
Reproduce the checkout bug on the active tab.
|
||||
After the failure:
|
||||
- chrome_list_console_messages
|
||||
- chrome_list_network_requests
|
||||
Cross-reference the timestamps and tell me what broke first.
|
||||
```
|
||||
|
||||
## Admin / ops
|
||||
|
||||
### Multi-tab cross-check
|
||||
|
||||
```text
|
||||
I have Stripe, Postmark, and our internal admin open in 3 tabs.
|
||||
For user <id>, chrome_snapshot each tab in turn and find
|
||||
any field where state disagrees. Output a 3-column table.
|
||||
```
|
||||
|
||||
### Bulk gentle action (safe form-fill, no submit)
|
||||
|
||||
```text
|
||||
Open our vendor portal "Add Vendor" form.
|
||||
For each row in ./vendors.csv:
|
||||
- chrome_fill the form
|
||||
- chrome_screenshot it
|
||||
- STOP before submit
|
||||
- chrome_evaluate "history.back()" to return to the list
|
||||
I will review screenshots and submit manually.
|
||||
```
|
||||
|
||||
### Auth-only data pull
|
||||
|
||||
```text
|
||||
My analytics dashboard is open and the cookie auth would die in headless mode.
|
||||
chrome_evaluate to read window.__APP_STATE__.dashboardData
|
||||
and dump today's KPIs as JSON.
|
||||
```
|
||||
|
||||
## Demos / PRs
|
||||
|
||||
### Capture screenshots for a PR description
|
||||
|
||||
```text
|
||||
On localhost:3000/feature-x:
|
||||
- empty state → ./pr/01-empty.png
|
||||
- filled state → ./pr/02-filled.png
|
||||
- error state (delete the API key from devtools first) → ./pr/03-error.png
|
||||
Save each with chrome_screenshot. Output a markdown block I can paste into the PR.
|
||||
```
|
||||
|
||||
### Record a guided demo flow
|
||||
|
||||
```text
|
||||
On my staging app:
|
||||
1. Walk the new-onboarding flow start to finish
|
||||
2. After each chrome_click or chrome_navigate, chrome_screenshot
|
||||
3. Save numbered PNGs under ./demo/
|
||||
4. Write narration captions for each step
|
||||
```
|
||||
|
||||
## Forms with frameworks
|
||||
|
||||
### React controlled inputs
|
||||
|
||||
```text
|
||||
Use `chrome_fill` for React inputs when you want to replace the full value.
|
||||
Pass `includeSnapshot=true` to verify the component re-rendered with the new value.
|
||||
```
|
||||
|
||||
### File upload without the native picker
|
||||
|
||||
```text
|
||||
chrome_upload_file paths=[./fixtures/avatar.png] selector="input[type=file]"
|
||||
# Uses Chrome file-input control. No native file picker opens.
|
||||
```
|
||||
|
||||
### Drag-to-reorder lists
|
||||
|
||||
```text
|
||||
chrome_drag fromUid=row-3 toUid=row-1
|
||||
# Uses Chrome pointer drag through its input layer.
|
||||
```
|
||||
|
||||
## Multi-session patterns
|
||||
|
||||
`pi-chrome` shares one bridge across all Pi sessions on the same machine. Useful patterns:
|
||||
|
||||
### Planner + Worker
|
||||
|
||||
- **Planner session** stays high level: "find the bug, decide the fix."
|
||||
- **Worker session** runs the actual `chrome_*` tools.
|
||||
- Both see the same Chrome state because they're both pointing at your real profile.
|
||||
|
||||
### Watcher
|
||||
|
||||
A third Pi session can run `chrome_snapshot` periodically in `background: true` mode and post summaries via `pi-qq` — handy for long-running flows.
|
||||
|
||||
## Chrome input
|
||||
|
||||
Interactive tools use Chrome's real input layer by default: clicks, typing, fill, keys, hover, drag, scroll, and touch. This is reliable for:
|
||||
|
||||
- sign-in flows
|
||||
- guarded buttons
|
||||
- audio/video controls
|
||||
- fullscreen and other user-activation checks
|
||||
- pages where DOM injection/evaluate is limited, if the agent can use screenshots + coordinates
|
||||
|
||||
Strict CSP note: `chrome_snapshot`/`chrome_evaluate` work even on pages that disallow `unsafe-eval`, because they run via CDP `Runtime.evaluate` (not page-level `eval`/`new Function`), which is not subject to page CSP. `chrome_screenshot`, tab/navigation tools, and real input also work under any CSP.
|
||||
|
||||
Chrome may show its debugger banner while pi-chrome is attached.
|
||||
@@ -0,0 +1,84 @@
|
||||
# pi-chrome FAQ
|
||||
|
||||
## Does this work with Brave / Arc / Edge / Vivaldi?
|
||||
|
||||
Yes. Any Chromium-based browser that supports unpacked extensions and the `chrome.debugger` API will work. The extension is named "Pi Chrome Connector" but the source is browser-agnostic. Firefox / WebKit are out of scope (different extension models).
|
||||
|
||||
## Will it slow my browser down?
|
||||
|
||||
The companion extension is idle when no Pi command is in flight. It uses Manifest V3 service worker activation, so it wakes for a request and goes back to sleep. No content script is injected globally.
|
||||
|
||||
## Does it work in Chrome incognito?
|
||||
|
||||
By default no — extensions need explicit "Allow in incognito" permission. Toggle it on `chrome://extensions` if you want pi-chrome to see incognito tabs. We don't recommend it for sensitive work.
|
||||
|
||||
## Will sites detect that I'm automating?
|
||||
|
||||
Interactive controls use Chrome's real input layer via CDP, so normal user-activation gates are satisfied and input is closer to real browser use than DOM-dispatched events. pi-chrome also shapes pointer/keyboard/scroll behavior, but this is not a guarantee of undetectability. Some detectors check for the `chrome.debugger` API attached, and Chrome will show the "Chrome is being debugged" banner.
|
||||
|
||||
The [`test-suite/`](../test-suite) grades browser-control behavior against common detection signals. Its `quality` bucket is adversarial signal, not a blanket promise that every site will treat automation as human.
|
||||
|
||||
## Why do I see a banner saying "Pi Chrome Connector started debugging this browser"?
|
||||
|
||||
That's Chrome's built-in warning when an extension uses `chrome.debugger`. pi-chrome uses Chrome's input layer for interactive controls, so the banner appears while attached.
|
||||
|
||||
## Can a malicious page escape and access my other tabs?
|
||||
|
||||
No — pages cannot directly talk to extensions. Commands flow agent → local bridge (`127.0.0.1:17318`) → extension → tab. The bridge binds to loopback only and rejects browser-origin command requests, so ordinary web pages cannot use CORS to drive it.
|
||||
|
||||
Chrome control is also locked per Pi session until you run `/chrome authorize`; `/chrome revoke` locks it again. The remaining risk surface is **other local processes running as you** that can connect to loopback and imitate Pi. If that's in your threat model, run pi-chrome in a separate OS user account.
|
||||
|
||||
## Can multiple Pi sessions use it at once?
|
||||
|
||||
Yes. The first session opens the local bridge; later sessions detect it and pipe their commands through the same bridge. Each Pi session must be authorized with `/chrome authorize` before its chrome_* tools work. Each session also owns its **own** dedicated automation window (ownership is keyed by session id inside the one extension), so concurrent sessions never navigate into or close each other's tabs.
|
||||
|
||||
## Does pi-chrome navigate my current tab?
|
||||
|
||||
No. The first chrome_* action that has no explicit target opens a **dedicated automation window** that pi-chrome owns (falling back to a dedicated tab only if a separate window can't be created), and reuses it for the rest of the session. Your existing tabs and windows are never reused or overwritten. Pass `targetId`/`urlIncludes`/`titleIncludes` to deliberately act on a tab you already have open.
|
||||
|
||||
The window survives `/reload` and Chrome service-worker restarts because ownership is tracked by id and mirrored to `chrome.storage.session`. It is closed when you run `/chrome revoke` and on real session end (not on `/reload`); cleanup only ever closes that session's own window/tab.
|
||||
|
||||
**After a full browser restart**, `chrome.storage.session` is cleared by Chrome. If Chrome's session-restore reopens the old automation window, pi-chrome no longer recognizes it (its tracking is gone), so it is left alone as an ordinary window — pi-chrome will open a fresh dedicated window for the new run rather than reclaim or close the restored one. pi-chrome never closes a window it can't positively identify as its own, so a user window is never at risk.
|
||||
|
||||
## Why ship as an unpacked extension?
|
||||
|
||||
pi-chrome ships as an unpacked extension so the source and broad browser permissions are easy to inspect and update with the npm package. The downside: you load it manually from `chrome://extensions` and reload it after package updates.
|
||||
|
||||
## What happens when I update pi-chrome?
|
||||
|
||||
`/chrome doctor` will warn you if the loaded extension is older than the installed `pi-chrome`. Reload it from `chrome://extensions` to pick up the new version. Updates that add Chrome permissions may require re-approval once.
|
||||
|
||||
## What's the install footprint?
|
||||
|
||||
- Pi side: one extension that registers 19 tools and a few slash commands.
|
||||
- Chrome side: one unpacked extension, ~2000 LOC of plain JavaScript, no dependencies.
|
||||
|
||||
## Can I script it without Pi?
|
||||
|
||||
The Pi-facing tools are thin wrappers around an HTTP bridge at `127.0.0.1:17318`. You could call it directly from any process, but the API is internal and may change. If you need a stable scripting interface, file an issue and we'll consider stabilizing.
|
||||
|
||||
## What can humans do that pi-chrome cannot?
|
||||
|
||||
pi-chrome controls web pages through Chrome extension APIs, page inspection, screenshots, and browser input. It is not full OS-level human control. Known gaps include native Chrome/OS dialogs (print/save-as, some permission bubbles, password-manager prompts), arbitrary OS app interaction, visual CAPTCHA challenges, hardware-backed auth (passkeys/security keys/biometrics), rich multi-touch/pinch/stylus gestures, and DOM inspection inside cross-origin iframes. Some of these can still be handled with screenshot + coordinate input or user assistance, but they are not first-class deterministic workflows.
|
||||
|
||||
## Does `chrome_evaluate` work on strict-CSP pages?
|
||||
|
||||
Yes. `chrome_evaluate` and `chrome_snapshot` run in the page's MAIN world through CDP `Runtime.evaluate`, which is a DevTools protocol command and is **not** subject to the page's Content-Security-Policy. They work even on pages that block `'unsafe-eval'` (e.g. github.com and many bank/SaaS apps). `chrome_navigate`'s `initScript` injects at document_start via CDP and likewise bypasses CSP. `chrome_screenshot`, tab tools, and real Chrome input also keep working under any CSP.
|
||||
|
||||
## How do I tell whether a click or type worked?
|
||||
|
||||
Use `includeSnapshot=true` on `chrome_click`, `chrome_type`, `chrome_fill`, or `chrome_key`. The tool returns the Chrome-input result plus a fresh snapshot, so the agent can verify text, URL, visible elements, or form values before continuing.
|
||||
|
||||
If the page did not change, take a fresh snapshot or screenshot and check for overlays, disabled controls, stale element uids, or app-side validation.
|
||||
|
||||
## How do I attach a file to a React file input?
|
||||
|
||||
`chrome_upload_file` — uses Chrome DevTools file-input control and fires `input` + `change` events. It does **not** open the native file picker. Works with React/Vue/Angular controlled inputs.
|
||||
|
||||
## Can it record videos?
|
||||
|
||||
Not yet. Screenshots only. Video recording is on the roadmap.
|
||||
|
||||
## How do I file a good bug report?
|
||||
|
||||
Include `/chrome doctor` output, the exact tool call, and the result envelope. If the page is public, link to it; if private, distill it into a benchmark page under `test-suite/challenges/`. See [CONTRIBUTING.md](../CONTRIBUTING.md).
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Pi Chrome Connector",
|
||||
"version": "0.15.46",
|
||||
"description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"tabGroups",
|
||||
"scripting",
|
||||
"storage",
|
||||
"activeTab",
|
||||
"alarms",
|
||||
"webNavigation",
|
||||
"debugger"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>",
|
||||
"http://127.0.0.1:17318/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "service_worker.js"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "Pi Chrome Connector"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,677 @@
|
||||
// Static MAIN-world snapshot implementation injected by the MV3 service worker.
|
||||
// Keep this file free of eval/new Function so `chrome_snapshot` works on strict-CSP pages.
|
||||
(() => {
|
||||
function getPiChromeState() {
|
||||
const state = window.__PI_CHROME_STATE__ || {
|
||||
nextElementUid: 1,
|
||||
elements: {},
|
||||
console: [],
|
||||
network: [],
|
||||
nextRequestId: 1,
|
||||
instrumentationInstalled: false,
|
||||
lastSnapshotDigest: null,
|
||||
};
|
||||
window.__PI_CHROME_STATE__ = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
function rememberElement(element) {
|
||||
const state = getPiChromeState();
|
||||
if (!element.__piChromeUid) element.__piChromeUid = "el-" + state.nextElementUid++;
|
||||
state.elements[element.__piChromeUid] = element;
|
||||
return element.__piChromeUid;
|
||||
}
|
||||
|
||||
function isElementVisible(element) {
|
||||
if (!element || !element.getBoundingClientRect) return false;
|
||||
const style = getComputedStyle(element);
|
||||
if (style.visibility === "hidden" || style.display === "none") return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return false;
|
||||
if (rect.bottom < 0 || rect.right < 0) return false;
|
||||
if (rect.top > innerHeight || rect.left > innerWidth) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function occluderAt(x, y, expected) {
|
||||
const top = document.elementFromPoint(x, y);
|
||||
if (!top || top === expected) return null;
|
||||
if (expected && expected.contains(top)) return null;
|
||||
if (top.contains(expected)) return null;
|
||||
return {
|
||||
tag: top.tagName.toLowerCase(),
|
||||
id: top.id || undefined,
|
||||
className: typeof top.className === "string" ? top.className : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function textOf(element, max) {
|
||||
return (element?.innerText || element?.textContent || "").replace(/\s+/g, " ").trim().slice(0, max || 500);
|
||||
}
|
||||
|
||||
function accessibleLabel(element) {
|
||||
if (!element) return "";
|
||||
const labelledBy = element.getAttribute("aria-labelledby");
|
||||
if (labelledBy) {
|
||||
const text = labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.innerText || "").join(" ").trim();
|
||||
if (text) return text;
|
||||
}
|
||||
const id = element.id;
|
||||
if (id) {
|
||||
try {
|
||||
const label = document.querySelector(`label[for="${cssEscape(id)}"]`);
|
||||
if (label?.innerText) return label.innerText;
|
||||
} catch {}
|
||||
}
|
||||
const wrappingLabel = element.closest?.("label");
|
||||
return (
|
||||
element.getAttribute("aria-label") ||
|
||||
element.getAttribute("title") ||
|
||||
element.getAttribute("placeholder") ||
|
||||
wrappingLabel?.innerText ||
|
||||
element.innerText ||
|
||||
element.textContent ||
|
||||
""
|
||||
).trim().replace(/\s+/g, " ").slice(0, 180);
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
return (window.CSS && CSS.escape) ? CSS.escape(value) : String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
||||
}
|
||||
|
||||
function roleOf(element) {
|
||||
const explicit = element.getAttribute("role");
|
||||
if (explicit) return explicit.toLowerCase();
|
||||
const tag = element.tagName.toLowerCase();
|
||||
const type = (element.getAttribute("type") || "").toLowerCase();
|
||||
if (tag === "a" && element.href) return "link";
|
||||
if (tag === "button" || type === "button" || type === "submit" || type === "reset") return "button";
|
||||
if (tag === "textarea") return "textbox";
|
||||
if (tag === "select") return "combobox";
|
||||
if (tag === "input") {
|
||||
if (["checkbox", "radio", "range", "search", "email", "password", "tel", "url", "number"].includes(type)) return type === "checkbox" || type === "radio" || type === "range" ? type : "textbox";
|
||||
return "textbox";
|
||||
}
|
||||
if (element.isContentEditable) return "textbox";
|
||||
if (tag.match(/^h[1-6]$/)) return "heading";
|
||||
return tag;
|
||||
}
|
||||
|
||||
function isSensitiveField(element) {
|
||||
if (!element) return false;
|
||||
const tag = element.tagName?.toLowerCase?.() || "";
|
||||
if (!/^(input|textarea|select)$/.test(tag) && !element.isContentEditable) return false;
|
||||
const type = (element.getAttribute("type") || "").toLowerCase();
|
||||
if (["password"].includes(type)) return true;
|
||||
const haystack = [
|
||||
type,
|
||||
element.getAttribute("name"),
|
||||
element.id,
|
||||
element.getAttribute("autocomplete"),
|
||||
element.getAttribute("aria-label"),
|
||||
element.getAttribute("placeholder"),
|
||||
element.getAttribute("data-testid"),
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
return /password|passwd|\bpwd\b|secret|token|bearer|api[-_ ]?key|access[-_ ]?key|auth[-_ ]?code|one[-_ ]?time|otp|2fa|mfa|verification[-_ ]?code|recovery[-_ ]?code|credit[-_ ]?card|card[-_ ]?number|cc-number|cc-csc|cvc|cvv|security[-_ ]?code|ssn|social[-_ ]?security/.test(haystack);
|
||||
}
|
||||
|
||||
function installPiChromeInstrumentation() {
|
||||
const state = getPiChromeState();
|
||||
if (state.instrumentationInstalled) return;
|
||||
state.instrumentationInstalled = true;
|
||||
const pushConsole = (level, args) => {
|
||||
state.console.push({
|
||||
id: state.console.length + 1,
|
||||
level,
|
||||
timestamp: Date.now(),
|
||||
url: location.href,
|
||||
args: Array.from(args).map((arg) => {
|
||||
try {
|
||||
if (typeof arg === "string") return arg;
|
||||
if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack };
|
||||
return JSON.parse(JSON.stringify(arg));
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}),
|
||||
});
|
||||
if (state.console.length > 500) state.console.splice(0, state.console.length - 500);
|
||||
};
|
||||
for (const level of ["debug", "log", "info", "warn", "error"]) {
|
||||
const original = console[level];
|
||||
if (typeof original !== "function" || original.__piChromeWrapped) continue;
|
||||
const wrapped = function(...args) {
|
||||
pushConsole(level, args);
|
||||
return original.apply(this, args);
|
||||
};
|
||||
wrapped.__piChromeWrapped = true;
|
||||
console[level] = wrapped;
|
||||
}
|
||||
window.addEventListener("error", (event) => pushConsole("pageerror", [event.message, event.filename + ":" + event.lineno + ":" + event.colno]));
|
||||
window.addEventListener("unhandledrejection", (event) => pushConsole("unhandledrejection", [event.reason]));
|
||||
|
||||
const record = (entry) => {
|
||||
state.network.push(entry);
|
||||
if (state.network.length > 1000) state.network.splice(0, state.network.length - 1000);
|
||||
return entry;
|
||||
};
|
||||
if (window.fetch && !window.fetch.__piChromeWrapped) {
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
const wrappedFetch = async (...args) => {
|
||||
const id = "req-" + state.nextRequestId++;
|
||||
const startedAt = Date.now();
|
||||
const input = args[0];
|
||||
const init = args[1] || {};
|
||||
const url = typeof input === "string" ? input : input?.url;
|
||||
const method = (init.method || input?.method || "GET").toUpperCase();
|
||||
const entry = record({ id, type: "fetch", method, url: String(url || ""), startedAt, pageUrl: location.href, status: "pending" });
|
||||
try {
|
||||
const response = await originalFetch(...args);
|
||||
entry.status = response.status;
|
||||
entry.statusText = response.statusText;
|
||||
entry.ok = response.ok;
|
||||
entry.responseUrl = response.url;
|
||||
entry.durationMs = Date.now() - startedAt;
|
||||
entry.responseHeaders = Array.from(response.headers.entries());
|
||||
entry.responseBodyOmitted = "response body capture is disabled by default";
|
||||
return response;
|
||||
} catch (error) {
|
||||
entry.error = error?.message || String(error);
|
||||
entry.durationMs = Date.now() - startedAt;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
wrappedFetch.__piChromeWrapped = true;
|
||||
window.fetch = wrappedFetch;
|
||||
}
|
||||
if (window.XMLHttpRequest && !XMLHttpRequest.prototype.open.__piChromeWrapped) {
|
||||
const originalOpen = XMLHttpRequest.prototype.open;
|
||||
const originalSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
||||
this.__piChromeRequest = { method: String(method || "GET").toUpperCase(), url: String(url || "") };
|
||||
return originalOpen.call(this, method, url, ...rest);
|
||||
};
|
||||
XMLHttpRequest.prototype.open.__piChromeWrapped = true;
|
||||
XMLHttpRequest.prototype.send = function(body) {
|
||||
const id = "req-" + state.nextRequestId++;
|
||||
const startedAt = Date.now();
|
||||
const info = this.__piChromeRequest || {};
|
||||
const entry = record({ id, type: "xhr", method: info.method || "GET", url: info.url || "", startedAt, pageUrl: location.href, status: "pending" });
|
||||
this.addEventListener("loadend", () => {
|
||||
entry.status = this.status;
|
||||
entry.statusText = this.statusText;
|
||||
entry.responseUrl = this.responseURL;
|
||||
entry.durationMs = Date.now() - startedAt;
|
||||
try { entry.responseHeadersText = this.getAllResponseHeaders(); } catch {}
|
||||
entry.responseBodyOmitted = "response body capture is disabled by default";
|
||||
});
|
||||
this.addEventListener("error", () => { entry.error = "XMLHttpRequest error"; entry.durationMs = Date.now() - startedAt; });
|
||||
return originalSend.call(this, body);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function hashString(text) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
function selectorFor(element) {
|
||||
const unique = (selector) => {
|
||||
try { return document.querySelectorAll(selector).length === 1; } catch { return false; }
|
||||
};
|
||||
if (element.id && unique("#" + cssEscape(element.id))) return "#" + cssEscape(element.id);
|
||||
const attr = ["aria-label", "name", "placeholder", "data-testid", "role"].find((name) => element.getAttribute(name));
|
||||
if (attr) {
|
||||
const candidate = element.tagName.toLowerCase() + "[" + attr + "=" + JSON.stringify(element.getAttribute(attr)) + "]";
|
||||
if (unique(candidate)) return candidate;
|
||||
}
|
||||
const parts = [];
|
||||
let current = element;
|
||||
while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 5) {
|
||||
let part = current.tagName.toLowerCase();
|
||||
if (current.classList.length > 0) part += "." + Array.from(current.classList).slice(0, 2).map(cssEscape).join(".");
|
||||
const siblings = Array.from(current.parentElement?.children ?? []).filter((sibling) => sibling.tagName === current.tagName);
|
||||
if (siblings.length > 1) part += ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")";
|
||||
parts.unshift(part);
|
||||
const candidate = parts.join(" > ");
|
||||
if (unique(candidate)) return candidate;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return parts.join(" > ");
|
||||
}
|
||||
|
||||
function directHeadingText(element) {
|
||||
const labelledBy = element.getAttribute?.("aria-labelledby");
|
||||
if (labelledBy) {
|
||||
const text = labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.innerText || "").join(" ").replace(/\s+/g, " ").trim();
|
||||
if (text) return text.slice(0, 180);
|
||||
}
|
||||
const aria = element.getAttribute?.("aria-label");
|
||||
if (aria) return aria.trim().slice(0, 180);
|
||||
const heading = Array.from(element.querySelectorAll?.("h1,h2,h3,h4,[role='heading']") || []).find(isElementVisible);
|
||||
if (heading) return textOf(heading, 180);
|
||||
return "";
|
||||
}
|
||||
|
||||
function meaningfulContainerFor(element) {
|
||||
let current = element.parentElement;
|
||||
let fallback = current;
|
||||
let depth = 0;
|
||||
while (current && current !== document.body && depth++ < 8) {
|
||||
if (!isElementVisible(current)) { current = current.parentElement; continue; }
|
||||
const tag = current.tagName.toLowerCase();
|
||||
const role = (current.getAttribute("role") || "").toLowerCase();
|
||||
const cls = typeof current.className === "string" ? current.className : "";
|
||||
const id = current.id || "";
|
||||
const named = Boolean(current.getAttribute("aria-label") || current.getAttribute("aria-labelledby") || directHeadingText(current));
|
||||
const semantic = /^(form|dialog|section|article|nav|header|main|aside|footer|li|tr|td|fieldset)$/.test(tag) ||
|
||||
/^(dialog|alertdialog|region|group|listitem|row|cell|tabpanel|menu|toolbar|navigation|main|banner|contentinfo|complementary)$/.test(role);
|
||||
const classHint = /card|panel|pane|modal|dialog|section|content|container|toolbar|menu|list|item|row|cell|header|footer|sidebar|drawer|popover|dropdown/i.test(`${id} ${cls}`);
|
||||
const rect = current.getBoundingClientRect();
|
||||
const childActions = current.querySelectorAll?.('a, button, input, textarea, select, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])').length || 0;
|
||||
if ((semantic || classHint || named) && rect.width > 20 && rect.height > 20 && childActions <= 80) return current;
|
||||
if (!fallback && rect.width > 20 && rect.height > 20) fallback = current;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return fallback || document.body;
|
||||
}
|
||||
|
||||
function contextForElement(element) {
|
||||
const container = meaningfulContainerFor(element);
|
||||
if (!container || container === document.body || container === element) return undefined;
|
||||
return {
|
||||
uid: rememberElement(container),
|
||||
tag: container.tagName.toLowerCase(),
|
||||
role: roleOf(container),
|
||||
label: directHeadingText(container) || accessibleLabel(container) || textOf(container, 140),
|
||||
rect: rectSummary(container),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeElement(element, index) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const occluded = occluderAt(cx, cy, element);
|
||||
const role = roleOf(element);
|
||||
const disabled = Boolean(element.disabled || element.getAttribute("aria-disabled") === "true");
|
||||
const rawValue = "value" in element && typeof element.value === "string" ? element.value : undefined;
|
||||
const sensitive = isSensitiveField(element);
|
||||
const value = rawValue && !sensitive ? rawValue.slice(0, 120) : undefined;
|
||||
const checked = "checked" in element ? Boolean(element.checked) : undefined;
|
||||
return {
|
||||
index,
|
||||
uid: rememberElement(element),
|
||||
tag: element.tagName.toLowerCase(),
|
||||
role,
|
||||
selector: selectorFor(element),
|
||||
label: accessibleLabel(element),
|
||||
href: element.href || undefined,
|
||||
type: element.getAttribute("type") || undefined,
|
||||
value: value || undefined,
|
||||
hasValue: rawValue ? rawValue.length > 0 : undefined,
|
||||
valueLength: rawValue && sensitive ? rawValue.length : undefined,
|
||||
valueRedacted: sensitive && rawValue ? true : undefined,
|
||||
checked,
|
||||
disabled,
|
||||
inert: Boolean(element.closest?.("[inert]")),
|
||||
pointerEvents: style.pointerEvents,
|
||||
occluded: occluded || undefined,
|
||||
context: contextForElement(element),
|
||||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||||
};
|
||||
}
|
||||
|
||||
function isInViewport(element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.bottom >= 0 && rect.right >= 0 && rect.top <= innerHeight && rect.left <= innerWidth;
|
||||
}
|
||||
|
||||
function formSummaries() {
|
||||
const fields = Array.from(document.querySelectorAll('input, textarea, select, [contenteditable="true"]'))
|
||||
.filter(isElementVisible)
|
||||
.slice(0, 80)
|
||||
.map((element, index) => ({
|
||||
...summarizeElement(element, index),
|
||||
required: Boolean(element.required || element.getAttribute("aria-required") === "true"),
|
||||
invalid: Boolean(element.matches?.(":invalid") || element.getAttribute("aria-invalid") === "true"),
|
||||
autocomplete: element.getAttribute("autocomplete") || undefined,
|
||||
}));
|
||||
const submits = Array.from(document.querySelectorAll('button, input[type="submit"], [role="button"]'))
|
||||
.filter(isElementVisible)
|
||||
.filter((element) => /submit|save|continue|next|send|sign in|log in|create|update|done/i.test(accessibleLabel(element) + " " + (element.getAttribute("type") || "")))
|
||||
.slice(0, 30)
|
||||
.map((element, index) => summarizeElement(element, index));
|
||||
return { fields, submits };
|
||||
}
|
||||
|
||||
function pageMap() {
|
||||
const landmarkSelectors = [
|
||||
["header", 'header, [role="banner"]'],
|
||||
["nav", 'nav, [role="navigation"]'],
|
||||
["main", 'main, [role="main"]'],
|
||||
["aside", 'aside, [role="complementary"]'],
|
||||
["footer", 'footer, [role="contentinfo"]'],
|
||||
["dialog", 'dialog, [role="dialog"], [aria-modal="true"]'],
|
||||
["form", "form"],
|
||||
];
|
||||
const regions = [];
|
||||
for (const [kind, selector] of landmarkSelectors) {
|
||||
for (const element of Array.from(document.querySelectorAll(selector)).filter(isElementVisible).slice(0, 12)) {
|
||||
const headings = Array.from(element.querySelectorAll("h1,h2,h3,[role='heading']")).filter(isElementVisible).slice(0, 6).map((h) => textOf(h, 120));
|
||||
const actions = Array.from(element.querySelectorAll('a, button, input, textarea, select, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])')).filter(isElementVisible).slice(0, 8).map((a) => {
|
||||
const summary = summarizeElement(a, 0);
|
||||
return { uid: summary.uid, role: summary.role, label: summary.label || summary.selector, disabled: summary.disabled || undefined };
|
||||
});
|
||||
regions.push({ kind, uid: rememberElement(element), label: accessibleLabel(element) || headings[0] || textOf(element, 100), headings, actions });
|
||||
}
|
||||
}
|
||||
const headings = Array.from(document.querySelectorAll("h1,h2,h3,[role='heading']")).filter(isElementVisible).slice(0, 30).map((element) => ({
|
||||
uid: rememberElement(element),
|
||||
level: Number(element.tagName?.slice(1)) || Number(element.getAttribute("aria-level")) || undefined,
|
||||
text: textOf(element, 180),
|
||||
}));
|
||||
return { regions, headings };
|
||||
}
|
||||
|
||||
function layoutSections(elements, forms) {
|
||||
const byUid = new Map();
|
||||
const addToSection = (summary, kind) => {
|
||||
const source = getPiChromeState().elements[summary.uid];
|
||||
const container = source ? meaningfulContainerFor(source) : null;
|
||||
if (!container || container === document.body) return;
|
||||
const uid = rememberElement(container);
|
||||
let section = byUid.get(uid);
|
||||
if (!section) {
|
||||
const rect = rectSummary(container);
|
||||
section = {
|
||||
uid,
|
||||
tag: container.tagName.toLowerCase(),
|
||||
role: roleOf(container),
|
||||
label: directHeadingText(container) || accessibleLabel(container) || textOf(container, 160),
|
||||
text: textOf(container, 260),
|
||||
rect,
|
||||
actions: [],
|
||||
fields: [],
|
||||
};
|
||||
byUid.set(uid, section);
|
||||
}
|
||||
const item = { uid: summary.uid, role: summary.role, label: summary.label || summary.selector, disabled: summary.disabled || undefined };
|
||||
if (kind === "field") section.fields.push(item);
|
||||
else section.actions.push(item);
|
||||
};
|
||||
for (const el of (elements || []).slice(0, 80)) addToSection(el, ["textbox", "checkbox", "radio", "combobox"].includes(el.role) ? "field" : "action");
|
||||
for (const field of (forms?.fields || []).slice(0, 80)) addToSection(field, "field");
|
||||
const sections = Array.from(byUid.values())
|
||||
.filter((section) => section.actions.length || section.fields.length)
|
||||
.sort((a, b) => a.rect.y - b.rect.y || a.rect.x - b.rect.x)
|
||||
.slice(0, 18);
|
||||
for (const section of sections) {
|
||||
section.actions = section.actions.slice(0, 10);
|
||||
section.fields = section.fields.slice(0, 10);
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
function tokenScore(haystack, query) {
|
||||
if (!query) return 0;
|
||||
const hay = String(haystack || "").toLowerCase();
|
||||
const tokens = String(query).toLowerCase().split(/\W+/).filter(Boolean);
|
||||
if (!tokens.length) return 0;
|
||||
let score = 0;
|
||||
for (const token of tokens) {
|
||||
if (hay.includes(token)) score += token.length <= 2 ? 1 : 3;
|
||||
}
|
||||
if (hay.includes(String(query).toLowerCase())) score += 8;
|
||||
return score;
|
||||
}
|
||||
|
||||
function queryMatches(query, elements, map) {
|
||||
if (!query) return [];
|
||||
const candidates = [];
|
||||
for (const element of elements) {
|
||||
const hay = [element.role, element.label, element.selector, element.type, element.href].filter(Boolean).join(" ");
|
||||
const score = tokenScore(hay, query);
|
||||
if (score > 0) candidates.push({ score, kind: "element", ...element });
|
||||
}
|
||||
const textNodes = [];
|
||||
for (const block of Array.from(document.querySelectorAll("h1,h2,h3,h4,p,li,td,th,label,summary,[role='alert']")).filter(isElementVisible).slice(0, 300)) {
|
||||
const text = textOf(block, 300);
|
||||
const score = tokenScore(text, query);
|
||||
if (score > 0) textNodes.push({ score, kind: "text", uid: rememberElement(block), tag: block.tagName.toLowerCase(), role: roleOf(block), text, rect: rectSummary(block) });
|
||||
}
|
||||
for (const region of map.regions || []) {
|
||||
const score = tokenScore([region.kind, region.label, ...(region.headings || [])].join(" "), query);
|
||||
if (score > 0) candidates.push({ score, kind: "region", ...region });
|
||||
}
|
||||
return candidates.concat(textNodes).sort((a, b) => b.score - a.score).slice(0, 20);
|
||||
}
|
||||
|
||||
function rectSummary(element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) };
|
||||
}
|
||||
|
||||
function activeElementSummary() {
|
||||
const el = document.activeElement;
|
||||
if (!el || el === document.body || el === document.documentElement) return null;
|
||||
return summarizeElement(el, 0);
|
||||
}
|
||||
|
||||
function modalSummary() {
|
||||
const selectors = 'dialog[open], [role="dialog"], [aria-modal="true"], [role="alertdialog"]';
|
||||
const modal = Array.from(document.querySelectorAll(selectors)).find(isElementVisible);
|
||||
if (!modal) return null;
|
||||
return { uid: rememberElement(modal), tag: modal.tagName.toLowerCase(), role: roleOf(modal), label: accessibleLabel(modal) || textOf(modal, 180), rect: rectSummary(modal) };
|
||||
}
|
||||
|
||||
function digestFor(snapshot) {
|
||||
return {
|
||||
url: snapshot.url,
|
||||
title: snapshot.title,
|
||||
textHash: hashString(snapshot.text || ""),
|
||||
focusedUid: snapshot.focused?.uid || null,
|
||||
modalUid: snapshot.modal?.uid || null,
|
||||
labels: (snapshot.elements || []).slice(0, 50).map((el) => ({ uid: el.uid, role: el.role, label: el.label, disabled: el.disabled, value: el.value, checked: el.checked })),
|
||||
};
|
||||
}
|
||||
|
||||
function diffSnapshot(previous, current) {
|
||||
if (!previous) return { firstSnapshot: true };
|
||||
const changes = [];
|
||||
if (previous.url !== current.url) changes.push({ kind: "url", before: previous.url, after: current.url });
|
||||
if (previous.title !== current.title) changes.push({ kind: "title", before: previous.title, after: current.title });
|
||||
if (previous.textHash !== current.textHash) changes.push({ kind: "textChanged" });
|
||||
if (previous.focusedUid !== current.focusedUid) changes.push({ kind: "focus", before: previous.focusedUid, after: current.focusedUid });
|
||||
if (previous.modalUid !== current.modalUid) changes.push({ kind: "modal", before: previous.modalUid, after: current.modalUid });
|
||||
const prevByUid = new Map((previous.labels || []).map((x) => [x.uid, x]));
|
||||
const curByUid = new Map((current.labels || []).map((x) => [x.uid, x]));
|
||||
const added = [];
|
||||
const removed = [];
|
||||
const updated = [];
|
||||
for (const cur of current.labels || []) {
|
||||
const prev = prevByUid.get(cur.uid);
|
||||
if (!prev) added.push(cur);
|
||||
else if (prev.label !== cur.label || prev.disabled !== cur.disabled || prev.value !== cur.value || prev.checked !== cur.checked) updated.push({ uid: cur.uid, before: prev, after: cur });
|
||||
}
|
||||
for (const prev of previous.labels || []) {
|
||||
if (!curByUid.has(prev.uid)) removed.push(prev);
|
||||
}
|
||||
return { changes, added: added.slice(0, 12), removed: removed.slice(0, 12), updated: updated.slice(0, 12) };
|
||||
}
|
||||
|
||||
function visibleTextSnippets(maxChars) {
|
||||
const snippets = [];
|
||||
const blocks = Array.from(document.querySelectorAll("h1,h2,h3,h4,p,li,td,th,label,summary,[role='alert']")).filter(isElementVisible);
|
||||
let used = 0;
|
||||
for (const block of blocks) {
|
||||
if (!isInViewport(block) && snippets.length > 12) continue;
|
||||
const text = textOf(block, 500);
|
||||
if (!text || snippets.some((s) => s.text === text)) continue;
|
||||
const next = { uid: rememberElement(block), tag: block.tagName.toLowerCase(), text, rect: rectSummary(block) };
|
||||
snippets.push(next);
|
||||
used += text.length;
|
||||
if (used >= maxChars || snippets.length >= 40) break;
|
||||
}
|
||||
return snippets;
|
||||
}
|
||||
|
||||
function snapshotPage(maxElements, containingText, roleFilter, nearUid, mode, query, maxTextChars) {
|
||||
installPiChromeInstrumentation();
|
||||
mode = ["auto", "interactive", "forms", "pageMap", "text", "changes", "full"].includes(mode) ? mode : "auto";
|
||||
const fullTextLimit = Number(maxTextChars || (mode === "full" ? 30000 : mode === "text" ? 18000 : 6000));
|
||||
let candidates = Array.from(document.querySelectorAll('a, button, input, textarea, select, summary, [role="button"], [role="link"], [role="menuitem"], [role="tab"], [role="checkbox"], [contenteditable="true"], [tabindex]:not([tabindex="-1"])'));
|
||||
if (containingText) {
|
||||
const needle = String(containingText).toLowerCase();
|
||||
candidates = candidates.filter((element) => accessibleLabel(element).toLowerCase().includes(needle));
|
||||
}
|
||||
if (roleFilter) {
|
||||
const wanted = String(roleFilter).toLowerCase();
|
||||
candidates = candidates.filter((element) => roleOf(element) === wanted || element.tagName.toLowerCase() === wanted);
|
||||
}
|
||||
let near;
|
||||
if (nearUid) near = getPiChromeState().elements[nearUid];
|
||||
if (near) {
|
||||
const nearRect = near.getBoundingClientRect();
|
||||
const cx = nearRect.left + nearRect.width / 2;
|
||||
const cy = nearRect.top + nearRect.height / 2;
|
||||
candidates.sort((a, b) => {
|
||||
const ra = a.getBoundingClientRect();
|
||||
const rb = b.getBoundingClientRect();
|
||||
const da = Math.hypot(ra.left + ra.width / 2 - cx, ra.top + ra.height / 2 - cy);
|
||||
const db = Math.hypot(rb.left + rb.width / 2 - cx, rb.top + rb.height / 2 - cy);
|
||||
return da - db;
|
||||
});
|
||||
} else {
|
||||
candidates.sort((a, b) => {
|
||||
const ar = a.getBoundingClientRect();
|
||||
const br = b.getBoundingClientRect();
|
||||
const avis = isInViewport(a) ? 0 : 1;
|
||||
const bvis = isInViewport(b) ? 0 : 1;
|
||||
return avis - bvis || ar.top - br.top || ar.left - br.left;
|
||||
});
|
||||
}
|
||||
const visibleCandidates = candidates.filter(isElementVisible);
|
||||
const elements = visibleCandidates.slice(0, maxElements).map((element, index) => summarizeElement(element, index));
|
||||
const queryElements = query
|
||||
? visibleCandidates.slice(0, Math.max(maxElements, 500)).map((element, index) => summarizeElement(element, index))
|
||||
: elements;
|
||||
const map = pageMap();
|
||||
const forms = formSummaries();
|
||||
const layout = layoutSections(elements, forms);
|
||||
const focused = activeElementSummary();
|
||||
const modal = modalSummary();
|
||||
const bodyText = document.body ? document.body.innerText.replace(/\s+\n/g, "\n").trim() : "";
|
||||
const text = bodyText.slice(0, fullTextLimit);
|
||||
const snapshot = {
|
||||
title: document.title,
|
||||
url: location.href,
|
||||
mode,
|
||||
query: query || undefined,
|
||||
viewport: { width: innerWidth, height: innerHeight, scrollX, scrollY },
|
||||
summary: {
|
||||
visibleText: textOf(document.body, 500),
|
||||
visibleInteractiveCount: elements.filter((el) => el.rect.y >= 0 && el.rect.y <= innerHeight).length,
|
||||
totalInteractiveSampled: elements.length,
|
||||
totalInteractiveVisible: visibleCandidates.length,
|
||||
focused: focused ? { uid: focused.uid, role: focused.role, label: focused.label } : undefined,
|
||||
modal: modal ? { uid: modal.uid, label: modal.label } : undefined,
|
||||
hints: [],
|
||||
},
|
||||
focused: focused || undefined,
|
||||
modal: modal || undefined,
|
||||
text,
|
||||
textTruncated: bodyText.length > text.length,
|
||||
textSnippets: visibleTextSnippets(mode === "text" ? 12000 : 3000),
|
||||
elements,
|
||||
forms,
|
||||
layout,
|
||||
pageMap: map,
|
||||
matches: queryMatches(query, queryElements, map),
|
||||
filter: { containingText: containingText || undefined, roleFilter: roleFilter || undefined, nearUid: nearUid || undefined },
|
||||
};
|
||||
if (snapshot.modal) snapshot.summary.hints.push("A modal/dialog is visible; interact with it before the underlying page.");
|
||||
const disabledImportant = elements.find((el) => el.disabled && /submit|save|merge|continue|next|send|approve|login|sign in/i.test(el.label || ""));
|
||||
if (disabledImportant) snapshot.summary.hints.push(`${disabledImportant.uid} '${disabledImportant.label}' is disabled.`);
|
||||
const occluded = elements.find((el) => el.occluded);
|
||||
if (occluded) snapshot.summary.hints.push(`${occluded.uid} '${occluded.label || occluded.role}' appears occluded by ${occluded.occluded.tag}.`);
|
||||
|
||||
const state = getPiChromeState();
|
||||
const currentDigest = digestFor(snapshot);
|
||||
snapshot.diff = diffSnapshot(state.lastSnapshotDigest, currentDigest);
|
||||
state.lastSnapshotDigest = currentDigest;
|
||||
|
||||
if (mode === "interactive") {
|
||||
delete snapshot.text;
|
||||
delete snapshot.textSnippets;
|
||||
delete snapshot.pageMap;
|
||||
} else if (mode === "forms") {
|
||||
delete snapshot.text;
|
||||
delete snapshot.textSnippets;
|
||||
snapshot.elements = elements.filter((el) => ["textbox", "checkbox", "radio", "combobox", "button"].includes(el.role));
|
||||
} else if (mode === "pageMap") {
|
||||
delete snapshot.text;
|
||||
delete snapshot.textSnippets;
|
||||
snapshot.elements = elements.slice(0, 20);
|
||||
} else if (mode === "changes") {
|
||||
delete snapshot.text;
|
||||
delete snapshot.textSnippets;
|
||||
delete snapshot.elements;
|
||||
delete snapshot.forms;
|
||||
delete snapshot.layout;
|
||||
delete snapshot.pageMap;
|
||||
} else if (mode === "text") {
|
||||
snapshot.elements = elements.slice(0, 20);
|
||||
} else if (mode !== "full") {
|
||||
snapshot.elements = elements.slice(0, Math.min(maxElements, 40));
|
||||
snapshot.text = text.slice(0, Math.min(text.length, 6000));
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function inspectTarget(uid, selector, shouldScrollIntoView) {
|
||||
installPiChromeInstrumentation();
|
||||
const state = getPiChromeState();
|
||||
let element = null;
|
||||
if (uid) element = state.elements[uid];
|
||||
if (!element && selector) element = document.querySelector(selector);
|
||||
if (!element || !element.isConnected) throw new Error(uid ? `No live element for uid: ${uid}. Take a fresh chrome_snapshot.` : `No element matches selector: ${selector}`);
|
||||
if (shouldScrollIntoView) element.scrollIntoView?.({ block: "center", inline: "center", behavior: "instant" });
|
||||
const summary = summarizeElement(element, 0);
|
||||
const ancestors = [];
|
||||
let current = element.parentElement;
|
||||
while (current && current !== document.body && ancestors.length < 6) {
|
||||
ancestors.push({ uid: rememberElement(current), tag: current.tagName.toLowerCase(), role: roleOf(current), label: accessibleLabel(current) || textOf(current, 100), selector: selectorFor(current) });
|
||||
current = current.parentElement;
|
||||
}
|
||||
const container = element.closest?.('form, dialog, [role="dialog"], [aria-modal="true"], section, article, main, aside') || element.parentElement || document.body;
|
||||
const nearbyText = Array.from(container.querySelectorAll("h1,h2,h3,h4,p,li,label,[role='alert']"))
|
||||
.filter(isElementVisible)
|
||||
.slice(0, 24)
|
||||
.map((node) => ({ uid: rememberElement(node), tag: node.tagName.toLowerCase(), text: textOf(node, 240), rect: rectSummary(node) }))
|
||||
.filter((entry) => entry.text);
|
||||
const nearbyActions = Array.from(container.querySelectorAll('a, button, input, textarea, select, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])'))
|
||||
.filter(isElementVisible)
|
||||
.slice(0, 30)
|
||||
.map((node, index) => summarizeElement(node, index));
|
||||
const form = element.closest?.("form");
|
||||
const formContext = form ? {
|
||||
uid: rememberElement(form),
|
||||
label: accessibleLabel(form) || textOf(form, 160),
|
||||
fields: Array.from(form.querySelectorAll('input, textarea, select, [contenteditable="true"]')).filter(isElementVisible).slice(0, 30).map((node, index) => summarizeElement(node, index)),
|
||||
actions: Array.from(form.querySelectorAll('button, input[type="submit"], [role="button"]')).filter(isElementVisible).slice(0, 12).map((node, index) => summarizeElement(node, index)),
|
||||
} : undefined;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const center = { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) };
|
||||
const clickSuggestion = summary.disabled || summary.inert || summary.pointerEvents === "none"
|
||||
? undefined
|
||||
: { uid: summary.uid, x: center.x, y: center.y };
|
||||
return { target: summary, ancestors, nearbyText, nearbyActions, formContext, clickSuggestion };
|
||||
}
|
||||
|
||||
globalThis.__piChromeSnapshotPage = snapshotPage;
|
||||
globalThis.__piChromeInspectTarget = inspectTarget;
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "pi-chrome",
|
||||
"version": "0.15.46",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-chrome",
|
||||
"version": "0.15.46",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"jiti": "2.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"typebox": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@earendil-works/pi-coding-agent": {
|
||||
"optional": true
|
||||
},
|
||||
"typebox": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"name": "pi-chrome",
|
||||
"version": "0.15.46",
|
||||
"scripts": {
|
||||
"test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && jiti test/session-snapshot-store.test.ts",
|
||||
"version": "node scripts/sync-manifest-version.js",
|
||||
"prepublishOnly": "node scripts/sync-manifest-version.js"
|
||||
},
|
||||
"description": "Let Pi use your existing signed-in Chrome profile after explicit authorization.",
|
||||
"keywords": [
|
||||
"pi",
|
||||
"pi-package",
|
||||
"pi-extension",
|
||||
"pi-agent",
|
||||
"chrome",
|
||||
"chrome-extension",
|
||||
"browser",
|
||||
"browser-automation",
|
||||
"browser-control",
|
||||
"web-automation",
|
||||
"agent",
|
||||
"ai-agent",
|
||||
"llm-agent",
|
||||
"llm-tools",
|
||||
"agentic",
|
||||
"playwright-alternative",
|
||||
"puppeteer-alternative",
|
||||
"selenium-alternative",
|
||||
"cdp-alternative",
|
||||
"authenticated-session",
|
||||
"real-profile",
|
||||
"web-debugging",
|
||||
"web-scraping",
|
||||
"screenshot",
|
||||
"automation-tools",
|
||||
"browser-use",
|
||||
"stagehand-alternative"
|
||||
],
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "tianrendong",
|
||||
"company": "Earendil Inc."
|
||||
},
|
||||
"homepage": "https://github.com/tianrendong/pi-chrome#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tianrendong/pi-chrome.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/tianrendong/pi-chrome/issues"
|
||||
},
|
||||
"type": "commonjs",
|
||||
"files": [
|
||||
"extensions",
|
||||
"src",
|
||||
"test",
|
||||
"docs",
|
||||
"test-suite",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"CONTRIBUTING.md",
|
||||
"SECURITY.md",
|
||||
"LICENSE",
|
||||
"UPSTREAM.md"
|
||||
],
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./extensions/chrome-profile-bridge/index.ts"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"jiti": "2.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"typebox": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@earendil-works/pi-coding-agent": {
|
||||
"optional": true
|
||||
},
|
||||
"typebox": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
// Sync browser-extension/manifest.json version from package.json.
|
||||
// Run automatically by the `version` npm lifecycle hook (npm version <bump>),
|
||||
// and pre-publish, so the Chrome extension version never drifts from the npm
|
||||
// package version.
|
||||
const { readFileSync, writeFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
const pkgPath = join(__dirname, "..", "package.json");
|
||||
const manifestPath = join(
|
||||
__dirname,
|
||||
"..",
|
||||
"extensions",
|
||||
"chrome-profile-bridge",
|
||||
"browser-extension",
|
||||
"manifest.json",
|
||||
);
|
||||
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
|
||||
if (manifest.version === pkg.version) {
|
||||
console.log(`manifest.json already at ${pkg.version}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const prev = manifest.version;
|
||||
manifest.version = pkg.version;
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
||||
console.log(`manifest.json ${prev} -> ${pkg.version}`);
|
||||
|
||||
// Stage so `npm version` includes manifest bump in the version commit.
|
||||
try {
|
||||
require("node:child_process").execSync(`git add "${manifestPath}"`, { stdio: "ignore" });
|
||||
} catch {
|
||||
/* not in a git checkout - ok */
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { join, relative, resolve, sep } from "node:path";
|
||||
|
||||
type SnapshotObject = Record<string, unknown>;
|
||||
|
||||
export type SnapshotTarget = {
|
||||
targetId?: string;
|
||||
urlIncludes?: string;
|
||||
titleIncludes?: string;
|
||||
};
|
||||
|
||||
export type SnapshotCaptureMetadata = {
|
||||
version: 1;
|
||||
captureId: string;
|
||||
capturedAt: string;
|
||||
path: string;
|
||||
bytes: number;
|
||||
targetKey: string;
|
||||
targetId?: string | number;
|
||||
mode?: string;
|
||||
elementCount: number;
|
||||
matchCount: number;
|
||||
staleAfterInteraction: true;
|
||||
};
|
||||
|
||||
export type StoredSnapshotCapture = {
|
||||
metadata: SnapshotCaptureMetadata;
|
||||
absolutePath: string;
|
||||
};
|
||||
|
||||
export type SnapshotCaptureFile = {
|
||||
version: 1;
|
||||
captureId: string;
|
||||
capturedAt: string;
|
||||
targetKey: string;
|
||||
snapshot: unknown;
|
||||
};
|
||||
|
||||
function hashSegment(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 20);
|
||||
}
|
||||
|
||||
function normalizeRelativePath(path: string): string {
|
||||
return path.split(sep).join("/");
|
||||
}
|
||||
|
||||
function snapshotObject(snapshot: unknown): SnapshotObject | undefined {
|
||||
return snapshot && typeof snapshot === "object" && !Array.isArray(snapshot)
|
||||
? snapshot as SnapshotObject
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function snapshotTargetKey(target: SnapshotTarget = {}): string {
|
||||
if (target.targetId) return `target:${target.targetId}`;
|
||||
if (target.urlIncludes) return `url:${target.urlIncludes}`;
|
||||
if (target.titleIncludes) return `title:${target.titleIncludes}`;
|
||||
return "default";
|
||||
}
|
||||
|
||||
export class SessionSnapshotStore {
|
||||
readonly workspaceRoot: string;
|
||||
readonly sessionRoot: string;
|
||||
readonly sessionHash: string;
|
||||
|
||||
constructor(workspaceRoot: string, sessionId: string) {
|
||||
this.workspaceRoot = resolve(workspaceRoot);
|
||||
this.sessionHash = hashSegment(sessionId || "unknown-session");
|
||||
this.sessionRoot = join(this.workspaceRoot, ".pi", "chrome-context", this.sessionHash);
|
||||
}
|
||||
|
||||
async save(snapshot: unknown, target: SnapshotTarget = {}): Promise<StoredSnapshotCapture> {
|
||||
const targetKey = snapshotTargetKey(target);
|
||||
const targetDirectory = join(this.sessionRoot, hashSegment(targetKey));
|
||||
const absolutePath = join(targetDirectory, "snapshot.json");
|
||||
const temporaryPath = join(targetDirectory, `.snapshot.${process.pid}.${randomUUID()}.tmp`);
|
||||
const captureId = randomUUID();
|
||||
const capturedAt = new Date().toISOString();
|
||||
const capture: SnapshotCaptureFile = {
|
||||
version: 1,
|
||||
captureId,
|
||||
capturedAt,
|
||||
targetKey,
|
||||
snapshot,
|
||||
};
|
||||
const serialized = `${JSON.stringify(capture, null, 2)}\n`;
|
||||
|
||||
await mkdir(targetDirectory, { recursive: true, mode: 0o700 });
|
||||
await chmod(this.sessionRoot, 0o700).catch(() => undefined);
|
||||
await chmod(targetDirectory, 0o700).catch(() => undefined);
|
||||
try {
|
||||
await writeFile(temporaryPath, serialized, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
await chmod(temporaryPath, 0o600).catch(() => undefined);
|
||||
await rename(temporaryPath, absolutePath);
|
||||
await chmod(absolutePath, 0o600).catch(() => undefined);
|
||||
} catch (error) {
|
||||
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const object = snapshotObject(snapshot);
|
||||
const elements = Array.isArray(object?.elements) ? object.elements : [];
|
||||
const matches = Array.isArray(object?.matches) ? object.matches : [];
|
||||
const targetId = typeof object?.targetId === "string" || typeof object?.targetId === "number"
|
||||
? object.targetId
|
||||
: undefined;
|
||||
const mode = typeof object?.mode === "string" ? object.mode : undefined;
|
||||
const path = normalizeRelativePath(relative(this.workspaceRoot, absolutePath));
|
||||
|
||||
return {
|
||||
absolutePath,
|
||||
metadata: {
|
||||
version: 1,
|
||||
captureId,
|
||||
capturedAt,
|
||||
path,
|
||||
bytes: Buffer.byteLength(serialized),
|
||||
targetKey,
|
||||
targetId,
|
||||
mode,
|
||||
elementCount: elements.length,
|
||||
matchCount: matches.length,
|
||||
staleAfterInteraction: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async invalidate(target?: SnapshotTarget): Promise<void> {
|
||||
if (!target) {
|
||||
await this.clear();
|
||||
return;
|
||||
}
|
||||
const targetDirectory = join(this.sessionRoot, hashSegment(snapshotTargetKey(target)));
|
||||
await rm(targetDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await rm(this.sessionRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
# pi-chrome browser-control benchmark
|
||||
|
||||
Static benchmark pages for evaluating tools that let agents control Chrome. The suite has two layers:
|
||||
|
||||
1. **Unit challenges** (`manifest.json`) — MiniWoB-style capability probes for
|
||||
forms, scroll containers, contenteditable, files, frames, Shadow DOM,
|
||||
network/console inspection, `isTrusted`, user activation, pointer paths, key
|
||||
cadence, native controls, drag/drop, touch, paste, and scroll momentum.
|
||||
2. **Long-horizon hermetic tasks** (`task-manifest.json`) — WebArena /
|
||||
BrowserGym-inspired multi-step tasks with fresh run IDs and deterministic
|
||||
programmatic graders.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd test-suite
|
||||
python3 -m http.server 8765
|
||||
# open http://127.0.0.1:8765/ in the Chrome window pi-chrome controls
|
||||
```
|
||||
|
||||
Each challenge page exposes:
|
||||
|
||||
- `window.__challenge` — id
|
||||
- `window.__verdict` — `"PENDING" | "PASS" | "FAIL" | "SKIP" | "WARN"`
|
||||
- `window.__reason` — array of reasons
|
||||
- `window.__events` — raw event log for forensics
|
||||
|
||||
`manifest.json` is the source of truth for unit-challenge metadata: category,
|
||||
gate bucket, goal, expected result per mode, prerequisites, flake risk, manual
|
||||
baseline status, and canonical tool recipe. `manifest.schema.json` documents the
|
||||
manifest shape. Recipes express tool intent; runners may need to adapt
|
||||
descriptive selectors (e.g. shadow/iframe notation), dynamic tab ids, and expand
|
||||
path placeholders like `$PWD`.
|
||||
|
||||
`task-manifest.json` is the source of truth for long-horizon tasks: BrowserGym-style
|
||||
`taskId`, seed, viewport, goal object, difficulty tier, max steps, declared
|
||||
action subsets, reset/setup URL, validate hook, optional cheat recipe, and
|
||||
programmatic grader expression. `task-manifest.schema.json` documents this shape.
|
||||
`browsergym-action-space.json` records BrowserGym-compatible action subsets.
|
||||
|
||||
## Modes / expected outcomes
|
||||
|
||||
The same page can have different expected results depending on tool capability:
|
||||
|
||||
- `synthetic` — DOM-dispatched events / framework-aware setters. Fast and quiet.
|
||||
- `trusted` — browser-trusted input, usually via `chrome.debugger`/CDP. Can show
|
||||
Chrome's debugging banner.
|
||||
- `manual` — human baseline in same browser/profile.
|
||||
|
||||
Expected values in `manifest.json`:
|
||||
|
||||
- `PASS` / `FAIL` — deterministic target for that mode.
|
||||
- `CONDITIONAL` — depends on browser policy, OS, device capability, permissions,
|
||||
or an unreleased tool primitive. Inspect `prerequisites`, `notes`, and
|
||||
`flakeRisk`.
|
||||
|
||||
Manual baselines are tracked separately with `manualBaseline`. `unverified`
|
||||
means the manual expectation is a target, not a recorded contract.
|
||||
|
||||
## Gate buckets
|
||||
|
||||
Each unit challenge has a `gate` field:
|
||||
|
||||
- `core` — required release blocker for normal trusted-mode pi-chrome shipping.
|
||||
- `conditional` — blocks only when declared prerequisites/capabilities are present
|
||||
(clipboard, touch, dialogs, native UI, etc.).
|
||||
- `quality` — adversarial humanization/fingerprint signal. Track regressions, but
|
||||
do not block general ship without an explicit product decision.
|
||||
|
||||
## Recommended unit-challenge agent flow
|
||||
|
||||
1. Navigate to dashboard:
|
||||
`http://127.0.0.1:8765/`.
|
||||
2. Pick mode (`synthetic`, `trusted`, or `manual`) and clear local verdicts.
|
||||
3. For each manifest row:
|
||||
- `chrome_navigate` to `http://127.0.0.1:8765/<file>`.
|
||||
- `chrome_snapshot` before acting; prefer snapshot `uid` over raw selector.
|
||||
- Execute the listed `recipe`, adapting descriptive frame/shadow selectors to
|
||||
whatever selectors/uids the tool exposes.
|
||||
- Read:
|
||||
```js
|
||||
JSON.stringify({
|
||||
v: window.__verdict,
|
||||
r: window.__reason,
|
||||
e: window.__events?.slice(-20)
|
||||
})
|
||||
```
|
||||
4. Return to dashboard and compare actual verdicts with expected values.
|
||||
5. Copy JSON report from dashboard for PRs or regression notes.
|
||||
|
||||
## Recommended long-horizon task flow
|
||||
|
||||
1. Load `task-manifest.json`.
|
||||
2. Replace `$RUN_ID` in `startUrl` with a fresh value.
|
||||
3. Navigate to the start URL and read the visible task instruction.
|
||||
4. Solve using normal browser tools only; avoid direct state mutation unless the
|
||||
benchmark mode explicitly allows evaluate-based actions.
|
||||
5. Click **Grade now** or evaluate the task grader expression:
|
||||
```js
|
||||
JSON.stringify({ v: window.__taskVerdict, r: window.__taskReason })
|
||||
```
|
||||
6. Record action count, observations used, tools used, verdict, and reason.
|
||||
|
||||
## Design principles copied from browser-agent benchmarks
|
||||
|
||||
- Prefer hermetic sites and deterministic graders over live sites and LLM judges.
|
||||
- Report action API and observation format; these strongly affect scores.
|
||||
- Use difficulty tiers: L1 atomic, L2 compositional, L3 cross-page/context-rich.
|
||||
- Include tedious cross-page memory and exact-value transfer tasks; short unit
|
||||
probes hide these failures.
|
||||
- Keep synthetic-event-gated tests because extension bridges face failures that
|
||||
CDP/Playwright-style benchmarks usually do not measure.
|
||||
|
||||
## Challenge categories
|
||||
|
||||
- `trusted-input` — browser-trusted click/key events.
|
||||
- `pointer-humanization` — paths, coordinates, movement continuity/rate.
|
||||
- `keyboard` / `focus-keyboard` — typing fidelity, modifiers, Tab flows.
|
||||
- `activation-gates` — clipboard/fullscreen/user activation.
|
||||
- `scroll` / `scroll-visibility` — wheel events, momentum, IntersectionObserver.
|
||||
- `drag-drop` — HTML5 drag/drop + `DataTransfer`.
|
||||
- `clipboard` — OS/browser paste path.
|
||||
- `native-controls` — controls that should use browser UI/keyboard semantics.
|
||||
- `frameworks` / `editing` — React-style value tracking, contenteditable.
|
||||
- `dom-complexity` / `frames` — Shadow DOM and iframe targeting.
|
||||
- `files` — file attachment to `<input type=file>`.
|
||||
- `observability` — console/network capture tools.
|
||||
- `csp` — strict Content Security Policy: screenshot/coordinate fallback (39) and the CDP eval/snapshot bypass that works under `script-src 'self'` without `unsafe-eval` (42).
|
||||
- `lazy-loading` — dynamic DOM readiness and wait behavior.
|
||||
- `fingerprint` — environment and stack fingerprint probes.
|
||||
- `agent-safety` — hidden honeypots and safe target selection.
|
||||
|
||||
## Current challenge inventory
|
||||
|
||||
The dashboard renders this from `manifest.json`. In brief:
|
||||
|
||||
1. trusted click
|
||||
2. trusted keyboard
|
||||
3. webdriver/runtime flags
|
||||
4. mouse entropy before click
|
||||
5. click timing
|
||||
6. click coordinate variation
|
||||
7. pointer event properties
|
||||
8. keyboard cadence
|
||||
9. beforeinput/input order
|
||||
10. user activation gates
|
||||
11. honeypot safety
|
||||
12. fingerprint consistency
|
||||
13. focus order
|
||||
14. wheel scroll
|
||||
15. drag/drop `DataTransfer`
|
||||
16. contenteditable selection
|
||||
17. paste clipboard
|
||||
18. native select
|
||||
19. hover dwell
|
||||
20. React value tracker
|
||||
21. keyboard modifiers
|
||||
22. touch events
|
||||
23. stack trace fingerprint
|
||||
24. viewport click coordinates
|
||||
25. pointer continuity
|
||||
26. mousemove rate
|
||||
27. scroll momentum
|
||||
28. intersection visibility
|
||||
29. Shadow DOM controls
|
||||
30. iframe targeting
|
||||
31. file upload
|
||||
32. keyboard Tab navigation
|
||||
33. network/console capture
|
||||
34. dialog handling
|
||||
35. target blank popup
|
||||
36. modal focus trap
|
||||
37. autocomplete combobox
|
||||
38. SPA route change
|
||||
39. strict CSP screenshot/coordinate fallback
|
||||
40. dynamic wait/readiness
|
||||
41. explicit tab lifecycle
|
||||
42. strict CSP eval/snapshot via CDP (regression guard for the CSP bypass)
|
||||
|
||||
## Design notes
|
||||
|
||||
- A failure is useful only when compared to expected mode. Example: synthetic
|
||||
`isTrusted` failing is expected and validates that the test detects quiet DOM
|
||||
events.
|
||||
- Some tests are capability-gated. Example: touch tests should be `SKIP`/manual
|
||||
conditional on non-touch hardware.
|
||||
- Fingerprint tests should warn before blocking. Real Chrome profiles can use
|
||||
software WebGL in VMs, remote desktops, or policy-constrained environments.
|
||||
- `notes/bypass-ideas.md` is historical guidance for older synthetic-only
|
||||
versions. Prefer `manifest.json` for current expected outcomes.
|
||||
- `notes/browsergym-compat.md` defines the reset/step/validate/observation/BID
|
||||
contract for external BrowserGym-style agents.
|
||||
- `notes/runner-spec.md`, `notes/scoring.md`, and `notes/profiles.md` define
|
||||
runner output, scoring, retry policy, and environment metadata.
|
||||
@@ -0,0 +1,130 @@
|
||||
// Tiny shared harness for challenge pages.
|
||||
// Each page calls Challenge.init({id, instructions}) then Challenge.pass()/fail()
|
||||
// based on its own listeners.
|
||||
(function () {
|
||||
const events = [];
|
||||
const state = {
|
||||
id: null,
|
||||
verdict: "PENDING",
|
||||
reason: [],
|
||||
events,
|
||||
details: [],
|
||||
thresholds: {},
|
||||
};
|
||||
|
||||
function render() {
|
||||
const el = document.getElementById("__verdict");
|
||||
if (!el) return;
|
||||
el.textContent = state.verdict;
|
||||
el.dataset.verdict = state.verdict;
|
||||
el.style.background =
|
||||
state.verdict === "PASS" ? "#1f7a1f" :
|
||||
state.verdict === "FAIL" ? "#a11" :
|
||||
state.verdict === "SKIP" ? "#76520b" :
|
||||
state.verdict === "WARN" ? "#6b5d00" : "#444";
|
||||
el.style.color = "#fff";
|
||||
const r = document.getElementById("__reason");
|
||||
if (r) r.textContent = state.reason.join("\n");
|
||||
}
|
||||
|
||||
function log(name, detail) {
|
||||
events.push({ t: performance.now(), name, ...detail });
|
||||
if (events.length > 500) events.shift();
|
||||
}
|
||||
|
||||
const Challenge = {
|
||||
init({ id, instructions, thresholds = {} }) {
|
||||
state.id = id;
|
||||
state.thresholds = parseThresholds(thresholds);
|
||||
document.title = `[${state.verdict}] ${id}`;
|
||||
const root = document.body;
|
||||
const bar = document.createElement("div");
|
||||
bar.style.cssText =
|
||||
"position:sticky;top:0;background:#111;color:#eee;padding:8px 12px;font:13px monospace;border-bottom:1px solid #333;z-index:9999";
|
||||
bar.innerHTML = `
|
||||
<b>${id}</b>
|
||||
<span id="__verdict" style="margin-left:8px;padding:2px 8px;border-radius:4px;background:#444">PENDING</span>
|
||||
<span style="margin-left:12px;opacity:.7">${instructions}</span>
|
||||
<pre id="__reason" style="white-space:pre-wrap;margin:6px 0 0;color:#bbb;font:12px monospace"></pre>
|
||||
`;
|
||||
root.insertBefore(bar, root.firstChild);
|
||||
window.__challenge = id;
|
||||
window.__verdict = state.verdict;
|
||||
window.__reason = state.reason;
|
||||
window.__events = state.events;
|
||||
render();
|
||||
},
|
||||
pass(...reasons) {
|
||||
if (state.verdict === "FAIL") return; // sticky
|
||||
state.verdict = "PASS";
|
||||
state.reason.push(...reasons.map((r) => "✓ " + r));
|
||||
window.__verdict = state.verdict;
|
||||
document.title = `[PASS] ${state.id}`;
|
||||
persist(); render();
|
||||
},
|
||||
fail(...reasons) {
|
||||
state.verdict = "FAIL";
|
||||
state.reason.push(...reasons.map((r) => "✗ " + r));
|
||||
window.__verdict = state.verdict;
|
||||
document.title = `[FAIL] ${state.id}`;
|
||||
persist(); render();
|
||||
},
|
||||
skip(...reasons) {
|
||||
if (state.verdict === "FAIL" || state.verdict === "PASS") return;
|
||||
state.verdict = "SKIP";
|
||||
state.reason.push(...reasons.map((r) => "↷ " + r));
|
||||
window.__verdict = state.verdict;
|
||||
document.title = `[SKIP] ${state.id}`;
|
||||
persist(); render();
|
||||
},
|
||||
warn(...reasons) {
|
||||
if (state.verdict === "FAIL" || state.verdict === "PASS") return;
|
||||
state.verdict = "WARN";
|
||||
state.reason.push(...reasons.map((r) => "! " + r));
|
||||
window.__verdict = state.verdict;
|
||||
document.title = `[WARN] ${state.id}`;
|
||||
persist(); render();
|
||||
},
|
||||
partial({ name, pass, reason, data }) {
|
||||
state.details.push({ name, pass: !!pass, reason: reason || "", data });
|
||||
log("partial", { name, pass: !!pass, reason, data });
|
||||
persist(); render();
|
||||
return !!pass;
|
||||
},
|
||||
finishPartials() {
|
||||
const failed = state.details.filter(d => !d.pass);
|
||||
if (failed.length) Challenge.fail(...failed.map(d => `${d.name}: ${d.reason || "failed"}`));
|
||||
else Challenge.pass(...state.details.map(d => `${d.name}: ok`));
|
||||
},
|
||||
getThreshold(name, fallback) {
|
||||
return Object.prototype.hasOwnProperty.call(state.thresholds, name) ? state.thresholds[name] : fallback;
|
||||
},
|
||||
log,
|
||||
state,
|
||||
};
|
||||
|
||||
function persist() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
"pi-chrome-suite:" + state.id,
|
||||
JSON.stringify({ id: state.id, verdict: state.verdict, reason: state.reason, details: state.details, thresholds: state.thresholds, events: state.events.slice(-50), ts: Date.now() })
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function parseThresholds(defaults) {
|
||||
const out = { ...defaults };
|
||||
try {
|
||||
const qs = new URLSearchParams(location.search);
|
||||
for (const [k, v] of qs) {
|
||||
if (!k.startsWith("threshold.")) continue;
|
||||
const key = k.slice("threshold.".length);
|
||||
const num = Number(v);
|
||||
out[key] = Number.isFinite(num) ? num : v;
|
||||
}
|
||||
} catch {}
|
||||
return out;
|
||||
}
|
||||
|
||||
window.Challenge = Challenge;
|
||||
})();
|
||||
@@ -0,0 +1,31 @@
|
||||
body { font: 14px system-ui, sans-serif; margin: 0; background: #1a1a1a; color: #eee; }
|
||||
main { padding: 24px; max-width: 720px; }
|
||||
code { background:#222;padding:1px 4px;border-radius:3px }
|
||||
a { color: #6cf; }
|
||||
button, input, select { font: 14px system-ui, sans-serif; }
|
||||
button { padding: 7px 11px; border-radius: 6px; border: 1px solid #555; background: #262626; color: #eee; cursor:pointer; }
|
||||
button:hover { background:#333; }
|
||||
input, select { padding: 6px 8px; border-radius: 6px; border: 1px solid #555; background:#111; color:#eee; }
|
||||
table { border-collapse: collapse; width: 100%; font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
th, td { border-bottom: 1px solid #333; padding: 8px; vertical-align: top; text-align:left; }
|
||||
th { position: sticky; top: 0; background:#151515; z-index:1; }
|
||||
tr.ok { background: rgba(31,122,31,.08); }
|
||||
tr.mismatch { background: rgba(170,17,17,.12); }
|
||||
tr.conditional { background: rgba(118,82,11,.12); }
|
||||
.panel { background:#202020; border:1px solid #333; border-radius:10px; padding:12px; margin:16px 0; }
|
||||
.controls { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
|
||||
.hint, .notes, .reason { color:#aaa; font-size:12px; margin-top:4px; }
|
||||
.summary { display:flex; flex-wrap:wrap; gap:8px; margin: 14px 0; }
|
||||
.pill { display:inline-block; border-radius:999px; padding:2px 8px; font-size:12px; font-weight:700; background:#444; color:#fff; }
|
||||
.pass { background:#1f7a1f; }
|
||||
.fail { background:#a11; }
|
||||
.skip, .conditional { background:#76520b; }
|
||||
.warn { background:#6b5d00; }
|
||||
.pending { background:#444; }
|
||||
.expected { background:#2b4b6b; }
|
||||
.risk { color:#ffd479; }
|
||||
.code, pre { background:#111; color:#eee; padding:12px; border-radius:6px; overflow:auto; }
|
||||
.copy-fallback { display:none; width:100%; min-height:180px; margin-top:10px; font:12px ui-monospace, SFMono-Regular, Menlo, monospace; background:#111; color:#eee; border:1px solid #555; border-radius:6px; padding:10px; }
|
||||
.copy-fallback[data-copied="true"] { border-color:#1f7a1f; }
|
||||
.copy-fallback[data-copied="false"] { border-color:#a11; }
|
||||
details summary { cursor:pointer; color:#9cf; }
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"subsets": {
|
||||
"chat": ["send_msg_to_user"],
|
||||
"infeas": ["report_infeasible"],
|
||||
"bid": ["click", "dblclick", "hover", "fill", "clear", "press", "focus", "select_option", "scroll", "drag_and_drop", "upload_file"],
|
||||
"coord": ["mouse_move", "mouse_up", "mouse_down", "mouse_click", "mouse_dblclick", "mouse_drag_and_drop", "mouse_upload_file", "scroll_at", "keyboard_down", "keyboard_up", "keyboard_press", "keyboard_type", "keyboard_insert_text"],
|
||||
"nav": ["go_back", "go_forward", "goto"],
|
||||
"tab": ["tab_close", "tab_focus", "new_tab"]
|
||||
},
|
||||
"signatures": {
|
||||
"fill": "fill(bid, value, enable_autocomplete_menu=false)",
|
||||
"click": "click(bid, button='left', modifiers=[])",
|
||||
"dblclick": "dblclick(bid, button='left', modifiers=[])",
|
||||
"hover": "hover(bid)",
|
||||
"press": "press(bid, key_comb)",
|
||||
"focus": "focus(bid)",
|
||||
"clear": "clear(bid)",
|
||||
"select_option": "select_option(bid, options)",
|
||||
"scroll": "scroll(delta_x, delta_y)",
|
||||
"drag_and_drop": "drag_and_drop(from_bid, to_bid)",
|
||||
"upload_file": "upload_file(bid, file)",
|
||||
"mouse_move": "mouse_move(x, y)",
|
||||
"mouse_click": "mouse_click(x, y, button='left')",
|
||||
"mouse_dblclick": "mouse_dblclick(x, y, button='left')",
|
||||
"mouse_up": "mouse_up(x, y, button='left')",
|
||||
"mouse_down": "mouse_down(x, y, button='left')",
|
||||
"mouse_drag_and_drop": "mouse_drag_and_drop(from_x, from_y, to_x, to_y)",
|
||||
"scroll_at": "scroll_at(x, y, dx, dy)",
|
||||
"keyboard_press": "keyboard_press(key)",
|
||||
"keyboard_down": "keyboard_down(key)",
|
||||
"keyboard_up": "keyboard_up(key)",
|
||||
"keyboard_type": "keyboard_type(text)",
|
||||
"keyboard_insert_text": "keyboard_insert_text(text)",
|
||||
"goto": "goto(url)",
|
||||
"go_back": "go_back()",
|
||||
"go_forward": "go_forward()",
|
||||
"tab_close": "tab_close()",
|
||||
"tab_focus": "tab_focus(index)",
|
||||
"new_tab": "new_tab()",
|
||||
"send_msg_to_user": "send_msg_to_user(text)",
|
||||
"report_infeasible": "report_infeasible(reason)",
|
||||
"noop": "noop(wait_ms=1000)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>01 isTrusted click</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: click the green button. Page only accepts <code>event.isTrusted === true</code>.</p>
|
||||
<button id="go" style="padding:20px 32px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me</button>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({
|
||||
id: "is-trusted-click",
|
||||
instructions: "click the green button; isTrusted must be true",
|
||||
});
|
||||
const btn = document.getElementById("go");
|
||||
btn.addEventListener("click", (e) => {
|
||||
Challenge.log("click", { isTrusted: e.isTrusted, x: e.clientX, y: e.clientY });
|
||||
if (e.isTrusted) Challenge.pass("click.isTrusted === true");
|
||||
else Challenge.fail("click.isTrusted === false (synthetic dispatchEvent)");
|
||||
}, { capture: true });
|
||||
// Also watch pointerdown to detect bot earlier.
|
||||
btn.addEventListener("pointerdown", (e) => {
|
||||
Challenge.log("pointerdown", { isTrusted: e.isTrusted, pressure: e.pressure, pointerType: e.pointerType });
|
||||
if (!e.isTrusted) Challenge.fail("pointerdown.isTrusted === false");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>02 isTrusted keyboard</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: type <code>hello</code> into the box. Each character must arrive via a trusted keydown/keypress/input.</p>
|
||||
<input id="t" placeholder="type hello" style="font-size:18px;padding:8px 12px;width:240px">
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({
|
||||
id: "is-trusted-keyboard",
|
||||
instructions: "type 'hello'; every keydown.isTrusted must be true",
|
||||
});
|
||||
const t = document.getElementById("t");
|
||||
let trustedKeys = 0, untrustedKeys = 0, inputs = 0, untrustedInputs = 0;
|
||||
t.addEventListener("keydown", (e) => {
|
||||
Challenge.log("keydown", { key: e.key, isTrusted: e.isTrusted });
|
||||
if (e.isTrusted) trustedKeys++; else untrustedKeys++;
|
||||
});
|
||||
t.addEventListener("input", (e) => {
|
||||
Challenge.log("input", { isTrusted: e.isTrusted, inputType: e.inputType, data: e.data });
|
||||
inputs++; if (!e.isTrusted) untrustedInputs++;
|
||||
});
|
||||
t.addEventListener("keyup", () => {
|
||||
if (t.value === "hello") {
|
||||
if (untrustedKeys || untrustedInputs) {
|
||||
Challenge.fail(`untrusted keydowns=${untrustedKeys}, untrusted inputs=${untrustedInputs}`);
|
||||
} else if (trustedKeys < 5) {
|
||||
Challenge.fail(`only ${trustedKeys} trusted keydowns (need >=5)`);
|
||||
} else {
|
||||
Challenge.pass(`${trustedKeys} trusted keydowns, ${inputs} trusted inputs`);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Programmatic value-set detection.
|
||||
const desc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value");
|
||||
Object.defineProperty(t, "value", {
|
||||
get() { return desc.get.call(this); },
|
||||
set(v) {
|
||||
Challenge.log("value-set", { v });
|
||||
if (untrustedKeys === 0 && trustedKeys === 0) {
|
||||
Challenge.fail("value set programmatically without any keydown");
|
||||
}
|
||||
return desc.set.call(this, v);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>03 webdriver / runtime fingerprint</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Auto-checks runtime properties commonly inspected by bot-detection scripts.</p>
|
||||
<pre id="rep" style="font:12px monospace;background:#111;color:#eee;padding:12px;border-radius:6px"></pre>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({
|
||||
id: "webdriver-flag",
|
||||
instructions: "no interaction; auto-verdict on load",
|
||||
});
|
||||
const checks = [];
|
||||
function chk(name, bad, note) {
|
||||
checks.push({ name, bad, note });
|
||||
if (bad) Challenge.log("flag", { name, note });
|
||||
}
|
||||
|
||||
chk("navigator.webdriver", navigator.webdriver === true, String(navigator.webdriver));
|
||||
chk("languages-empty", !navigator.languages || navigator.languages.length === 0,
|
||||
JSON.stringify(navigator.languages));
|
||||
chk("plugins-empty", !navigator.plugins || navigator.plugins.length === 0,
|
||||
`length=${navigator.plugins && navigator.plugins.length}`);
|
||||
chk("permissions-notifications-quirk", false, "checked async below");
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const p = await navigator.permissions.query({ name: "notifications" });
|
||||
// Headless Chrome historically returned "denied" while Notification.permission === "default".
|
||||
const mismatch = p.state === "denied" && Notification.permission === "default";
|
||||
checks.push({ name: "permissions-notifications-quirk", bad: mismatch,
|
||||
note: `perm=${p.state} api=${Notification.permission}` });
|
||||
finalize();
|
||||
} catch (e) {
|
||||
checks.push({ name: "permissions-notifications-quirk", bad: false, note: "n/a " + e.message });
|
||||
finalize();
|
||||
}
|
||||
})();
|
||||
|
||||
function finalize() {
|
||||
const rep = document.getElementById("rep");
|
||||
rep.textContent = checks.map(c => `${c.bad ? "✗" : "✓"} ${c.name} ${c.note}`).join("\n");
|
||||
const failed = checks.filter(c => c.bad);
|
||||
if (failed.length) Challenge.fail(...failed.map(c => `${c.name}: ${c.note}`));
|
||||
else Challenge.pass("all runtime checks clean");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>04 mouse entropy</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: click the green button after moving the mouse in an organic path. Page requires
|
||||
≥15 <code>mousemove</code> events with non-zero <code>movementX/Y</code> variance before the click.</p>
|
||||
<button id="go" style="padding:20px 32px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me</button>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({
|
||||
id: "mouse-entropy",
|
||||
instructions: "click the button after natural mouse movement",
|
||||
});
|
||||
const moves = [];
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
moves.push({ x: e.clientX, y: e.clientY, mx: e.movementX, my: e.movementY, t: e.timeStamp });
|
||||
if (moves.length > 200) moves.shift();
|
||||
});
|
||||
document.getElementById("go").addEventListener("click", (e) => {
|
||||
if (moves.length < 15) return Challenge.fail(`only ${moves.length} mousemove events before click`);
|
||||
const dx = moves.map(m => Math.abs(m.mx)).reduce((a,b)=>a+b,0);
|
||||
const dy = moves.map(m => Math.abs(m.my)).reduce((a,b)=>a+b,0);
|
||||
if (dx + dy < 50) return Challenge.fail(`movement deltas tiny: dx=${dx}, dy=${dy}`);
|
||||
// Detect "teleport then click" — all moves clustered at the button.
|
||||
const r = document.getElementById("go").getBoundingClientRect();
|
||||
const inside = moves.filter(m => m.x>=r.left && m.x<=r.right && m.y>=r.top && m.y<=r.bottom).length;
|
||||
if (inside === moves.length) return Challenge.fail("all mousemoves inside target rect (teleport)");
|
||||
Challenge.pass(`${moves.length} moves, |dx|+|dy|=${dx+dy}, ${inside} inside target`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>05 event timing</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: click the button 3 times. Page rejects when <code>pointerdown→pointerup</code> is <30ms
|
||||
or always identical, and when between-click gaps are too uniform.</p>
|
||||
<button id="go" style="padding:20px 32px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me (0/3)</button>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "event-timing", instructions: "click button 3 times with human-like timing" });
|
||||
const btn = document.getElementById("go");
|
||||
const ups = [], downs = [], clickTs = [];
|
||||
btn.addEventListener("pointerdown", (e) => downs.push(e.timeStamp));
|
||||
btn.addEventListener("pointerup", (e) => ups.push(e.timeStamp));
|
||||
btn.addEventListener("click", (e) => {
|
||||
clickTs.push(e.timeStamp);
|
||||
btn.textContent = `Click me (${clickTs.length}/3)`;
|
||||
if (clickTs.length < 3) return;
|
||||
const holds = downs.map((d,i)=> (ups[i]??d) - d);
|
||||
const allSame = holds.every(h => Math.abs(h - holds[0]) < 0.5);
|
||||
const tooFast = holds.some(h => h < 30);
|
||||
const gaps = clickTs.slice(1).map((t,i)=> t - clickTs[i]);
|
||||
const gapsUniform = gaps.length > 1 && Math.abs(gaps[0]-gaps[1]) < 5;
|
||||
Challenge.log("timing", { holds, gaps });
|
||||
if (tooFast) return Challenge.fail(`pointer hold too short: ${holds.join(",")}ms`);
|
||||
if (allSame) return Challenge.fail(`pointer holds identical: ${holds.join(",")}ms`);
|
||||
if (gapsUniform) return Challenge.fail(`between-click gaps suspiciously uniform: ${gaps.join(",")}ms`);
|
||||
Challenge.pass(`holds=${holds.map(h=>h.toFixed(1)).join(",")}ms gaps=${gaps.map(g=>g.toFixed(0)).join(",")}ms`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>06 click coordinates</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: click the green button 5 times. Page rejects when clicks always land on the exact element center.</p>
|
||||
<button id="go" style="padding:24px 40px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me (0/5)</button>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "click-coordinates", instructions: "click button 5 times; coordinates must vary" });
|
||||
const btn = document.getElementById("go");
|
||||
const pts = [];
|
||||
btn.addEventListener("click", (e) => {
|
||||
const r = btn.getBoundingClientRect();
|
||||
const cx = r.left + r.width/2, cy = r.top + r.height/2;
|
||||
pts.push({ x: e.clientX, y: e.clientY, dx: e.clientX-cx, dy: e.clientY-cy });
|
||||
btn.textContent = `Click me (${pts.length}/5)`;
|
||||
if (pts.length < 5) return;
|
||||
const onCenter = pts.filter(p => Math.abs(p.dx) < 0.51 && Math.abs(p.dy) < 0.51).length;
|
||||
const unique = new Set(pts.map(p => `${p.x},${p.y}`)).size;
|
||||
Challenge.log("coords", { pts });
|
||||
if (onCenter >= 4) return Challenge.fail(`${onCenter}/5 clicks on exact center`);
|
||||
if (unique <= 1) return Challenge.fail(`only ${unique} unique click coords`);
|
||||
Challenge.pass(`${unique} unique coords, ${onCenter} on center`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>07 pointer properties</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: click the button. Page inspects <code>pointerType</code>, <code>pressure</code>,
|
||||
<code>movementX/Y</code> on the preceding pointermove, and that pointerId is non-zero.</p>
|
||||
<button id="go" style="padding:20px 32px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me</button>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "pointer-properties", instructions: "click button; pointer event details must look real" });
|
||||
const btn = document.getElementById("go");
|
||||
let lastMove = null;
|
||||
window.addEventListener("pointermove", (e) => { lastMove = { mx: e.movementX, my: e.movementY, pid: e.pointerId, type: e.pointerType }; });
|
||||
btn.addEventListener("pointerdown", (e) => {
|
||||
Challenge.log("pointerdown", { type: e.pointerType, pressure: e.pressure, pid: e.pointerId, mx: e.movementX, my: e.movementY });
|
||||
const bad = [];
|
||||
if (e.pointerType !== "mouse" && e.pointerType !== "touch" && e.pointerType !== "pen") bad.push(`pointerType=${e.pointerType}`);
|
||||
if (e.pointerType === "mouse" && e.pressure !== 0.5) bad.push(`mouse pressure=${e.pressure} (real=0.5)`);
|
||||
if (e.pointerId === 0) bad.push("pointerId=0");
|
||||
if (!lastMove) bad.push("no preceding pointermove");
|
||||
else if (lastMove.mx === 0 && lastMove.my === 0) bad.push("preceding pointermove had movementX=movementY=0");
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass(`type=${e.pointerType} pressure=${e.pressure} pid=${e.pointerId}`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>08 keyboard cadence</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: type <code>secret</code>. Page demands per-key keydown+keypress+keyup with non-uniform gaps and non-zero hold time.</p>
|
||||
<input id="t" placeholder="type 'secret'" style="font-size:18px;padding:8px 12px;width:240px">
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "keyboard-cadence", instructions: "type 'secret' with realistic per-key cadence" });
|
||||
const t = document.getElementById("t");
|
||||
const evs = [];
|
||||
["keydown","keypress","keyup","input"].forEach(name => {
|
||||
t.addEventListener(name, (e) => evs.push({ name, key: e.key ?? e.data, t: e.timeStamp, trusted: e.isTrusted }));
|
||||
});
|
||||
t.addEventListener("keyup", () => {
|
||||
if (t.value !== "secret") return;
|
||||
const downs = evs.filter(e => e.name === "keydown");
|
||||
const presses = evs.filter(e => e.name === "keypress");
|
||||
const ups = evs.filter(e => e.name === "keyup");
|
||||
const bad = [];
|
||||
if (downs.length < 6) bad.push(`only ${downs.length} keydowns (need >=6)`);
|
||||
if (presses.length < 6) bad.push(`only ${presses.length} keypress events`);
|
||||
if (ups.length < 6) bad.push(`only ${ups.length} keyups`);
|
||||
const holds = downs.slice(0, ups.length).map((d,i)=> ups[i].t - d.t);
|
||||
if (holds.some(h => h <= 0)) bad.push(`some keyup at-or-before keydown: ${holds.join(",")}`);
|
||||
if (holds.length && holds.every(h => Math.abs(h-holds[0]) < 0.5)) bad.push(`hold times identical: ${holds.join(",")}`);
|
||||
const gaps = downs.slice(1).map((d,i)=> d.t - downs[i].t);
|
||||
if (gaps.length && gaps.every(g => Math.abs(g-gaps[0]) < 0.5)) bad.push(`keydown gaps identical: ${gaps.join(",")}`);
|
||||
Challenge.log("cadence", { holds, gaps });
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass(`holds=${holds.map(h=>h.toFixed(0)).join(",")} gaps=${gaps.map(g=>g.toFixed(0)).join(",")}`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>09 framework input invariants</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: type <code>abc</code>. Page asserts React-style invariants: <code>beforeinput</code>
|
||||
fires <em>before</em> <code>input</code>, both per-character, <code>inputType==="insertText"</code>,
|
||||
value mutates between events, and no <code>compositionstart</code> for plain typing.</p>
|
||||
<input id="t" placeholder="type abc" style="font-size:18px;padding:8px 12px;width:240px">
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "composition-input", instructions: "type 'abc' producing framework-correct event order" });
|
||||
const t = document.getElementById("t");
|
||||
const evs = [];
|
||||
let sawCompositionStart = false;
|
||||
["beforeinput","input","compositionstart","compositionend"].forEach(name => {
|
||||
t.addEventListener(name, (e) => {
|
||||
evs.push({ name, t: e.timeStamp, inputType: e.inputType, data: e.data, value: t.value });
|
||||
if (name === "compositionstart") sawCompositionStart = true;
|
||||
});
|
||||
});
|
||||
t.addEventListener("input", () => {
|
||||
if (t.value !== "abc") return;
|
||||
const before = evs.filter(e => e.name === "beforeinput");
|
||||
const inp = evs.filter(e => e.name === "input");
|
||||
const bad = [];
|
||||
if (sawCompositionStart) bad.push("compositionstart fired for plain ASCII typing");
|
||||
if (before.length !== 3) bad.push(`beforeinput count=${before.length} (need 3)`);
|
||||
if (inp.length !== 3) bad.push(`input count=${inp.length} (need 3)`);
|
||||
if (before.some(b => b.inputType !== "insertText")) bad.push(`beforeinput.inputType=${before.map(b=>b.inputType).join(",")}`);
|
||||
// beforeinput must precede matching input.
|
||||
for (let i = 0; i < Math.min(before.length, inp.length); i++) {
|
||||
if (before[i].t > inp[i].t) bad.push(`beforeinput[${i}] after input[${i}]`);
|
||||
}
|
||||
// Value must reflect each char incrementally.
|
||||
const seq = inp.map(e => e.value).join("|");
|
||||
if (seq !== "a|ab|abc") bad.push(`value seq at input events: ${seq} (need a|ab|abc)`);
|
||||
Challenge.log("seq", { evs });
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass(`event order and invariants OK; seq=${seq}`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>10 user activation gates</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: click the button. Handler then tries gated APIs (clipboard.writeText, fullscreen).
|
||||
Success requires <code>navigator.userActivation.isActive</code> and at least one gated API to succeed.</p>
|
||||
<button id="go" style="padding:20px 32px;font-size:18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me</button>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "user-activation", instructions: "click button; user-activation must unlock gated APIs" });
|
||||
document.getElementById("go").addEventListener("click", async () => {
|
||||
// Capture activation state synchronously before any await (activation can be consumed by gated APIs).
|
||||
const ua = navigator.userActivation;
|
||||
const wasActive = !!ua?.isActive;
|
||||
const hadBeenActive = !!ua?.hasBeenActive;
|
||||
Challenge.log("activation", { isActive: wasActive, hasBeenActive: hadBeenActive });
|
||||
let clip = "skip", fs = "skip";
|
||||
try { await navigator.clipboard.writeText("pi-chrome-test"); clip = "ok"; }
|
||||
catch (e) { clip = "err:" + e.name; }
|
||||
try { await document.documentElement.requestFullscreen(); fs = "ok"; document.exitFullscreen?.(); }
|
||||
catch (e) { fs = "err:" + e.name; }
|
||||
Challenge.log("gates", { clip, fs });
|
||||
if (!wasActive && !hadBeenActive) {
|
||||
return Challenge.fail("userActivation.isActive/hasBeenActive both false (synthetic click)");
|
||||
}
|
||||
const okCount = [clip, fs].filter(x => x === "ok").length;
|
||||
if (okCount === 0) {
|
||||
return Challenge.skip(`activation present, but gated APIs blocked by environment/policy: clipboard=${clip}; fullscreen=${fs}`);
|
||||
}
|
||||
const warnings = [];
|
||||
if (clip.startsWith("err")) warnings.push("clipboard.writeText " + clip);
|
||||
if (fs.startsWith("err")) warnings.push("requestFullscreen " + fs);
|
||||
if (warnings.length) Challenge.pass(`activation active; ${okCount}/2 gates succeeded; ${warnings.join("; ")}`);
|
||||
else Challenge.pass(`activation active; clipboard=${clip}; fullscreen=${fs}`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>11 honeypot fields</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: fill <em>only</em> the visible "Name" field with <code>alex</code> and submit.
|
||||
Three honeypots (a, b, c) are present that a human cannot see/tab to. Filling any honeypot fails.</p>
|
||||
<form id="f">
|
||||
<label>Name <input name="name" id="name"></label>
|
||||
<!-- off-screen -->
|
||||
<input name="a" aria-hidden="true" tabindex="-1" autocomplete="off"
|
||||
style="position:absolute;left:-9999px;top:-9999px">
|
||||
<!-- display:none -->
|
||||
<input name="b" aria-hidden="true" tabindex="-1" autocomplete="off" style="display:none">
|
||||
<!-- zero-size + visually hidden -->
|
||||
<input name="c" aria-hidden="true" tabindex="-1" autocomplete="off"
|
||||
style="opacity:0;width:0;height:0;border:0;padding:0">
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "honeypot", instructions: "fill only the Name field with 'alex', then submit" });
|
||||
const form = document.getElementById("f");
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(form);
|
||||
const filled = ["a","b","c"].filter(k => (fd.get(k) ?? "").toString().length > 0);
|
||||
Challenge.log("submit", { name: fd.get("name"), a: fd.get("a"), b: fd.get("b"), c: fd.get("c") });
|
||||
if (filled.length) return Challenge.fail("honeypot(s) filled: " + filled.join(","));
|
||||
if ((fd.get("name") ?? "") !== "alex") return Challenge.fail(`name="${fd.get("name")}" (need 'alex')`);
|
||||
Challenge.pass("only visible field filled");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,59 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>12 fingerprint consistency</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Auto-check. Looks for cross-API consistency mismatches that betray instrumented Chrome.</p>
|
||||
<pre id="rep" style="font:12px monospace;background:#111;color:#eee;padding:12px;border-radius:6px"></pre>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "fingerprint", instructions: "no interaction" });
|
||||
(async () => {
|
||||
const checks = [];
|
||||
const ua = navigator.userAgent;
|
||||
const isChrome = /Chrome\/\d+/.test(ua);
|
||||
checks.push({ name: "ua-is-chrome", bad: !isChrome, note: ua });
|
||||
|
||||
// UA-CH: Chrome should expose userAgentData with brands incl Chromium.
|
||||
const uad = navigator.userAgentData;
|
||||
const hasUAD = !!uad;
|
||||
checks.push({ name: "userAgentData-present", bad: isChrome && !hasUAD, note: hasUAD ? JSON.stringify(uad.brands) : "missing" });
|
||||
|
||||
// Chrome runtime object exists in normal Chrome.
|
||||
checks.push({ name: "window.chrome", bad: isChrome && typeof window.chrome === "undefined",
|
||||
note: typeof window.chrome });
|
||||
|
||||
// languages must match accept-language semantics.
|
||||
checks.push({ name: "languages-includes-language", bad: !navigator.languages?.includes(navigator.language),
|
||||
note: `${navigator.language} vs ${JSON.stringify(navigator.languages)}` });
|
||||
|
||||
// WebGL vendor/renderer should not be Brian Paul / SwiftShader on user profile.
|
||||
try {
|
||||
const gl = document.createElement("canvas").getContext("webgl");
|
||||
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
|
||||
const vendor = dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR);
|
||||
const renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
|
||||
const swiftshader = /SwiftShader|llvmpipe|Software/i.test(renderer);
|
||||
checks.push({ name: "webgl-not-software", bad: false, warn: swiftshader, note: `${vendor} / ${renderer}${swiftshader ? " (software renderer; warning in VM/remote contexts)" : ""}` });
|
||||
} catch (e) {
|
||||
checks.push({ name: "webgl-not-software", bad: false, note: "n/a " + e.message });
|
||||
}
|
||||
|
||||
// hardwareConcurrency / deviceMemory plausibility.
|
||||
checks.push({ name: "hardwareConcurrency", bad: !(navigator.hardwareConcurrency > 0), note: String(navigator.hardwareConcurrency) });
|
||||
|
||||
// navigator.webdriver again, separate from challenge 03 so it appears here too.
|
||||
checks.push({ name: "webdriver-false", bad: navigator.webdriver === true, note: String(navigator.webdriver) });
|
||||
|
||||
const rep = document.getElementById("rep");
|
||||
rep.textContent = checks.map(c => `${c.bad ? "✗" : c.warn ? "!" : "✓"} ${c.name} ${c.note}`).join("\n");
|
||||
const failed = checks.filter(c => c.bad);
|
||||
const warned = checks.filter(c => c.warn);
|
||||
if (failed.length) Challenge.fail(...failed.map(c => `${c.name}: ${c.note}`));
|
||||
else if (warned.length) Challenge.warn(...warned.map(c => `${c.name}: ${c.note}`));
|
||||
else Challenge.pass("fingerprint consistent with real Chrome");
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>13 focus order</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: focus the input by <em>clicking</em> it (not <code>.focus()</code>), then type <code>x</code>.
|
||||
Pointerdown must precede focus, and <code>:focus-visible</code> must be false for pointer focus.</p>
|
||||
<input id="t" placeholder="click then type x" style="font-size:18px;padding:8px 12px;width:240px">
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "focus-order", instructions: "click input then type 'x'" });
|
||||
const t = document.getElementById("t");
|
||||
let lastPointer = -Infinity, lastFocus = -Infinity, sawPointer = false;
|
||||
t.addEventListener("pointerdown", (e) => { lastPointer = e.timeStamp; sawPointer = true; });
|
||||
t.addEventListener("focus", (e) => {
|
||||
lastFocus = e.timeStamp;
|
||||
if (!sawPointer) Challenge.fail("focus arrived with no preceding pointerdown");
|
||||
else if (lastFocus < lastPointer) Challenge.fail("focus before pointerdown");
|
||||
});
|
||||
t.addEventListener("input", () => {
|
||||
if (t.value !== "x") return;
|
||||
// For pointer-driven focus, :focus-visible should be false.
|
||||
const fv = t.matches(":focus-visible");
|
||||
Challenge.log("focus-visible", { fv });
|
||||
if (fv) Challenge.fail(":focus-visible true after pointer click (looks like keyboard focus)");
|
||||
else if (window.__verdict !== "FAIL") Challenge.pass("pointerdown→focus→input order ok, :focus-visible=false");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>14 wheel scroll</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: scroll the box to the bottom. Page rejects raw <code>scrollTop</code> assignment without
|
||||
<code>wheel</code> events.</p>
|
||||
<div id="box" style="height:200px;overflow:auto;border:1px solid #555;background:#fff;color:#111;padding:8px">
|
||||
<div style="height:1200px">scroll me ⬇<br><br>...lots of content...</div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "wheel-scroll", instructions: "scroll the box to its bottom" });
|
||||
const box = document.getElementById("box");
|
||||
let wheelCount = 0, lastWheelTs = -Infinity;
|
||||
box.addEventListener("wheel", (e) => { wheelCount++; lastWheelTs = e.timeStamp; Challenge.log("wheel", { dy: e.deltaY }); }, { passive: true });
|
||||
box.addEventListener("scroll", () => {
|
||||
Challenge.log("scroll", { top: box.scrollTop });
|
||||
if (box.scrollTop + box.clientHeight >= box.scrollHeight - 2) {
|
||||
if (wheelCount === 0) Challenge.fail("scrolled to bottom with zero wheel events");
|
||||
else if (performance.now() - lastWheelTs > 1500) Challenge.fail("wheel events too far before final scroll");
|
||||
else Challenge.pass(`${wheelCount} wheel events accompanied scroll`);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>15 drag-drop DataTransfer</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: drag the <b>red box</b> into the <b>drop zone</b>. Page asserts a full HTML5
|
||||
drag sequence with a populated <code>DataTransfer</code> — synthetic pointer drags
|
||||
dispatched without <code>dragstart</code>/<code>drop</code> + DataTransfer payload fail.</p>
|
||||
<div style="display:flex;gap:24px;align-items:center;margin-top:16px">
|
||||
<div id="src" draggable="true"
|
||||
style="width:80px;height:80px;background:#c33;color:#fff;display:flex;align-items:center;justify-content:center;border-radius:6px;cursor:grab">
|
||||
DRAG
|
||||
</div>
|
||||
<div id="dst"
|
||||
style="width:200px;height:120px;border:2px dashed #888;display:flex;align-items:center;justify-content:center;color:#888">
|
||||
DROP HERE
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "drag-drop-datatransfer", instructions: "drag red box into drop zone via real HTML5 drag" });
|
||||
const src = document.getElementById("src");
|
||||
const dst = document.getElementById("dst");
|
||||
|
||||
const seen = { dragstart: 0, drag: 0, dragenter: 0, dragover: 0, drop: 0, dragend: 0 };
|
||||
let dtHadTypes = false, dtPayload = null, isTrustedAll = true;
|
||||
|
||||
src.addEventListener("dragstart", (e) => {
|
||||
seen.dragstart++;
|
||||
if (!e.isTrusted) isTrustedAll = false;
|
||||
try { e.dataTransfer.setData("text/plain", "payload-" + Math.random().toString(36).slice(2,8)); } catch {}
|
||||
Challenge.log("dragstart", { hasDt: !!e.dataTransfer });
|
||||
});
|
||||
src.addEventListener("drag", (e) => { seen.drag++; if (!e.isTrusted) isTrustedAll = false; });
|
||||
src.addEventListener("dragend", (e) => { seen.dragend++; if (!e.isTrusted) isTrustedAll = false; });
|
||||
|
||||
dst.addEventListener("dragenter", (e) => { seen.dragenter++; e.preventDefault(); if (!e.isTrusted) isTrustedAll = false; });
|
||||
dst.addEventListener("dragover", (e) => { seen.dragover++; e.preventDefault(); if (!e.isTrusted) isTrustedAll = false; });
|
||||
dst.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
seen.drop++;
|
||||
if (!e.isTrusted) isTrustedAll = false;
|
||||
const dt = e.dataTransfer;
|
||||
if (dt) {
|
||||
dtHadTypes = (dt.types && dt.types.length > 0);
|
||||
try { dtPayload = dt.getData("text/plain"); } catch {}
|
||||
}
|
||||
Challenge.log("drop", { types: dt && [...dt.types], payload: dtPayload });
|
||||
// dragend fires AFTER drop per spec; give it a tick.
|
||||
setTimeout(evaluate, 50);
|
||||
});
|
||||
|
||||
function evaluate() {
|
||||
const bad = [];
|
||||
if (!isTrustedAll) bad.push("at least one drag event isTrusted=false");
|
||||
for (const k of ["dragstart","dragover","drop","dragend"]) {
|
||||
if (!seen[k]) bad.push(`missing ${k}`);
|
||||
}
|
||||
if (!dtHadTypes) bad.push("DataTransfer.types empty on drop");
|
||||
if (!dtPayload || !dtPayload.startsWith("payload-")) bad.push("DataTransfer payload missing on drop");
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass(`full drag cycle, payload=${dtPayload}`);
|
||||
}
|
||||
|
||||
// Fallback: pointer-drag without dragstart triggers fail after a delay.
|
||||
let pointerDownInSrc = false;
|
||||
src.addEventListener("pointerdown", () => { pointerDownInSrc = true;
|
||||
setTimeout(() => { if (!seen.dragstart && pointerDownInSrc) Challenge.fail("pointerdown on src but no dragstart fired"); }, 1500);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,54 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>16 contenteditable selection</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: focus the editable region and type <code>hello</code>. Page asserts that
|
||||
<code>window.getSelection()</code> reflects per-keystroke caret movement
|
||||
(<code>rangeCount===1</code>, <code>collapsed</code> caret, anchor inside the editor,
|
||||
offset advances 1 per char) and that <code>selectionchange</code> fires.</p>
|
||||
<div id="ed" contenteditable="true"
|
||||
style="min-height:60px;padding:10px;border:1px solid #555;background:#fff;color:#111;font:16px monospace"></div>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "contenteditable-selection", instructions: "focus editor and type 'hello'" });
|
||||
const ed = document.getElementById("ed");
|
||||
const offsets = [];
|
||||
let selChanges = 0;
|
||||
|
||||
document.addEventListener("selectionchange", () => {
|
||||
selChanges++;
|
||||
const s = window.getSelection();
|
||||
if (!s || s.rangeCount === 0) return;
|
||||
if (!ed.contains(s.anchorNode)) return;
|
||||
offsets.push({ off: s.anchorOffset, collapsed: s.isCollapsed, t: performance.now(), len: ed.textContent.length });
|
||||
});
|
||||
|
||||
ed.addEventListener("input", async () => {
|
||||
if (ed.textContent !== "hello") return;
|
||||
// selectionchange is async — wait a tick so the final caret update is observed.
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const bad = [];
|
||||
const sel = window.getSelection();
|
||||
if (!sel) bad.push("getSelection()==null");
|
||||
else {
|
||||
if (sel.rangeCount !== 1) bad.push(`rangeCount=${sel.rangeCount} (need 1)`);
|
||||
if (!sel.isCollapsed) bad.push("selection not collapsed after typing");
|
||||
if (!ed.contains(sel.anchorNode)) bad.push("selection anchor outside editor");
|
||||
if (sel.anchorOffset !== 5) bad.push(`anchorOffset=${sel.anchorOffset} (need 5)`);
|
||||
}
|
||||
if (selChanges < 5) bad.push(`selectionchange count=${selChanges} (need ≥5)`);
|
||||
// offsets should advance monotonically 1,2,3,4,5 (allowing extras from focus)
|
||||
const monotonic = offsets.map(o => o.off);
|
||||
const peak = Math.max(0, ...monotonic);
|
||||
if (peak !== 5) bad.push(`peak caret offset=${peak} during typing (need 5)`);
|
||||
// every caret reading must be collapsed (no spurious ranges)
|
||||
if (offsets.some(o => !o.collapsed)) bad.push("non-collapsed range observed during typing");
|
||||
Challenge.log("sel", { offsets, selChanges });
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass(`selectionchanges=${selChanges}, caret advanced 0→5`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>17 paste clipboard</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: paste the string <code>pi-chrome</code> into the input via a real OS paste
|
||||
(Cmd/Ctrl+V firing a trusted <code>paste</code> event with populated
|
||||
<code>clipboardData</code>). Programmatic <code>value=</code> or <code>setRangeText</code>
|
||||
fail. The <code>input</code> event's <code>inputType</code> must be
|
||||
<code>insertFromPaste</code>.</p>
|
||||
<input id="t" placeholder="paste here" style="font-size:18px;padding:8px 12px;width:280px">
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "paste-clipboard", instructions: "paste 'pi-chrome' via OS clipboard" });
|
||||
const t = document.getElementById("t");
|
||||
let sawPaste = false, pasteIsTrusted = false, pastePayload = null;
|
||||
t.addEventListener("paste", (e) => {
|
||||
sawPaste = true;
|
||||
pasteIsTrusted = e.isTrusted;
|
||||
try { pastePayload = e.clipboardData && e.clipboardData.getData("text/plain"); } catch {}
|
||||
Challenge.log("paste", { isTrusted: e.isTrusted, payload: pastePayload });
|
||||
});
|
||||
|
||||
let lastInputType = null;
|
||||
t.addEventListener("input", (e) => {
|
||||
lastInputType = e.inputType;
|
||||
if (t.value !== "pi-chrome") return;
|
||||
const bad = [];
|
||||
if (!sawPaste) bad.push("no paste event fired before value matched");
|
||||
if (!pasteIsTrusted) bad.push("paste event isTrusted=false");
|
||||
if (pastePayload !== "pi-chrome") bad.push(`clipboardData payload="${pastePayload}" (need 'pi-chrome')`);
|
||||
if (lastInputType !== "insertFromPaste") bad.push(`input.inputType="${lastInputType}" (need 'insertFromPaste')`);
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass("trusted paste with clipboardData text/plain match");
|
||||
});
|
||||
|
||||
// Also fail loudly if value is set without any input event (raw .value=)
|
||||
let inputFired = false;
|
||||
t.addEventListener("input", () => { inputFired = true; });
|
||||
const obs = new MutationObserver(() => {});
|
||||
obs.observe(t, { attributes: true, attributeFilter: ["value"] });
|
||||
setInterval(() => {
|
||||
if (t.value === "pi-chrome" && !inputFired) Challenge.fail("value matched without input event firing");
|
||||
}, 200);
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,56 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>18 native select option</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: select the option <code>banana</code> from the native <code><select></code>.
|
||||
Page asserts a real picker interaction: <code>mousedown</code>/<code>pointerdown</code>
|
||||
on the <select> (opening the OS picker), then a <code>change</code> event with
|
||||
<code>isTrusted=true</code>. Programmatically setting <code>.value</code> or clicking the
|
||||
option element directly fails (clicking <option> does not change selection in
|
||||
real browsers).</p>
|
||||
<select id="s" style="font-size:18px;padding:6px 10px">
|
||||
<option value="">— pick a fruit —</option>
|
||||
<option value="apple">apple</option>
|
||||
<option value="banana">banana</option>
|
||||
<option value="cherry">cherry</option>
|
||||
</select>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "native-select", instructions: "pick 'banana' from native select" });
|
||||
const s = document.getElementById("s");
|
||||
|
||||
let sawPointerDownOnSelect = false, pointerDownTrusted = false;
|
||||
s.addEventListener("pointerdown", (e) => { sawPointerDownOnSelect = true; pointerDownTrusted = e.isTrusted; });
|
||||
s.addEventListener("mousedown", (e) => { sawPointerDownOnSelect = true; pointerDownTrusted ||= e.isTrusted; });
|
||||
|
||||
// Trap: if a click bubbles up from an <option>, that's bot-like (real OS pickers don't dispatch this).
|
||||
s.addEventListener("click", (e) => {
|
||||
if (e.target && e.target.tagName === "OPTION") {
|
||||
Challenge.fail("click event with target=<option> bubbled to <select> (synthetic option click)");
|
||||
}
|
||||
});
|
||||
|
||||
let changeTrusted = false;
|
||||
s.addEventListener("change", (e) => {
|
||||
changeTrusted = e.isTrusted;
|
||||
Challenge.log("change", { v: s.value, isTrusted: e.isTrusted });
|
||||
if (s.value !== "banana") return Challenge.fail(`selected="${s.value}" (need 'banana')`);
|
||||
const bad = [];
|
||||
if (!changeTrusted) bad.push("change event isTrusted=false");
|
||||
if (!sawPointerDownOnSelect) bad.push("no pointerdown/mousedown on <select> before change");
|
||||
if (!pointerDownTrusted) bad.push("pointerdown on select isTrusted=false");
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass("native picker opened and trusted change fired");
|
||||
});
|
||||
|
||||
// Also trip if value mutates without change firing.
|
||||
let changed = false;
|
||||
s.addEventListener("change", () => { changed = true; });
|
||||
setInterval(() => {
|
||||
if (s.value === "banana" && !changed) Challenge.fail("value='banana' without any change event");
|
||||
}, 200);
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>19 hover dwell</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: hover the trigger and wait until the <b>Confirm</b> button reveals (≥600ms dwell
|
||||
with continuous <code>pointermove</code> activity inside the trigger), then click Confirm.
|
||||
Instant hover-then-click without dwell, or hover with no intermediate pointermove,
|
||||
looks robotic and fails.</p>
|
||||
<div id="trig" style="display:inline-block;padding:16px 24px;background:#2a4;color:#fff;border-radius:6px;cursor:pointer">
|
||||
hover me
|
||||
</div>
|
||||
<button id="go" style="display:none;margin-left:12px;padding:10px 18px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Confirm</button>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "hover-dwell", instructions: "hover ≥600ms with movement, then click Confirm" });
|
||||
const trig = document.getElementById("trig");
|
||||
const btn = document.getElementById("go");
|
||||
|
||||
let enterTs = 0, lastMoveTs = 0, moveCount = 0;
|
||||
trig.addEventListener("pointerenter", (e) => { enterTs = e.timeStamp; lastMoveTs = e.timeStamp; moveCount = 0; });
|
||||
trig.addEventListener("pointermove", (e) => { lastMoveTs = e.timeStamp; moveCount++; });
|
||||
trig.addEventListener("pointerleave", () => {
|
||||
if (!btn.dataset.revealed) { enterTs = 0; lastMoveTs = 0; moveCount = 0; }
|
||||
});
|
||||
|
||||
// Reveal after sustained dwell + activity.
|
||||
setInterval(() => {
|
||||
if (btn.dataset.revealed) return;
|
||||
if (!enterTs) return;
|
||||
const dwell = performance.now() - enterTs;
|
||||
if (dwell >= 600 && moveCount >= 3) {
|
||||
btn.style.display = "inline-block";
|
||||
btn.dataset.revealed = "1";
|
||||
btn.dataset.revealTs = performance.now();
|
||||
Challenge.log("revealed", { dwell, moveCount });
|
||||
}
|
||||
}, 50);
|
||||
|
||||
btn.addEventListener("click", (e) => {
|
||||
if (!btn.dataset.revealed) return Challenge.fail("Confirm clicked before reveal (display:none)");
|
||||
const sinceReveal = e.timeStamp - Number(btn.dataset.revealTs);
|
||||
if (sinceReveal < 80) return Challenge.fail(`clicked ${sinceReveal.toFixed(0)}ms after reveal (too instant)`);
|
||||
if (!e.isTrusted) return Challenge.fail("Confirm click isTrusted=false");
|
||||
Challenge.pass(`dwell+activity+human-latency reveal (Δ=${sinceReveal.toFixed(0)}ms)`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,78 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>20 React _valueTracker</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: set the input to <code>react-ok</code> in a way that React would accept.
|
||||
The page mimics React's <code>_valueTracker</code>: it stamps the input with a
|
||||
hidden tracker holding the last-seen value. An <code>input</code> event is only
|
||||
considered "framework-real" if the tracker's recorded value differs from the
|
||||
current <code>input.value</code> at the moment the event fires
|
||||
(i.e. somebody used the native <code>HTMLInputElement.value</code> setter and
|
||||
dispatched <code>input</code> — what React internals require).</p>
|
||||
<p>Setting <code>el.value = ...</code> directly and dispatching a synthetic <code>input</code>
|
||||
fails because the native setter wasn't used. Calling <code>setRangeText</code> without
|
||||
dispatching <code>input</code> also fails.</p>
|
||||
<input id="t" placeholder="type or fill 'react-ok'" style="font-size:18px;padding:8px 12px;width:240px">
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "react-value-tracker", instructions: "make input value 'react-ok' as React expects" });
|
||||
|
||||
const t = document.getElementById("t");
|
||||
|
||||
// Install a fake React-style _valueTracker.
|
||||
(function attachTracker(node) {
|
||||
const nativeProto = Object.getPrototypeOf(node);
|
||||
const desc = Object.getOwnPropertyDescriptor(nativeProto, "value");
|
||||
const nativeGet = desc.get, nativeSet = desc.set;
|
||||
let tracked = nativeGet.call(node); // last value the "framework" has seen
|
||||
node._valueTracker = {
|
||||
getValue() { return tracked; },
|
||||
setValue(v) { tracked = v; },
|
||||
};
|
||||
// Wrap the value property on the *instance* to intercept assignments.
|
||||
Object.defineProperty(node, "value", {
|
||||
configurable: true,
|
||||
get() { return nativeGet.call(this); },
|
||||
set(v) {
|
||||
// If somebody assigns via instance property (the bad path), sync the tracker
|
||||
// so the input event below will look "stale" and we can detect it.
|
||||
tracked = String(v);
|
||||
nativeSet.call(this, v);
|
||||
},
|
||||
});
|
||||
})(t);
|
||||
|
||||
let passed = false;
|
||||
t.addEventListener("input", (e) => {
|
||||
if (passed) return;
|
||||
const proto = Object.getPrototypeOf(t);
|
||||
const nativeGet = Object.getOwnPropertyDescriptor(proto, "value").get;
|
||||
const real = nativeGet.call(t);
|
||||
const tracked = t._valueTracker.getValue();
|
||||
Challenge.log("input", { real, tracked, isTrusted: e.isTrusted });
|
||||
if (real !== "react-ok") return;
|
||||
if (!e.isTrusted && tracked === real) {
|
||||
return Challenge.fail("synthetic input event but _valueTracker already up-to-date (instance value= setter used, not native)");
|
||||
}
|
||||
// The "good" path: native setter ran (tracker still stale), then input dispatched.
|
||||
// After the framework consumes the event it would syncs the tracker.
|
||||
t._valueTracker.setValue(real);
|
||||
passed = true;
|
||||
Challenge.pass(`value 'react-ok' delivered with stale tracker (native setter path); isTrusted=${e.isTrusted}`);
|
||||
});
|
||||
|
||||
// Trap: value mutates without input ever firing.
|
||||
setInterval(() => {
|
||||
if (passed) return;
|
||||
const proto = Object.getPrototypeOf(t);
|
||||
const nativeGet = Object.getOwnPropertyDescriptor(proto, "value").get;
|
||||
if (nativeGet.call(t) === "react-ok" && !passed) {
|
||||
// Only fail if we've been sitting on the value with no event for a while.
|
||||
setTimeout(() => { if (!passed && nativeGet.call(t) === "react-ok") Challenge.fail("value='react-ok' but no input event consumed it"); }, 600);
|
||||
}
|
||||
}, 300);
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,65 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>21 keyboard modifiers</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: focus the input and type a capital <code>A</code> via <b>Shift+a</b>
|
||||
(not Caps Lock, not pasted, not assigned). Page asserts a real Shift chord:
|
||||
<code>keydown</code> for <code>Shift</code> arrives <em>before</em> <code>keydown</code> for
|
||||
<code>A</code>; the <code>A</code> event has <code>shiftKey=true</code>,
|
||||
<code>key==="A"</code>, <code>code==="KeyA"</code>, <code>getModifierState("Shift")===true</code>;
|
||||
and <code>keyup</code> for Shift arrives <em>after</em> <code>keyup</code> for <code>A</code>.</p>
|
||||
<input id="t" placeholder="type Shift+a" style="font-size:18px;padding:8px 12px;width:240px">
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "keyboard-modifiers", instructions: "type capital A using Shift+a chord" });
|
||||
const t = document.getElementById("t");
|
||||
|
||||
const log = [];
|
||||
for (const name of ["keydown","keypress","keyup","input"]) {
|
||||
t.addEventListener(name, (e) => {
|
||||
log.push({
|
||||
name,
|
||||
key: e.key, code: e.code,
|
||||
shiftKey: e.shiftKey, isTrusted: e.isTrusted,
|
||||
modShift: e.getModifierState ? e.getModifierState("Shift") : null,
|
||||
t: e.timeStamp,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
t.addEventListener("input", () => {
|
||||
if (t.value !== "A") return;
|
||||
const bad = [];
|
||||
const downShift = log.find(e => e.name === "keydown" && e.key === "Shift");
|
||||
const downA = log.find(e => e.name === "keydown" && e.code === "KeyA");
|
||||
const upShift = log.find(e => e.name === "keyup" && e.key === "Shift");
|
||||
const upA = log.find(e => e.name === "keyup" && e.code === "KeyA");
|
||||
|
||||
if (!downShift) bad.push("no keydown for Shift");
|
||||
if (!downA) bad.push("no keydown for KeyA");
|
||||
if (!upShift) bad.push("no keyup for Shift");
|
||||
if (!upA) bad.push("no keyup for KeyA");
|
||||
|
||||
if (downShift && downA && downShift.t > downA.t) bad.push("Shift keydown after A keydown");
|
||||
if (upShift && upA && upShift.t < upA.t) bad.push("Shift keyup before A keyup");
|
||||
|
||||
if (downA) {
|
||||
if (downA.key !== "A") bad.push(`A keydown.key="${downA.key}" (need 'A')`);
|
||||
if (downA.code !== "KeyA") bad.push(`A keydown.code="${downA.code}"`);
|
||||
if (!downA.shiftKey) bad.push("A keydown.shiftKey=false");
|
||||
if (downA.modShift !== true) bad.push("getModifierState('Shift')!==true on A keydown");
|
||||
if (!downA.isTrusted) bad.push("A keydown isTrusted=false");
|
||||
}
|
||||
if (downShift) {
|
||||
if (!downShift.isTrusted) bad.push("Shift keydown isTrusted=false");
|
||||
}
|
||||
|
||||
Challenge.log("chord", { log });
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass("Shift+a chord, modifiers + ordering correct");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,66 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>22 touch events</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: tap the green pad on a touch-capable device (or via DevTools touch
|
||||
emulation). Page asserts a real <code>touchstart</code>/<code>touchend</code> sequence:
|
||||
<code>touches</code> / <code>changedTouches</code> are
|
||||
<code>TouchList</code> objects containing <code>Touch</code> instances with
|
||||
<code>identifier</code>, <code>clientX/Y</code>, <code>radiusX/Y</code>, and
|
||||
<code>force</code> in [0,1]. Synthetic <code>PointerEvent</code> with
|
||||
<code>pointerType="touch"</code> alone does not satisfy this — touch events must
|
||||
fire too.</p>
|
||||
<p><b>Note for caller:</b> requires Chrome touch emulation on (DevTools → Sensors →
|
||||
"Touch: Force enabled") or a real touchscreen. This is intentional: pi-chrome's
|
||||
synthetic <code>pointerType:'touch'</code> does NOT also dispatch TouchEvents.</p>
|
||||
<div id="pad" style="width:200px;height:200px;background:#1f7a1f;border-radius:12px;color:#fff;display:flex;align-items:center;justify-content:center;font:18px monospace;touch-action:none;user-select:none">
|
||||
TAP
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "touch-events", instructions: "tap the pad on a touch-capable device" });
|
||||
const pad = document.getElementById("pad");
|
||||
|
||||
let touchStart = null, touchEnd = null;
|
||||
pad.addEventListener("touchstart", (e) => {
|
||||
touchStart = e;
|
||||
Challenge.log("touchstart", { tl: e.touches.length, ct: e.changedTouches.length, isTrusted: e.isTrusted });
|
||||
}, { passive: true });
|
||||
pad.addEventListener("touchend", (e) => {
|
||||
touchEnd = e;
|
||||
const bad = [];
|
||||
if (!touchStart) return Challenge.fail("touchend without touchstart");
|
||||
if (!e.isTrusted || !touchStart.isTrusted) bad.push("touch event isTrusted=false");
|
||||
|
||||
const ts = touchStart.changedTouches;
|
||||
if (!ts || ts.length === 0) bad.push("touchstart.changedTouches empty");
|
||||
else {
|
||||
const T = ts[0];
|
||||
// Window.Touch should exist as the constructor.
|
||||
if (typeof window.Touch !== "function") bad.push("window.Touch constructor missing (no native TouchEvent support)");
|
||||
if (!(T instanceof Touch)) bad.push("changedTouches[0] not instanceof Touch");
|
||||
if (typeof T.identifier !== "number") bad.push("Touch.identifier not number");
|
||||
if (!Number.isFinite(T.clientX) || !Number.isFinite(T.clientY)) bad.push("Touch.clientX/Y invalid");
|
||||
if (!("force" in T) || typeof T.force !== "number" || T.force < 0 || T.force > 1) bad.push(`Touch.force=${T.force}`);
|
||||
if (!("radiusX" in T) || typeof T.radiusX !== "number") bad.push("Touch.radiusX missing");
|
||||
}
|
||||
|
||||
if (!(touchStart instanceof TouchEvent)) bad.push("touchstart not instanceof TouchEvent");
|
||||
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass("real TouchEvent + Touch object surface present");
|
||||
}, { passive: true });
|
||||
|
||||
// Capability check: if TouchEvent constructor missing entirely, hint immediately on click.
|
||||
pad.addEventListener("click", () => {
|
||||
if (!touchStart && typeof window.TouchEvent !== "function") {
|
||||
Challenge.fail("TouchEvent constructor not available on this UA (enable touch emulation)");
|
||||
} else if (!touchStart) {
|
||||
Challenge.fail("click fired but no touchstart — synthetic pointer without TouchEvent");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,76 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>23 stack-trace fingerprint</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: click the button. The page samples call stacks from inside several
|
||||
instrumented globals (<code>Function.prototype.toString</code>,
|
||||
<code>document.querySelector</code>, <code>Element.prototype.click</code>) and inspects
|
||||
the stack of <em>this script's own</em> handler. It fails if it sees telltales of
|
||||
evaluator-injected frames (e.g. <code>at <anonymous></code> as the only frame, the
|
||||
bridge's <code>new Function</code> wrapper, <code>callFunctionOn</code>,
|
||||
<code>executeScript</code>, or extension URLs).</p>
|
||||
<button id="go" style="padding:14px 22px;font-size:16px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me</button>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "stack-trace-fingerprint", instructions: "click the button" });
|
||||
|
||||
// Suspicious stack-frame patterns commonly observed when code is invoked via
|
||||
// CDP Runtime.evaluate / chrome.scripting.executeScript / new Function bodies
|
||||
// dispatched from an MV3 service worker.
|
||||
const SUSPICIOUS = [
|
||||
/chrome-extension:\/\//i,
|
||||
/\bnew Function\b/,
|
||||
/Runtime\.evaluate/i,
|
||||
/Runtime\.callFunctionOn/i,
|
||||
/executeScript/i,
|
||||
/content[_-]?script/i,
|
||||
/^\s*at\s+eval\b/m,
|
||||
];
|
||||
|
||||
function inspectStack(stack) {
|
||||
if (!stack) return ["empty stack"];
|
||||
const hits = SUSPICIOUS.filter(r => r.test(stack)).map(r => r.source);
|
||||
const lines = stack.split("\n").filter(l => l.trim().startsWith("at "));
|
||||
// Do not fail on generic <anonymous> frames alone: inline scripts, extensions,
|
||||
// and browser versions vary here. This test should catch concrete automation
|
||||
// tells, not punish legitimate stack formatting differences.
|
||||
const reasons = [];
|
||||
if (hits.length) reasons.push("suspicious frames: " + hits.join(","));
|
||||
return reasons;
|
||||
}
|
||||
|
||||
// Hook some commonly-touched APIs so any pre-click bridge instrumentation also
|
||||
// leaves a trail. Their stacks get inspected the moment the click handler fires.
|
||||
const probeStacks = [];
|
||||
const oTo = Function.prototype.toString;
|
||||
Function.prototype.toString = function () {
|
||||
probeStacks.push({ where: "Function.toString", stack: new Error().stack });
|
||||
return oTo.apply(this, arguments);
|
||||
};
|
||||
const oQS = Document.prototype.querySelector;
|
||||
Document.prototype.querySelector = function (sel) {
|
||||
probeStacks.push({ where: "document.querySelector", sel, stack: new Error().stack });
|
||||
return oQS.apply(this, arguments);
|
||||
};
|
||||
|
||||
document.getElementById("go").addEventListener("click", (e) => {
|
||||
const ownStack = new Error().stack || "";
|
||||
const reasons = inspectStack(ownStack);
|
||||
// Also check any probe stacks gathered before the click — the bridge often
|
||||
// queries the DOM right before dispatching.
|
||||
const probeBad = [];
|
||||
for (const p of probeStacks) {
|
||||
const r = inspectStack(p.stack);
|
||||
if (r.length) probeBad.push(`${p.where}: ${r.join("; ")}`);
|
||||
}
|
||||
Challenge.log("stacks", { ownStack, probeStacks });
|
||||
if (!e.isTrusted) return Challenge.fail("click isTrusted=false");
|
||||
if (reasons.length) return Challenge.fail(...reasons);
|
||||
if (probeBad.length) return Challenge.fail(...probeBad.slice(0, 3));
|
||||
Challenge.pass("call stack matches an in-page event handler");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>24 viewport edge clicks</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: click the button. Page rejects any pointer event whose
|
||||
<code>clientX</code>/<code>clientY</code> falls outside the viewport
|
||||
(<code>x < 0</code>, <code>x > innerWidth</code>, etc.) or lands on a coordinate
|
||||
that's not actually inside the button's <code>getBoundingClientRect()</code>.
|
||||
Real pointing devices can't dispatch clicks at negative coordinates;
|
||||
bridges that pass uncoerced selector-center coords (or coordinates from a
|
||||
stale layout) trip this.</p>
|
||||
<button id="go" style="margin:40px;padding:14px 22px;font-size:16px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Click me</button>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "viewport-edge-clicks", instructions: "click the button at valid viewport coords" });
|
||||
|
||||
const btn = document.getElementById("go");
|
||||
|
||||
// Track ALL pointer/click events on document — even ones that miss the button.
|
||||
let suspicious = null;
|
||||
for (const name of ["pointerdown","pointerup","click","mousedown","mouseup"]) {
|
||||
document.addEventListener(name, (e) => {
|
||||
const x = e.clientX, y = e.clientY;
|
||||
const w = window.innerWidth, h = window.innerHeight;
|
||||
if (x < 0 || y < 0 || x > w || y > h) {
|
||||
suspicious = `${name} at (${x},${y}) outside viewport ${w}x${h}`;
|
||||
Challenge.fail(suspicious);
|
||||
}
|
||||
if ((x === 0 && y === 0) && e.isTrusted === false) {
|
||||
suspicious = `${name} at (0,0) with isTrusted=false (default synthetic coords)`;
|
||||
Challenge.fail(suspicious);
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
btn.addEventListener("click", (e) => {
|
||||
if (suspicious) return;
|
||||
if (!e.isTrusted) return Challenge.fail("click isTrusted=false");
|
||||
const r = btn.getBoundingClientRect();
|
||||
const x = e.clientX, y = e.clientY;
|
||||
const inside = x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
||||
if (!inside) return Challenge.fail(`click at (${x},${y}) outside button rect ${JSON.stringify({l:r.left,t:r.top,r:r.right,b:r.bottom})}`);
|
||||
// Coord must not be exactly integer-center (covered by #06 but reinforced here).
|
||||
Challenge.log("click", { x, y, rect: r });
|
||||
Challenge.pass(`click at (${x.toFixed(1)},${y.toFixed(1)}) inside button rect`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>25 pointer continuity</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: click <b>A</b>, then click <b>B</b>. Page requires pointer continuity:
|
||||
between the two clicks there must be intermediate <code>pointermove</code> events
|
||||
whose path roughly connects the two click points (no teleport). Two clicks
|
||||
>100px apart with zero moves between is a synthetic-bridge tell.</p>
|
||||
<div style="display:flex;justify-content:space-between;margin-top:20px">
|
||||
<button id="a" style="padding:14px 22px;background:#36c;color:#fff;border:0;border-radius:6px">A</button>
|
||||
<div style="flex:1"></div>
|
||||
<button id="b" style="padding:14px 22px;background:#c36;color:#fff;border:0;border-radius:6px">B</button>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "pointer-continuity", instructions: "click A, move mouse to B, click B" });
|
||||
|
||||
const a = document.getElementById("a");
|
||||
const b = document.getElementById("b");
|
||||
|
||||
const moves = [];
|
||||
window.addEventListener("pointermove", (e) => {
|
||||
moves.push({ x: e.clientX, y: e.clientY, t: e.timeStamp });
|
||||
if (moves.length > 500) moves.shift();
|
||||
});
|
||||
|
||||
let aClickTs = 0, aPoint = null;
|
||||
a.addEventListener("click", (e) => {
|
||||
if (!e.isTrusted) return Challenge.fail("A click isTrusted=false");
|
||||
aClickTs = e.timeStamp;
|
||||
aPoint = { x: e.clientX, y: e.clientY };
|
||||
Challenge.log("A click", aPoint);
|
||||
});
|
||||
|
||||
b.addEventListener("click", (e) => {
|
||||
if (!aClickTs) return Challenge.fail("B clicked before A");
|
||||
if (!e.isTrusted) return Challenge.fail("B click isTrusted=false");
|
||||
const bPoint = { x: e.clientX, y: e.clientY };
|
||||
const between = moves.filter(m => m.t > aClickTs && m.t < e.timeStamp);
|
||||
Challenge.log("B click", { bPoint, betweenCount: between.length });
|
||||
const dist = Math.hypot(bPoint.x - aPoint.x, bPoint.y - aPoint.y);
|
||||
if (dist < 100) return Challenge.fail(`A-B distance only ${dist.toFixed(0)}px — buttons should be further apart in viewport`);
|
||||
if (between.length < 5) return Challenge.fail(`only ${between.length} pointermove(s) between A and B (need ≥5)`);
|
||||
|
||||
// Path coverage: at least some intermediate move must be ≥30% of the way across.
|
||||
const inSpan = between.filter(m => {
|
||||
const px = (m.x - aPoint.x) / (bPoint.x - aPoint.x || 1);
|
||||
return px > 0.25 && px < 0.75;
|
||||
});
|
||||
if (inSpan.length === 0) return Challenge.fail("no pointermove samples in mid-span between A and B (teleport)");
|
||||
|
||||
// Move timestamps should be spread, not bunched in <20ms.
|
||||
const span = between[between.length-1].t - between[0].t;
|
||||
if (span < 30) return Challenge.fail(`pointermoves bunched in ${span.toFixed(0)}ms (need spread ≥30ms)`);
|
||||
|
||||
Challenge.pass(`${between.length} moves bridging A→B over ${span.toFixed(0)}ms, dist=${dist.toFixed(0)}px`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,57 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>26 mousemove rate</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: sweep the mouse across the box for ~1s, then click the green button.
|
||||
Page measures the inter-event timestamp distribution of <code>mousemove</code>
|
||||
events. Real pointing devices fire at ~60–250 Hz (median Δt 4–18ms). Bridges
|
||||
that flood synthetic moves on a tight <code>for</code>-loop or
|
||||
<code>setTimeout(...,1)</code> produce a degenerate distribution
|
||||
(most Δt < 2ms, or all Δt identical, or median > 50ms with no jitter).</p>
|
||||
<div id="pad" style="height:160px;border:1px solid #555;background:#fff;color:#111;display:flex;align-items:center;justify-content:center;font:13px monospace;user-select:none">
|
||||
sweep me
|
||||
</div>
|
||||
<button id="go" style="margin-top:14px;padding:12px 20px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">Done</button>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "mousemove-rate", instructions: "sweep mouse across pad ~1s, then click Done" });
|
||||
|
||||
const pad = document.getElementById("pad");
|
||||
const stamps = [];
|
||||
pad.addEventListener("mousemove", (e) => { stamps.push(e.timeStamp); });
|
||||
|
||||
document.getElementById("go").addEventListener("click", (e) => {
|
||||
if (!e.isTrusted) return Challenge.fail("Done click isTrusted=false");
|
||||
if (stamps.length < 20) return Challenge.fail(`only ${stamps.length} mousemove events (need ≥20)`);
|
||||
|
||||
const deltas = [];
|
||||
for (let i = 1; i < stamps.length; i++) deltas.push(stamps[i] - stamps[i-1]);
|
||||
deltas.sort((a,b) => a-b);
|
||||
const median = deltas[Math.floor(deltas.length/2)];
|
||||
const p10 = deltas[Math.floor(deltas.length*0.1)];
|
||||
const p90 = deltas[Math.floor(deltas.length*0.9)];
|
||||
const mean = deltas.reduce((s,x)=>s+x,0) / deltas.length;
|
||||
const variance = deltas.reduce((s,x)=>s+(x-mean)**2,0) / deltas.length;
|
||||
const std = Math.sqrt(variance);
|
||||
|
||||
const allSame = deltas.every(d => Math.abs(d - deltas[0]) < 0.05);
|
||||
const allTiny = deltas.every(d => d < 1.5);
|
||||
|
||||
Challenge.log("dist", { n: deltas.length, median, p10, p90, mean, std });
|
||||
const bad = [];
|
||||
if (allSame) bad.push(`all Δt identical (~${deltas[0].toFixed(2)}ms) — scripted loop`);
|
||||
if (allTiny) bad.push(`all Δt < 1.5ms — synthetic flood`);
|
||||
if (median < 2) bad.push(`median Δt ${median.toFixed(2)}ms too fast for a real device`);
|
||||
if (median > 60) bad.push(`median Δt ${median.toFixed(0)}ms too slow (real moves ~4–18ms)`);
|
||||
if (std < 0.5) bad.push(`Δt std=${std.toFixed(2)}ms — no jitter (scripted)`);
|
||||
// Ratio p90/p10 should be ≥1.8 for organic motion.
|
||||
if (p10 > 0 && (p90 / p10) < 1.5) bad.push(`p90/p10 ratio ${(p90/p10).toFixed(2)} — distribution too narrow`);
|
||||
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass(`n=${deltas.length}, median=${median.toFixed(1)}ms, std=${std.toFixed(1)}ms`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,66 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>27 scroll momentum tail</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: scroll the box to its bottom using a real wheel/trackpad gesture.
|
||||
Page records the <code>deltaY</code> sequence and requires a momentum-like tail:
|
||||
a peak <code>|deltaY|</code>, then a decreasing decay over several events
|
||||
(≥4 wheel events whose <code>|deltaY|</code> is strictly less than the previous
|
||||
by >5%). Constant or single-shot wheel events (one big delta then nothing)
|
||||
fail.</p>
|
||||
<div id="box" style="height:200px;overflow:auto;border:1px solid #555;background:#fff;color:#111;padding:8px">
|
||||
<div style="height:2400px">scroll me ⬇ … continues …</div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "scroll-momentum", instructions: "scroll the box to its bottom with a real gesture" });
|
||||
|
||||
const box = document.getElementById("box");
|
||||
const deltas = []; // signed
|
||||
const tsLog = [];
|
||||
|
||||
box.addEventListener("wheel", (e) => {
|
||||
deltas.push(e.deltaY);
|
||||
tsLog.push(e.timeStamp);
|
||||
if (deltas.length > 200) { deltas.shift(); tsLog.shift(); }
|
||||
Challenge.log("wheel", { dy: e.deltaY, isTrusted: e.isTrusted });
|
||||
if (!e.isTrusted) Challenge.fail("wheel event isTrusted=false");
|
||||
}, { passive: true });
|
||||
|
||||
box.addEventListener("scroll", () => {
|
||||
if (box.scrollTop + box.clientHeight < box.scrollHeight - 2) return;
|
||||
// We hit bottom. Analyze the deltaY trace.
|
||||
if (deltas.length === 0) return Challenge.fail("reached bottom with zero wheel events");
|
||||
if (deltas.length < 6) return Challenge.fail(`only ${deltas.length} wheel events (need ≥6 for momentum)`);
|
||||
|
||||
const abs = deltas.map(Math.abs);
|
||||
// All same sign? Trackpad inertia keeps signs consistent.
|
||||
const allSameSign = deltas.every(d => Math.sign(d) === Math.sign(deltas[0]));
|
||||
if (!allSameSign) return Challenge.fail("wheel deltaY signs flip — not a continuous scroll gesture");
|
||||
|
||||
// All identical magnitudes? Robotic.
|
||||
const allEqual = abs.every(a => Math.abs(a - abs[0]) < 0.01);
|
||||
if (allEqual) return Challenge.fail(`all |deltaY|=${abs[0]} — no momentum`);
|
||||
|
||||
// Find peak and require decay tail after it.
|
||||
const peakIdx = abs.indexOf(Math.max(...abs));
|
||||
const tail = abs.slice(peakIdx);
|
||||
let decays = 0;
|
||||
for (let i = 1; i < tail.length; i++) {
|
||||
if (tail[i] < tail[i-1] * 0.95) decays++;
|
||||
}
|
||||
Challenge.log("trace", { abs, peakIdx, decays });
|
||||
if (decays < 3) return Challenge.fail(`only ${decays} decay steps after peak — no momentum tail`);
|
||||
|
||||
// Timestamps within the tail should span ≥150ms (real inertia lasts hundreds of ms).
|
||||
const tailTs = tsLog.slice(peakIdx);
|
||||
const span = tailTs[tailTs.length-1] - tailTs[0];
|
||||
if (span < 100) return Challenge.fail(`momentum tail only spans ${span.toFixed(0)}ms`);
|
||||
|
||||
Challenge.pass(`${deltas.length} wheels, peak |dY|=${abs[peakIdx].toFixed(1)}, ${decays} decay steps over ${span.toFixed(0)}ms`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>28 IntersectionObserver visibility</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: scroll until the hidden <b>TARGET</b> button comes into view, then click it.
|
||||
Page uses an <code>IntersectionObserver</code> to track when TARGET crosses the
|
||||
viewport. Real scrolling produces a sequence of intersection updates with
|
||||
gradually increasing <code>intersectionRatio</code> (0 → 1) across animation
|
||||
frames. Programmatic teleports (<code>scrollIntoView({behavior:"instant"})</code>,
|
||||
direct <code>scrollTop=</code>) produce exactly one observer callback with
|
||||
<code>ratio≈1</code> and no rAF samples in between — fail.</p>
|
||||
<div id="scroller" style="height:240px;overflow:auto;border:1px solid #555;background:#fff;color:#111">
|
||||
<div style="height:1600px;padding:8px">keep scrolling …</div>
|
||||
<button id="target" style="margin:0 auto 1600px;display:block;padding:14px 22px;background:#1f7a1f;color:#fff;border:0;border-radius:6px">TARGET</button>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "intersection-visibility", instructions: "scroll until TARGET visible, then click" });
|
||||
|
||||
const scroller = document.getElementById("scroller");
|
||||
const target = document.getElementById("target");
|
||||
|
||||
const ratioSamples = [];
|
||||
const rafSamples = [];
|
||||
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) ratioSamples.push({ r: e.intersectionRatio, t: performance.now() });
|
||||
}, { root: scroller, threshold: Array.from({length: 21}, (_,i) => i/20) });
|
||||
io.observe(target);
|
||||
|
||||
// Track rAF ticks during which scroll was happening.
|
||||
let lastScrollTop = scroller.scrollTop;
|
||||
let scrollingFrames = 0;
|
||||
function tick() {
|
||||
if (scroller.scrollTop !== lastScrollTop) {
|
||||
scrollingFrames++;
|
||||
rafSamples.push({ st: scroller.scrollTop, t: performance.now() });
|
||||
lastScrollTop = scroller.scrollTop;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
target.addEventListener("click", (e) => {
|
||||
if (!e.isTrusted) return Challenge.fail("TARGET click isTrusted=false");
|
||||
|
||||
// Need the target to actually be in the viewport at click time.
|
||||
const rect = target.getBoundingClientRect();
|
||||
const sRect = scroller.getBoundingClientRect();
|
||||
const inViewport = rect.top < sRect.bottom && rect.bottom > sRect.top;
|
||||
if (!inViewport) return Challenge.fail("TARGET clicked while still off-screen");
|
||||
|
||||
// Require at least a gradient of intersection ratios from low to high.
|
||||
const lows = ratioSamples.filter(s => s.r > 0 && s.r < 0.3).length;
|
||||
const mids = ratioSamples.filter(s => s.r >= 0.3 && s.r < 0.7).length;
|
||||
const highs = ratioSamples.filter(s => s.r >= 0.7).length;
|
||||
Challenge.log("io", { samples: ratioSamples.length, lows, mids, highs, scrollingFrames });
|
||||
|
||||
const bad = [];
|
||||
if (ratioSamples.length < 4) bad.push(`only ${ratioSamples.length} IntersectionObserver samples (teleport)`);
|
||||
if (lows === 0) bad.push("no low-ratio IO samples — target appeared instantly");
|
||||
if (mids === 0) bad.push("no mid-ratio IO samples — no smooth approach");
|
||||
if (scrollingFrames < 5) bad.push(`only ${scrollingFrames} rAF frames saw scroll motion`);
|
||||
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass(`IO gradient seen: lows=${lows} mids=${mids} highs=${highs}, rafFrames=${scrollingFrames}`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>29 shadow DOM controls</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: discover controls inside an <code>open</code> Shadow DOM, click the button, then type <code>shadow-ok</code> into the shadow input.</p>
|
||||
<div id="host"></div>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "shadow-dom-controls", instructions: "click shadow button, type 'shadow-ok' in shadow input" });
|
||||
const host = document.getElementById("host");
|
||||
const root = host.attachShadow({ mode: "open" });
|
||||
root.innerHTML = `
|
||||
<style>
|
||||
.card { border:1px solid #555; border-radius:8px; padding:16px; background:#222; display:inline-block; }
|
||||
button,input { font:16px system-ui; padding:8px 10px; margin:6px; }
|
||||
</style>
|
||||
<div class="card" role="group" aria-label="Shadow test controls">
|
||||
<button id="shadowBtn">Arm shadow form</button>
|
||||
<input id="shadowInput" aria-label="Shadow value" placeholder="shadow-ok" disabled>
|
||||
<span id="status">waiting</span>
|
||||
</div>
|
||||
`;
|
||||
const btn = root.getElementById("shadowBtn");
|
||||
const input = root.getElementById("shadowInput");
|
||||
const status = root.getElementById("status");
|
||||
let armed = false;
|
||||
btn.addEventListener("click", (e) => {
|
||||
armed = true;
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
status.textContent = `armed; click trusted=${e.isTrusted}`;
|
||||
Challenge.log("shadow-click", { isTrusted: e.isTrusted });
|
||||
});
|
||||
input.addEventListener("input", (e) => {
|
||||
Challenge.log("shadow-input", { value: input.value, isTrusted: e.isTrusted });
|
||||
if (input.value !== "shadow-ok") return;
|
||||
if (!armed) return Challenge.fail("input reached target value before shadow button click armed the form");
|
||||
Challenge.pass("shadow button clicked and shadow input filled");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>30 iframe targeting</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: interact with controls inside a same-origin iframe. Type <code>frame-ok</code> then click Submit inside the frame.</p>
|
||||
<iframe id="ifr" title="same-origin challenge frame" style="width:520px;height:220px;border:1px solid #555;border-radius:8px;background:#fff"></iframe>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "iframe-targeting", instructions: "type 'frame-ok' and click Submit inside iframe" });
|
||||
const frame = document.getElementById("ifr");
|
||||
frame.srcdoc = `<!doctype html><meta charset="utf-8">
|
||||
<style>body{font:16px system-ui;margin:24px;background:#202020;color:#eee}input,button{font:16px system-ui;padding:8px 10px;margin:6px}</style>
|
||||
<p>Inside frame: fill and submit.</p>
|
||||
<input id="insideText" aria-label="Frame value" placeholder="frame-ok">
|
||||
<button id="insideButton">Submit frame</button>
|
||||
<pre id="log"></pre>
|
||||
<script>
|
||||
const input = document.getElementById('insideText');
|
||||
const btn = document.getElementById('insideButton');
|
||||
const log = document.getElementById('log');
|
||||
let focused = false;
|
||||
input.addEventListener('focus', () => { focused = true; parent.postMessage({ type:'frame-focus' }, '*'); });
|
||||
input.addEventListener('input', e => parent.postMessage({ type:'frame-input', value: input.value, trusted: e.isTrusted }, '*'));
|
||||
btn.addEventListener('click', e => {
|
||||
const payload = { type:'frame-submit', value: input.value, focused, trusted: e.isTrusted };
|
||||
log.textContent = JSON.stringify(payload, null, 2);
|
||||
parent.postMessage(payload, '*');
|
||||
});
|
||||
<\/script>`;
|
||||
window.addEventListener("message", (e) => {
|
||||
if (!e.data || typeof e.data !== "object") return;
|
||||
Challenge.log("frame-message", e.data);
|
||||
if (e.data.type !== "frame-submit") return;
|
||||
const bad = [];
|
||||
if (e.data.value !== "frame-ok") bad.push(`iframe input value="${e.data.value}" (need frame-ok)`);
|
||||
if (!e.data.focused) bad.push("iframe input was not focused before submit");
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass(`iframe controls reached; submit trusted=${e.data.trusted}`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>31 file upload</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: attach <code>test-suite/fixtures/pi-chrome-upload.txt</code>. Page reads name and contents from the File object.</p>
|
||||
<input id="file" type="file" aria-label="Upload fixture file" style="font-size:16px;padding:10px;border:1px solid #555;border-radius:6px">
|
||||
<pre id="rep" style="font:12px monospace;background:#111;color:#eee;padding:12px;border-radius:6px"></pre>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "file-upload", instructions: "upload fixtures/pi-chrome-upload.txt" });
|
||||
const input = document.getElementById("file");
|
||||
const rep = document.getElementById("rep");
|
||||
input.addEventListener("change", async (e) => {
|
||||
const f = input.files && input.files[0];
|
||||
if (!f) return Challenge.fail("change fired but input.files[0] missing");
|
||||
const text = await f.text();
|
||||
const info = { name: f.name, size: f.size, type: f.type, trusted: e.isTrusted, text };
|
||||
rep.textContent = JSON.stringify(info, null, 2);
|
||||
Challenge.log("file-change", info);
|
||||
const bad = [];
|
||||
if (f.name !== "pi-chrome-upload.txt") bad.push(`file.name=${f.name}`);
|
||||
if (text !== "pi-chrome upload fixture\n") bad.push(`file content=${JSON.stringify(text)}`);
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass(`uploaded ${f.name}; change.isTrusted=${e.isTrusted}`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,61 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>32 keyboard tab navigation</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: use keyboard only. Click Start, then Tab through fields, type <code>one</code>, <code>two</code>, <code>three</code>, Tab to Submit, press Enter.</p>
|
||||
<button id="start">Start here</button>
|
||||
<form id="f" style="margin-top:16px;display:grid;gap:10px;max-width:360px">
|
||||
<label>First <input id="a" name="a" autocomplete="off"></label>
|
||||
<label>Second <input id="b" name="b" autocomplete="off"></label>
|
||||
<label>Third <input id="c" name="c" autocomplete="off"></label>
|
||||
<button id="submit" type="submit">Submit</button>
|
||||
</form>
|
||||
<pre id="rep" style="font:12px monospace;background:#111;color:#eee;padding:12px;border-radius:6px"></pre>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "keyboard-tab-navigation", instructions: "keyboard-only tab flow; submit with Enter" });
|
||||
const ids = ["start", "a", "b", "c", "submit"];
|
||||
const focusLog = [];
|
||||
const keyLog = [];
|
||||
const rep = document.getElementById("rep");
|
||||
for (const id of ids) {
|
||||
const el = document.getElementById(id);
|
||||
el.addEventListener("focus", (e) => {
|
||||
focusLog.push({ id, t: e.timeStamp });
|
||||
rep.textContent = JSON.stringify({ focusLog, keyLog }, null, 2);
|
||||
});
|
||||
el.addEventListener("keydown", (e) => {
|
||||
keyLog.push({ id, key: e.key, trusted: e.isTrusted, t: e.timeStamp });
|
||||
});
|
||||
}
|
||||
let sawPointerAfterStart = false;
|
||||
document.addEventListener("pointerdown", (e) => {
|
||||
if (e.target && e.target.id !== "start") sawPointerAfterStart = true;
|
||||
});
|
||||
document.getElementById("start").addEventListener("click", () => setTimeout(() => document.getElementById("start").focus(), 0));
|
||||
document.getElementById("f").addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const vals = {
|
||||
a: document.getElementById("a").value,
|
||||
b: document.getElementById("b").value,
|
||||
c: document.getElementById("c").value
|
||||
};
|
||||
const bad = [];
|
||||
if (vals.a !== "one" || vals.b !== "two" || vals.c !== "three") bad.push(`values=${JSON.stringify(vals)}`);
|
||||
const seq = focusLog.map(x => x.id).join(">");
|
||||
for (const id of ids) if (!focusLog.some(x => x.id === id)) bad.push(`never focused ${id}`);
|
||||
const enter = keyLog.find(x => x.id === "submit" && x.key === "Enter");
|
||||
if (!enter) bad.push("no Enter keydown on submit button");
|
||||
else if (!enter.trusted) bad.push("Enter on submit isTrusted=false");
|
||||
const tabCount = keyLog.filter(x => x.key === "Tab").length;
|
||||
if (tabCount < 4) bad.push(`only ${tabCount} Tab keydowns (need ≥4)`);
|
||||
if (sawPointerAfterStart) bad.push("pointerdown after Start; flow was not keyboard-only");
|
||||
Challenge.log("keyboard-submit", { vals, seq, keyLog, sawPointerAfterStart });
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass(`keyboard-only focus sequence ${seq}`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>33 network and console capture</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: click button. Page emits console messages and performs a fetch. Benchmark harness should verify <code>chrome_list_console_messages</code> and <code>chrome_list_network_requests</code> captured them.</p>
|
||||
<button id="go" style="padding:16px 24px;font-size:18px">Emit console + fetch</button>
|
||||
<pre id="rep" style="font:12px monospace;background:#111;color:#eee;padding:12px;border-radius:6px"></pre>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "network-console-capture", instructions: "clear capture, click, then inspect console/network tools" });
|
||||
document.getElementById("go").addEventListener("click", async (e) => {
|
||||
console.log("pi-chrome-benchmark-console", { clicked: true, trusted: e.isTrusted });
|
||||
console.warn("pi-chrome-benchmark-warning");
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch("data:application/json,%7B%22ok%22%3Atrue%2C%22source%22%3A%22pi-chrome-benchmark%22%7D");
|
||||
data = await res.json();
|
||||
} catch (err) {
|
||||
return Challenge.fail("fetch failed: " + err.message);
|
||||
}
|
||||
document.getElementById("rep").textContent = JSON.stringify(data, null, 2);
|
||||
Challenge.log("fetch-result", data);
|
||||
if (data && data.ok && data.source === "pi-chrome-benchmark") {
|
||||
Challenge.pass("page fetch completed; verify tool-level console/network capture separately");
|
||||
} else {
|
||||
Challenge.fail("unexpected fetch payload: " + JSON.stringify(data));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>34 dialog handling</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: handle browser dialogs. Click Prompt, enter <code>pi-chrome</code>, accept it; then click Confirm and accept it. Browser automation must expose/dismiss native dialogs.</p>
|
||||
<button id="promptBtn">Prompt</button>
|
||||
<button id="confirmBtn">Confirm</button>
|
||||
<button id="alertBtn">Alert smoke</button>
|
||||
<pre id="rep" class="code"></pre>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "dialog-handling", instructions: "accept prompt('pi-chrome') and confirm()" });
|
||||
const state = { prompt: null, confirm: null, alert: false };
|
||||
function paint(){ rep.textContent = JSON.stringify(state, null, 2); }
|
||||
function check(){
|
||||
paint();
|
||||
if (state.prompt === "pi-chrome" && state.confirm === true) Challenge.pass("prompt and confirm handled with expected values");
|
||||
}
|
||||
promptBtn.addEventListener("click", () => { state.prompt = prompt("Enter token", ""); Challenge.log("prompt-result", { value: state.prompt }); check(); });
|
||||
confirmBtn.addEventListener("click", () => { state.confirm = confirm("Accept benchmark confirm?"); Challenge.log("confirm-result", { value: state.confirm }); check(); });
|
||||
alertBtn.addEventListener("click", () => { alert("pi-chrome alert smoke"); state.alert = true; Challenge.log("alert-dismissed", {}); paint(); });
|
||||
window.addEventListener("beforeunload", e => { e.preventDefault(); e.returnValue = "benchmark beforeunload"; });
|
||||
paint();
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>35 target blank popup</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: click <code>target=_blank</code> link, detect new tab, switch to it, and verify child page reports PASS.</p>
|
||||
<a id="open" target="_blank" rel="opener" href="35-target-blank-popup.html?child=1" style="font-size:18px">Open child tab</a>
|
||||
<p id="msg"></p>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "target-blank-popup", instructions: "open child tab and inspect it" });
|
||||
const qs = new URLSearchParams(location.search);
|
||||
if (qs.get("child") === "1") {
|
||||
document.getElementById("msg").textContent = "Child tab opened.";
|
||||
Challenge.pass("child tab loaded on same origin");
|
||||
try { localStorage.setItem("pi-chrome-suite:target-blank-popup", JSON.stringify({ id:"target-blank-popup", verdict:"PASS", reason:["✓ child tab loaded"], ts: Date.now() })); } catch {}
|
||||
} else {
|
||||
document.getElementById("msg").textContent = "Parent page. Link should open new tab.";
|
||||
function maybePassFromStorage(raw) {
|
||||
if (!raw) return;
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
if (v.verdict === "PASS") Challenge.pass("child reported PASS via storage event/localStorage");
|
||||
} catch {}
|
||||
}
|
||||
window.addEventListener("storage", e => {
|
||||
if (e.key === "pi-chrome-suite:target-blank-popup") maybePassFromStorage(e.newValue);
|
||||
});
|
||||
maybePassFromStorage(localStorage.getItem("pi-chrome-suite:target-blank-popup"));
|
||||
document.getElementById("open").addEventListener("click", e => Challenge.log("popup-click", { trusted: e.isTrusted }));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,49 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>36 modal focus trap</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: open modal, use Tab to cycle focus inside modal only, then Escape closes it and focus returns to opener.</p>
|
||||
<button id="open">Open modal</button>
|
||||
<div id="modal" role="dialog" aria-modal="true" aria-label="Focus trap modal" hidden style="position:fixed;inset:20%;background:#222;border:2px solid #6cf;border-radius:10px;padding:20px;z-index:5">
|
||||
<button id="first">First</button>
|
||||
<input id="middle" aria-label="Modal input">
|
||||
<button id="last">Last</button>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "modal-focus-trap", instructions: "open modal, Tab cycles inside, Escape closes" });
|
||||
const order = [];
|
||||
const modal = document.getElementById("modal");
|
||||
const nodes = [first, middle, last];
|
||||
let escaped = false, modalOpened = false, outsideFocusWhileOpen = false;
|
||||
for (const el of [open, ...nodes]) el.addEventListener("focus", () => { order.push(el.id); Challenge.log("focus", { id: el.id }); });
|
||||
document.addEventListener("focusin", e => {
|
||||
if (modalOpened && !modal.hidden && !modal.contains(e.target)) outsideFocusWhileOpen = true;
|
||||
});
|
||||
open.addEventListener("click", () => { modal.hidden = false; modalOpened = true; first.focus(); });
|
||||
document.addEventListener("keydown", e => {
|
||||
if (modal.hidden) return;
|
||||
if (e.key === "Tab") {
|
||||
const i = nodes.indexOf(document.activeElement);
|
||||
if (i >= 0) { e.preventDefault(); nodes[(i + (e.shiftKey ? -1 : 1) + nodes.length) % nodes.length].focus(); }
|
||||
}
|
||||
if (e.key === "Escape") { modal.hidden = true; escaped = true; open.focus(); queueMicrotask(check); }
|
||||
});
|
||||
function hasAdjacent(seq, a, b) {
|
||||
return seq.some((x, i) => x === a && seq[i + 1] === b);
|
||||
}
|
||||
function check(){
|
||||
const afterOpen = order.slice(order.indexOf("first"));
|
||||
const bad = [];
|
||||
if (!escaped) bad.push("Escape did not close modal");
|
||||
if (document.activeElement !== open) bad.push("focus did not return to opener");
|
||||
if (!afterOpen.includes("middle") || !afterOpen.includes("last")) bad.push(`focus cycle incomplete: ${afterOpen.join(">")}`);
|
||||
if (!hasAdjacent(afterOpen, "last", "first") && !hasAdjacent(afterOpen, "first", "last")) bad.push(`no wrap-around focus trap observed: ${afterOpen.join(">")}`);
|
||||
if (outsideFocusWhileOpen) bad.push("focus escaped outside modal while open");
|
||||
if (bad.length) Challenge.fail(...bad); else Challenge.pass(`modal focus trap ok: ${afterOpen.join(">")}`);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>37 autocomplete combobox</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: type <code>as</code>, use ArrowDown then Enter to choose <code>Aster Lamp</code> from ARIA combobox.</p>
|
||||
<label>Product <input id="combo" role="combobox" aria-autocomplete="list" aria-controls="list" aria-expanded="false" autocomplete="off"></label>
|
||||
<ul id="list" role="listbox" style="border:1px solid #555;max-width:260px;padding:6px;display:none"></ul>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "autocomplete-combobox", instructions: "type 'as', ArrowDown, Enter to choose Aster Lamp" });
|
||||
const items = ["Aster Lamp", "Aspen Desk", "Cable Kit", "Desk Mat", "Red Mug"];
|
||||
let active = -1, opened = false, keyPath = [];
|
||||
combo.addEventListener("input", renderList);
|
||||
combo.addEventListener("keydown", e => {
|
||||
keyPath.push(e.key);
|
||||
const opts = [...list.querySelectorAll('[role="option"]')];
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); active = Math.min(active + 1, opts.length - 1); paintActive(opts); }
|
||||
if (e.key === "Enter" && active >= 0 && opts[active]) { e.preventDefault(); combo.value = opts[active].textContent; close(); check(e); }
|
||||
});
|
||||
function renderList(){
|
||||
const q = combo.value.toLowerCase();
|
||||
const matches = items.filter(x => x.toLowerCase().includes(q));
|
||||
list.innerHTML = matches.map((x,i)=>`<li role="option" id="opt-${i}" style="padding:4px;cursor:pointer">${x}</li>`).join("");
|
||||
list.style.display = matches.length ? "block" : "none";
|
||||
combo.setAttribute("aria-expanded", matches.length ? "true" : "false");
|
||||
active = -1; opened ||= matches.length > 0;
|
||||
}
|
||||
function paintActive(opts){ opts.forEach((o,i)=>o.style.background = i===active ? "#2b4b6b" : ""); if(opts[active]) combo.setAttribute("aria-activedescendant", opts[active].id); }
|
||||
function close(){ list.style.display="none"; combo.setAttribute("aria-expanded","false"); }
|
||||
function check(e){
|
||||
const bad=[];
|
||||
if (!opened) bad.push("listbox never opened");
|
||||
if (combo.value !== "Aster Lamp") bad.push(`selected ${combo.value}`);
|
||||
if (!keyPath.includes("ArrowDown") || !keyPath.includes("Enter")) bad.push(`missing keyboard selection path: ${keyPath.join(",")}`);
|
||||
if (!e.isTrusted) bad.push("Enter key isTrusted=false");
|
||||
if (bad.length) Challenge.fail(...bad); else Challenge.pass("ARIA combobox selected via keyboard");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>38 SPA route change</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: navigate within SPA using links/buttons. URL must change via <code>history.pushState</code> without full reload. Then go Back to Settings, forward to Billing, and Save.</p>
|
||||
<nav><a href="/settings" id="settings">Settings</a> <a href="/settings/billing" id="billing">Billing</a></nav>
|
||||
<section id="view"></section>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "spa-route-change", instructions: "Settings → Billing → Back → Billing → Save, no full reload" });
|
||||
let navs = 0, popstates = 0;
|
||||
window.__spaBootId = window.__spaBootId || Math.random().toString(36).slice(2);
|
||||
const bootId = window.__spaBootId;
|
||||
const navigationEntriesAtBoot = performance.getEntriesByType("navigation").length;
|
||||
function route(path){ history.pushState({}, "", path); navs++; render(); Challenge.log("route", { path, navs }); }
|
||||
settings.addEventListener("click", e => { e.preventDefault(); route("/settings"); });
|
||||
billing.addEventListener("click", e => { e.preventDefault(); route("/settings/billing"); });
|
||||
window.addEventListener("popstate", () => { popstates++; render(); Challenge.log("popstate", { path: location.pathname, popstates }); });
|
||||
function render(){
|
||||
if (location.pathname.endsWith("/settings/billing")) view.innerHTML = `<h2>Billing</h2><button id="save">Save billing settings</button>`;
|
||||
else if (location.pathname.endsWith("/settings")) view.innerHTML = `<h2>Settings</h2><p>Choose Billing.</p>`;
|
||||
else view.innerHTML = `<h2>Home</h2>`;
|
||||
const save = document.getElementById("save");
|
||||
if (save) save.addEventListener("click", e => {
|
||||
const bad=[];
|
||||
if (!location.pathname.endsWith("/settings/billing")) bad.push(`wrong route ${location.pathname}`);
|
||||
if (navs < 2) bad.push(`only ${navs} SPA route changes`);
|
||||
if (popstates < 1) bad.push("browser Back/popstate was not observed");
|
||||
if (window.__spaBootId !== bootId) bad.push("window sentinel changed; reload detected");
|
||||
if (performance.getEntriesByType("navigation").length !== navigationEntriesAtBoot) bad.push("navigation entry count changed; reload suspected");
|
||||
if (!e.isTrusted) bad.push("save click isTrusted=false");
|
||||
if (bad.length) Challenge.fail(...bad); else Challenge.pass("SPA pushState flow completed without reload");
|
||||
});
|
||||
}
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'">
|
||||
<title>39 strict CSP fallback</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: page blocks <code>unsafe-eval</code>. Agent should fall back to screenshot/coordinate observation and click the target without relying on snapshot/evaluate.</p>
|
||||
<p id="hint">The green target is fixed at known viewport coordinates. Use a screenshot, then click inside it.</p>
|
||||
<button id="cspTarget" aria-label="strict CSP target">CSP TARGET</button>
|
||||
</main>
|
||||
<script src="39-strict-csp-fallback.js"></script>
|
||||
</body>
|
||||
@@ -0,0 +1,27 @@
|
||||
Challenge.init({ id: "strict-csp-fallback", instructions: "use screenshot/coordinates; click CSP TARGET" });
|
||||
|
||||
const btn = document.getElementById("cspTarget");
|
||||
btn.style.cssText = [
|
||||
"position:fixed",
|
||||
"left:220px",
|
||||
"top:220px",
|
||||
"width:180px",
|
||||
"height:72px",
|
||||
"font:700 16px system-ui",
|
||||
"background:#1f7a1f",
|
||||
"color:white",
|
||||
"border:0",
|
||||
"border-radius:10px",
|
||||
"box-shadow:0 0 0 4px rgba(31,122,31,.25)"
|
||||
].join(";");
|
||||
|
||||
document.getElementById("cspTarget").addEventListener("click", (e) => {
|
||||
const r = btn.getBoundingClientRect();
|
||||
const bad = [];
|
||||
if (!e.isTrusted) bad.push("click isTrusted=false");
|
||||
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) {
|
||||
bad.push(`click coordinates ${e.clientX},${e.clientY} outside target rect ${Math.round(r.left)},${Math.round(r.top)},${Math.round(r.right)},${Math.round(r.bottom)}`);
|
||||
}
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass("strict CSP page completed via trusted viewport click");
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>40 dynamic wait readiness</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: wait until an async control exists and is enabled, then click it. Direct early clicks or fixed sleeps should not be required.</p>
|
||||
<div id="status" role="status">Loading async action…</div>
|
||||
<div id="mount" style="margin-top:20px"></div>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "dynamic-wait-readiness", instructions: "wait for async button, then click" });
|
||||
const start = performance.now();
|
||||
setTimeout(() => {
|
||||
const btn = document.createElement("button");
|
||||
btn.id = "readyAction";
|
||||
btn.textContent = "Run ready action";
|
||||
btn.disabled = true;
|
||||
btn.style.cssText = "padding:14px 22px;font-size:16px;background:#1f7a1f;color:#fff;border:0;border-radius:6px";
|
||||
document.getElementById("mount").appendChild(btn);
|
||||
document.getElementById("status").textContent = "Button mounted; enabling soon…";
|
||||
Challenge.log("mounted", { at: performance.now() - start });
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.dataset.ready = "true";
|
||||
document.getElementById("status").textContent = "Ready.";
|
||||
Challenge.log("enabled", { at: performance.now() - start });
|
||||
}, 450);
|
||||
btn.addEventListener("click", (e) => {
|
||||
const elapsed = performance.now() - start;
|
||||
const bad = [];
|
||||
if (!e.isTrusted) bad.push("click isTrusted=false");
|
||||
if (btn.disabled || btn.dataset.ready !== "true") bad.push("clicked before enabled/ready");
|
||||
if (elapsed < 650) bad.push(`clicked too early at ${elapsed.toFixed(0)}ms`);
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass(`waited for async ready state (${elapsed.toFixed(0)}ms)`);
|
||||
});
|
||||
}, 350);
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>41 tab lifecycle</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: create a new tab, inspect it, close it, return to parent, then verify lifecycle state.</p>
|
||||
<p id="msg"></p>
|
||||
<button id="verify">Verify tab lifecycle</button>
|
||||
</main>
|
||||
<script>
|
||||
Challenge.init({ id: "tab-lifecycle", instructions: "new tab → inspect child → close child → return parent → Verify" });
|
||||
const qs = new URLSearchParams(location.search);
|
||||
const key = "pi-chrome-suite:tab-lifecycle:state";
|
||||
function readState(){ try { return JSON.parse(localStorage.getItem(key) || "{}"); } catch { return {}; } }
|
||||
function replaceState(next){ localStorage.setItem(key, JSON.stringify({ ...next, ts: Date.now() })); }
|
||||
function writeState(patch){ localStorage.setItem(key, JSON.stringify({ ...readState(), ...patch, ts: Date.now() })); }
|
||||
|
||||
if (qs.get("child") === "1") {
|
||||
document.title = "[CHILD] 41 tab lifecycle";
|
||||
document.getElementById("msg").textContent = "Child tab opened. Close this tab, then return to parent.";
|
||||
writeState({ opened: true, childUrl: location.href, childTitle: document.title });
|
||||
window.addEventListener("pagehide", () => writeState({ closed: true, closedAt: Date.now() }));
|
||||
window.addEventListener("beforeunload", () => writeState({ closed: true, closedAt: Date.now() }));
|
||||
// Do not call Challenge.pass here: parent verification must be the only suite PASS.
|
||||
Challenge.log("child-loaded", { url: location.href });
|
||||
} else {
|
||||
document.getElementById("msg").textContent = "Parent tab. Runner should open this same file with ?child=1 in a new tab, inspect it, close it, then return here.";
|
||||
replaceState({ parentLoaded: true, closed: false });
|
||||
document.getElementById("verify").addEventListener("click", (e) => {
|
||||
const s = readState();
|
||||
const bad = [];
|
||||
if (!e.isTrusted) bad.push("verify click isTrusted=false");
|
||||
if (!s.opened) bad.push("child tab did not record opened=true");
|
||||
if (!s.childUrl || !s.childUrl.includes("child=1")) bad.push("child URL not recorded");
|
||||
if (!s.closed) bad.push("child tab close/pagehide was not recorded");
|
||||
Challenge.log("tab-state", s);
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass("new/inspect/close/return tab lifecycle completed");
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'">
|
||||
<title>42 strict CSP evaluate/snapshot</title>
|
||||
<link rel="stylesheet" href="../_style.css">
|
||||
<script src="../_lib.js"></script>
|
||||
<body>
|
||||
<main>
|
||||
<p>Goal: this page ships a strict CSP (<code>script-src 'self'</code>, no <code>unsafe-eval</code>), which blocks <code>eval</code>/<code>new Function</code>. <code>chrome_evaluate</code> and <code>chrome_snapshot</code> must still work because they run through CDP, which is not subject to page CSP.</p>
|
||||
<p id="hint">A secret token is exposed only at <code>window.__cspToken</code> — it is never written into the DOM. Use <code>chrome_evaluate</code> to read it, type it into the field (snapshot/uid to find the field), then click Verify.</p>
|
||||
<label for="tokenInput">Token:</label>
|
||||
<input id="tokenInput" type="text" autocomplete="off" aria-label="csp token">
|
||||
<button id="verify" aria-label="verify token">Verify</button>
|
||||
</main>
|
||||
<script src="42-strict-csp-evaluate.js"></script>
|
||||
</body>
|
||||
@@ -0,0 +1,21 @@
|
||||
Challenge.init({
|
||||
id: "strict-csp-evaluate",
|
||||
instructions: "under strict CSP: read window.__cspToken via chrome_evaluate, type it into the field, click Verify",
|
||||
});
|
||||
|
||||
// Secret available only via JS evaluation. It is intentionally NOT rendered into the DOM and
|
||||
// is defined non-enumerable, so the only way to obtain it is to evaluate window.__cspToken in
|
||||
// the page (which proves chrome_evaluate works despite script-src 'self' blocking eval).
|
||||
const token = "csp-" + Math.random().toString(36).slice(2, 10);
|
||||
Object.defineProperty(window, "__cspToken", { value: token, enumerable: false, configurable: false, writable: false });
|
||||
|
||||
document.getElementById("verify").addEventListener("click", (e) => {
|
||||
const bad = [];
|
||||
if (!e.isTrusted) bad.push("verify click isTrusted=false (use trusted/CDP input)");
|
||||
const val = (document.getElementById("tokenInput").value || "").trim();
|
||||
if (val !== token) {
|
||||
bad.push(`token mismatch: got "${val}" expected "${token}" — chrome_evaluate must read window.__cspToken under strict CSP`);
|
||||
}
|
||||
if (bad.length) Challenge.fail(...bad);
|
||||
else Challenge.pass("strict CSP: chrome_evaluate read the hidden token via CDP and trusted input submitted it");
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
pi-chrome upload fixture
|
||||
@@ -0,0 +1,12 @@
|
||||
window.MiniShopCheats = {
|
||||
"mini-shop-red-second-cheapest": [
|
||||
{ tool: "chrome_click", params: { selector: "button[aria-label=\"add Ruby Notebook\"]" } },
|
||||
{ tool: "chrome_click", params: { selector: "#checkout" } }
|
||||
],
|
||||
run: {
|
||||
"mini-shop-red-second-cheapest": async () => {
|
||||
document.querySelector('button[aria-label="add Ruby Notebook"]').click();
|
||||
document.querySelector('#checkout').click();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
window.MiniShopGrader = {
|
||||
validate(task, state, products, run, seed = 0) {
|
||||
let bad = [];
|
||||
const rubric = {};
|
||||
if (task === 'mini-shop-red-second-cheapest') {
|
||||
const reds = products.filter(p => p.color === 'red').sort((a,b)=>a.price-b.price);
|
||||
const target = reds[1];
|
||||
rubric.addedTarget = state.cart.includes(target.id);
|
||||
rubric.noWrongItems = state.cart.every(id => id === target.id);
|
||||
rubric.checkedOut = /^MS-\d{4}$/.test(state.orderId || '');
|
||||
if (!rubric.addedTarget) bad.push(`cart missing second-cheapest red item ${target.name}`);
|
||||
const wrong = state.cart.filter(id => id !== target.id);
|
||||
if (wrong.length) bad.push(`cart contains wrong product(s): ${wrong.join(',')}`);
|
||||
if (!rubric.checkedOut) bad.push('missing checkout order id');
|
||||
} else bad.push(`unknown task ${task}`);
|
||||
const passed = bad.length === 0;
|
||||
return { reward: passed ? 1 : 0, done: passed, message: passed ? 'all deterministic mini-shop checks passed' : bad.join('; '), info: { task, run, seed, rubric, bad, state } };
|
||||
},
|
||||
grade(task, state, products, run, seed = 0) {
|
||||
const validation = this.validate(task, state, products, run, seed);
|
||||
const verdict = validation.done ? 'PASS' : 'PENDING';
|
||||
window.__taskId = task;
|
||||
window.__taskVerdict = verdict;
|
||||
window.__taskReason = validation.done ? [validation.message] : validation.info.bad;
|
||||
window.__taskValidation = validation;
|
||||
try { localStorage.setItem(`pi-chrome-task:${task}:${run}`, JSON.stringify({ id: task, run, seed, verdict, reason: window.__taskReason, validation, state, ts: Date.now() })); } catch {}
|
||||
return validation;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>MiniShop hermetic site</title>
|
||||
<link rel="stylesheet" href="../../../_style.css">
|
||||
<style>
|
||||
main{max-width:1100px}.products{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px}.card{border:1px solid #333;border-radius:10px;background:#202020;padding:12px}.cart{position:sticky;top:8px;background:#111;border:1px solid #555;border-radius:10px;padding:12px;margin-bottom:12px}.red{color:#ff8b8b}.blue{color:#8bbcff}.green{color:#9f9}.muted{color:#aaa;font-size:12px}</style>
|
||||
<body>
|
||||
<main>
|
||||
<h1>MiniShop</h1>
|
||||
<div class="cart"><b>Cart</b> <span id="cartCount">0</span> items <button id="checkout" data-task-role="checkout">Checkout</button> <span id="order"></span> <span id="reward" class="muted"></span><div id="cartItems"></div></div>
|
||||
<p id="taskText"></p>
|
||||
<section class="products" id="productsEl"></section>
|
||||
</main>
|
||||
<script src="grader.js?v=2"></script>
|
||||
<script src="cheats.js?v=2"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const params=new URLSearchParams(location.search);
|
||||
if(!params.get('run')){params.set('run', crypto.randomUUID ? crypto.randomUUID() : String(Date.now())); history.replaceState(null,'',`?${params}`);}
|
||||
const task=params.get('task')||'mini-shop-red-second-cheapest'; const run=params.get('run'); const seed=Number(params.get('seed')||0);
|
||||
const key=`mini-shop:${task}:${run}:${seed}`;
|
||||
const products=[
|
||||
{id:'p1',name:'Crimson Mug',color:'red',price:12.00},
|
||||
{id:'p2',name:'Ruby Notebook',color:'red',price:7.50},
|
||||
{id:'p3',name:'Scarlet Pen',color:'red',price:3.25},
|
||||
{id:'p4',name:'Blue Cable',color:'blue',price:5.10},
|
||||
{id:'p5',name:'Green Mat',color:'green',price:9.00},
|
||||
{id:'p6',name:'Red Stapler',color:'red',price:18.00},
|
||||
{id:'p7',name:'Blue Lamp',color:'blue',price:22.00},
|
||||
{id:'p8',name:'Green Clip',color:'green',price:2.40}
|
||||
];
|
||||
let state=JSON.parse(localStorage.getItem(key)||'{"cart":[],"orderId":"","actionCount":0,"startedAt":0}');
|
||||
if(!state.startedAt) state.startedAt=Date.now();
|
||||
taskText.textContent='Task: add the second-cheapest red product to cart, checkout, and leave order id visible.';
|
||||
function save(){localStorage.setItem(key,JSON.stringify(state)); window.__miniShopState=state; const v=MiniShopGrader.grade(task,state,products,run,seed); window.__taskReward=v.reward; window.__taskTerminated=v.done; window.__taskInfo=v.info; reward.textContent=`reward=${v.reward} actions=${state.actionCount||0}`; renderCart();}
|
||||
function assignBids(){let n=0; document.querySelectorAll('button,a,input,select,textarea').forEach(el=>{const bid='b'+(++n).toString(36); el.setAttribute('bid',bid); el.setAttribute('data-bid',bid); const r=el.getBoundingClientRect(); el.setAttribute('browsergym_visibility_ratio',(r.width&&r.height?1:0).toString());});}
|
||||
function render(){productsEl.innerHTML=''; products.forEach(p=>{const d=document.createElement('div');d.className='card';d.innerHTML=`<h3 class="${p.color}">${p.name}</h3><p>Color: ${p.color}</p><p>Price: $${p.price.toFixed(2)}</p><button aria-label="add ${p.name}" data-id="${p.id}" data-task-role="add-to-cart">Add to cart</button>`;productsEl.appendChild(d)});document.querySelectorAll('[data-id]').forEach(b=>b.onclick=()=>{state.actionCount=(state.actionCount||0)+1;state.cart.push(b.dataset.id);save()});renderCart();assignBids();}
|
||||
function renderCart(){
|
||||
cartCount.textContent=state.cart.length; order.textContent=state.orderId?`Order ${state.orderId}`:'';
|
||||
cartItems.innerHTML=state.cart.map((id,i)=>{const p=products.find(x=>x.id===id);return `<div>${p?.name||id} <button data-remove-index="${i}" aria-label="remove ${p?.name||id}" data-task-role="remove-from-cart">Remove</button></div>`}).join('');
|
||||
cartItems.querySelectorAll('[data-remove-index]').forEach(b=>b.onclick=()=>{state.actionCount=(state.actionCount||0)+1;state.cart.splice(Number(b.dataset.removeIndex),1);save();render();});
|
||||
assignBids();
|
||||
}
|
||||
function orderId(){ return 'MS-' + String(1000 + ((seed * 2654435761 + state.cart.join('').length * 97) % 9000)).padStart(4, '0'); }
|
||||
checkout.onclick=()=>{state.actionCount=(state.actionCount||0)+1;state.orderId=orderId();save();};
|
||||
function observation(){return {goal:taskText.textContent,url:location.href,title:document.title,reward:window.__taskReward,actionCount:state.actionCount||0,focusedElementBid:document.activeElement?.getAttribute('bid')||null};}
|
||||
async function runCheat(){const fn=window.MiniShopCheats?.run?.[task]; if(fn) await fn(); else for(const step of (window.MiniShopCheats?.[task]||[])) console.log('cheat step',step); save();}
|
||||
window.__miniShopState=state; window.__miniShopProducts=products; window.__miniShopTask=task;
|
||||
window.__task={id:task,run,seed,goal:taskText.textContent,difficulty:'L2',step:()=>{const v=MiniShopGrader.grade(task,state,products,run,seed);return {observation:observation(),reward:v.reward,terminated:v.done,truncated:false,info:v.info}},reset:()=>{localStorage.removeItem(key);state={cart:[],orderId:'',actionCount:0,startedAt:Date.now()};render();save();return {observation:observation(),info:{task,run,seed}}},rubric:()=>MiniShopGrader.validate(task,state,products,run,seed).info.rubric,cheat:runCheat,validate:()=>MiniShopGrader.validate(task,state,products,run,seed),teardown:()=>undefined};
|
||||
window.__miniShop=window.__task;
|
||||
window.__miniShop.cheatHelpers={click:(sel)=>document.querySelector(sel).click()};
|
||||
render(); save();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"id": "mini-shop-red-second-cheapest",
|
||||
"difficulty": "L2",
|
||||
"intent": "Add the second-cheapest red product to cart, checkout, and leave the generated order id visible.",
|
||||
"gradeSource": "harness",
|
||||
"grader": "MiniShopGrader.grade(task, state, products, run)"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,193 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>pi-chrome browser-control benchmark</title>
|
||||
<link rel="stylesheet" href="_style.css">
|
||||
<body>
|
||||
<main style="max-width:1280px">
|
||||
<h1>pi-chrome browser-control benchmark</h1>
|
||||
<p>This benchmark measures how well Chrome-control tools let agents do real browser work: DOM discovery, trusted input, keyboard/focus, scroll, drag/drop, files, frames, clipboard, and observability.</p>
|
||||
|
||||
<section class="panel">
|
||||
<div class="controls">
|
||||
<button id="refresh">Refresh verdicts</button>
|
||||
<button id="clear">Clear local verdicts</button>
|
||||
<button id="copy">Copy JSON report</button>
|
||||
<label>Filter <input id="filter" placeholder="category, id, expected..." /></label>
|
||||
<label>Mode
|
||||
<select id="mode">
|
||||
<option value="trusted">trusted</option>
|
||||
<option value="synthetic">synthetic</option>
|
||||
<option value="manual">manual</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">Open a row, drive it with <code>chrome_*</code> tools, then return here. Verdicts are read from <code>localStorage</code>. Expected outcomes come from <code>manifest.json</code>.</p>
|
||||
<textarea id="copyFallback" class="copy-fallback" readonly aria-label="JSON report fallback"></textarea>
|
||||
</section>
|
||||
|
||||
<div id="summary" class="summary"></div>
|
||||
<table id="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>id</th><th>gate</th><th>category</th><th>verdict</th><th>expected</th><th>baseline</th><th>risk</th><th>goal / notes</th><th>open</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<h2>Long-horizon hermetic tasks</h2>
|
||||
<p class="hint">These WebArena/BrowserGym-inspired tasks use deterministic in-page graders and fresh <code>$RUN_ID</code> state. They test multi-step browser work beyond event fidelity.</p>
|
||||
<table id="taskTbl">
|
||||
<thead><tr><th>id</th><th>difficulty</th><th>category</th><th>intent</th><th>requires</th><th>open</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<h2>Agent run loop</h2>
|
||||
<pre class="code">python3 -m http.server 8765
|
||||
# For each manifest entry:
|
||||
# 1. chrome_navigate http://127.0.0.1:8765/<file>
|
||||
# 2. chrome_snapshot before acting; prefer uid selectors.
|
||||
# 3. Execute recipe with selected mode, adapting descriptive frame/shadow selectors to tool uids.
|
||||
# 4. chrome_evaluate JSON.stringify({v:window.__verdict,r:window.__reason,e:window.__events?.slice(-20)})
|
||||
# 5. Compare verdict to manifest.expected[mode]; CONDITIONAL means inspect prerequisites/notes.</pre>
|
||||
</main>
|
||||
<script>
|
||||
let manifest = [];
|
||||
let taskManifest = [];
|
||||
const tbody = document.querySelector("#tbl tbody");
|
||||
const taskTbody = document.querySelector("#taskTbl tbody");
|
||||
const modeEl = document.getElementById("mode");
|
||||
const filterEl = document.getElementById("filter");
|
||||
|
||||
function verdictFor(id) {
|
||||
const raw = localStorage.getItem("pi-chrome-suite:" + id);
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw); } catch { return null; }
|
||||
}
|
||||
function classFor(v) {
|
||||
if (!v) return "pending";
|
||||
return String(v).toLowerCase();
|
||||
}
|
||||
function expectedClass(actual, expected) {
|
||||
if (!actual) return "pending";
|
||||
if (expected === "CONDITIONAL") return actual === "PASS" ? "ok" : "conditional";
|
||||
return actual === expected ? "ok" : "mismatch";
|
||||
}
|
||||
function report() {
|
||||
const mode = modeEl.value;
|
||||
return manifest.map(m => {
|
||||
const v = verdictFor(m.id);
|
||||
return {
|
||||
id: m.id,
|
||||
category: m.category,
|
||||
gate: m.gate ?? "core",
|
||||
file: m.file,
|
||||
verdict: v?.verdict ?? "PENDING",
|
||||
reason: v?.reason ?? [],
|
||||
expected: m.expected?.[mode] ?? "—",
|
||||
timestamp: v?.ts ?? null,
|
||||
goal: m.goal,
|
||||
prerequisites: m.prerequisites ?? [],
|
||||
notes: m.notes ?? []
|
||||
};
|
||||
});
|
||||
}
|
||||
function paint() {
|
||||
const q = filterEl.value.trim().toLowerCase();
|
||||
const mode = modeEl.value;
|
||||
tbody.innerHTML = "";
|
||||
const rows = report();
|
||||
const counts = rows.reduce((acc, r) => (acc[r.verdict] = (acc[r.verdict] || 0) + 1, acc), {});
|
||||
const mismatches = rows.filter(r => r.verdict !== "PENDING" && r.expected !== "CONDITIONAL" && r.expected !== "—" && r.verdict !== r.expected).length;
|
||||
const coreMismatches = rows.filter(r => (r.gate ?? "core") === "core" && r.verdict !== "PENDING" && r.expected !== "CONDITIONAL" && r.expected !== "—" && r.verdict !== r.expected).length;
|
||||
const gates = rows.reduce((acc, r) => (acc[r.gate ?? "core"] = (acc[r.gate ?? "core"] || 0) + 1, acc), {});
|
||||
document.getElementById("summary").innerHTML = `
|
||||
<span class="pill pass">PASS ${counts.PASS || 0}</span>
|
||||
<span class="pill fail">FAIL ${counts.FAIL || 0}</span>
|
||||
<span class="pill skip">SKIP ${counts.SKIP || 0}</span>
|
||||
<span class="pill warn">WARN ${counts.WARN || 0}</span>
|
||||
<span class="pill pending">PENDING ${counts.PENDING || 0}</span>
|
||||
<span class="pill ${coreMismatches ? "fail" : "pass"}">core unexpected ${coreMismatches}</span>
|
||||
<span class="pill ${mismatches ? "fail" : "pass"}">all unexpected ${mismatches}</span>
|
||||
<span class="pill expected">core ${gates.core || 0}</span>
|
||||
<span class="pill expected">conditional ${gates.conditional || 0}</span>
|
||||
<span class="pill expected">quality ${gates.quality || 0}</span>
|
||||
`;
|
||||
for (const m of manifest) {
|
||||
const v = verdictFor(m.id);
|
||||
const verdict = v?.verdict ?? "PENDING";
|
||||
const expected = m.expected?.[mode] ?? "—";
|
||||
const hay = JSON.stringify({ id:m.id, gate:m.gate, category:m.category, verdict, expected, goal:m.goal, notes:m.notes }).toLowerCase();
|
||||
if (q && !hay.includes(q)) continue;
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = expectedClass(verdict, expected);
|
||||
const reason = (v?.reason || []).join(" / ");
|
||||
const notes = [...(m.prerequisites || []).map(x => "pre: " + x), ...(m.notes || [])].join("; ");
|
||||
tr.innerHTML = `
|
||||
<td><code>${m.id}</code></td>
|
||||
<td><span class="pill expected">${m.gate || "core"}</span></td>
|
||||
<td>${m.category || "—"}</td>
|
||||
<td><span class="pill ${classFor(verdict)}">${verdict}</span><div class="reason">${escapeHtml(reason)}</div></td>
|
||||
<td><span class="pill expected">${expected}</span></td>
|
||||
<td><span class="pill ${m.manualBaseline === "verified" ? "pass" : "pending"}">${m.manualBaseline || "unverified"}</span></td>
|
||||
<td>${m.flakeRisk ? `<span class="risk">${m.flakeRisk}</span>` : ""}</td>
|
||||
<td><div>${escapeHtml(m.goal || "")}</div><div class="notes">${escapeHtml(notes)}</div><details><summary>recipe</summary><pre>${escapeHtml(JSON.stringify(m.recipe || [], null, 2))}</pre></details></td>
|
||||
<td><a target="_blank" href="${m.file}">open</a></td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]));
|
||||
}
|
||||
function paintTasks() {
|
||||
taskTbody.innerHTML = "";
|
||||
const run = "manual-" + new Date().toISOString().slice(0,10);
|
||||
for (const t of taskManifest) {
|
||||
const url = t.startUrl.replaceAll("$RUN_ID", run).replaceAll("$SEED", String(t.seed ?? 0));
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td><code>${t.taskId || t.id}</code><div class="notes">seed=${t.seed ?? 0} maxSteps=${t.maxSteps ?? "—"}</div></td>
|
||||
<td><span class="pill expected">${t.difficulty}</span></td>
|
||||
<td>${t.category}</td>
|
||||
<td>${escapeHtml(t.goal || t.intent)}</td>
|
||||
<td><div class="notes">${escapeHtml((t.requires || []).join(", "))}</div><div class="notes">actions: ${escapeHtml((t.actionSubsets || []).join(", "))}</div></td>
|
||||
<td><a target="_blank" href="${url}">open</a></td>
|
||||
`;
|
||||
taskTbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
async function init() {
|
||||
manifest = await fetch("manifest.json", { cache: "no-store" }).then(r => r.json());
|
||||
taskManifest = await fetch("task-manifest.json", { cache: "no-store" }).then(r => r.json()).catch(() => []);
|
||||
paint();
|
||||
paintTasks();
|
||||
}
|
||||
document.getElementById("refresh").addEventListener("click", paint);
|
||||
document.getElementById("clear").addEventListener("click", () => {
|
||||
if (!confirm("Clear pi-chrome-suite verdicts for this origin?")) return;
|
||||
for (const m of manifest) localStorage.removeItem("pi-chrome-suite:" + m.id);
|
||||
paint();
|
||||
});
|
||||
document.getElementById("copy").addEventListener("click", async () => {
|
||||
const text = JSON.stringify({ mode: modeEl.value, generatedAt: new Date().toISOString(), rows: report() }, null, 2);
|
||||
const box = document.getElementById("copyFallback");
|
||||
box.value = text;
|
||||
box.style.display = "block";
|
||||
box.focus();
|
||||
box.select();
|
||||
let copied = false;
|
||||
try { await navigator.clipboard.writeText(text); copied = true; } catch {}
|
||||
if (!copied) {
|
||||
try { copied = document.execCommand("copy"); } catch {}
|
||||
}
|
||||
box.dataset.copied = copied ? "true" : "false";
|
||||
console.log(text);
|
||||
});
|
||||
modeEl.addEventListener("change", paint);
|
||||
filterEl.addEventListener("input", paint);
|
||||
window.addEventListener("storage", paint);
|
||||
setInterval(paint, 1000);
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "pi-chrome benchmark manifest",
|
||||
"description": "Manifest for browser-control benchmark pages. Recipes are canonical intent, not guaranteed raw tool JSON: runners may need to adapt descriptive selectors (frame/shadow) and expand path placeholders such as ${REPO_ROOT} before invoking tools.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "file", "category", "goal", "expected", "recipe"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"file": { "type": "string" },
|
||||
"category": { "$ref": "#/$defs/category" },
|
||||
"difficulty": { "enum": ["L1", "L2", "L3"] },
|
||||
"gate": { "description": "Ship-gate bucket. core blocks releases, conditional requires declared capability/environment, quality is adversarial/flaky signal and should not block general ship.", "enum": ["core", "conditional", "quality"] },
|
||||
"tags": { "type": "array", "items": { "type": "string" } },
|
||||
"goal": { "type": "string" },
|
||||
"gradeSource": { "enum": ["page", "harness", "both"], "default": "page" },
|
||||
"expected": {
|
||||
"type": "object",
|
||||
"description": "Expected verdict by execution mode. CONDITIONAL means prerequisites, browser policy, hardware, or missing tool primitives decide outcome.",
|
||||
"required": ["synthetic", "trusted", "manual"],
|
||||
"properties": {
|
||||
"synthetic": { "$ref": "#/$defs/outcome" },
|
||||
"trusted": { "$ref": "#/$defs/outcome" },
|
||||
"manual": { "$ref": "#/$defs/outcome" }
|
||||
}
|
||||
},
|
||||
"manualBaseline": { "description": "Whether human baseline has been explicitly verified in this repo/environment.", "enum": ["unverified", "verified"] },
|
||||
"manualBaselineVerifiedAt": { "type": "string", "format": "date-time" },
|
||||
"recipe": {
|
||||
"type": "array",
|
||||
"description": "Ordered tool intent. Params may include descriptive selectors (e.g. frame/shadow notation) or path placeholders for runners to adapt.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["tool", "params"],
|
||||
"properties": {
|
||||
"tool": { "type": "string" },
|
||||
"params": { "type": "object", "description": "Tool params. For upload recipes, paths are strings after runner expansion; ${REPO_ROOT} means repository root." }
|
||||
}
|
||||
}
|
||||
},
|
||||
"requires": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"cdp": { "type": "boolean" },
|
||||
"clipboard": { "type": "boolean" },
|
||||
"touch": { "type": "boolean" },
|
||||
"secureOrigin": { "type": "boolean" },
|
||||
"fileSystem": { "type": "boolean" },
|
||||
"dialogs": { "type": "boolean" },
|
||||
"downloads": { "type": "boolean" },
|
||||
"popups": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"prerequisites": { "type": "array", "items": { "type": "string" } },
|
||||
"notes": { "type": "array", "items": { "type": "string" } },
|
||||
"thresholds": { "type": "object", "additionalProperties": true },
|
||||
"timeoutMs": { "type": "number" },
|
||||
"verdictDelayMs": { "type": "number" },
|
||||
"flakeRisk": { "enum": ["low", "medium", "high"] }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"outcome": { "enum": ["PASS", "FAIL", "SKIP", "WARN", "CONDITIONAL"] },
|
||||
"category": {
|
||||
"enum": [
|
||||
"activation-gates", "agent-safety", "aria-snapshot", "canvas-svg", "clipboard", "contextmenu", "csp", "dialogs", "dom-complexity", "downloads", "drag-drop", "editing", "files", "fingerprint", "focus-keyboard", "forms", "frames", "frameworks", "hover", "keyboard", "lazy-loading", "native-controls", "observability", "pointer-humanization", "popups", "scroll", "scroll-visibility", "spa-routing", "task-completion", "timing", "touch", "trusted-input"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# BrowserGym compatibility contract
|
||||
|
||||
Pi-chrome benchmark tasks mirror BrowserGym without importing Gymnasium.
|
||||
|
||||
## Task fields
|
||||
|
||||
Each `task-manifest.json` entry maps to `AbstractBrowserTask` concepts:
|
||||
|
||||
- `taskId` / `id` — stable namespaced id
|
||||
- `seed` — deterministic state seed
|
||||
- `viewport`, `slowMoMs`, `timeoutMs`, `locale`, `timezoneId`
|
||||
- `goal` and `goalObject` — text and OpenAI-style message objects
|
||||
- `setupUrl` — page URL that performs setup from `task`, `run`, `seed`
|
||||
- `validateScript` — JS validate hook returning reward contract
|
||||
- `cheatScript` — gold recipe used to sanity-check grader
|
||||
- `maxSteps`, `humanBaseline`, `difficulty`, `tags`
|
||||
- `actionSubsets`, `allowedActions` — BrowserGym HighLevelActionSet metadata
|
||||
|
||||
## Reset / step return
|
||||
|
||||
Runner should expose:
|
||||
|
||||
```text
|
||||
reset() -> (obs, info)
|
||||
step(action) -> (obs, reward, terminated, truncated, info)
|
||||
```
|
||||
|
||||
`terminated` means task done. `truncated` means max step or timeout.
|
||||
|
||||
## Validate return
|
||||
|
||||
In-page validators return:
|
||||
|
||||
```js
|
||||
{ reward, done, message, info }
|
||||
```
|
||||
|
||||
`reward` is incremental. Current hermetic tasks use binary terminal reward. `info.rubric` carries per-check details.
|
||||
|
||||
## Observation schema target
|
||||
|
||||
Runner observations should include BrowserGym-like keys:
|
||||
|
||||
- `chat_messages`
|
||||
- `goal`, `goal_object`
|
||||
- `open_pages_urls`, `open_pages_titles`, `active_page_index`
|
||||
- `url`, `screenshot`, `dom_object`, `axtree_object`
|
||||
- `extra_element_properties`
|
||||
- `focused_element_bid`
|
||||
- `last_action`, `last_action_error`
|
||||
- `elapsed_time`
|
||||
|
||||
## BID plan
|
||||
|
||||
BrowserGym uses `bid` attributes as stable element handles. Pi-chrome currently returns snapshot `uid`. Compatibility path:
|
||||
|
||||
1. During snapshot, assign every interactive element a deterministic `bid`.
|
||||
2. Return both `uid` and `bid`.
|
||||
3. Add `visibility`, `bbox`, `clickable`, `set_of_marks` metadata.
|
||||
4. Maintain uid↔bid mapping so agents can use either addressing mode.
|
||||
|
||||
Target attributes:
|
||||
|
||||
- `bid`
|
||||
- `browsergym_visibility_ratio`
|
||||
- `browsergym_set_of_marks`
|
||||
|
||||
## Action subsets
|
||||
|
||||
See `../browsergym-action-space.json` for BrowserGym-compatible subsets and function signatures.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Historical bypass notes
|
||||
|
||||
This file predates the current `trusted:true` / `chrome.debugger` path and is
|
||||
kept as design history. For current benchmark expectations, recipes, and
|
||||
capability notes, use `../manifest.json`.
|
||||
|
||||
Older context: `pi-chrome` originally ran mostly through a content-script bridge.
|
||||
That limited the solution space — most "real-event" tricks needed the
|
||||
`chrome.debugger` API or `chrome.input.synthesizeMouseEvent`.
|
||||
|
||||
## 01–02 isTrusted click / keyboard
|
||||
**Hard.** `Event.isTrusted` is true only for events the browser dispatched
|
||||
itself. Two viable paths:
|
||||
|
||||
- **`chrome.debugger` + `Input.dispatchMouseEvent` / `Input.dispatchKeyEvent`.**
|
||||
Adds `"debugger"` permission. Chrome shows a "X is debugging this browser"
|
||||
banner, but events arrive as `isTrusted=true`. Best fix for the highest-value
|
||||
cases; gate it behind a toggle the user opts into.
|
||||
- **`chrome.input.ime.*`** — not applicable for general typing.
|
||||
|
||||
Today's `dispatchEvent` path will always fail any `isTrusted` check.
|
||||
|
||||
## 03 / 12 webdriver + fingerprint
|
||||
Cheap wins:
|
||||
- Stub `navigator.webdriver` via `chrome.scripting.executeScript({ world: "MAIN", injectImmediately: true, … })` that defines `navigator.webdriver` getter to `undefined`. Already half-supported through `initScript` on `chrome_navigate` — extend to a per-tab content script registered with `chrome.scripting.registerContentScripts({ runAt: "document_start", world: "MAIN" })`.
|
||||
- Spoof languages/plugins/permissions only if the user explicitly enables it — by default the real Chrome profile already looks legitimate. The bigger risk is `chrome_evaluate` *itself* leaving traces (Function-constructor stack frames in errors).
|
||||
|
||||
## 04 mouse entropy
|
||||
Generate a humanised pointer path before any click:
|
||||
- Bezier interpolation from current cursor pos (or a random off-target start) to the target point.
|
||||
- 20–60 steps, easing curve, ±jitter on each step, varying `movementX/Y`.
|
||||
- Implement in `clickPage` before the existing pointerdown sequence; reuse `pointerEventSequence` for each interpolated point.
|
||||
|
||||
## 05 event timing
|
||||
- Insert `await sleep(rand(40,140))` between `pointerdown` and `pointerup`.
|
||||
- Insert `await sleep(rand(80,220))` between successive synthesised clicks when
|
||||
the caller issues them in a loop.
|
||||
- Vary by ±20%.
|
||||
|
||||
## 06 click coordinates
|
||||
- After resolving target rect in `clickPage`, pick `(cx ± rand(-rw*0.3,rw*0.3), cy ± rand(-rh*0.3,rh*0.3))` instead of dead center. Already returns the chosen point — keep that.
|
||||
|
||||
## 07 pointer properties
|
||||
- Set `pressure: 0.5` for `pointerdown` (currently inits don't set pressure; browser default for mouse pointerdown is 0.5).
|
||||
- Set `pointerId: 1` for mouse (already done) and 2+ for touch.
|
||||
- Carry `movementX/Y` from the previous interpolated step (needs path state).
|
||||
|
||||
## 08 / 09 keyboard cadence + framework invariants
|
||||
- Per character: dispatch `keydown` (cancelable, fills `key` + `code` + `keyCode`), then `keypress` for printables, then `beforeinput` `{inputType:"insertText", data:ch}`, then mutate the input's value to `cur+ch` via the native setter, then `input` `{inputType:"insertText", data:ch}`, then `keyup`. Sleep 40–120 ms between chars.
|
||||
- Today the bridge fills the entire value in one go and emits one `beforeinput`/`input`. Replace with the per-char loop above.
|
||||
- Make sure not to dispatch `compositionstart`/`compositionend` for plain ASCII.
|
||||
|
||||
## 10 user activation
|
||||
**Impossible with synthetic events.** `navigator.userActivation.isActive` only
|
||||
flips on browser-trusted events. Same fix as 01/02 — `chrome.debugger` path.
|
||||
Document the limitation; offer a `useDebuggerForActivationGates` opt-in.
|
||||
|
||||
## 11 honeypot
|
||||
Pure agent-side concern. Before filling/clicking by selector, the bridge could:
|
||||
- Reject targets with `display:none`, `visibility:hidden`, `aria-hidden=true`,
|
||||
or off-screen position (`getBoundingClientRect()` outside viewport bounds and
|
||||
not scrolled-into-view), unless the caller passes `force: true`.
|
||||
- That logic largely exists (`elementVisible` / `occludedBy` are returned) —
|
||||
promote it from informational to a guard.
|
||||
|
||||
## 13 focus order
|
||||
- When clicking a focusable element, dispatch `pointerdown` first; only after
|
||||
`mousedown` defaults run let focus settle naturally (browser will move focus
|
||||
on the synthetic `mousedown` only if `isTrusted` is true — alas, no). Manual
|
||||
workaround: dispatch the pointer/mouse sequence, then explicitly call
|
||||
`target.focus({ preventScroll: true })`, then dispatch `focus` with
|
||||
`{ relatedTarget: previouslyFocused }`. Set `:focus-visible` heuristic via
|
||||
`target.blur(); target.focus({ focusVisible: false })` (Chrome 124+).
|
||||
|
||||
## 14 wheel scroll
|
||||
- For scroll operations, dispatch sequential `wheel` events with `deltaY`
|
||||
chunks (e.g., 60–120 per tick), and let the browser scroll, instead of
|
||||
setting `scrollTop` directly. Provide `chrome_scroll({ uid, dy, dx })` as a
|
||||
first-class tool.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Calibration profiles
|
||||
|
||||
Benchmark results depend on Chrome profile, OS, input device, and extension mode.
|
||||
Record these with every run:
|
||||
|
||||
- OS and version
|
||||
- Chrome version
|
||||
- pi-chrome package version
|
||||
- companion extension version
|
||||
- trusted mode: off / auto / on
|
||||
- input hardware: trackpad vs detent mouse, touch support
|
||||
- permissions: clipboard, fullscreen, downloads
|
||||
- viewport size and deviceScaleFactor
|
||||
- loaded privacy/security extensions
|
||||
|
||||
Known environment-sensitive areas:
|
||||
|
||||
- WebGL fingerprint can warn/fail in VMs, remote desktops, GPU-disabled Chrome.
|
||||
- Scroll momentum expects trackpad-like decay; detent mouse wheels may fail manually.
|
||||
- Touch tests require touch events enabled/supported.
|
||||
- Dialog/download/file-picker tests require native browser UI handling or manual intervention.
|
||||
- Stack traces vary across Chrome versions; tests should only fail concrete automation URLs/Runtime frames.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Recipe runner spec
|
||||
|
||||
Future Node/Pi runner should:
|
||||
|
||||
1. Read `manifest.json`.
|
||||
2. Probe environment capabilities (`trusted`, `clipboard`, `touch`, `dialogs`, `downloads`, `fileSystem`, `strictCspFallback`).
|
||||
3. For each entry:
|
||||
- read `gate` (`core`, `conditional`, `quality`) and score in that bucket
|
||||
- skip if `requires` is unsatisfied and expected is `CONDITIONAL`
|
||||
- navigate to challenge URL
|
||||
- execute `recipe` in order
|
||||
- wait `verdictDelayMs || 500`
|
||||
- evaluate `JSON.stringify({v:window.__verdict,r:window.__reason,d:window.Challenge?.state?.details,e:window.__events?.slice(-20)})`, except strict-CSP pages where eval is expected to fail; read persisted verdict from dashboard/localStorage after leaving the CSP page
|
||||
- compare to `expected[mode]`
|
||||
4. Output:
|
||||
- Markdown summary
|
||||
- JSON details
|
||||
- JUnit XML for CI
|
||||
|
||||
Runner must adapt recipe intent:
|
||||
|
||||
- expand `${REPO_ROOT}` path placeholders
|
||||
- adapt unsupported shadow/iframe selector notation to snapshot uids or evaluate fallback
|
||||
- preserve hook install ordering for console/network capture tests
|
||||
- substitute dynamic tab ids from `chrome_tab list` recipes
|
||||
- support screenshot/coordinate fallback for strict CSP pages where snapshot/evaluate are blocked
|
||||
- record whether trusted/CDP path was used
|
||||
|
||||
Long-horizon task runner should read `task-manifest.json`, replace `$RUN_ID`, solve task, then evaluate task grader expression.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Benchmark scoring
|
||||
|
||||
## Unit challenges (`manifest.json`)
|
||||
|
||||
Score actual verdict against `expected[mode]`, not raw PASS count.
|
||||
|
||||
Gate buckets:
|
||||
|
||||
- `core`: headline release gate. Exact expected verdict required.
|
||||
- `conditional`: gate only when declared capability/prerequisite is present; otherwise report skipped/conditional.
|
||||
- `quality`: adversarial humanization/fingerprint signal. Report trend and regressions, but do not block general release unless explicitly promoted.
|
||||
|
||||
Expected values:
|
||||
|
||||
- `PASS` / `FAIL` expected: exact match required inside active gate bucket.
|
||||
- `CONDITIONAL`: exclude from core headline score unless environment prerequisite is declared present.
|
||||
- `SKIP` / `WARN`: report separately.
|
||||
|
||||
Recommended flake policy:
|
||||
|
||||
- Run each non-destructive challenge up to 2 retries.
|
||||
- Take best verdict only for known-flaky timing/scroll tests.
|
||||
- Always keep all reasons/events in detailed report.
|
||||
|
||||
Difficulty weighting:
|
||||
|
||||
- L1 = 1
|
||||
- L2 = 2
|
||||
- L3 = 3
|
||||
|
||||
Report both unweighted and weighted score. Also report per-category score.
|
||||
|
||||
## Long-horizon tasks (`task-manifest.json`)
|
||||
|
||||
Task score is deterministic grader PASS / not PASS. Record:
|
||||
|
||||
- action count
|
||||
- wall time
|
||||
- tools used
|
||||
- observation mode (`snapshot`, screenshot, accessibility, evaluate)
|
||||
- whether direct state mutation/evaluate was allowed
|
||||
- final grader reason
|
||||
|
||||
Do not use LLM judge for hermetic tasks unless grader cannot express the target.
|
||||
@@ -0,0 +1,49 @@
|
||||
window.ChoreDeskCheats = {
|
||||
"choredesk-l1-ticket-priority": [
|
||||
{ tool: "chrome_fill", params: { selector: "select[aria-label=\"T-104 priority\"]", text: "High" } },
|
||||
{ tool: "chrome_fill", params: { selector: "select[aria-label=\"T-104 status\"]", text: "In Progress" } },
|
||||
{ tool: "chrome_click", params: { selector: "button[aria-label=\"save tickets T-104\"]" } }
|
||||
],
|
||||
"choredesk-l2-refund-message": [
|
||||
{ tool: "chrome_click", params: { selector: "a[data-view=\"customers\"]" } },
|
||||
{ tool: "chrome_click", params: { selector: "a[data-view=\"orders\"]" } },
|
||||
{ tool: "chrome_click", params: { selector: "a[data-view=\"messages\"]" } },
|
||||
{ tool: "chrome_fill", params: { selector: "#msgSubject", text: "Refund review ORD-778" } },
|
||||
{ tool: "chrome_fill", params: { selector: "#msgBody", text: "Noah Kim noah.kim@example.com order ORD-778 total $84.20" } },
|
||||
{ tool: "chrome_click", params: { selector: "#sendMsg" } }
|
||||
],
|
||||
"choredesk-l3-restock-ticket": [
|
||||
{ tool: "chrome_click", params: { selector: "a[data-view=\"catalog\"]" } },
|
||||
{ tool: "chrome_click", params: { selector: "a[data-view=\"inventory\"]" } },
|
||||
{ tool: "chrome_fill", params: { selector: "input[aria-label=\"AST-LAMP reorder quantity\"]", text: "12" } },
|
||||
{ tool: "chrome_click", params: { selector: "button[aria-label=\"save inventory AST-LAMP\"]" } },
|
||||
{ tool: "chrome_click", params: { selector: "a[data-view=\"tickets\"]" } },
|
||||
{ tool: "chrome_fill", params: { selector: "input[aria-label=\"T-205 internal note\"]", text: "reordered 12 AST-LAMP" } },
|
||||
{ tool: "chrome_click", params: { selector: "button[aria-label=\"save tickets T-205\"]" } }
|
||||
],
|
||||
run: {
|
||||
"choredesk-l1-ticket-priority": async () => {
|
||||
await window.__choredesk.cheatHelpers.route("tickets");
|
||||
window.__choredesk.cheatHelpers.set("select[aria-label=\"T-104 priority\"]", "High");
|
||||
window.__choredesk.cheatHelpers.set("select[aria-label=\"T-104 status\"]", "In Progress");
|
||||
document.querySelector("button[aria-label=\"save tickets T-104\"]").click();
|
||||
},
|
||||
"choredesk-l2-refund-message": async () => {
|
||||
await window.__choredesk.cheatHelpers.route("customers");
|
||||
await window.__choredesk.cheatHelpers.route("orders");
|
||||
await window.__choredesk.cheatHelpers.route("messages");
|
||||
window.__choredesk.cheatHelpers.set("#msgSubject", "Refund review ORD-778");
|
||||
window.__choredesk.cheatHelpers.set("#msgBody", "Noah Kim noah.kim@example.com order ORD-778 total $84.20");
|
||||
document.querySelector("#sendMsg").click();
|
||||
},
|
||||
"choredesk-l3-restock-ticket": async () => {
|
||||
await window.__choredesk.cheatHelpers.route("catalog");
|
||||
await window.__choredesk.cheatHelpers.route("inventory");
|
||||
window.__choredesk.cheatHelpers.set("input[aria-label=\"AST-LAMP reorder quantity\"]", "12");
|
||||
document.querySelector("button[aria-label=\"save inventory AST-LAMP\"]").click();
|
||||
await window.__choredesk.cheatHelpers.route("tickets");
|
||||
window.__choredesk.cheatHelpers.set("input[aria-label=\"T-205 internal note\"]", "reordered 12 AST-LAMP");
|
||||
document.querySelector("button[aria-label=\"save tickets T-205\"]").click();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>ChoreDesk hermetic task site</title>
|
||||
<link rel="stylesheet" href="../../_style.css">
|
||||
<style>
|
||||
main { max-width: 1180px; }
|
||||
.top { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
|
||||
.tabs { display:flex; gap:8px; margin:16px 0; flex-wrap:wrap; }
|
||||
.tab { border:1px solid #444; border-radius:999px; padding:6px 10px; color:#eee; text-decoration:none; background:#222; }
|
||||
.tab[aria-current="page"] { background:#2b4b6b; }
|
||||
.card { border:1px solid #333; border-radius:10px; background:#202020; padding:14px; }
|
||||
table { font-size:13px; }
|
||||
td input, td select { width: 100%; box-sizing:border-box; }
|
||||
textarea { width:100%; min-height:110px; background:#111; color:#eee; border:1px solid #555; border-radius:6px; padding:8px; }
|
||||
.muted { color:#aaa; font-size:12px; }
|
||||
.taskbar { border:1px solid #4b3b16; background:#221b0d; border-radius:10px; padding:12px; margin:12px 0; }
|
||||
.verdict { display:inline-block; border-radius:999px; padding:2px 8px; font-weight:700; background:#444; }
|
||||
.verdict.PASS { background:#1f7a1f; }
|
||||
.verdict.FAIL { background:#a11; }
|
||||
.verdict.PENDING { background:#444; }
|
||||
</style>
|
||||
<body>
|
||||
<main>
|
||||
<div class="top">
|
||||
<h1 style="margin-right:auto">ChoreDesk</h1>
|
||||
<button id="reset" data-task-role="reset-button">Reset run</button>
|
||||
<button id="grade" data-task-role="grade-button">Grade now</button>
|
||||
<button id="cheat" data-task-role="cheat-button">Run cheat</button>
|
||||
</div>
|
||||
<div class="taskbar">
|
||||
<div><b id="taskId"></b> <span id="difficulty"></span> <span id="verdict" class="verdict PENDING">PENDING</span> <span id="reward" class="muted"></span></div>
|
||||
<p id="instruction"></p>
|
||||
<pre id="reason" class="muted"></pre>
|
||||
</div>
|
||||
<nav class="tabs" aria-label="ChoreDesk sections"></nav>
|
||||
<section id="view"></section>
|
||||
</main>
|
||||
<script src="cheats.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (!params.get("run")) {
|
||||
params.set("run", crypto.randomUUID ? crypto.randomUUID() : String(Date.now()));
|
||||
history.replaceState(null, "", `?${params}`);
|
||||
}
|
||||
const task = params.get("task") || "choredesk-l1-ticket-priority";
|
||||
const run = params.get("run");
|
||||
const seed = Number(params.get("seed") || 0);
|
||||
const nonce = params.get("nonce") || (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2));
|
||||
if (!params.get("nonce")) { params.set("nonce", nonce); history.replaceState(null, "", `?${params}`); }
|
||||
const storeKey = `pi-chrome-task-state:${task}:${run}:${seed}:${nonce}`;
|
||||
const verdictKey = `pi-chrome-task:${task}:${run}`;
|
||||
const views = ["tickets", "customers", "orders", "catalog", "inventory", "messages", "audit"];
|
||||
let view = params.get("view") || "tickets";
|
||||
let bidCounter = 0;
|
||||
|
||||
const TASKS = {
|
||||
"choredesk-l1-ticket-priority": {
|
||||
difficulty: "L1 atomic",
|
||||
instruction: "Set ticket T-104 priority to High and status to In Progress. Save the ticket.",
|
||||
rubric(s) {
|
||||
const t = s.tickets.find(x => x.id === "T-104");
|
||||
return [
|
||||
{ id:"priorityHigh", weight:.33, pass:t?.priority === "High", description:"T-104 priority is High", reason:`T-104 priority=${t?.priority}` },
|
||||
{ id:"statusInProgress", weight:.33, pass:t?.status === "In Progress", description:"T-104 status is In Progress", reason:`T-104 status=${t?.status}` },
|
||||
{ id:"saved", weight:.34, pass:s.audit.some(a => a.kind === "ticket-save" && a.id === "T-104" && a.via === "ui-event"), description:"T-104 saved through UI", reason:"no UI save audit for T-104" }
|
||||
];
|
||||
}
|
||||
},
|
||||
"choredesk-l2-refund-message": {
|
||||
difficulty: "L2 compositional",
|
||||
instruction: "A customer named Noah Kim reports a duplicate charge. Find Noah's email and order ORD-778 total, then send Billing a message with subject 'Refund review ORD-778'. Body must include Noah's email and the exact order total.",
|
||||
rubric(s) {
|
||||
const msg = s.messages.find(m => m.to === "Billing" && m.subject.trim() === "Refund review ORD-778");
|
||||
return [
|
||||
{ id:"visitedCustomers", weight:.15, pass:!!s.viewsVisited.customers, description:"Opened Customers view", reason:"never opened Customers view" },
|
||||
{ id:"visitedOrders", weight:.15, pass:!!s.viewsVisited.orders, description:"Opened Orders view", reason:"never opened Orders view" },
|
||||
{ id:"subject", weight:.20, pass:!!msg, description:"Billing message with exact subject exists", reason:"missing Billing message with exact subject" },
|
||||
{ id:"email", weight:.25, pass:!!msg && /\bnoah\.kim@example\.com\b/i.test(msg.body), description:"Body includes Noah email", reason:"message body missing Noah email" },
|
||||
{ id:"total", weight:.25, pass:!!msg && /(?<![\d.])\$?84\.20\b/.test(msg.body), description:"Body includes exact ORD-778 total", reason:"message body missing exact ORD-778 total 84.20" }
|
||||
];
|
||||
}
|
||||
},
|
||||
"choredesk-l3-restock-ticket": {
|
||||
difficulty: "L3 cross-page tedious",
|
||||
instruction: "Ticket T-205 says Aster Lamp is delayed. Find the Aster Lamp SKU in Catalog, set its Inventory reorder quantity to 12, then add an internal note to T-205 saying 'reordered 12 AST-LAMP'.",
|
||||
rubric(s) {
|
||||
const inv = s.inventory.find(x => x.sku === "AST-LAMP");
|
||||
const t = s.tickets.find(x => x.id === "T-205");
|
||||
return [
|
||||
{ id:"visitedCatalog", weight:.15, pass:!!s.viewsVisited.catalog, description:"Opened Catalog view", reason:"never opened Catalog view" },
|
||||
{ id:"visitedInventory", weight:.15, pass:!!s.viewsVisited.inventory, description:"Opened Inventory view", reason:"never opened Inventory view" },
|
||||
{ id:"inventory", weight:.25, pass:Number(inv?.reorderQty) === 12 && s.audit.some(a => a.kind === "inventory-save" && a.id === "AST-LAMP" && a.via === "ui-event"), description:"AST-LAMP reorderQty=12 saved through UI", reason:`AST-LAMP reorderQty=${inv?.reorderQty}; missing UI save audit` },
|
||||
{ id:"ticketNote", weight:.25, pass:!!t && t.note.includes("reordered 12 AST-LAMP"), description:"T-205 note contains exact phrase", reason:"T-205 internal note missing exact phrase" },
|
||||
{ id:"ticketSaved", weight:.20, pass:s.audit.some(a => a.kind === "ticket-save" && a.id === "T-205" && a.via === "ui-event"), description:"T-205 saved through UI", reason:"no UI save audit for T-205" }
|
||||
];
|
||||
}
|
||||
}
|
||||
};
|
||||
const SEED = {
|
||||
tickets: [
|
||||
{ id:"T-101", customer:"Rosa Patel", subject:"Where is my desk mat?", priority:"Normal", status:"Open", note:"" },
|
||||
{ id:"T-104", customer:"Iris Zhao", subject:"Invoice address typo", priority:"Low", status:"Open", note:"" },
|
||||
{ id:"T-205", customer:"Mateo Silva", subject:"Aster Lamp delayed", priority:"Normal", status:"Open", note:"" },
|
||||
{ id:"T-309", customer:"Noah Kim", subject:"Duplicate charge", priority:"Normal", status:"Open", note:"" }
|
||||
],
|
||||
customers: [
|
||||
{ name:"Noah Kim", email:"noah.kim@example.com", tier:"Gold" },
|
||||
{ name:"Rosa Patel", email:"rosa.patel@example.com", tier:"Silver" },
|
||||
{ name:"Iris Zhao", email:"iris.zhao@example.com", tier:"Bronze" },
|
||||
{ name:"Mateo Silva", email:"mateo.silva@example.com", tier:"Gold" }
|
||||
],
|
||||
orders: [
|
||||
{ id:"ORD-778", customer:"Noah Kim", item:"Cable Kit", total:"$84.20", status:"Paid" },
|
||||
{ id:"ORD-812", customer:"Rosa Patel", item:"Desk Mat", total:"$28.00", status:"Shipped" },
|
||||
{ id:"ORD-919", customer:"Mateo Silva", item:"Aster Lamp", total:"$134.50", status:"Delayed" }
|
||||
],
|
||||
catalog: [
|
||||
{ item:"Aster Lamp", sku:"AST-LAMP", vendor:"Northwind Lighting" },
|
||||
{ item:"Desk Mat", sku:"DSK-MAT", vendor:"Woven Works" },
|
||||
{ item:"Cable Kit", sku:"CBL-KIT", vendor:"Copper Lane" }
|
||||
],
|
||||
inventory: [
|
||||
{ sku:"AST-LAMP", onHand:0, reorderQty:0 },
|
||||
{ sku:"DSK-MAT", onHand:42, reorderQty:0 },
|
||||
{ sku:"CBL-KIT", onHand:6, reorderQty:0 }
|
||||
],
|
||||
messages: [],
|
||||
audit: [],
|
||||
viewsVisited: {},
|
||||
actionCount: 0,
|
||||
startedAt: Date.now()
|
||||
};
|
||||
let state = loadState();
|
||||
function clone(x){ return JSON.parse(JSON.stringify(x)); }
|
||||
function loadState(){ const raw = localStorage.getItem(storeKey); return raw ? JSON.parse(raw) : clone(SEED); }
|
||||
function saveState(){ localStorage.setItem(storeKey, JSON.stringify(state)); }
|
||||
function countAction(kind){ state.actionCount = (state.actionCount || 0) + 1; state.lastAction = kind; }
|
||||
function audit(entry){ state.audit.push({ ts:new Date().toISOString(), via:"ui-event", trusted:!!entry.trusted, ...entry }); saveState(); grade(false); }
|
||||
function rubric(){ return (TASKS[task] || TASKS["choredesk-l1-ticket-priority"]).rubric(state); }
|
||||
function validate(){
|
||||
const items = rubric();
|
||||
const total = items.reduce((a,x)=>a+x.weight,0) || 1;
|
||||
const got = items.filter(x=>x.pass).reduce((a,x)=>a+x.weight,0);
|
||||
const reward = Math.round((got / total) * 1000) / 1000;
|
||||
const done = reward === 1;
|
||||
const bad = items.filter(x=>!x.pass).map(x=>x.reason || `${x.id} failed`);
|
||||
return { reward, done, message: done ? "all deterministic checks passed" : bad.join("; "), info: { task, seed, run, actionCount:state.actionCount||0, elapsedMs:Date.now()-(state.startedAt||Date.now()), viewsVisited:state.viewsVisited, rubric:items, state } };
|
||||
}
|
||||
function observation(){
|
||||
return { goal:TASKS[task]?.instruction, url:location.href, activeView:view, title:document.title, reward:window.__taskReward, actionCount:state.actionCount||0, viewsVisited:state.viewsVisited, focusedElementBid:document.activeElement?.getAttribute("bid") || null };
|
||||
}
|
||||
function setVerdict(verdict, reason, validation = validate()){
|
||||
window.__taskVerdict = verdict;
|
||||
window.__taskReason = reason;
|
||||
window.__taskValidation = validation;
|
||||
window.__taskReward = validation.reward;
|
||||
window.__taskTerminated = validation.done;
|
||||
window.__taskTruncated = (state.actionCount || 0) >= Number(params.get("maxSteps") || 9999);
|
||||
window.__taskInfo = validation.info;
|
||||
localStorage.setItem(verdictKey, JSON.stringify({ id: task, run, seed, verdict, reason, validation, state, ts: Date.now() }));
|
||||
document.getElementById("verdict").textContent = verdict;
|
||||
document.getElementById("verdict").className = "verdict " + verdict;
|
||||
document.getElementById("reward").textContent = `reward=${validation.reward} actions=${state.actionCount || 0}`;
|
||||
document.getElementById("reason").textContent = reason.join("\n");
|
||||
}
|
||||
function grade(showPass=true){ const v = validate(); if (!v.done) setVerdict(showPass ? "FAIL" : "PENDING", v.info.rubric.filter(x=>!x.pass).map(x=>`${x.id}: ${x.reason}`), v); else setVerdict("PASS", [v.message], v); return v; }
|
||||
function route(v){ view = v; state.viewsVisited[v] = (state.viewsVisited[v] || 0) + 1; countAction("route:" + v); saveState(); history.replaceState(null, "", `?task=${encodeURIComponent(task)}&run=${encodeURIComponent(run)}&seed=${seed}&nonce=${nonce}&view=${encodeURIComponent(view)}`); render(); }
|
||||
function renderShell(){
|
||||
const spec = TASKS[task] || TASKS["choredesk-l1-ticket-priority"];
|
||||
document.getElementById("taskId").textContent = task;
|
||||
document.getElementById("difficulty").textContent = spec.difficulty;
|
||||
document.getElementById("instruction").textContent = spec.instruction;
|
||||
const nav = document.querySelector(".tabs"); nav.innerHTML = "";
|
||||
for (const v of views) { const a = document.createElement("a"); a.href="#"; a.className="tab"; a.textContent=v[0].toUpperCase()+v.slice(1); a.dataset.view=v; a.setAttribute("data-task-role", "nav-tab"); if(v===view)a.setAttribute("aria-current","page"); a.addEventListener("click", e=>{e.preventDefault(); route(v);}); nav.appendChild(a); }
|
||||
}
|
||||
function render(){
|
||||
renderShell(); bidCounter = 0;
|
||||
const root = document.getElementById("view"); root.innerHTML = "";
|
||||
if (view === "tickets") root.appendChild(tableEditor("tickets", state.tickets, ["id","customer","subject","priority","status","note"], saveTicket));
|
||||
if (view === "customers") root.appendChild(readTable(state.customers, ["name","email","tier"]));
|
||||
if (view === "orders") root.appendChild(readTable(state.orders, ["id","customer","item","total","status"]));
|
||||
if (view === "catalog") root.appendChild(readTable(state.catalog, ["item","sku","vendor"]));
|
||||
if (view === "inventory") root.appendChild(tableEditor("inventory", state.inventory, ["sku","onHand","reorderQty"], saveInventory));
|
||||
if (view === "messages") root.appendChild(messageComposer());
|
||||
if (view === "audit") root.appendChild(readTable(state.audit.map((entry,i)=>({ n:i+1, entry: JSON.stringify(entry) })), ["n","entry"]));
|
||||
assignBids(); grade(false);
|
||||
}
|
||||
function readTable(rows, cols){
|
||||
const wrap = document.createElement("div"); wrap.className="card";
|
||||
wrap.innerHTML = `<table><thead><tr>${cols.map(c=>`<th>${c}</th>`).join("")}</tr></thead><tbody></tbody></table>`;
|
||||
const tb = wrap.querySelector("tbody");
|
||||
for (const r of rows) { const tr = document.createElement("tr"); tr.innerHTML = cols.map(c => `<td>${escapeHtml(r[c] ?? "")}</td>`).join(""); tb.appendChild(tr); }
|
||||
return wrap;
|
||||
}
|
||||
function tableEditor(kind, rows, cols, onSave){
|
||||
const wrap = document.createElement("div"); wrap.className="card";
|
||||
wrap.innerHTML = `<table><thead><tr>${cols.map(c=>`<th>${c}</th>`).join("")}<th>save</th></tr></thead><tbody></tbody></table>`;
|
||||
const tb = wrap.querySelector("tbody");
|
||||
rows.forEach((r) => {
|
||||
const key = r.id || r.sku; const tr = document.createElement("tr"); tr.dataset.itemId = key; if (r.id) tr.dataset.ticketId = r.id; if (r.sku) tr.dataset.sku = r.sku;
|
||||
for (const c of cols) {
|
||||
const td = document.createElement("td");
|
||||
const stableAttrs = `${r.id ? `data-ticket-id="${r.id}"` : ""} ${r.sku ? `data-sku="${r.sku}"` : ""}`;
|
||||
if (c === "priority") td.innerHTML = `<select data-item-id="${key}" ${stableAttrs} data-col="${c}" aria-label="${key} priority"><option>Low</option><option>Normal</option><option>High</option></select>`;
|
||||
else if (c === "status") td.innerHTML = `<select data-item-id="${key}" ${stableAttrs} data-col="${c}" aria-label="${key} status"><option>Open</option><option>In Progress</option><option>Waiting</option><option>Closed</option></select>`;
|
||||
else if (c === "note") td.innerHTML = `<input data-item-id="${key}" ${stableAttrs} data-col="${c}" aria-label="${key} internal note">`;
|
||||
else if (c === "reorderQty") td.innerHTML = `<input type="number" data-item-id="${key}" ${stableAttrs} data-col="${c}" aria-label="${key} reorder quantity">`;
|
||||
else td.textContent = r[c];
|
||||
tr.appendChild(td);
|
||||
}
|
||||
const save = document.createElement("td"); save.innerHTML = `<button data-save-id="${key}" data-task-role="save-button" aria-label="save ${kind} ${key}">Save</button>`; tr.appendChild(save); tb.appendChild(tr);
|
||||
});
|
||||
wrap.querySelectorAll("[data-item-id]").forEach(el => { const row = rows.find(r => (r.id || r.sku) === el.dataset.itemId); el.value = row[el.dataset.col]; el.addEventListener("input", () => { row[el.dataset.col] = el.value; }); });
|
||||
wrap.querySelectorAll("[data-save-id]").forEach(btn => btn.addEventListener("click", e => onSave(rows.find(r => (r.id || r.sku) === btn.dataset.saveId), e)));
|
||||
return wrap;
|
||||
}
|
||||
function saveTicket(t, e){ countAction("save-ticket"); saveState(); audit({ kind:"ticket-save", id:t.id, trusted:e?.isTrusted }); render(); }
|
||||
function saveInventory(i, e){ countAction("save-inventory"); i.reorderQty = Number(i.reorderQty); saveState(); audit({ kind:"inventory-save", id:i.sku, trusted:e?.isTrusted }); render(); }
|
||||
function messageComposer(){
|
||||
const wrap = document.createElement("div"); wrap.className="card";
|
||||
wrap.innerHTML = `<label>From <input id="msgFrom" aria-label="message from" value="Agent"></label><br><br><label>To <select id="msgTo"><option>Billing</option><option>Support</option><option>Warehouse</option></select></label><br><br><label>Subject <input id="msgSubject" aria-label="message subject"></label><br><br><label>Body <textarea id="msgBody" aria-label="message body"></textarea></label><br><button id="sendMsg" data-task-role="send-message">Send message</button><h3>Sent messages</h3><div id="sent"></div>`;
|
||||
wrap.querySelector("#sendMsg").addEventListener("click", e => { countAction("send-message"); state.messages.push({ from:msgFrom.value, to:msgTo.value, subject:msgSubject.value, body:msgBody.value, trusted:e.isTrusted }); audit({ kind:"message-send", id:msgSubject.value, trusted:e.isTrusted }); render(); });
|
||||
wrap.querySelector("#sent").appendChild(readTable(state.messages, ["from","to","subject","body"]));
|
||||
return wrap;
|
||||
}
|
||||
function assignBids(){ document.querySelectorAll("button,input,select,textarea,a[href]").forEach(el => { const id = `b${(++bidCounter).toString(36)}`; el.setAttribute("bid", id); el.setAttribute("data-bid", id); const r = el.getBoundingClientRect(); el.setAttribute("browsergym_visibility_ratio", (r.width > 0 && r.height > 0 ? 1 : 0).toString()); }); }
|
||||
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c])); }
|
||||
async function runCheat(){ const fn = window.ChoreDeskCheats?.run?.[task]; if (fn) await fn(); else for (const step of (window.ChoreDeskCheats?.[task] || [])) console.log("cheat step", step); grade(true); }
|
||||
document.getElementById("reset").addEventListener("click", () => { localStorage.removeItem(storeKey); localStorage.removeItem(verdictKey); state = loadState(); render(); });
|
||||
document.getElementById("grade").addEventListener("click", () => grade(true));
|
||||
document.getElementById("cheat").addEventListener("click", () => runCheat());
|
||||
window.__task = { id:task, run, seed, goal:TASKS[task]?.instruction, difficulty:TASKS[task]?.difficulty, step:() => { const v=grade(false); return { observation:observation(), reward:v.reward, terminated:v.done, truncated:window.__taskTruncated, info:v.info }; }, reset:() => { localStorage.removeItem(storeKey); state=loadState(); render(); return { observation:observation(), info:{task,run,seed} }; }, rubric:() => rubric(), cheat:runCheat, validate, teardown:()=>undefined };
|
||||
window.__choredesk = window.__task;
|
||||
window.__choredesk.cheatHelpers = { route: async (v) => { route(v); await new Promise(r => setTimeout(r, 0)); }, set: (sel, value) => { const el = document.querySelector(sel); if (!el) throw new Error('cheat selector missing: ' + sel); el.value = value; el.dispatchEvent(new Event('input', { bubbles:true })); el.dispatchEvent(new Event('change', { bubbles:true })); } };
|
||||
state.viewsVisited[view] = (state.viewsVisited[view] || 0) + 1; saveState(); render();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve the test suite on a fixed local port so pi-chrome can drive it.
|
||||
cd "$(dirname "$0")"
|
||||
PORT="${PORT:-8765}"
|
||||
echo "serving http://127.0.0.1:${PORT}/"
|
||||
exec python3 -m http.server "${PORT}" --bind 127.0.0.1
|
||||
@@ -0,0 +1,416 @@
|
||||
[
|
||||
{
|
||||
"id": "choredesk-l1-ticket-priority",
|
||||
"site": "choredesk",
|
||||
"startUrl": "scenarios/choredesk/index.html?task=choredesk-l1-ticket-priority&run=$RUN_ID&seed=$SEED",
|
||||
"difficulty": "L1",
|
||||
"category": "enterprise-crud",
|
||||
"intent": "Set ticket T-104 priority to High and status to In Progress, then save it.",
|
||||
"expectedActions": {
|
||||
"min": 3,
|
||||
"max": 6
|
||||
},
|
||||
"requires": [
|
||||
"snapshot",
|
||||
"click",
|
||||
"select/change",
|
||||
"form editing"
|
||||
],
|
||||
"grader": {
|
||||
"type": "inPageProgrammatic",
|
||||
"expression": "window.__taskVerdict || (window.__taskValidation?.done ? \"PASS\" : \"FAIL\")",
|
||||
"passValue": "PASS"
|
||||
},
|
||||
"browserGymLike": {
|
||||
"observation": [
|
||||
"dom",
|
||||
"accessibility_tree",
|
||||
"screenshot_optional"
|
||||
],
|
||||
"actionSpace": [
|
||||
"click",
|
||||
"type",
|
||||
"select",
|
||||
"evaluate_optional"
|
||||
],
|
||||
"reset": "navigate startUrl with fresh $RUN_ID or click Reset run",
|
||||
"stepReturn": "obs,reward,terminated,truncated,info"
|
||||
},
|
||||
"viewport": {
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
},
|
||||
"slowMoMs": 1000,
|
||||
"timeoutMs": 5000,
|
||||
"locale": null,
|
||||
"timezoneId": null,
|
||||
"humanBaseline": null,
|
||||
"actionSubsets": [
|
||||
"bid",
|
||||
"nav",
|
||||
"tab",
|
||||
"infeas",
|
||||
"chat"
|
||||
],
|
||||
"rewardShape": "binary",
|
||||
"taskId": "choredesk.L1.ticket-priority",
|
||||
"seed": 101,
|
||||
"maxSteps": 8,
|
||||
"allowedActions": [
|
||||
"click",
|
||||
"fill",
|
||||
"select_option",
|
||||
"goto",
|
||||
"report_infeasible",
|
||||
"send_msg_to_user"
|
||||
],
|
||||
"setupUrl": "scenarios/choredesk/index.html?task=choredesk-l1-ticket-priority&run=$RUN_ID&seed=$SEED",
|
||||
"validateScript": "scenarios/choredesk/index.html#window.__choredesk.validate()",
|
||||
"cheatScript": "scenarios/choredesk/cheats.js#choredesk-l1-ticket-priority",
|
||||
"tags": [
|
||||
"form-fill",
|
||||
"ticket",
|
||||
"select"
|
||||
],
|
||||
"rubric": [
|
||||
{
|
||||
"id": "priorityHigh",
|
||||
"weight": 0.33,
|
||||
"description": "T-104 priority is High"
|
||||
},
|
||||
{
|
||||
"id": "statusInProgress",
|
||||
"weight": 0.33,
|
||||
"description": "T-104 status is In Progress"
|
||||
},
|
||||
{
|
||||
"id": "saved",
|
||||
"weight": 0.34,
|
||||
"description": "T-104 was saved"
|
||||
}
|
||||
],
|
||||
"goal": "Set ticket T-104 priority to High and status to In Progress, then save it.",
|
||||
"goalObject": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Set ticket T-104 priority to High and status to In Progress, then save it."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"gradeSource": "page"
|
||||
},
|
||||
{
|
||||
"id": "choredesk-l2-refund-message",
|
||||
"site": "choredesk",
|
||||
"startUrl": "scenarios/choredesk/index.html?task=choredesk-l2-refund-message&run=$RUN_ID&seed=$SEED",
|
||||
"difficulty": "L2",
|
||||
"category": "cross-page-memory",
|
||||
"intent": "Resolve a customer billing task by looking up customer email and order total on separate pages, then composing a message with exact required fields.",
|
||||
"expectedActions": {
|
||||
"min": 8,
|
||||
"max": 14
|
||||
},
|
||||
"requires": [
|
||||
"multi-page navigation",
|
||||
"table lookup",
|
||||
"working memory",
|
||||
"form composition"
|
||||
],
|
||||
"grader": {
|
||||
"type": "inPageProgrammatic",
|
||||
"expression": "window.__taskVerdict || (window.__taskValidation?.done ? \"PASS\" : \"FAIL\")",
|
||||
"passValue": "PASS"
|
||||
},
|
||||
"browserGymLike": {
|
||||
"observation": [
|
||||
"dom",
|
||||
"accessibility_tree",
|
||||
"screenshot_optional"
|
||||
],
|
||||
"actionSpace": [
|
||||
"click",
|
||||
"type",
|
||||
"select",
|
||||
"evaluate_optional"
|
||||
],
|
||||
"reset": "navigate startUrl with fresh $RUN_ID or click Reset run",
|
||||
"stepReturn": "obs,reward,terminated,truncated,info"
|
||||
},
|
||||
"viewport": {
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
},
|
||||
"slowMoMs": 1000,
|
||||
"timeoutMs": 5000,
|
||||
"locale": null,
|
||||
"timezoneId": null,
|
||||
"humanBaseline": null,
|
||||
"actionSubsets": [
|
||||
"bid",
|
||||
"nav",
|
||||
"tab",
|
||||
"infeas",
|
||||
"chat"
|
||||
],
|
||||
"rewardShape": "binary",
|
||||
"taskId": "choredesk.L2.refund-message",
|
||||
"seed": 202,
|
||||
"maxSteps": 16,
|
||||
"allowedActions": [
|
||||
"click",
|
||||
"fill",
|
||||
"select_option",
|
||||
"goto",
|
||||
"report_infeasible",
|
||||
"send_msg_to_user"
|
||||
],
|
||||
"setupUrl": "scenarios/choredesk/index.html?task=choredesk-l2-refund-message&run=$RUN_ID&seed=$SEED",
|
||||
"validateScript": "scenarios/choredesk/index.html#window.__choredesk.validate()",
|
||||
"cheatScript": "scenarios/choredesk/cheats.js#choredesk-l2-refund-message",
|
||||
"tags": [
|
||||
"lookup",
|
||||
"cross-page-memory",
|
||||
"message-compose"
|
||||
],
|
||||
"rubric": [
|
||||
{
|
||||
"id": "messageSubject",
|
||||
"weight": 0.34,
|
||||
"description": "Billing message subject matches"
|
||||
},
|
||||
{
|
||||
"id": "email",
|
||||
"weight": 0.33,
|
||||
"description": "Body includes Noah email"
|
||||
},
|
||||
{
|
||||
"id": "total",
|
||||
"weight": 0.33,
|
||||
"description": "Body includes exact order total"
|
||||
}
|
||||
],
|
||||
"goal": "Resolve a customer billing task by looking up customer email and order total on separate pages, then composing a message with exact required fields.",
|
||||
"goalObject": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Resolve a customer billing task by looking up customer email and order total on separate pages, then composing a message with exact required fields."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"gradeSource": "page"
|
||||
},
|
||||
{
|
||||
"id": "choredesk-l3-restock-ticket",
|
||||
"site": "choredesk",
|
||||
"startUrl": "scenarios/choredesk/index.html?task=choredesk-l3-restock-ticket&run=$RUN_ID&seed=$SEED",
|
||||
"difficulty": "L3",
|
||||
"category": "cross-page-state-update",
|
||||
"intent": "Use ticket text to identify an item, map item to SKU in Catalog, update Inventory reorder quantity, and add exact internal note back on the ticket.",
|
||||
"expectedActions": {
|
||||
"min": 12,
|
||||
"max": 20
|
||||
},
|
||||
"requires": [
|
||||
"multi-page navigation",
|
||||
"entity resolution",
|
||||
"state mutation",
|
||||
"exact text note",
|
||||
"audit-save behavior"
|
||||
],
|
||||
"grader": {
|
||||
"type": "inPageProgrammatic",
|
||||
"expression": "window.__taskVerdict || (window.__taskValidation?.done ? \"PASS\" : \"FAIL\")",
|
||||
"passValue": "PASS"
|
||||
},
|
||||
"browserGymLike": {
|
||||
"observation": [
|
||||
"dom",
|
||||
"accessibility_tree",
|
||||
"screenshot_optional"
|
||||
],
|
||||
"actionSpace": [
|
||||
"click",
|
||||
"type",
|
||||
"select",
|
||||
"evaluate_optional"
|
||||
],
|
||||
"reset": "navigate startUrl with fresh $RUN_ID or click Reset run",
|
||||
"stepReturn": "obs,reward,terminated,truncated,info"
|
||||
},
|
||||
"viewport": {
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
},
|
||||
"slowMoMs": 1000,
|
||||
"timeoutMs": 5000,
|
||||
"locale": null,
|
||||
"timezoneId": null,
|
||||
"humanBaseline": null,
|
||||
"actionSubsets": [
|
||||
"bid",
|
||||
"nav",
|
||||
"tab",
|
||||
"infeas",
|
||||
"chat"
|
||||
],
|
||||
"rewardShape": "binary",
|
||||
"taskId": "choredesk.L3.restock-ticket",
|
||||
"seed": 303,
|
||||
"maxSteps": 24,
|
||||
"allowedActions": [
|
||||
"click",
|
||||
"fill",
|
||||
"select_option",
|
||||
"goto",
|
||||
"report_infeasible",
|
||||
"send_msg_to_user"
|
||||
],
|
||||
"setupUrl": "scenarios/choredesk/index.html?task=choredesk-l3-restock-ticket&run=$RUN_ID&seed=$SEED",
|
||||
"validateScript": "scenarios/choredesk/index.html#window.__choredesk.validate()",
|
||||
"cheatScript": "scenarios/choredesk/cheats.js#choredesk-l3-restock-ticket",
|
||||
"tags": [
|
||||
"entity-resolution",
|
||||
"cross-page-state-update",
|
||||
"exact-note"
|
||||
],
|
||||
"rubric": [
|
||||
{
|
||||
"id": "inventory",
|
||||
"weight": 0.4,
|
||||
"description": "AST-LAMP reorder quantity is 12 and saved"
|
||||
},
|
||||
{
|
||||
"id": "ticketNote",
|
||||
"weight": 0.4,
|
||||
"description": "T-205 note contains exact phrase"
|
||||
},
|
||||
{
|
||||
"id": "audit",
|
||||
"weight": 0.2,
|
||||
"description": "Both save audit entries exist"
|
||||
}
|
||||
],
|
||||
"goal": "Use ticket text to identify an item, map item to SKU in Catalog, update Inventory reorder quantity, and add exact internal note back on the ticket.",
|
||||
"goalObject": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Use ticket text to identify an item, map item to SKU in Catalog, update Inventory reorder quantity, and add exact internal note back on the ticket."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"gradeSource": "page"
|
||||
},
|
||||
{
|
||||
"id": "mini-shop-red-second-cheapest",
|
||||
"site": "mini-shop",
|
||||
"startUrl": "fixtures/sites/mini-shop/index.html?task=mini-shop-red-second-cheapest&run=$RUN_ID&seed=$SEED",
|
||||
"difficulty": "L2",
|
||||
"category": "task-completion",
|
||||
"intent": "Add the second-cheapest red product to cart, checkout, and leave the generated order id visible.",
|
||||
"expectedActions": {
|
||||
"min": 5,
|
||||
"max": 9
|
||||
},
|
||||
"requires": [
|
||||
"filter/sort reasoning",
|
||||
"cart state",
|
||||
"checkout flow"
|
||||
],
|
||||
"grader": {
|
||||
"type": "inPageProgrammatic",
|
||||
"expression": "window.__taskVerdict || (window.__taskValidation?.done ? \"PASS\" : \"FAIL\")",
|
||||
"passValue": "PASS"
|
||||
},
|
||||
"browserGymLike": {
|
||||
"observation": [
|
||||
"dom",
|
||||
"accessibility_tree",
|
||||
"screenshot_optional"
|
||||
],
|
||||
"actionSpace": [
|
||||
"click",
|
||||
"read_table",
|
||||
"evaluate_optional"
|
||||
],
|
||||
"reset": "navigate startUrl with fresh $RUN_ID",
|
||||
"stepReturn": "obs,reward,terminated,truncated,info"
|
||||
},
|
||||
"viewport": {
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
},
|
||||
"slowMoMs": 1000,
|
||||
"timeoutMs": 5000,
|
||||
"locale": null,
|
||||
"timezoneId": null,
|
||||
"humanBaseline": null,
|
||||
"actionSubsets": [
|
||||
"bid",
|
||||
"nav",
|
||||
"tab",
|
||||
"infeas",
|
||||
"chat"
|
||||
],
|
||||
"rewardShape": "binary",
|
||||
"taskId": "mini-shop.L2.red-second-cheapest",
|
||||
"seed": 404,
|
||||
"maxSteps": 12,
|
||||
"allowedActions": [
|
||||
"click",
|
||||
"goto",
|
||||
"report_infeasible",
|
||||
"send_msg_to_user"
|
||||
],
|
||||
"setupUrl": "fixtures/sites/mini-shop/index.html?task=mini-shop-red-second-cheapest&run=$RUN_ID&seed=$SEED",
|
||||
"validateScript": "fixtures/sites/mini-shop/grader.js#MiniShopGrader.validate",
|
||||
"cheatScript": "fixtures/sites/mini-shop/cheats.js#mini-shop-red-second-cheapest",
|
||||
"tags": [
|
||||
"shopping",
|
||||
"sort",
|
||||
"cart",
|
||||
"checkout"
|
||||
],
|
||||
"rubric": [
|
||||
{
|
||||
"id": "addedTarget",
|
||||
"weight": 0.4,
|
||||
"description": "Cart includes second-cheapest red product"
|
||||
},
|
||||
{
|
||||
"id": "noWrongItems",
|
||||
"weight": 0.3,
|
||||
"description": "Cart has no wrong products"
|
||||
},
|
||||
{
|
||||
"id": "checkedOut",
|
||||
"weight": 0.3,
|
||||
"description": "Checkout produced order id"
|
||||
}
|
||||
],
|
||||
"goal": "Add the second-cheapest red product to cart, checkout, and leave the generated order id visible.",
|
||||
"goalObject": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Add the second-cheapest red product to cart, checkout, and leave the generated order id visible."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"gradeSource": "both"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "pi-chrome long-horizon task manifest",
|
||||
"description": "BrowserGym-inspired task manifest: setup URL, deterministic seed, goal object, action subsets, reward contract, optional cheat recipe, and in-page validate hook.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "taskId", "site", "startUrl", "setupUrl", "difficulty", "category", "goal", "goalObject", "grader", "actionSubsets", "maxSteps"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"taskId": { "type": "string" },
|
||||
"site": { "type": "string" },
|
||||
"seed": { "type": "integer" },
|
||||
"startUrl": { "type": "string", "description": "Relative URL. $RUN_ID and $SEED should be replaced by runner for isolated deterministic state." },
|
||||
"setupUrl": { "type": "string" },
|
||||
"validateScript": { "type": "string" },
|
||||
"cheatScript": { "type": "string" },
|
||||
"viewport": { "type": "object", "properties": { "width": { "type": "integer" }, "height": { "type": "integer" } } },
|
||||
"slowMoMs": { "type": "integer" },
|
||||
"timeoutMs": { "type": "integer" },
|
||||
"locale": { "type": ["string", "null"] },
|
||||
"timezoneId": { "type": ["string", "null"] },
|
||||
"difficulty": { "enum": ["L1", "L2", "L3"] },
|
||||
"category": { "enum": ["enterprise-crud", "cross-page-memory", "cross-page-state-update", "task-completion"] },
|
||||
"gradeSource": { "enum": ["page", "harness", "both"] },
|
||||
"tags": { "type": "array", "items": { "type": "string" } },
|
||||
"intent": { "type": "string" },
|
||||
"goal": { "type": "string" },
|
||||
"goalObject": { "type": "array", "items": { "type": "object" } },
|
||||
"expectedActions": { "type": "object", "required": ["min", "max"], "properties": { "min": { "type": "integer" }, "max": { "type": "integer" } } },
|
||||
"maxSteps": { "type": "integer" },
|
||||
"humanBaseline": { "type": ["number", "null"], "minimum": 0, "maximum": 1 },
|
||||
"actionSubsets": { "type": "array", "items": { "enum": ["chat", "infeas", "bid", "coord", "nav", "tab"] } },
|
||||
"allowedActions": { "type": "array", "items": { "type": "string" } },
|
||||
"requires": { "type": "array", "items": { "type": "string" } },
|
||||
"rewardShape": { "enum": ["binary", "graded", "shaped"] },
|
||||
"rubric": { "type": "array", "items": { "type": "object", "required": ["id", "weight", "description"], "properties": { "id": { "type": "string" }, "weight": { "type": "number" }, "description": { "type": "string" } } } },
|
||||
"grader": {
|
||||
"type": "object",
|
||||
"required": ["type", "expression", "passValue"],
|
||||
"properties": {
|
||||
"type": { "enum": ["inPageProgrammatic"] },
|
||||
"expression": { "type": "string", "description": "Should evaluate to BrowserGym-like validate object or legacy {v,r}." },
|
||||
"passValue": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"browserGymLike": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"observation": { "type": "array", "items": { "type": "string" } },
|
||||
"actionSpace": { "type": "array", "items": { "type": "string" } },
|
||||
"reset": { "type": "string" },
|
||||
"stepReturn": { "const": "obs,reward,terminated,truncated,info" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
// Unit harness for pi-chrome's dedicated automation tab/window isolation in service_worker.js.
|
||||
//
|
||||
// Feature under test: pi-chrome must never navigate or replace the user's active tab. Page and
|
||||
// navigation actions without an explicit target are routed to a dedicated automation target that
|
||||
// the *calling Pi session* created and owns. Ownership is session-scoped (one extension brokers
|
||||
// every session) and mirrored to chrome.storage.session so a service-worker restart re-hydrates
|
||||
// it instead of orphaning the window. Cleanup closes only the calling session's owned target.
|
||||
//
|
||||
// Like csp-eval.test.mjs we load the *real* worker into a vm sandbox with a stateful chrome.*
|
||||
// mock, then exercise the real helpers and the real dispatch() paths. Chrome state (tabs/windows/
|
||||
// storage.session) can be shared across two sandbox loads to simulate a service-worker restart.
|
||||
|
||||
import vm from "node:vm";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const workerPath = path.resolve(__dirname, "../../extensions/chrome-profile-bridge/browser-extension/service_worker.js");
|
||||
const src = fs.readFileSync(workerPath, "utf8");
|
||||
|
||||
let failures = 0;
|
||||
let passes = 0;
|
||||
function ok(cond, msg) {
|
||||
if (cond) { passes++; }
|
||||
else { failures++; console.error(` ✗ ${msg}`); }
|
||||
}
|
||||
async function throwsWith(fn, re, msg) {
|
||||
try { await fn(); ok(false, `${msg} (expected throw)`); }
|
||||
catch (e) { ok(re.test(String(e.message || e)), `${msg} (got: ${e.message})`); }
|
||||
}
|
||||
|
||||
// ---- stateful Chrome mock. `state` (tabs/windows/storage) can be shared to simulate a
|
||||
// service-worker restart: the browser keeps its tabs/windows/session-storage, the worker memory
|
||||
// is wiped (a fresh sandbox).
|
||||
function makeChromeState() {
|
||||
const tabs = new Map(); // id -> { id, windowId, url, active, groupId }
|
||||
const windows = new Map(); // id -> { id }
|
||||
const groups = new Map(); // groupId -> { id, title, color, collapsed, windowId }
|
||||
const storage = {}; // chrome.storage.session backing
|
||||
let nextTabId = 1;
|
||||
let nextWindowId = 1;
|
||||
let nextGroupId = 1;
|
||||
const alloc = { tab: () => nextTabId++, window: () => nextWindowId++, group: () => nextGroupId++ };
|
||||
|
||||
// Seed a user window with two real user tabs (Gmail + a research article, the active one).
|
||||
const userWindowId = alloc.window();
|
||||
windows.set(userWindowId, { id: userWindowId });
|
||||
const userGmail = { id: alloc.tab(), windowId: userWindowId, url: "https://mail.google.com/", active: false, groupId: -1 };
|
||||
const userArticle = { id: alloc.tab(), windowId: userWindowId, url: "https://example.com/research-article", active: true, groupId: -1 };
|
||||
tabs.set(userGmail.id, userGmail);
|
||||
tabs.set(userArticle.id, userArticle);
|
||||
|
||||
return { tabs, windows, groups, storage, alloc, userWindowId, userGmail, userArticle };
|
||||
}
|
||||
|
||||
function makeChrome(state, { withWindows = true, withStorage = true, withTabGroups = false } = {}) {
|
||||
const { tabs, windows, groups, storage, alloc, userWindowId } = state;
|
||||
const noop = () => {};
|
||||
const listener = { addListener: noop, removeListener: noop };
|
||||
|
||||
const chrome = {
|
||||
runtime: { id: "unittestextension", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener, lastError: null },
|
||||
alarms: { onAlarm: listener, create: noop, clear: noop, clearAll: noop },
|
||||
action: { onClicked: listener },
|
||||
debugger: { sendCommand: noop, attach: async () => {}, detach: async () => {}, getTargets: (cb) => cb([]), onDetach: listener },
|
||||
scripting: { executeScript: async () => [{ result: undefined }], registerContentScripts: async () => {}, unregisterContentScripts: async () => {} },
|
||||
webNavigation: { onCommitted: listener },
|
||||
tabs: {
|
||||
onUpdated: listener,
|
||||
query: async (q = {}) => {
|
||||
let list = [...tabs.values()];
|
||||
if (q.active === true) list = list.filter((t) => t.active);
|
||||
if (typeof q.windowId === "number") list = list.filter((t) => t.windowId === q.windowId);
|
||||
return list.map((t) => ({ ...t }));
|
||||
},
|
||||
get: async (id) => { const t = tabs.get(id); if (!t) throw new Error(`No tab with id ${id}`); return { ...t }; },
|
||||
create: async ({ url = "about:blank", active = false, windowId = userWindowId } = {}) => {
|
||||
const tab = { id: alloc.tab(), windowId, url, active, groupId: -1 };
|
||||
tabs.set(tab.id, tab);
|
||||
return { ...tab };
|
||||
},
|
||||
update: async (id, props = {}) => { const t = tabs.get(id); if (!t) throw new Error(`No tab with id ${id}`); Object.assign(t, props); return { ...t }; },
|
||||
remove: async (id) => { tabs.delete(id); },
|
||||
group: async ({ groupId, tabIds = [] } = {}) => {
|
||||
let gid = groupId;
|
||||
if (typeof gid !== "number") {
|
||||
gid = alloc.group();
|
||||
const firstTab = tabs.get(tabIds[0]);
|
||||
groups.set(gid, { id: gid, title: "", color: "grey", collapsed: false, windowId: firstTab ? firstTab.windowId : userWindowId });
|
||||
}
|
||||
for (const tid of tabIds) { const t = tabs.get(tid); if (t) t.groupId = gid; }
|
||||
return gid;
|
||||
},
|
||||
ungroup: async (id) => { const ids = Array.isArray(id) ? id : [id]; for (const tid of ids) { const t = tabs.get(tid); if (t) t.groupId = -1; } },
|
||||
},
|
||||
storage: withStorage ? {
|
||||
session: {
|
||||
get: async (key) => (key in storage ? { [key]: storage[key] } : {}),
|
||||
set: async (obj) => { Object.assign(storage, obj); },
|
||||
},
|
||||
} : undefined,
|
||||
};
|
||||
|
||||
if (withTabGroups) {
|
||||
chrome.tabGroups = {
|
||||
query: async ({ windowId } = {}) => [...groups.values()].filter((g) => windowId === undefined || g.windowId === windowId).map((g) => ({ ...g })),
|
||||
get: async (id) => { const g = groups.get(id); if (!g) throw new Error(`No group ${id}`); return { ...g }; },
|
||||
update: async (id, props = {}) => { const g = groups.get(id); if (!g) throw new Error(`No group ${id}`); Object.assign(g, props); return { ...g }; },
|
||||
};
|
||||
}
|
||||
|
||||
if (withWindows) {
|
||||
chrome.windows = {
|
||||
create: async ({ url = "about:blank", focused = false } = {}) => {
|
||||
const id = alloc.window();
|
||||
windows.set(id, { id });
|
||||
const tab = { id: alloc.tab(), windowId: id, url, active: true, groupId: -1 };
|
||||
tabs.set(tab.id, tab);
|
||||
return { id, focused, tabs: [{ ...tab }] };
|
||||
},
|
||||
get: async (id) => { const w = windows.get(id); if (!w) throw new Error(`No window with id ${id}`); return { ...w }; },
|
||||
remove: async (id) => { windows.delete(id); for (const [tid, t] of [...tabs]) if (t.windowId === id) tabs.delete(tid); },
|
||||
update: async () => {},
|
||||
};
|
||||
} else {
|
||||
chrome.windows = { update: async () => {} }; // no create/get/remove -> tab fallback path
|
||||
}
|
||||
|
||||
return chrome;
|
||||
}
|
||||
|
||||
function loadWorker(chrome) {
|
||||
const noop = () => {};
|
||||
const sandbox = {
|
||||
console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean,
|
||||
Error, TypeError, Map, Set, BigInt, Symbol, structuredClone,
|
||||
setTimeout, clearTimeout, setInterval: () => 0, clearInterval: noop,
|
||||
fetch: async () => { throw new Error("no network in unit test"); },
|
||||
navigator: { userAgent: "unit-test" },
|
||||
WebSocket: function () {},
|
||||
chrome,
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(src, sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
const SK = "session:alpha"; // a representative sessionKey
|
||||
|
||||
async function run() {
|
||||
// ===== Isolation: navigation does not touch the user's active/other tabs. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state));
|
||||
const userActiveUrl = state.userArticle.url;
|
||||
|
||||
const nav = await w.dispatch("page.navigate", { url: "https://pi.test/task", waitUntilLoad: false, sessionKey: SK });
|
||||
ok(state.userArticle.url === userActiveUrl, "navigate: active user tab (research article) is not overwritten");
|
||||
ok(state.userGmail.url === "https://mail.google.com/", "navigate: other user tab (Gmail) untouched");
|
||||
ok(nav.url === "https://pi.test/task", "navigate: automation target navigated to requested URL");
|
||||
ok(nav.id !== state.userArticle.id && nav.id !== state.userGmail.id, "navigate: did not reuse any user tab");
|
||||
ok(nav.windowId !== state.userWindowId, "navigate: automation target lives in a dedicated window");
|
||||
|
||||
const status = await w.dispatch("automation.status", { sessionKey: SK });
|
||||
ok(status.tabId === nav.id && status.windowId === nav.windowId, "ownership: target ids tracked for the session");
|
||||
ok(w.isPiChromeOwnedTarget(nav.id, SK) === true, "ownership: isPiChromeOwnedTarget(owned, session) === true");
|
||||
ok(w.isPiChromeOwnedTarget(state.userArticle.id) === false, "ownership: user tab is never owned (any session)");
|
||||
|
||||
// Reuse: a later navigation reuses the same owned target.
|
||||
const nav2 = await w.dispatch("page.navigate", { url: "https://pi.test/step-2", waitUntilLoad: false, sessionKey: SK });
|
||||
ok(nav2.id === nav.id && nav2.windowId === nav.windowId, "reuse: second navigation reuses the same automation window/tab");
|
||||
ok(state.userArticle.url === userActiveUrl, "reuse: user tab still untouched after second navigation");
|
||||
|
||||
// Cleanup closes only the owned window; user tabs/windows survive.
|
||||
const cleanup = await w.dispatch("automation.cleanup", { sessionKey: SK });
|
||||
ok(cleanup.closedWindowId === nav.windowId, "cleanup: closed the owned window");
|
||||
ok(state.tabs.has(state.userArticle.id) && state.tabs.has(state.userGmail.id), "cleanup: user tabs never closed");
|
||||
ok(state.windows.has(state.userWindowId), "cleanup: user window never closed");
|
||||
ok(!state.tabs.has(nav.id), "cleanup: the owned automation tab is gone");
|
||||
const status2 = await w.dispatch("automation.status", { sessionKey: SK });
|
||||
ok(status2.tabId === null && status2.windowId === null, "cleanup: ownership cleared");
|
||||
}
|
||||
|
||||
// ===== Session-group integration: the dedicated-window tab joins this session's group. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state, { withTabGroups: true }));
|
||||
// index.ts tags page.* actions with joinSessionGroup + sessionGroupTitle; replicate that here.
|
||||
const groupTitle = "Pi Session: alpha";
|
||||
const nav = await w.dispatch("page.navigate", {
|
||||
url: "https://pi.test/grouped", waitUntilLoad: false,
|
||||
sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle,
|
||||
});
|
||||
const navTab = state.tabs.get(nav.id);
|
||||
ok(navTab.windowId !== state.userWindowId, "group: automation tab is in its dedicated window");
|
||||
ok(typeof navTab.groupId === "number" && navTab.groupId >= 0, "group: automation tab joined a tab group");
|
||||
const grp = state.groups.get(navTab.groupId);
|
||||
ok(grp && grp.title === groupTitle, "group: the group is titled with this session's title");
|
||||
ok(grp.windowId === navTab.windowId, "group: the session group lives inside the dedicated automation window (not the user window)");
|
||||
|
||||
// A second page action reuses the same tab and does not spawn a second group.
|
||||
const groupsBefore = state.groups.size;
|
||||
await w.dispatch("page.navigate", { url: "https://pi.test/grouped-2", waitUntilLoad: false, sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle });
|
||||
ok(state.groups.size === groupsBefore, "group: reusing the automation tab does not create a second group");
|
||||
}
|
||||
|
||||
// ===== tab.new joins the existing session group instead of creating one group per window. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state, { withTabGroups: true }));
|
||||
const groupTitle = "Pi Session: alpha";
|
||||
const nav = await w.dispatch("page.navigate", {
|
||||
url: "https://pi.test/group-owner", waitUntilLoad: false,
|
||||
sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle,
|
||||
});
|
||||
const navTab = state.tabs.get(nav.id);
|
||||
const groupId = navTab.groupId;
|
||||
const groupsBefore = state.groups.size;
|
||||
|
||||
const opened = await w.dispatch("tab.new", { url: "https://pi.test/new-tab", groupTitle, sessionKey: SK });
|
||||
ok(state.groups.size === groupsBefore, "tab.new-group: did not create another same-session group");
|
||||
ok(opened.tab.groupId === groupId, "tab.new-group: opened tab joined the existing session group");
|
||||
ok(opened.tab.windowId === nav.windowId, "tab.new-group: opened tab was created in the existing group's window");
|
||||
|
||||
const forced = await w.dispatch("tab.new", { url: "https://pi.test/no-opt-out", groupTitle, group: false, sessionKey: SK });
|
||||
ok(forced.tab.groupId === groupId, "tab.new-group: group:false is ignored; tab still joins the session group");
|
||||
ok(state.groups.size === groupsBefore, "tab.new-group: group:false does not create another group");
|
||||
|
||||
const blankTitle = await w.dispatch("tab.new", { url: "https://pi.test/blank-title", groupTitle: "", group: false, sessionKey: SK });
|
||||
ok(typeof blankTitle.tab.groupId === "number" && blankTitle.tab.groupId >= 0, "tab.new-group: groupTitle:'' still creates a grouped tab");
|
||||
ok(blankTitle.group.title === "Pi", "tab.new-group: blank groupTitle falls back to a group instead of opting out");
|
||||
|
||||
const nav2 = await w.dispatch("page.navigate", {
|
||||
url: "https://pi.test/new-automation-target", waitUntilLoad: false,
|
||||
sessionKey: "session:beta", joinSessionGroup: true, sessionGroupTitle: groupTitle,
|
||||
});
|
||||
ok(state.groups.size === groupsBefore + 1, "automation-target-group: reused the existing session group, only blank-title Pi group was extra");
|
||||
ok(nav2.groupId === groupId, "automation-target-group: new automation target joined the existing session group");
|
||||
ok(nav2.windowId === nav.windowId, "automation-target-group: new automation target was created in the existing group's window");
|
||||
}
|
||||
|
||||
// ===== tab.new never leaves an ungrouped tab behind when grouping fails. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const chrome = makeChrome(state, { withTabGroups: true });
|
||||
const w = loadWorker(chrome);
|
||||
const tabsBefore = state.tabs.size;
|
||||
chrome.tabs.group = async () => { throw new Error("group blew up"); };
|
||||
|
||||
await throwsWith(
|
||||
() => w.dispatch("tab.new", { url: "https://pi.test/group-fail", groupTitle: "Pi Session: alpha", sessionKey: SK }),
|
||||
/group blew up/,
|
||||
"tab.new-group-fail: surfaces grouping error",
|
||||
);
|
||||
ok(state.tabs.size === tabsBefore, "tab.new-group-fail: closes the created tab instead of leaving it ungrouped");
|
||||
}
|
||||
|
||||
// ===== Grouping is best-effort: a tabGroups failure must not break navigation. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const chrome = makeChrome(state, { withTabGroups: true });
|
||||
chrome.tabs.group = async () => { throw new Error("group blew up"); };
|
||||
const w = loadWorker(chrome);
|
||||
const nav = await w.dispatch("page.navigate", { url: "https://pi.test/group-fail", waitUntilLoad: false, sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: "Pi Session: alpha" });
|
||||
ok(nav.url === "https://pi.test/group-fail", "group-fail: navigation still succeeds when grouping throws");
|
||||
ok(state.tabs.get(nav.id).windowId !== state.userWindowId, "group-fail: still used the dedicated automation window");
|
||||
}
|
||||
|
||||
// ===== Concurrency: two sessions get separate windows; cleanup is per-session. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state));
|
||||
const a = await w.dispatch("page.navigate", { url: "https://pi.test/a", waitUntilLoad: false, sessionKey: "session:A" });
|
||||
const b = await w.dispatch("page.navigate", { url: "https://pi.test/b", waitUntilLoad: false, sessionKey: "session:B" });
|
||||
ok(a.id !== b.id && a.windowId !== b.windowId, "concurrency: each session gets its own dedicated window/tab");
|
||||
ok(w.isPiChromeOwnedTarget(a.id, "session:A") && !w.isPiChromeOwnedTarget(a.id, "session:B"), "concurrency: ownership is scoped to the creating session");
|
||||
|
||||
// Cleaning up session A must not touch session B's target.
|
||||
await w.dispatch("automation.cleanup", { sessionKey: "session:A" });
|
||||
ok(!state.tabs.has(a.id), "concurrency: cleanup closed session A's tab");
|
||||
ok(state.tabs.has(b.id), "concurrency: cleanup left session B's tab open");
|
||||
const bStatus = await w.dispatch("automation.status", { sessionKey: "session:B" });
|
||||
ok(bStatus.tabId === b.id, "concurrency: session B still owns its target after A cleanup");
|
||||
}
|
||||
|
||||
// ===== Service-worker restart / reconnect: persisted ownership re-hydrates from storage. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w1 = loadWorker(makeChrome(state));
|
||||
const nav = await w1.dispatch("page.navigate", { url: "https://pi.test/persist", waitUntilLoad: false, sessionKey: SK });
|
||||
ok(typeof state.storage.piChromeAutomationTargets === "object", "restart: ownership was persisted to storage.session");
|
||||
|
||||
// Simulate the MV3 service worker being suspended and restarted: fresh sandbox (memory wiped),
|
||||
// same browser tabs/windows + same session storage.
|
||||
const w2 = loadWorker(makeChrome(state));
|
||||
const statusAfterRestart = await w2.dispatch("automation.status", { sessionKey: SK });
|
||||
ok(statusAfterRestart.tabId === nav.id && statusAfterRestart.windowId === nav.windowId, "restart: re-hydrated the owned target from storage");
|
||||
|
||||
// A navigation after restart must REUSE the existing window, not orphan it with a new one.
|
||||
const windowsBefore = state.windows.size;
|
||||
const nav2 = await w2.dispatch("page.navigate", { url: "https://pi.test/persist-2", waitUntilLoad: false, sessionKey: SK });
|
||||
ok(nav2.id === nav.id && nav2.windowId === nav.windowId, "restart: navigation after restart reuses the persisted window (no orphan)");
|
||||
ok(state.windows.size === windowsBefore, "restart: no new window created after restart");
|
||||
|
||||
// Cleanup after restart works and clears persisted state.
|
||||
await w2.dispatch("automation.cleanup", { sessionKey: SK });
|
||||
const persisted = state.storage.piChromeAutomationTargets || {};
|
||||
ok(!(SK in persisted), "restart: cleanup removed the session from persisted storage");
|
||||
}
|
||||
|
||||
// ===== Restart after the user manually closed the window: no orphan, fresh target. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w1 = loadWorker(makeChrome(state));
|
||||
const nav = await w1.dispatch("page.navigate", { url: "https://pi.test/closed", waitUntilLoad: false, sessionKey: SK });
|
||||
await state.windows.delete(nav.windowId); // user closed pi-chrome's window
|
||||
for (const [tid, t] of [...state.tabs]) if (t.windowId === nav.windowId) state.tabs.delete(tid);
|
||||
|
||||
const w2 = loadWorker(makeChrome(state)); // SW restart
|
||||
const nav2 = await w2.dispatch("page.navigate", { url: "https://pi.test/reopened", waitUntilLoad: false, sessionKey: SK });
|
||||
ok(nav2.id !== nav.id, "restart-after-close: a fresh automation target is created when the persisted one is gone");
|
||||
ok(state.tabs.has(nav2.id), "restart-after-close: new target exists");
|
||||
}
|
||||
|
||||
// ===== tab.* management never auto-creates / never falls back to the user's active tab. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state));
|
||||
const windowsBefore = state.windows.size;
|
||||
const tabsBefore = state.tabs.size;
|
||||
|
||||
await throwsWith(
|
||||
() => w.dispatch("tab.close", { sessionKey: SK }),
|
||||
/no automation tab yet|Pass targetId/,
|
||||
"tab.close: with no target and no owned target, errors instead of closing the user's active tab",
|
||||
);
|
||||
ok(state.tabs.has(state.userArticle.id), "tab.close: user's active tab was NOT closed");
|
||||
ok(state.windows.size === windowsBefore && state.tabs.size === tabsBefore, "tab.close: did not spawn a throwaway tab/window");
|
||||
|
||||
await throwsWith(() => w.dispatch("tab.activate", { sessionKey: SK }), /no automation tab yet|Pass targetId/, "tab.activate: errors with no target/owned target");
|
||||
|
||||
// Once an automation target exists, management actions operate on it (not on the user tab).
|
||||
const nav = await w.dispatch("page.navigate", { url: "https://pi.test/manage", waitUntilLoad: false, sessionKey: SK });
|
||||
const closed = await w.dispatch("tab.close", { sessionKey: SK });
|
||||
ok(closed.closed === nav.id, "tab.close: with an owned target, closes that target");
|
||||
ok(state.tabs.has(state.userArticle.id), "tab.close: user tab still safe after closing the owned target");
|
||||
}
|
||||
|
||||
// ===== Explicit targeting still works on any existing tab (no regression). =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state));
|
||||
const nav = await w.dispatch("page.navigate", { url: "https://pi.test/explicit", targetId: String(state.userGmail.id), waitUntilLoad: false, sessionKey: SK });
|
||||
ok(nav.id === state.userGmail.id, "explicit: targetId routes to the requested existing tab");
|
||||
ok(state.userGmail.url === "https://pi.test/explicit", "explicit: explicitly targeted tab is navigated");
|
||||
const status = await w.dispatch("automation.status", { sessionKey: SK });
|
||||
ok(status.tabId === null, "explicit: explicit targeting does not create/claim an automation target");
|
||||
}
|
||||
|
||||
// ===== Window-unavailable fallback: a dedicated TAB is used, and the user's window is safe. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state, { withWindows: false }));
|
||||
const target = await w.getOrCreateAutomationTarget(SK);
|
||||
ok(target.id !== state.userArticle.id && target.id !== state.userGmail.id, "fallback: created a dedicated tab, not a user tab");
|
||||
ok(w.isPiChromeOwnedTarget(target.id, SK) === true, "fallback: dedicated tab is owned");
|
||||
const cleanup = await w.cleanupAutomationTarget(SK);
|
||||
ok(cleanup.closedTabId === target.id && cleanup.closedWindowId === null, "fallback: cleanup closes only the owned tab (never the shared window)");
|
||||
ok(state.windows.has(state.userWindowId), "fallback: cleanup never closes the user/shared window");
|
||||
ok(state.tabs.has(state.userArticle.id) && state.tabs.has(state.userGmail.id), "fallback: cleanup leaves user tabs intact");
|
||||
}
|
||||
|
||||
// ===== Robust cleanup: no-op when nothing created, and when target already closed manually. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state));
|
||||
const empty = await w.cleanupAutomationTarget(SK);
|
||||
ok(empty.closedWindowId === null && empty.closedTabId === null, "cleanup: no-op when nothing was ever created");
|
||||
|
||||
const t = await w.getOrCreateAutomationTarget(SK);
|
||||
// User closed pi-chrome's window manually (Chrome closes its tabs too).
|
||||
state.windows.delete(t.windowId);
|
||||
for (const [tid, tab] of [...state.tabs]) if (tab.windowId === t.windowId) state.tabs.delete(tid);
|
||||
const stale = await w.cleanupAutomationTarget(SK);
|
||||
ok(stale.closedWindowId === null && stale.closedTabId === null, "cleanup: robust when owned window was already closed");
|
||||
}
|
||||
|
||||
console.log(`\n${passes} passed, ${failures} failed`);
|
||||
if (failures) process.exit(1);
|
||||
}
|
||||
|
||||
run().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,192 @@
|
||||
// Unit harness for the CSP-bypass layer in service_worker.js.
|
||||
//
|
||||
// The real CSP bypass (CDP Runtime.evaluate not being subject to page CSP) can only be
|
||||
// proven in a browser — see challenge 39-strict-csp-fallback. These tests instead validate
|
||||
// the JS *logic* of the refactor that the bypass depends on:
|
||||
// - evaluateInTab: wrapper-string construction, expression/statement fallback, value
|
||||
// marker round-trip (undefined/function/symbol/bigint/Error/DOMRect), error propagation.
|
||||
// - executeInTab: 2-phase define-then-invoke, envelope unwrap, error propagation, and that
|
||||
// all real HELPER_FUNCS serialize+assign without a parse error.
|
||||
// - page.waitFor: service-worker-side polling via evaluateInTab (selector + expression).
|
||||
//
|
||||
// We load the worker into a vm sandbox with mocked chrome.* APIs, then replace `cdp` with a
|
||||
// shim that evaluates the expression in a separate "page world" vm context (simulating CDP
|
||||
// Runtime.evaluate returnByValue). No browser, no network, no deps.
|
||||
|
||||
import vm from "node:vm";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const workerPath = path.resolve(__dirname, "../../extensions/chrome-profile-bridge/browser-extension/service_worker.js");
|
||||
const src = fs.readFileSync(workerPath, "utf8");
|
||||
|
||||
let failures = 0;
|
||||
let passes = 0;
|
||||
function ok(cond, msg) {
|
||||
if (cond) { passes++; }
|
||||
else { failures++; console.error(` ✗ ${msg}`); }
|
||||
}
|
||||
async function throwsWith(fn, re, msg) {
|
||||
try { await fn(); ok(false, `${msg} (expected throw)`); }
|
||||
catch (e) { ok(re.test(String(e.message || e)), `${msg} (got: ${e.message})`); }
|
||||
}
|
||||
|
||||
// ---- page world: simulates the page's MAIN world for Runtime.evaluate ----
|
||||
const pageGlobals = {
|
||||
console, JSON, Date, Math, Promise, Object, Array, String, Number, Boolean,
|
||||
Error, TypeError, SyntaxError, RangeError, BigInt, Symbol, structuredClone,
|
||||
setTimeout, parseInt, parseFloat, isNaN,
|
||||
document: {
|
||||
title: "page title",
|
||||
_present: new Set(),
|
||||
querySelector(sel) { return this._present.has(sel) ? { sel } : null; },
|
||||
},
|
||||
};
|
||||
pageGlobals.window = pageGlobals;
|
||||
pageGlobals.globalThis = pageGlobals;
|
||||
const pageWorld = vm.createContext(pageGlobals);
|
||||
|
||||
// Simulate CDP Runtime.evaluate returnByValue serialization.
|
||||
function toCdpResult(v) {
|
||||
if (v === undefined) return { result: { type: "undefined" } };
|
||||
if (v === null) return { result: { type: "object", subtype: "null", value: null } };
|
||||
const t = typeof v;
|
||||
if (t === "number" || t === "string" || t === "boolean")
|
||||
return { result: { type: t, value: v } };
|
||||
// object/array: returnByValue deep-clones JSON-able structures
|
||||
return { result: { type: "object", value: JSON.parse(JSON.stringify(v)) } };
|
||||
}
|
||||
|
||||
// ---- worker sandbox ----
|
||||
const noop = () => {};
|
||||
const listener = { addListener: noop, removeListener: noop };
|
||||
const sandbox = {
|
||||
console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean,
|
||||
Error, TypeError, Map, Set, BigInt, Symbol, structuredClone,
|
||||
setTimeout, clearTimeout,
|
||||
setInterval: () => 0,
|
||||
clearInterval: noop,
|
||||
fetch: async () => { throw new Error("no network in unit test"); },
|
||||
navigator: { userAgent: "unit-test" },
|
||||
WebSocket: function () {},
|
||||
chrome: {
|
||||
runtime: { id: "unittestextension", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener, lastError: null },
|
||||
alarms: { onAlarm: listener, create: noop, clear: noop, clearAll: noop },
|
||||
action: { onClicked: listener },
|
||||
debugger: { sendCommand: noop, attach: async () => {}, detach: async () => {}, getTargets: (cb) => cb([]) },
|
||||
scripting: { executeScript: async () => [{ result: undefined }] },
|
||||
tabs: { query: async () => [], get: async () => ({}), create: async () => ({}), update: async () => ({}), remove: async () => {} },
|
||||
windows: { update: async () => {} },
|
||||
webNavigation: { onCommitted: listener },
|
||||
},
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(src, sandbox);
|
||||
|
||||
// ---- override the page-touching primitives with the page-world shim ----
|
||||
sandbox.attachDebugger = async () => ({});
|
||||
sandbox.bringToFront = async () => {};
|
||||
sandbox.getTabByParams = async (p) => ({ id: (p && p.targetId) || 1, windowId: 1 });
|
||||
sandbox.cdp = async (_tabId, method, params) => {
|
||||
if (method !== "Runtime.evaluate") return {};
|
||||
try {
|
||||
const value = await vm.runInContext(params.expression, pageWorld);
|
||||
return toCdpResult(value);
|
||||
} catch (e) {
|
||||
return { exceptionDetails: { exception: { className: e.name, description: String(e.stack || e.message) }, text: "Uncaught " + String(e) } };
|
||||
}
|
||||
};
|
||||
// Phase-2 of executeInTab: run the injected wrapper func against the page world,
|
||||
// where Phase-1 (via cdp shim above) already defined window.__piAction + helpers.
|
||||
sandbox.chrome.scripting.executeScript = async ({ func, args }) => {
|
||||
const fn = vm.runInContext("(" + func.toString() + ")", pageWorld);
|
||||
const result = await fn(...(args || []));
|
||||
return [{ result }];
|
||||
};
|
||||
|
||||
const { evaluateInTab, executeInTab, dispatch } = sandbox;
|
||||
|
||||
async function run() {
|
||||
// ===== evaluateInTab: primitives & objects =====
|
||||
ok((await evaluateInTab({ expression: "2 + 2" })) === 4, "evaluate: arithmetic expression");
|
||||
ok((await evaluateInTab({ expression: "document.title" })) === "page title", "evaluate: expression without return");
|
||||
ok((await evaluateInTab({ expression: "'a' + 'b'" })) === "ab", "evaluate: string concat");
|
||||
const obj = await evaluateInTab({ expression: "({a:1, b:[2,3]})" });
|
||||
ok(obj && obj.a === 1 && obj.b[1] === 3, "evaluate: object literal round-trips");
|
||||
|
||||
// ===== value markers =====
|
||||
ok((await evaluateInTab({ expression: "void 0" })) === undefined, "evaluate: undefined marker -> undefined");
|
||||
ok((await evaluateInTab({ expression: "10n" })) === "10", "evaluate: bigint marker -> string");
|
||||
ok(/^\[Function:/.test(await evaluateInTab({ expression: "(function foo(){})" })), "evaluate: function marker");
|
||||
ok((await evaluateInTab({ expression: "Promise.resolve(42)" })) === 42, "evaluate: promise is awaited");
|
||||
|
||||
// DOMRect-like (toJSON + width/height/top) is expanded, not flattened to {}
|
||||
const rect = await evaluateInTab({ expression: "({ x:1,y:2,width:3,height:4,top:2,right:4,bottom:6,left:1, toJSON(){return {}} })" });
|
||||
ok(rect && rect.width === 3 && rect.bottom === 6, "evaluate: DOMRect-like expanded");
|
||||
|
||||
// ===== statement-form fallback (expression form is a SyntaxError) =====
|
||||
// `let x=...; x` is not a valid expression, so the wrapper must retry as a statement body.
|
||||
ok((await evaluateInTab({ expression: "let x = 5; x" })) === undefined, "evaluate: statement form falls back (no return -> undefined)");
|
||||
ok((await evaluateInTab({ expression: "let y = 7; return y" })) === 7, "evaluate: statement form with explicit return");
|
||||
|
||||
// ===== error propagation =====
|
||||
await throwsWith(() => evaluateInTab({ expression: "throw new Error('boom')" }), /chrome_evaluate failed[\s\S]*boom/, "evaluate: runtime error propagates");
|
||||
|
||||
// ===== executeInTab: 2-phase define + invoke =====
|
||||
// Real HELPER_FUNCS get serialized + assigned in Phase 1; a parse error there would throw here.
|
||||
const sum = await executeInTab({ targetId: 1 }, function add(a, b) { return a + b; }, [3, 4]);
|
||||
ok(sum === 7, "executeInTab: action runs with args after helper injection");
|
||||
|
||||
const asyncResult = await executeInTab({ targetId: 1 }, async function asyncEcho(v) { return v * 2; }, [21]);
|
||||
ok(asyncResult === 42, "executeInTab: async action awaited");
|
||||
|
||||
await throwsWith(
|
||||
() => executeInTab({ targetId: 1 }, function boom() { throw new Error("action failed"); }, []),
|
||||
/action failed/,
|
||||
"executeInTab: thrown action error propagates via envelope",
|
||||
);
|
||||
|
||||
// ===== page.waitFor (service-worker-side polling) =====
|
||||
pageGlobals.document._present.add("#ready");
|
||||
const wf = await dispatch("page.waitFor", { targetId: 1, kind: "selector", value: "#ready", timeoutMs: 1000, intervalMs: 20 });
|
||||
ok(wf && typeof wf.elapsedMs === "number", "waitFor: selector present resolves");
|
||||
|
||||
const wfExpr = await dispatch("page.waitFor", { targetId: 1, kind: "expression", value: "1 === 1", timeoutMs: 1000, intervalMs: 20 });
|
||||
ok(wfExpr && typeof wfExpr.elapsedMs === "number", "waitFor: truthy expression resolves");
|
||||
|
||||
await throwsWith(
|
||||
() => dispatch("page.waitFor", { targetId: 1, kind: "selector", value: "#never", timeoutMs: 120, intervalMs: 30 }),
|
||||
/Timed out after 120ms/,
|
||||
"waitFor: missing selector times out",
|
||||
);
|
||||
|
||||
// ===== usKeyLayoutForChar / cdpKeyInfo: US-layout key codes =====
|
||||
// Regression: punctuation must NOT use charCodeAt() (".":46 collides with VK_DELETE,
|
||||
// "-":45 with VK_INSERT), which made apps drop the char on keydown.
|
||||
const { usKeyLayoutForChar, cdpKeyInfo } = sandbox;
|
||||
const period = usKeyLayoutForChar(".");
|
||||
ok(period.code === "Period" && period.keyCode === 190 && !period.needShift, "keylayout: '.' -> Period/190 (not 46)");
|
||||
const dash = usKeyLayoutForChar("-");
|
||||
ok(dash.code === "Minus" && dash.keyCode === 189, "keylayout: '-' -> Minus/189 (not 45)");
|
||||
const slash = usKeyLayoutForChar("/");
|
||||
ok(slash.code === "Slash" && slash.keyCode === 191, "keylayout: '/' -> Slash/191");
|
||||
const at = usKeyLayoutForChar("@");
|
||||
ok(at.code === "Digit2" && at.keyCode === 50 && at.needShift, "keylayout: '@' -> Digit2/50 + shift");
|
||||
const A = usKeyLayoutForChar("A");
|
||||
ok(A.code === "KeyA" && A.keyCode === 65 && A.needShift, "keylayout: 'A' -> KeyA/65 + shift");
|
||||
const a = usKeyLayoutForChar("a");
|
||||
ok(a.code === "KeyA" && a.keyCode === 65 && !a.needShift, "keylayout: 'a' -> KeyA/65 no shift");
|
||||
const dot = cdpKeyInfo(".");
|
||||
ok(dot.code === "Period" && dot.windowsVirtualKeyCode === 190 && dot.text === ".", "cdpKeyInfo: '.' -> Period/190 with text");
|
||||
const ent = cdpKeyInfo("Enter");
|
||||
ok(ent.code === "Enter" && ent.windowsVirtualKeyCode === 13, "cdpKeyInfo: named key 'Enter' unaffected");
|
||||
|
||||
console.log(`\n${passes} passed, ${failures} failed`);
|
||||
if (failures) process.exit(1);
|
||||
}
|
||||
|
||||
run().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { SessionSnapshotStore, snapshotTargetKey } from "../src/session-snapshot-store.ts";
|
||||
import { CHROME_TOOL_NAMES, formatChromeFindMatches, formatSnapshotCapture } from "../extensions/chrome-profile-bridge/index.ts";
|
||||
|
||||
test("snapshotTargetKey prefers explicit target selectors", () => {
|
||||
assert.equal(snapshotTargetKey(), "default");
|
||||
assert.equal(snapshotTargetKey({ titleIncludes: "Inbox" }), "title:Inbox");
|
||||
assert.equal(snapshotTargetKey({ urlIncludes: "example.com" }), "url:example.com");
|
||||
assert.equal(snapshotTargetKey({ targetId: "42", urlIncludes: "ignored" }), "target:42");
|
||||
});
|
||||
|
||||
test("SessionSnapshotStore atomically keeps only the latest capture per target", async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), "pi-chrome-snapshot-"));
|
||||
const store = new SessionSnapshotStore(workspace, "session/private/id");
|
||||
const first = await store.save({ mode: "interactive", targetId: 42, elements: [{ uid: "el-1" }], matches: [] });
|
||||
const second = await store.save({ mode: "changes", targetId: 42, elements: [{ uid: "el-2" }], matches: [{ uid: "el-2" }] });
|
||||
|
||||
assert.equal(first.metadata.path, second.metadata.path);
|
||||
assert.notEqual(first.metadata.captureId, second.metadata.captureId);
|
||||
assert.equal(second.metadata.elementCount, 1);
|
||||
assert.equal(second.metadata.matchCount, 1);
|
||||
assert.equal(second.metadata.targetId, 42);
|
||||
assert.ok(!second.metadata.path.includes("private"));
|
||||
|
||||
const stored = JSON.parse(await readFile(second.absolutePath, "utf8"));
|
||||
assert.equal(stored.version, 1);
|
||||
assert.equal(stored.captureId, second.metadata.captureId);
|
||||
assert.equal(stored.snapshot.elements[0].uid, "el-2");
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
assert.equal((await stat(store.sessionRoot)).mode & 0o777, 0o700);
|
||||
assert.equal((await stat(second.absolutePath)).mode & 0o777, 0o600);
|
||||
}
|
||||
|
||||
await store.clear();
|
||||
await assert.rejects(stat(second.absolutePath), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("target invalidation and session cleanup do not affect another session", async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), "pi-chrome-isolation-"));
|
||||
const firstSession = new SessionSnapshotStore(workspace, "session-a");
|
||||
const secondSession = new SessionSnapshotStore(workspace, "session-b");
|
||||
const firstTarget = { targetId: "101" };
|
||||
const secondTarget = { targetId: "202" };
|
||||
const first = await firstSession.save({ elements: [] }, firstTarget);
|
||||
const otherTarget = await firstSession.save({ elements: [] }, secondTarget);
|
||||
const otherSession = await secondSession.save({ elements: [] }, firstTarget);
|
||||
|
||||
await firstSession.invalidate(firstTarget);
|
||||
await assert.rejects(stat(first.absolutePath), { code: "ENOENT" });
|
||||
await stat(otherTarget.absolutePath);
|
||||
await stat(otherSession.absolutePath);
|
||||
|
||||
await firstSession.clear();
|
||||
await assert.rejects(stat(otherTarget.absolutePath), { code: "ENOENT" });
|
||||
await stat(otherSession.absolutePath);
|
||||
await secondSession.clear();
|
||||
});
|
||||
|
||||
test("snapshot result formatting stays bounded and points to Context Mode", () => {
|
||||
const text = formatSnapshotCapture({
|
||||
version: 1,
|
||||
captureId: "capture-1",
|
||||
capturedAt: "2026-08-28T00:00:00.000Z",
|
||||
path: ".pi/chrome-context/session/target/snapshot.json",
|
||||
bytes: 80_000,
|
||||
targetKey: "target:42",
|
||||
targetId: 42,
|
||||
mode: "interactive",
|
||||
elementCount: 80,
|
||||
matchCount: 3,
|
||||
staleAfterInteraction: true,
|
||||
});
|
||||
|
||||
assert.match(text, /ctx_execute_file/);
|
||||
assert.match(text, /\.pi\/chrome-context\/session\/target\/snapshot\.json/);
|
||||
assert.doesNotMatch(text, /raw-secret-page-content/);
|
||||
});
|
||||
|
||||
test("chrome_find formatting is capped and authorization tracks all 21 tools", () => {
|
||||
const matches = Array.from({ length: 25 }, (_, index) => ({
|
||||
kind: "element",
|
||||
uid: `el-${index}`,
|
||||
role: "button",
|
||||
label: `Button ${index}`,
|
||||
rect: { x: index, y: index, width: 10, height: 10 },
|
||||
}));
|
||||
const text = formatChromeFindMatches({ query: "button", matches });
|
||||
|
||||
assert.match(text, /el-0/);
|
||||
assert.match(text, /el-19/);
|
||||
assert.doesNotMatch(text, /el-20/);
|
||||
assert.equal(CHROME_TOOL_NAMES.length, 21);
|
||||
assert.ok(CHROME_TOOL_NAMES.includes("chrome_find"));
|
||||
assert.ok(CHROME_TOOL_NAMES.includes("chrome_inspect"));
|
||||
});
|
||||
@@ -74,9 +74,10 @@ test("all package extensions load together without global registration conflicts
|
||||
assert.ok(packageJson.pi.extensions.includes("./pi-ask-user/index.ts"), "the bundle must load pi-ask-user");
|
||||
assert.equal(packageJson.dependencies["pi-ask-user"], "file:./pi-ask-user");
|
||||
assert.ok(packageJson.files.includes("pi-ask-user"), "the packed bundle must include pi-ask-user source");
|
||||
const chromeExtension = "./node_modules/pi-chrome/extensions/chrome-profile-bridge/index.ts";
|
||||
assert.equal(packageJson.dependencies["pi-chrome"], "0.15.46", "the browser bridge package must stay exactly pinned");
|
||||
assert.ok(packageJson.pi.extensions.includes(chromeExtension), "the bundle must load pi-chrome's Chrome profile bridge");
|
||||
const chromeExtension = "./pi-chrome/extensions/chrome-profile-bridge/index.ts";
|
||||
assert.equal(packageJson.dependencies["pi-chrome"], "file:./pi-chrome", "the browser bridge must use the locally maintained source");
|
||||
assert.ok(packageJson.files.includes("pi-chrome"), "the packed bundle must include the locally maintained pi-chrome source");
|
||||
assert.ok(packageJson.pi.extensions.includes(chromeExtension), "the bundle must load the local Chrome profile bridge directly");
|
||||
assert.ok(
|
||||
packageJson.pi.extensions.indexOf(chromeExtension) <
|
||||
packageJson.pi.extensions.indexOf("./pi-tool-search/extensions/index.ts"),
|
||||
|
||||
@@ -50,6 +50,14 @@ test("context routing analyzes large files without a full read", () => {
|
||||
assert.match(section, /host permission approval does not bypass this Context Mode boundary/);
|
||||
});
|
||||
|
||||
test("Chrome snapshot routing keeps raw page data out of model context", () => {
|
||||
const section = buildToolRoutingSection(["chrome_snapshot", "ctx_execute_file"]);
|
||||
|
||||
assert.match(section, /immediately analyze the returned workspace-relative capture path with ctx_execute_file/);
|
||||
assert.match(section, /never use read or cat for the raw JSON/);
|
||||
assert.match(section, /take a fresh snapshot after any interaction/);
|
||||
});
|
||||
|
||||
test("hashline routing uses small reads and fresh anchors for edits", () => {
|
||||
const section = buildToolRoutingSection(["read", "edit"]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user