mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
5 Commits
feat/eval-
...
feat/hack-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c222d85a89 | ||
|
|
bf14bcab7b | ||
|
|
76e9f33f59 | ||
|
|
e0caa87172 | ||
|
|
b06838ff75 |
100
packages/browseros-agent/apps/eval/data/vl-seeds-v2.jsonl
vendored
Normal file
100
packages/browseros-agent/apps/eval/data/vl-seeds-v2.jsonl
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
{"site":"hn","url":"https://news.ycombinator.com/","category":"news","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"lobsters","url":"https://lobste.rs/","category":"news","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"tldr","url":"https://tldr.tech/","category":"news","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"theverge","url":"https://www.theverge.com/","category":"news","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"techcrunch","url":"https://techcrunch.com/","category":"news","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"arstechnica","url":"https://arstechnica.com/","category":"news","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"bbc_news","url":"https://www.bbc.com/news","category":"news","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"npr","url":"https://www.npr.org/","category":"news","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"guardian","url":"https://www.theguardian.com/international","category":"news","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"reuters","url":"https://www.reuters.com/","category":"news","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"mdn","url":"https://developer.mozilla.org/","category":"docs","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"react_dev","url":"https://react.dev/","category":"docs","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"nextjs_docs","url":"https://nextjs.org/docs","category":"docs","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"vue_docs","url":"https://vuejs.org/guide/introduction.html","category":"docs","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"svelte_docs","url":"https://svelte.dev/docs/introduction","category":"docs","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"tailwind_docs","url":"https://tailwindcss.com/docs","category":"docs","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"fastapi_docs","url":"https://fastapi.tiangolo.com/","category":"docs","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"django_docs","url":"https://docs.djangoproject.com/en/5.0/","category":"docs","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"rust_book","url":"https://doc.rust-lang.org/book/","category":"docs","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"go_pkg","url":"https://pkg.go.dev/","category":"docs","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"amazon_home","url":"https://www.amazon.com/","category":"ecommerce","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"amazon_book","url":"https://www.amazon.com/dp/0735619670","category":"ecommerce","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"ebay_home","url":"https://www.ebay.com/","category":"ecommerce","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"etsy_home","url":"https://www.etsy.com/","category":"ecommerce","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"walmart_home","url":"https://www.walmart.com/","category":"ecommerce","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"bestbuy_home","url":"https://www.bestbuy.com/","category":"ecommerce","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"target_home","url":"https://www.target.com/","category":"ecommerce","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"ikea_home","url":"https://www.ikea.com/us/en/","category":"ecommerce","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"rei_home","url":"https://www.rei.com/","category":"ecommerce","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"costco_home","url":"https://www.costco.com/","category":"ecommerce","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"youtube","url":"https://www.youtube.com/","category":"media","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"vimeo","url":"https://vimeo.com/","category":"media","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"twitch_directory","url":"https://www.twitch.tv/directory","category":"media","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"dailymotion","url":"https://www.dailymotion.com/","category":"media","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"soundcloud","url":"https://soundcloud.com/discover","category":"media","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"archive_org","url":"https://archive.org/","category":"media","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"ted_talks","url":"https://www.ted.com/talks","category":"media","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"khan_academy","url":"https://www.khanacademy.org/","category":"media","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"coursera_home","url":"https://www.coursera.org/","category":"media","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"edx_home","url":"https://www.edx.org/","category":"media","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"reddit_programming","url":"https://www.reddit.com/r/programming/","category":"social","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"reddit_python","url":"https://www.reddit.com/r/python/","category":"social","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"reddit_ml","url":"https://www.reddit.com/r/MachineLearning/","category":"social","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"hn_newest","url":"https://news.ycombinator.com/newest","category":"social","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"lobsters_recent","url":"https://lobste.rs/recent","category":"social","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"mastodon_explore","url":"https://mastodon.social/explore","category":"social","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"github_explore","url":"https://github.com/explore","category":"social","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"github_topics","url":"https://github.com/topics","category":"social","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"stackexchange_sites","url":"https://stackexchange.com/sites","category":"social","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"stackoverflow_questions","url":"https://stackoverflow.com/questions","category":"social","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"wikipedia_main","url":"https://en.wikipedia.org/wiki/Main_Page","category":"reference","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"wikipedia_roman","url":"https://en.wikipedia.org/wiki/Roman_Empire","category":"reference","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"wiktionary","url":"https://en.wiktionary.org/wiki/Wiktionary:Main_Page","category":"reference","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"wikidata","url":"https://www.wikidata.org/wiki/Wikidata:Main_Page","category":"reference","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"wikivoyage","url":"https://en.wikivoyage.org/wiki/Main_Page","category":"reference","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"simple_wiki","url":"https://simple.wikipedia.org/wiki/Main_Page","category":"reference","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"gutenberg","url":"https://www.gutenberg.org/","category":"reference","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"openlibrary","url":"https://openlibrary.org/","category":"reference","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"scholar_react","url":"https://scholar.google.com/scholar?q=react+hooks","category":"reference","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"mit_ocw","url":"https://ocw.mit.edu/","category":"reference","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"google_react","url":"https://www.google.com/search?q=react+hooks","category":"search","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"bing_react","url":"https://www.bing.com/search?q=react+hooks","category":"search","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"ddg_react","url":"https://duckduckgo.com/?q=react+hooks","category":"search","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"brave_react","url":"https://search.brave.com/search?q=react+hooks","category":"search","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"ecosia_react","url":"https://www.ecosia.org/search?q=react+hooks","category":"search","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"qwant_react","url":"https://www.qwant.com/?q=react+hooks","category":"search","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"you_react","url":"https://you.com/search?q=react+hooks","category":"search","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"perplexity_home","url":"https://www.perplexity.ai/","category":"search","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"phind_home","url":"https://www.phind.com/","category":"search","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"mojeek_react","url":"https://www.mojeek.com/search?q=react+hooks","category":"search","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"github_trending","url":"https://github.com/trending","category":"dashboard","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"github_pulls","url":"https://github.com/pulls?q=is%3Apr+is%3Aopen+author%3Atorvalds","category":"dashboard","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"github_issues","url":"https://github.com/issues?q=is%3Aopen+author%3Atorvalds","category":"dashboard","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"npm_react","url":"https://www.npmjs.com/package/react","category":"dashboard","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"npm_search_ai","url":"https://www.npmjs.com/search?q=ai+sdk","category":"dashboard","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"pypi_django","url":"https://pypi.org/project/django/","category":"dashboard","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"pypi_search","url":"https://pypi.org/search/?q=fastapi","category":"dashboard","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"dockerhub","url":"https://hub.docker.com/explore","category":"dashboard","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"crates_io","url":"https://crates.io/","category":"dashboard","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"packagist","url":"https://packagist.org/","category":"dashboard","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"medium_top","url":"https://medium.com/","category":"longform","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"substack_top","url":"https://substack.com/discover","category":"longform","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"dev_to","url":"https://dev.to/","category":"longform","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"hashnode_top","url":"https://hashnode.com/","category":"longform","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"css_tricks","url":"https://css-tricks.com/","category":"longform","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"smashing_mag","url":"https://www.smashingmagazine.com/","category":"longform","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"a_list_apart","url":"https://alistapart.com/","category":"longform","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"github_blog","url":"https://github.blog/","category":"longform","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"mozilla_blog","url":"https://blog.mozilla.org/","category":"longform","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"nytimes_open","url":"https://open.nytimes.com/","category":"longform","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"google_flights","url":"https://www.google.com/travel/flights","category":"app","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"kayak_home","url":"https://www.kayak.com/","category":"app","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"airbnb_lisbon","url":"https://www.airbnb.com/s/Lisbon--Portugal","category":"app","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"booking_lisbon","url":"https://www.booking.com/searchresults.html?ss=Lisbon","category":"app","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"expedia_home","url":"https://www.expedia.com/","category":"app","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"opentable_lisbon","url":"https://www.opentable.com/lisbon-portugal-restaurants","category":"app","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"openstreetmap","url":"https://www.openstreetmap.org/","category":"app","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"weather_com","url":"https://weather.com/","category":"app","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"accuweather","url":"https://www.accuweather.com/","category":"app","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
{"site":"wolframalpha","url":"https://www.wolframalpha.com/","category":"app","states":[{"kind":"initial"},{"kind":"scroll","pixels":800},{"kind":"scroll","pixels":1600},{"kind":"scroll","pixels":2400},{"kind":"scroll","pixels":3200}]}
|
||||
10
packages/browseros-agent/apps/eval/data/vl-seeds.jsonl
vendored
Normal file
10
packages/browseros-agent/apps/eval/data/vl-seeds.jsonl
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{"site":"hn","url":"https://news.ycombinator.com/","states":[{"kind":"initial"},{"kind":"scroll","pixels":600},{"kind":"scroll","pixels":1200}]}
|
||||
{"site":"wikipedia","url":"https://en.wikipedia.org/wiki/Main_Page","states":[{"kind":"initial"},{"kind":"scroll","pixels":600},{"kind":"scroll","pixels":1200}]}
|
||||
{"site":"github_trending","url":"https://github.com/trending","states":[{"kind":"initial"},{"kind":"scroll","pixels":600},{"kind":"scroll","pixels":1200}]}
|
||||
{"site":"reddit","url":"https://www.reddit.com/r/programming/","states":[{"kind":"initial"},{"kind":"scroll","pixels":600},{"kind":"scroll","pixels":1200}]}
|
||||
{"site":"mdn","url":"https://developer.mozilla.org/","states":[{"kind":"initial"},{"kind":"scroll","pixels":600},{"kind":"scroll","pixels":1200}]}
|
||||
{"site":"arxiv","url":"https://arxiv.org/list/cs.CL/recent","states":[{"kind":"initial"},{"kind":"scroll","pixels":600},{"kind":"scroll","pixels":1200}]}
|
||||
{"site":"stackoverflow","url":"https://stackoverflow.com/questions","states":[{"kind":"initial"},{"kind":"scroll","pixels":600},{"kind":"scroll","pixels":1200}]}
|
||||
{"site":"ycombinator","url":"https://www.ycombinator.com/companies","states":[{"kind":"initial"},{"kind":"scroll","pixels":600},{"kind":"scroll","pixels":1200}]}
|
||||
{"site":"npm","url":"https://www.npmjs.com/package/react","states":[{"kind":"initial"},{"kind":"scroll","pixels":600},{"kind":"scroll","pixels":1200}]}
|
||||
{"site":"youtube","url":"https://www.youtube.com/","states":[{"kind":"initial"},{"kind":"scroll","pixels":600},{"kind":"scroll","pixels":1200}]}
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"eval": "bun --env-file=.env.development run src/index.ts",
|
||||
"collect": "bun --env-file=.env.development run src/collect.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
108
packages/browseros-agent/apps/eval/src/collect.ts
vendored
Normal file
108
packages/browseros-agent/apps/eval/src/collect.ts
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { runCollection } from './runner/collection-runner'
|
||||
|
||||
const HELP = `
|
||||
VL training-data collector
|
||||
|
||||
Usage:
|
||||
bun run collect --seeds <path.jsonl> [options]
|
||||
|
||||
Options:
|
||||
--seeds <path> JSONL file with CollectionTarget entries (required)
|
||||
--out <dir> Output directory (default: results/vl-data/<timestamp>)
|
||||
--workers <n> Parallel workers (default: 1)
|
||||
--limit <n> Stop after N targets (default: all)
|
||||
--headless Run BrowserOS headless (default: false)
|
||||
--profile-seed <dir> Seed each worker's user-data-dir from this snapshot.
|
||||
Expected layout: <dir>/Default/<profile files>.
|
||||
Use scripts/copy-browseros-profile.sh to build one.
|
||||
When set, --use-mock-keychain is dropped so cookies
|
||||
encrypted against the OS keychain still decrypt.
|
||||
-h, --help Show this help
|
||||
`
|
||||
|
||||
async function main() {
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
seeds: { type: 'string' },
|
||||
out: { type: 'string' },
|
||||
workers: { type: 'string', default: '1' },
|
||||
limit: { type: 'string' },
|
||||
headless: { type: 'boolean', default: false },
|
||||
'profile-seed': { type: 'string' },
|
||||
help: { type: 'boolean', short: 'h', default: false },
|
||||
},
|
||||
})
|
||||
|
||||
if (values.help) {
|
||||
console.log(HELP)
|
||||
process.exit(0)
|
||||
}
|
||||
if (!values.seeds) {
|
||||
console.error('error: --seeds is required')
|
||||
console.log(HELP)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const workers = parsePositiveInt(values.workers, '--workers')
|
||||
const limit = values.limit
|
||||
? parsePositiveInt(values.limit, '--limit')
|
||||
: undefined
|
||||
|
||||
const projectRoot = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'../../..',
|
||||
)
|
||||
const outDir = values.out
|
||||
? resolve(process.cwd(), values.out)
|
||||
: resolve(projectRoot, `results/vl-data/${timestamp()}`)
|
||||
|
||||
const profileSeed = values['profile-seed']
|
||||
? resolve(process.cwd(), values['profile-seed'])
|
||||
: undefined
|
||||
|
||||
const { writtenCount, errors } = await runCollection({
|
||||
seedsPath: resolve(process.cwd(), values.seeds),
|
||||
outDir,
|
||||
projectRoot,
|
||||
workers,
|
||||
limit,
|
||||
headless: values.headless,
|
||||
profileSeed,
|
||||
})
|
||||
|
||||
console.log(`\nWrote ${writtenCount} record(s) to ${outDir}`)
|
||||
if (errors.length > 0) {
|
||||
console.error(`Validation failed with ${errors.length} error(s):`)
|
||||
for (const err of errors.slice(0, 20)) console.error(` ${err}`)
|
||||
if (errors.length > 20)
|
||||
console.error(` ... and ${errors.length - 20} more`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('Validation passed.')
|
||||
}
|
||||
|
||||
function timestamp(): string {
|
||||
const d = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function parsePositiveInt(raw: string, flag: string): number {
|
||||
const n = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(n) || n < 1) {
|
||||
console.error(`error: ${flag} must be a positive integer (got "${raw}")`)
|
||||
process.exit(1)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : String(err))
|
||||
process.exit(1)
|
||||
})
|
||||
84
packages/browseros-agent/apps/eval/src/collectors/record-writer.ts
vendored
Normal file
84
packages/browseros-agent/apps/eval/src/collectors/record-writer.ts
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
||||
import { join, relative } from 'node:path'
|
||||
import { VL_VIEWPORT_HEIGHT, VL_VIEWPORT_WIDTH } from '../constants'
|
||||
import type { CollectedRecord } from '../types/collection-target'
|
||||
|
||||
export interface PreparedRecord
|
||||
extends Omit<CollectedRecord, 'id' | 'screenshot_path'> {}
|
||||
|
||||
export interface WriteResult {
|
||||
id: string
|
||||
screenshotPath: string
|
||||
jsonPath: string
|
||||
}
|
||||
|
||||
export class RecordWriter {
|
||||
private readonly siteCounts = new Map<string, number>()
|
||||
|
||||
constructor(
|
||||
private readonly outDir: string,
|
||||
private readonly projectRoot: string,
|
||||
) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await mkdir(join(this.outDir, 'screenshots'), { recursive: true })
|
||||
await mkdir(join(this.outDir, 'raw'), { recursive: true })
|
||||
}
|
||||
|
||||
async write(record: PreparedRecord, pngBase64: string): Promise<WriteResult> {
|
||||
const shortUuid = randomUUID().replace(/-/g, '').slice(0, 8)
|
||||
const id = `${record.site}_${shortUuid}`
|
||||
|
||||
const pngPath = join(this.outDir, 'screenshots', `${id}.png`)
|
||||
const jsonPath = join(this.outDir, 'raw', `${id}.json`)
|
||||
|
||||
// temp + rename so a crash between png and json writes doesn't leave
|
||||
// orphan files that future validators would flag.
|
||||
await writeAtomic(pngPath, Buffer.from(pngBase64, 'base64'))
|
||||
|
||||
const finalRecord: CollectedRecord = {
|
||||
...record,
|
||||
id,
|
||||
screenshot_path: relative(this.projectRoot, pngPath),
|
||||
}
|
||||
await writeAtomic(jsonPath, `${JSON.stringify(finalRecord, null, 2)}\n`)
|
||||
|
||||
this.siteCounts.set(
|
||||
record.site,
|
||||
(this.siteCounts.get(record.site) ?? 0) + 1,
|
||||
)
|
||||
return { id, screenshotPath: pngPath, jsonPath }
|
||||
}
|
||||
|
||||
async writeManifest(collectedAt: Date, collectorTag: string): Promise<void> {
|
||||
const sites = [...this.siteCounts.entries()].map(([site, states]) => ({
|
||||
site,
|
||||
states,
|
||||
}))
|
||||
const manifest = {
|
||||
collected_at: collectedAt.toISOString(),
|
||||
collector: collectorTag,
|
||||
total_records: [...this.siteCounts.values()].reduce((a, b) => a + b, 0),
|
||||
sites,
|
||||
viewport: { width: VL_VIEWPORT_WIDTH, height: VL_VIEWPORT_HEIGHT },
|
||||
}
|
||||
await writeAtomic(
|
||||
join(this.outDir, 'meta.json'),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
getSiteCounts(): Map<string, number> {
|
||||
return new Map(this.siteCounts)
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAtomic(
|
||||
path: string,
|
||||
data: string | Buffer | Uint8Array,
|
||||
): Promise<void> {
|
||||
const tmp = `${path}.tmp`
|
||||
await writeFile(tmp, data)
|
||||
await rename(tmp, path)
|
||||
}
|
||||
39
packages/browseros-agent/apps/eval/src/collectors/snapshot-parser.ts
vendored
Normal file
39
packages/browseros-agent/apps/eval/src/collectors/snapshot-parser.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
export interface ParsedSnapshotLine {
|
||||
backend_id: number
|
||||
role: string
|
||||
name: string
|
||||
snapshot_line: string
|
||||
}
|
||||
|
||||
const LINE_RE = /^\[(\d+)\]\s+(\S+)(?:\s+"((?:[^"\\]|\\.)*)")?/
|
||||
|
||||
export class SnapshotParseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly lineIndex: number,
|
||||
public readonly line: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'SnapshotParseError'
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSnapshot(snapshot: string): ParsedSnapshotLine[] {
|
||||
const lines = snapshot.split('\n')
|
||||
return lines.map((line, i) => {
|
||||
const match = line.match(LINE_RE)
|
||||
if (!match) {
|
||||
throw new SnapshotParseError(
|
||||
`Snapshot line ${i + 1} does not match [N] role format`,
|
||||
i,
|
||||
line,
|
||||
)
|
||||
}
|
||||
return {
|
||||
backend_id: Number.parseInt(match[1], 10),
|
||||
role: match[2],
|
||||
name: match[3] ?? '',
|
||||
snapshot_line: line,
|
||||
}
|
||||
})
|
||||
}
|
||||
94
packages/browseros-agent/apps/eval/src/collectors/target-loader.ts
vendored
Normal file
94
packages/browseros-agent/apps/eval/src/collectors/target-loader.ts
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
type CollectionTarget,
|
||||
CollectionTargetSchema,
|
||||
} from '../types/collection-target'
|
||||
|
||||
export class TargetLoadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly path: string,
|
||||
public readonly cause?: Error,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'TargetLoadError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCollectionTargets(
|
||||
path: string,
|
||||
): Promise<CollectionTarget[]> {
|
||||
let content: string
|
||||
try {
|
||||
content = await readFile(path, 'utf-8')
|
||||
} catch (error) {
|
||||
throw new TargetLoadError(
|
||||
`Failed to read seeds file: ${path}`,
|
||||
path,
|
||||
error instanceof Error ? error : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
const lines = content
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().length > 0)
|
||||
|
||||
if (lines.length === 0) {
|
||||
throw new TargetLoadError('Seeds file is empty', path)
|
||||
}
|
||||
|
||||
const targets: CollectionTarget[] = []
|
||||
const errors: Array<{ line: number; error: string }> = []
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineNumber = i + 1
|
||||
try {
|
||||
const parsed = JSON.parse(lines[i])
|
||||
targets.push(CollectionTargetSchema.parse(parsed))
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
errors.push({
|
||||
line: lineNumber,
|
||||
error: `Invalid JSON: ${error.message}`,
|
||||
})
|
||||
} else if (error instanceof z.ZodError) {
|
||||
const issues = error.issues
|
||||
.map((iss) => `${iss.path.join('.')}: ${iss.message}`)
|
||||
.join(', ')
|
||||
errors.push({ line: lineNumber, error: `Validation: ${issues}` })
|
||||
} else {
|
||||
errors.push({ line: lineNumber, error: `Unknown: ${String(error)}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const summary = errors
|
||||
.slice(0, 5)
|
||||
.map((e) => ` Line ${e.line}: ${e.error}`)
|
||||
.join('\n')
|
||||
const more =
|
||||
errors.length > 5 ? `\n ... and ${errors.length - 5} more` : ''
|
||||
throw new TargetLoadError(
|
||||
`Failed to parse ${errors.length} target(s):\n${summary}${more}`,
|
||||
path,
|
||||
)
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const duplicates: string[] = []
|
||||
for (const t of targets) {
|
||||
if (seen.has(t.site)) duplicates.push(t.site)
|
||||
seen.add(t.site)
|
||||
}
|
||||
if (duplicates.length > 0) {
|
||||
throw new TargetLoadError(
|
||||
`Duplicate site slugs: ${duplicates.join(', ')}`,
|
||||
path,
|
||||
)
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
125
packages/browseros-agent/apps/eval/src/collectors/validator.ts
vendored
Normal file
125
packages/browseros-agent/apps/eval/src/collectors/validator.ts
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
import { access, readdir, readFile } from 'node:fs/promises'
|
||||
import { basename, extname, isAbsolute, join, resolve } from 'node:path'
|
||||
import {
|
||||
type CollectedRecord,
|
||||
CollectedRecordSchema,
|
||||
} from '../types/collection-target'
|
||||
|
||||
export async function validateOutput(
|
||||
outDir: string,
|
||||
projectRoot: string = process.cwd(),
|
||||
): Promise<string[]> {
|
||||
const errors: string[] = []
|
||||
const rawDir = join(outDir, 'raw')
|
||||
|
||||
let files: string[]
|
||||
try {
|
||||
files = await readdir(rawDir)
|
||||
} catch {
|
||||
return [`${rawDir}: directory not readable`]
|
||||
}
|
||||
const jsonFiles = files.filter((n) => n.endsWith('.json'))
|
||||
if (jsonFiles.length === 0) return [`${rawDir}: no .json records`]
|
||||
|
||||
for (const f of jsonFiles) {
|
||||
const fileErrors = await validateRecordFile(rawDir, f, projectRoot)
|
||||
errors.push(...fileErrors)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
async function validateRecordFile(
|
||||
rawDir: string,
|
||||
filename: string,
|
||||
projectRoot: string,
|
||||
): Promise<string[]> {
|
||||
const errors: string[] = []
|
||||
const fullPath = join(rawDir, filename)
|
||||
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(fullPath, 'utf-8')
|
||||
} catch (e) {
|
||||
return [`${filename}: cannot read: ${stringErr(e)}`]
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch (e) {
|
||||
return [`${filename}: invalid JSON: ${stringErr(e)}`]
|
||||
}
|
||||
|
||||
const result = CollectedRecordSchema.safeParse(parsed)
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((iss) => `${iss.path.join('.')}: ${iss.message}`)
|
||||
.join(', ')
|
||||
return [`${filename}: schema: ${issues}`]
|
||||
}
|
||||
const record = result.data
|
||||
|
||||
if (record.id !== basename(filename, extname(filename))) {
|
||||
errors.push(`${filename}: id "${record.id}" does not match filename stem`)
|
||||
}
|
||||
|
||||
const lineCount = record.snapshot.split('\n').length
|
||||
if (lineCount !== record.elements.length) {
|
||||
errors.push(
|
||||
`${filename}: snapshot has ${lineCount} lines but elements has ${record.elements.length}`,
|
||||
)
|
||||
}
|
||||
|
||||
errors.push(...validateElements(filename, record))
|
||||
errors.push(
|
||||
...(await validateScreenshotExists(filename, record, projectRoot)),
|
||||
)
|
||||
return errors
|
||||
}
|
||||
|
||||
function validateElements(filename: string, record: CollectedRecord): string[] {
|
||||
const errors: string[] = []
|
||||
const seen = new Set<number>()
|
||||
for (const el of record.elements) {
|
||||
if (seen.has(el.backend_id)) {
|
||||
errors.push(`${filename}: duplicate backend_id ${el.backend_id}`)
|
||||
}
|
||||
seen.add(el.backend_id)
|
||||
if (el.bbox[0] > el.bbox[2] || el.bbox[1] > el.bbox[3]) {
|
||||
errors.push(
|
||||
`${filename}: backend_id ${el.backend_id} has bad bbox ${JSON.stringify(el.bbox)}`,
|
||||
)
|
||||
}
|
||||
if (!el.snapshot_line.startsWith(`[${el.backend_id}]`)) {
|
||||
errors.push(
|
||||
`${filename}: backend_id ${el.backend_id} snapshot_line does not start with [${el.backend_id}]`,
|
||||
)
|
||||
}
|
||||
if (!record.snapshot.includes(el.snapshot_line)) {
|
||||
errors.push(
|
||||
`${filename}: snapshot_line for [${el.backend_id}] not found in snapshot`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
async function validateScreenshotExists(
|
||||
filename: string,
|
||||
record: CollectedRecord,
|
||||
projectRoot: string,
|
||||
): Promise<string[]> {
|
||||
const screenshotPath = isAbsolute(record.screenshot_path)
|
||||
? record.screenshot_path
|
||||
: resolve(projectRoot, record.screenshot_path)
|
||||
try {
|
||||
await access(screenshotPath)
|
||||
return []
|
||||
} catch {
|
||||
return [`${filename}: screenshot_path missing on disk (${screenshotPath})`]
|
||||
}
|
||||
}
|
||||
|
||||
function stringErr(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
150
packages/browseros-agent/apps/eval/src/collectors/vl-collector.ts
vendored
Normal file
150
packages/browseros-agent/apps/eval/src/collectors/vl-collector.ts
vendored
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { Browser } from '@browseros/server/browser'
|
||||
import {
|
||||
LAYOUT_SETTLE_MS,
|
||||
VL_VIEWPORT_HEIGHT,
|
||||
VL_VIEWPORT_WIDTH,
|
||||
} from '../constants'
|
||||
import type {
|
||||
CollectionState,
|
||||
CollectionTarget,
|
||||
ElementRecord,
|
||||
} from '../types/collection-target'
|
||||
import { sleep } from '../utils/sleep'
|
||||
import type { RecordWriter } from './record-writer'
|
||||
import { parseSnapshot } from './snapshot-parser'
|
||||
|
||||
export interface VlCollectorDeps {
|
||||
browser: Browser
|
||||
pageId: number
|
||||
writer: RecordWriter
|
||||
log?: (msg: string) => void
|
||||
}
|
||||
|
||||
export class VlCollector {
|
||||
constructor(private readonly deps: VlCollectorDeps) {}
|
||||
|
||||
async collect(target: CollectionTarget): Promise<number> {
|
||||
const { browser, pageId, writer, log } = this.deps
|
||||
await browser.setViewport(pageId, VL_VIEWPORT_WIDTH, VL_VIEWPORT_HEIGHT, 1)
|
||||
await browser.goto(pageId, target.url)
|
||||
|
||||
let written = 0
|
||||
for (const state of target.states) {
|
||||
try {
|
||||
await this.applyState(state)
|
||||
await sleep(LAYOUT_SETTLE_MS)
|
||||
const { record, pngBase64 } = await this.captureOne(target)
|
||||
const result = await writer.write(record, pngBase64)
|
||||
written++
|
||||
log?.(` captured ${result.id} (${state.kind})`)
|
||||
} catch (error) {
|
||||
log?.(` failed ${target.site} ${state.kind}: ${errorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
private async applyState(state: CollectionState): Promise<void> {
|
||||
const { browser, pageId } = this.deps
|
||||
switch (state.kind) {
|
||||
case 'initial':
|
||||
return
|
||||
case 'scroll':
|
||||
await browser.evaluate(pageId, `window.scrollTo(0, ${state.pixels})`)
|
||||
return
|
||||
case 'click_and_wait': {
|
||||
const { x1, y1, x2, y2 } = await browser.getElementBbox(
|
||||
pageId,
|
||||
state.backend_id,
|
||||
)
|
||||
const cx = Math.floor((x1 + x2) / 2)
|
||||
const cy = Math.floor((y1 + y2) / 2)
|
||||
await browser.clickAt(pageId, cx, cy)
|
||||
await sleep(state.wait_ms)
|
||||
return
|
||||
}
|
||||
case 'evaluate':
|
||||
await browser.evaluate(pageId, state.expression)
|
||||
await sleep(state.wait_ms)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private async captureOne(target: CollectionTarget): Promise<{
|
||||
record: Omit<
|
||||
import('../types/collection-target').CollectedRecord,
|
||||
'id' | 'screenshot_path'
|
||||
>
|
||||
pngBase64: string
|
||||
}> {
|
||||
const { browser, pageId } = this.deps
|
||||
|
||||
// Snapshot, scroll_y, url, and screenshot must reflect the same page state
|
||||
// — bbox resolution can take seconds on a busy page and would let scroll
|
||||
// drift, so capture all frozen-state fields adjacent in time.
|
||||
const rawSnapshot = await browser.snapshot(pageId)
|
||||
const snapshot = rawSnapshot.replace(/\n$/, '')
|
||||
const scrollY = toInt(
|
||||
(await browser.evaluate(pageId, 'window.scrollY')).value,
|
||||
)
|
||||
const resolvedUrl = coerceString(
|
||||
(await browser.evaluate(pageId, 'window.location.href')).value,
|
||||
target.url,
|
||||
)
|
||||
const screenshot = await browser.screenshot(pageId, {
|
||||
format: 'png',
|
||||
fullPage: false,
|
||||
})
|
||||
|
||||
const parsed = parseSnapshot(snapshot)
|
||||
const elements: ElementRecord[] = []
|
||||
for (const line of parsed) {
|
||||
let bbox: [number, number, number, number]
|
||||
try {
|
||||
const box = await browser.getElementBbox(pageId, line.backend_id)
|
||||
bbox = [box.x1, box.y1, box.x2, box.y2]
|
||||
} catch {
|
||||
bbox = [0, 0, 0, 0]
|
||||
}
|
||||
elements.push({
|
||||
backend_id: line.backend_id,
|
||||
role: line.role,
|
||||
name: line.name,
|
||||
bbox,
|
||||
snapshot_line: line.snapshot_line,
|
||||
in_viewport: overlapsViewport(bbox),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
record: {
|
||||
url: resolvedUrl,
|
||||
site: target.site,
|
||||
viewport: { width: VL_VIEWPORT_WIDTH, height: VL_VIEWPORT_HEIGHT },
|
||||
scroll_y: scrollY,
|
||||
snapshot,
|
||||
elements,
|
||||
},
|
||||
pngBase64: screenshot.data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function overlapsViewport(bbox: [number, number, number, number]): boolean {
|
||||
const [x1, y1, x2, y2] = bbox
|
||||
if (x1 === 0 && y1 === 0 && x2 === 0 && y2 === 0) return false
|
||||
return x1 < VL_VIEWPORT_WIDTH && x2 > 0 && y1 < VL_VIEWPORT_HEIGHT && y2 > 0
|
||||
}
|
||||
|
||||
function toInt(v: unknown): number {
|
||||
const n = typeof v === 'number' ? v : Number(v)
|
||||
return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0
|
||||
}
|
||||
|
||||
function coerceString(v: unknown, fallback: string): string {
|
||||
return typeof v === 'string' && v.length > 0 ? v : fallback
|
||||
}
|
||||
|
||||
function errorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
@@ -6,3 +6,7 @@ export const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
|
||||
export const SCREENSHOT_TIMEOUT_MS = 65_000 // 65s — ensures we get extension's error (60s)
|
||||
export const MAX_ACTIONS_PER_DELEGATION = 15
|
||||
export const CLADO_REQUEST_TIMEOUT_MS = 120_000
|
||||
|
||||
export const VL_VIEWPORT_WIDTH = 1280
|
||||
export const VL_VIEWPORT_HEIGHT = 800
|
||||
export const LAYOUT_SETTLE_MS = 300
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
@@ -52,16 +53,19 @@ export class BrowserOSAppManager {
|
||||
private readonly workerIndex: number
|
||||
private readonly loadExtensions: boolean
|
||||
private readonly headless: boolean
|
||||
private readonly profileSeed: string | null
|
||||
|
||||
constructor(
|
||||
workerIndex: number = 0,
|
||||
basePorts?: EvalPorts,
|
||||
loadExtensions: boolean = false,
|
||||
headless: boolean = false,
|
||||
profileSeed: string | null = null,
|
||||
) {
|
||||
this.workerIndex = workerIndex
|
||||
this.loadExtensions = loadExtensions
|
||||
this.headless = headless
|
||||
this.profileSeed = profileSeed
|
||||
const base = basePorts ?? { cdp: 9010, server: 9110, extension: 9310 }
|
||||
this.ports = {
|
||||
cdp: base.cdp + workerIndex,
|
||||
@@ -123,16 +127,25 @@ export class BrowserOSAppManager {
|
||||
// Unique temp dir per worker per restart
|
||||
this.tempDir = mkdtempSync('/tmp/browseros-eval-')
|
||||
|
||||
if (this.profileSeed) {
|
||||
this.cloneProfileSeed(this.tempDir)
|
||||
}
|
||||
|
||||
console.log(
|
||||
` [W${this.workerIndex}] Ports: CDP=${cdp} Server=${server} Extension=${extension}${this.headless ? ' (headless)' : ''}`,
|
||||
)
|
||||
console.log(` [W${this.workerIndex}] Profile: ${this.tempDir}`)
|
||||
console.log(
|
||||
` [W${this.workerIndex}] Profile: ${this.tempDir}${this.profileSeed ? ' (seeded)' : ''}`,
|
||||
)
|
||||
|
||||
// --- Chrome Launch (matches start.ts startManualBrowser) ---
|
||||
// Drop --use-mock-keychain when a real profile seed is in use — otherwise
|
||||
// cookies encrypted against the OS keychain (where the login sessions
|
||||
// live) decrypt to garbage and the seed's logged-in state is lost.
|
||||
const chromeArgs = [
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--use-mock-keychain',
|
||||
...(this.profileSeed ? [] : ['--use-mock-keychain']),
|
||||
'--disable-browseros-server',
|
||||
'--disable-browseros-extensions',
|
||||
...(this.headless ? ['--headless=new'] : []),
|
||||
@@ -196,6 +209,43 @@ export class BrowserOSAppManager {
|
||||
console.log(` [W${this.workerIndex}] Server healthy`)
|
||||
}
|
||||
|
||||
private cloneProfileSeed(tempDir: string): void {
|
||||
const seed = this.profileSeed
|
||||
if (!seed) return
|
||||
const seedDefault = join(seed, 'Default')
|
||||
if (!existsSync(seedDefault)) {
|
||||
throw new Error(`Profile seed missing Default/ subfolder: ${seedDefault}`)
|
||||
}
|
||||
|
||||
// APFS clone is O(1) on macOS; slower byte-copy on other filesystems.
|
||||
const result = spawnSync({
|
||||
cmd: ['cp', '-c', '-R', seedDefault, join(tempDir, 'Default')],
|
||||
stdout: 'ignore',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
if (result.exitCode !== 0) {
|
||||
const stderr = result.stderr?.toString?.() ?? ''
|
||||
throw new Error(`Failed to clone profile seed: ${stderr.trim()}`)
|
||||
}
|
||||
|
||||
const seedLocalState = join(seed, 'Local State')
|
||||
const destLocalState = join(tempDir, 'Local State')
|
||||
if (existsSync(seedLocalState)) {
|
||||
copyFileSync(seedLocalState, destLocalState)
|
||||
} else {
|
||||
writeFileSync(destLocalState, '{}')
|
||||
}
|
||||
|
||||
// Stale singleton files from the source instance would block Chrome.
|
||||
for (const f of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||
try {
|
||||
rmSync(join(tempDir, 'Default', f), { force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForCdp(): Promise<boolean> {
|
||||
const startTime = Date.now()
|
||||
while (Date.now() - startTime < CDP_WAIT_TIMEOUT_MS) {
|
||||
|
||||
171
packages/browseros-agent/apps/eval/src/runner/collection-runner.ts
vendored
Normal file
171
packages/browseros-agent/apps/eval/src/runner/collection-runner.ts
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { Browser } from '@browseros/server/browser'
|
||||
import { CdpBackend } from '@browseros/server/browser/backends/cdp'
|
||||
import { RecordWriter } from '../collectors/record-writer'
|
||||
import { loadCollectionTargets } from '../collectors/target-loader'
|
||||
import { validateOutput } from '../collectors/validator'
|
||||
import { VlCollector } from '../collectors/vl-collector'
|
||||
import type { CollectionTarget } from '../types/collection-target'
|
||||
import type { EvalPorts } from '../utils/dev-config'
|
||||
import { BrowserOSAppManager } from './browseros-app-manager'
|
||||
|
||||
const DEFAULT_BASE_PORTS: EvalPorts = {
|
||||
cdp: 9010,
|
||||
server: 9110,
|
||||
extension: 9310,
|
||||
}
|
||||
|
||||
export interface CollectionRunnerOptions {
|
||||
seedsPath: string
|
||||
outDir: string
|
||||
projectRoot: string
|
||||
workers: number
|
||||
limit?: number
|
||||
headless: boolean
|
||||
profileSeed?: string
|
||||
basePorts?: EvalPorts
|
||||
}
|
||||
|
||||
class TargetQueue {
|
||||
private index = 0
|
||||
private stopped = false
|
||||
constructor(private readonly targets: CollectionTarget[]) {}
|
||||
next(): CollectionTarget | null {
|
||||
if (this.stopped || this.index >= this.targets.length) return null
|
||||
return this.targets[this.index++]
|
||||
}
|
||||
stop(): void {
|
||||
this.stopped = true
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCollection(
|
||||
opts: CollectionRunnerOptions,
|
||||
): Promise<{ writtenCount: number; errors: string[] }> {
|
||||
if (opts.profileSeed && !existsSync(join(opts.profileSeed, 'Default'))) {
|
||||
throw new Error(
|
||||
`profile seed missing Default/ subfolder: ${join(opts.profileSeed, 'Default')}`,
|
||||
)
|
||||
}
|
||||
|
||||
const loaded = await loadCollectionTargets(opts.seedsPath)
|
||||
const targets = opts.limit ? loaded.slice(0, opts.limit) : loaded
|
||||
|
||||
const startedAt = new Date()
|
||||
const writer = new RecordWriter(opts.outDir, opts.projectRoot)
|
||||
await writer.init()
|
||||
|
||||
const queue = new TargetQueue(targets)
|
||||
const appManagers: BrowserOSAppManager[] = []
|
||||
const workerCount = Math.max(1, Math.min(opts.workers, targets.length))
|
||||
|
||||
const cleanupSignal = setupSignalHandlers(queue, appManagers)
|
||||
|
||||
let writtenCount = 0
|
||||
try {
|
||||
const workers = Array.from({ length: workerCount }, (_, i) =>
|
||||
runWorker(i, queue, writer, opts, appManagers),
|
||||
)
|
||||
const counts = await Promise.all(workers)
|
||||
writtenCount = counts.reduce((a, b) => a + b, 0)
|
||||
} finally {
|
||||
await Promise.allSettled(appManagers.map((m) => m.killApp()))
|
||||
cleanupSignal()
|
||||
}
|
||||
|
||||
await writer.writeManifest(startedAt, resolveCollectorTag())
|
||||
const errors = await validateOutput(opts.outDir, opts.projectRoot)
|
||||
return { writtenCount, errors }
|
||||
}
|
||||
|
||||
async function runWorker(
|
||||
workerIndex: number,
|
||||
queue: TargetQueue,
|
||||
writer: RecordWriter,
|
||||
opts: CollectionRunnerOptions,
|
||||
appManagers: BrowserOSAppManager[],
|
||||
): Promise<number> {
|
||||
const basePorts = opts.basePorts ?? DEFAULT_BASE_PORTS
|
||||
const appManager = new BrowserOSAppManager(
|
||||
workerIndex,
|
||||
basePorts,
|
||||
false,
|
||||
opts.headless,
|
||||
opts.profileSeed ?? null,
|
||||
)
|
||||
await appManager.restart()
|
||||
appManagers.push(appManager)
|
||||
|
||||
const { cdp: cdpPort } = appManager.getPorts()
|
||||
const cdp = new CdpBackend({ port: cdpPort })
|
||||
await cdp.connect()
|
||||
const browser = new Browser(cdp)
|
||||
|
||||
const pages = await browser.listPages()
|
||||
const pageId = pages[0]?.pageId
|
||||
if (pageId === undefined) {
|
||||
throw new Error(`Worker ${workerIndex}: no initial page available`)
|
||||
}
|
||||
|
||||
const collector = new VlCollector({
|
||||
browser,
|
||||
pageId,
|
||||
writer,
|
||||
log: (msg) => console.log(`worker ${workerIndex}:${msg}`),
|
||||
})
|
||||
|
||||
let written = 0
|
||||
try {
|
||||
while (true) {
|
||||
const target = queue.next()
|
||||
if (!target) break
|
||||
console.log(
|
||||
`worker ${workerIndex}: collecting ${target.site} (${target.url})`,
|
||||
)
|
||||
written += await collector.collect(target)
|
||||
}
|
||||
} finally {
|
||||
await cdp.disconnect().catch(() => {})
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
function setupSignalHandlers(
|
||||
queue: TargetQueue,
|
||||
appManagers: BrowserOSAppManager[],
|
||||
): () => void {
|
||||
let tripped = false
|
||||
const onSignal = () => {
|
||||
if (tripped) return
|
||||
tripped = true
|
||||
console.log('\nShutting down: draining in-flight targets...')
|
||||
queue.stop()
|
||||
// Kick the app managers so workers unblock quickly. The outer `finally`
|
||||
// in runCollection will also await killApp — allSettled tolerates the
|
||||
// double-kill cleanly.
|
||||
for (const m of appManagers) {
|
||||
m.killApp().catch(() => {})
|
||||
}
|
||||
}
|
||||
process.on('SIGINT', onSignal)
|
||||
process.on('SIGTERM', onSignal)
|
||||
return () => {
|
||||
process.off('SIGINT', onSignal)
|
||||
process.off('SIGTERM', onSignal)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCollectorTag(): string {
|
||||
try {
|
||||
const sha = execSync('git rev-parse --short HEAD', {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
})
|
||||
.toString()
|
||||
.trim()
|
||||
return sha ? `browseros-agent@${sha}` : 'browseros-agent@unknown'
|
||||
} catch {
|
||||
return 'browseros-agent@unknown'
|
||||
}
|
||||
}
|
||||
59
packages/browseros-agent/apps/eval/src/types/collection-target.ts
vendored
Normal file
59
packages/browseros-agent/apps/eval/src/types/collection-target.ts
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const CollectionStateSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('initial') }),
|
||||
z.object({
|
||||
kind: z.literal('scroll'),
|
||||
pixels: z.number().int().nonnegative(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('click_and_wait'),
|
||||
backend_id: z.number().int().positive(),
|
||||
wait_ms: z.number().int().positive().default(1000),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('evaluate'),
|
||||
expression: z.string().min(1),
|
||||
wait_ms: z.number().int().nonnegative().default(300),
|
||||
}),
|
||||
])
|
||||
|
||||
export const CollectionTargetSchema = z.object({
|
||||
site: z.string().regex(/^[a-z0-9_]+$/, 'site must match [a-z0-9_]+'),
|
||||
url: z.string().url(),
|
||||
states: z.array(CollectionStateSchema).min(1).max(10),
|
||||
category: z.string().optional(),
|
||||
})
|
||||
|
||||
export const ElementRecordSchema = z.object({
|
||||
backend_id: z.number().int(),
|
||||
role: z.string(),
|
||||
name: z.string(),
|
||||
bbox: z.tuple([
|
||||
z.number().int(),
|
||||
z.number().int(),
|
||||
z.number().int(),
|
||||
z.number().int(),
|
||||
]),
|
||||
snapshot_line: z.string(),
|
||||
in_viewport: z.boolean(),
|
||||
})
|
||||
|
||||
export const CollectedRecordSchema = z.object({
|
||||
id: z.string().regex(/^[a-z0-9_]+_[0-9a-f]{8}$/),
|
||||
url: z.string().url(),
|
||||
site: z.string(),
|
||||
viewport: z.object({
|
||||
width: z.literal(1280),
|
||||
height: z.literal(800),
|
||||
}),
|
||||
scroll_y: z.number().int().nonnegative(),
|
||||
screenshot_path: z.string(),
|
||||
snapshot: z.string(),
|
||||
elements: z.array(ElementRecordSchema),
|
||||
})
|
||||
|
||||
export type CollectionState = z.infer<typeof CollectionStateSchema>
|
||||
export type CollectionTarget = z.infer<typeof CollectionTargetSchema>
|
||||
export type ElementRecord = z.infer<typeof ElementRecordSchema>
|
||||
export type CollectedRecord = z.infer<typeof CollectedRecordSchema>
|
||||
@@ -1,4 +1,16 @@
|
||||
// Config types
|
||||
|
||||
// Collection target + record types
|
||||
export {
|
||||
type CollectedRecord,
|
||||
CollectedRecordSchema,
|
||||
type CollectionState,
|
||||
CollectionStateSchema,
|
||||
type CollectionTarget,
|
||||
CollectionTargetSchema,
|
||||
type ElementRecord,
|
||||
ElementRecordSchema,
|
||||
} from './collection-target'
|
||||
export {
|
||||
type AgentConfig,
|
||||
AgentConfigSchema,
|
||||
@@ -44,7 +56,6 @@ export {
|
||||
type UserMessage,
|
||||
UserMessageSchema,
|
||||
} from './message'
|
||||
|
||||
// Result types
|
||||
export {
|
||||
type AgentResult,
|
||||
|
||||
108
packages/browseros-agent/apps/eval/tests/collectors/record-writer.test.ts
vendored
Normal file
108
packages/browseros-agent/apps/eval/tests/collectors/record-writer.test.ts
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { RecordWriter } from '../../src/collectors/record-writer'
|
||||
import type { CollectedRecord } from '../../src/types/collection-target'
|
||||
|
||||
const TINY_PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
|
||||
|
||||
function makeRecord(
|
||||
site: string,
|
||||
): Omit<CollectedRecord, 'id' | 'screenshot_path'> {
|
||||
return {
|
||||
url: 'https://example.com/',
|
||||
site,
|
||||
viewport: { width: 1280, height: 800 },
|
||||
scroll_y: 0,
|
||||
snapshot: '[1] button "ok"',
|
||||
elements: [
|
||||
{
|
||||
backend_id: 1,
|
||||
role: 'button',
|
||||
name: 'ok',
|
||||
bbox: [10, 20, 30, 40],
|
||||
snapshot_line: '[1] button "ok"',
|
||||
in_viewport: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('RecordWriter', () => {
|
||||
let outDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
outDir = await mkdtemp(join(tmpdir(), 'vl-writer-'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
await rm(outDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('writes screenshot + json with an assigned id', async () => {
|
||||
const writer = new RecordWriter(outDir, outDir)
|
||||
await writer.init()
|
||||
const result = await writer.write(makeRecord('hn'), TINY_PNG_BASE64)
|
||||
expect(result.id).toMatch(/^hn_[0-9a-f]{8}$/)
|
||||
|
||||
const pngFiles = await readdir(join(outDir, 'screenshots'))
|
||||
expect(pngFiles).toEqual([`${result.id}.png`])
|
||||
|
||||
const jsonText = await readFile(
|
||||
join(outDir, 'raw', `${result.id}.json`),
|
||||
'utf-8',
|
||||
)
|
||||
const record = JSON.parse(jsonText) as CollectedRecord
|
||||
expect(record.id).toBe(result.id)
|
||||
expect(record.screenshot_path).toContain(`screenshots/${result.id}.png`)
|
||||
expect(record.snapshot).toBe('[1] button "ok"')
|
||||
})
|
||||
|
||||
it('writes atomically — no .tmp files remain after a successful write', async () => {
|
||||
const writer = new RecordWriter(outDir, outDir)
|
||||
await writer.init()
|
||||
await writer.write(makeRecord('hn'), TINY_PNG_BASE64)
|
||||
const pngFiles = await readdir(join(outDir, 'screenshots'))
|
||||
const jsonFiles = await readdir(join(outDir, 'raw'))
|
||||
for (const f of [...pngFiles, ...jsonFiles]) {
|
||||
expect(f.endsWith('.tmp')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('writes manifest with site counts and collector tag', async () => {
|
||||
const writer = new RecordWriter(outDir, outDir)
|
||||
await writer.init()
|
||||
await writer.write(makeRecord('hn'), TINY_PNG_BASE64)
|
||||
await writer.write(makeRecord('hn'), TINY_PNG_BASE64)
|
||||
await writer.write(makeRecord('wiki'), TINY_PNG_BASE64)
|
||||
|
||||
const collectedAt = new Date('2026-04-18T12:00:00Z')
|
||||
await writer.writeManifest(collectedAt, 'browseros-agent@abc123')
|
||||
|
||||
const manifest = JSON.parse(
|
||||
await readFile(join(outDir, 'meta.json'), 'utf-8'),
|
||||
)
|
||||
expect(manifest.collector).toBe('browseros-agent@abc123')
|
||||
expect(manifest.collected_at).toBe('2026-04-18T12:00:00.000Z')
|
||||
expect(manifest.total_records).toBe(3)
|
||||
expect(manifest.viewport).toEqual({ width: 1280, height: 800 })
|
||||
const siteCounts = Object.fromEntries(
|
||||
(manifest.sites as Array<{ site: string; states: number }>).map((s) => [
|
||||
s.site,
|
||||
s.states,
|
||||
]),
|
||||
)
|
||||
expect(siteCounts).toEqual({ hn: 2, wiki: 1 })
|
||||
})
|
||||
|
||||
it('generates a fresh id per write even for the same site', async () => {
|
||||
const writer = new RecordWriter(outDir, outDir)
|
||||
await writer.init()
|
||||
const first = await writer.write(makeRecord('hn'), TINY_PNG_BASE64)
|
||||
const second = await writer.write(makeRecord('hn'), TINY_PNG_BASE64)
|
||||
expect(second.id).not.toBe(first.id)
|
||||
const files = await readdir(join(outDir, 'raw'))
|
||||
expect(files.length).toBe(2)
|
||||
})
|
||||
})
|
||||
93
packages/browseros-agent/apps/eval/tests/collectors/snapshot-parser.test.ts
vendored
Normal file
93
packages/browseros-agent/apps/eval/tests/collectors/snapshot-parser.test.ts
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
parseSnapshot,
|
||||
SnapshotParseError,
|
||||
} from '../../src/collectors/snapshot-parser'
|
||||
|
||||
describe('parseSnapshot', () => {
|
||||
it('parses a simple button line with name', () => {
|
||||
const result = parseSnapshot('[337] button "Search"')
|
||||
expect(result).toEqual([
|
||||
{
|
||||
backend_id: 337,
|
||||
role: 'button',
|
||||
name: 'Search',
|
||||
snapshot_line: '[337] button "Search"',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('parses an empty-name element (searchbox with no accessible name)', () => {
|
||||
const result = parseSnapshot('[22] searchbox ""')
|
||||
expect(result[0]).toEqual({
|
||||
backend_id: 22,
|
||||
role: 'searchbox',
|
||||
name: '',
|
||||
snapshot_line: '[22] searchbox ""',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses an element with no name at all', () => {
|
||||
const result = parseSnapshot('[5] checkbox')
|
||||
expect(result[0]).toEqual({
|
||||
backend_id: 5,
|
||||
role: 'checkbox',
|
||||
name: '',
|
||||
snapshot_line: '[5] checkbox',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a searchbox with a value attribute', () => {
|
||||
const line = '[22] searchbox "" value="query"'
|
||||
const result = parseSnapshot(line)
|
||||
expect(result[0].backend_id).toBe(22)
|
||||
expect(result[0].role).toBe('searchbox')
|
||||
expect(result[0].name).toBe('')
|
||||
expect(result[0].snapshot_line).toBe(line)
|
||||
})
|
||||
|
||||
it('parses a disabled link', () => {
|
||||
const line = '[18] link "past" (disabled)'
|
||||
const result = parseSnapshot(line)
|
||||
expect(result[0]).toEqual({
|
||||
backend_id: 18,
|
||||
role: 'link',
|
||||
name: 'past',
|
||||
snapshot_line: line,
|
||||
})
|
||||
})
|
||||
|
||||
it('parses cursor-interactive "clickable" role', () => {
|
||||
const result = parseSnapshot('[99] clickable "Open menu"')
|
||||
expect(result[0].role).toBe('clickable')
|
||||
expect(result[0].name).toBe('Open menu')
|
||||
})
|
||||
|
||||
it('parses a multi-line snapshot and preserves order', () => {
|
||||
const snapshot = [
|
||||
'[12] link "Hacker News"',
|
||||
'[14] link "new"',
|
||||
'[22] searchbox ""',
|
||||
'[25] button "Search"',
|
||||
].join('\n')
|
||||
const result = parseSnapshot(snapshot)
|
||||
expect(result.length).toBe(4)
|
||||
expect(result.map((r) => r.backend_id)).toEqual([12, 14, 22, 25])
|
||||
})
|
||||
|
||||
it('preserves snapshot_line byte-for-byte', () => {
|
||||
const line = '[100] button "foo\\"bar" (expanded, required)'
|
||||
const result = parseSnapshot(line)
|
||||
expect(result[0].snapshot_line).toBe(line)
|
||||
})
|
||||
|
||||
it('throws SnapshotParseError for a line without [N] prefix', () => {
|
||||
expect(() => parseSnapshot('not a snapshot line')).toThrow(
|
||||
SnapshotParseError,
|
||||
)
|
||||
})
|
||||
|
||||
it('throws SnapshotParseError for a line with wrong [N] format', () => {
|
||||
expect(() => parseSnapshot('[abc] button')).toThrow(SnapshotParseError)
|
||||
})
|
||||
})
|
||||
122
packages/browseros-agent/apps/eval/tests/collectors/target-loader.test.ts
vendored
Normal file
122
packages/browseros-agent/apps/eval/tests/collectors/target-loader.test.ts
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
loadCollectionTargets,
|
||||
TargetLoadError,
|
||||
} from '../../src/collectors/target-loader'
|
||||
|
||||
describe('loadCollectionTargets', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'vl-seeds-'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function writeSeeds(lines: string[]): Promise<string> {
|
||||
const path = join(dir, 'seeds.jsonl')
|
||||
await writeFile(path, lines.join('\n'))
|
||||
return path
|
||||
}
|
||||
|
||||
it('loads a single valid target', async () => {
|
||||
const path = await writeSeeds([
|
||||
JSON.stringify({
|
||||
site: 'hn',
|
||||
url: 'https://news.ycombinator.com/',
|
||||
states: [{ kind: 'initial' }],
|
||||
}),
|
||||
])
|
||||
const targets = await loadCollectionTargets(path)
|
||||
expect(targets.length).toBe(1)
|
||||
expect(targets[0].site).toBe('hn')
|
||||
expect(targets[0].states[0]).toEqual({ kind: 'initial' })
|
||||
})
|
||||
|
||||
it('applies default wait_ms for click_and_wait state', async () => {
|
||||
const path = await writeSeeds([
|
||||
JSON.stringify({
|
||||
site: 'a',
|
||||
url: 'https://example.com/',
|
||||
states: [{ kind: 'click_and_wait', backend_id: 42 }],
|
||||
}),
|
||||
])
|
||||
const [target] = await loadCollectionTargets(path)
|
||||
expect(target.states[0]).toEqual({
|
||||
kind: 'click_and_wait',
|
||||
backend_id: 42,
|
||||
wait_ms: 1000,
|
||||
})
|
||||
})
|
||||
|
||||
it('loads multiple targets and preserves order', async () => {
|
||||
const path = await writeSeeds([
|
||||
JSON.stringify({
|
||||
site: 'a',
|
||||
url: 'https://a.example/',
|
||||
states: [{ kind: 'initial' }],
|
||||
}),
|
||||
JSON.stringify({
|
||||
site: 'b',
|
||||
url: 'https://b.example/',
|
||||
states: [{ kind: 'scroll', pixels: 500 }],
|
||||
}),
|
||||
])
|
||||
const targets = await loadCollectionTargets(path)
|
||||
expect(targets.map((t) => t.site)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('throws on duplicate site slugs', async () => {
|
||||
const path = await writeSeeds([
|
||||
JSON.stringify({
|
||||
site: 'dup',
|
||||
url: 'https://a.example/',
|
||||
states: [{ kind: 'initial' }],
|
||||
}),
|
||||
JSON.stringify({
|
||||
site: 'dup',
|
||||
url: 'https://b.example/',
|
||||
states: [{ kind: 'initial' }],
|
||||
}),
|
||||
])
|
||||
await expect(loadCollectionTargets(path)).rejects.toThrow(TargetLoadError)
|
||||
})
|
||||
|
||||
it('throws with line number on invalid JSON', async () => {
|
||||
const path = await writeSeeds([
|
||||
JSON.stringify({
|
||||
site: 'a',
|
||||
url: 'https://a.example/',
|
||||
states: [{ kind: 'initial' }],
|
||||
}),
|
||||
'not json',
|
||||
])
|
||||
await expect(loadCollectionTargets(path)).rejects.toThrow(/Line 2/)
|
||||
})
|
||||
|
||||
it('throws on invalid site slug (uppercase)', async () => {
|
||||
const path = await writeSeeds([
|
||||
JSON.stringify({
|
||||
site: 'BadSlug',
|
||||
url: 'https://a.example/',
|
||||
states: [{ kind: 'initial' }],
|
||||
}),
|
||||
])
|
||||
await expect(loadCollectionTargets(path)).rejects.toThrow(/site/)
|
||||
})
|
||||
|
||||
it('throws on empty file', async () => {
|
||||
const path = await writeSeeds([])
|
||||
await expect(loadCollectionTargets(path)).rejects.toThrow(/empty/)
|
||||
})
|
||||
|
||||
it('throws on missing file', async () => {
|
||||
await expect(
|
||||
loadCollectionTargets(join(dir, 'nope.jsonl')),
|
||||
).rejects.toThrow(TargetLoadError)
|
||||
})
|
||||
})
|
||||
121
packages/browseros-agent/apps/eval/tests/collectors/validator.test.ts
vendored
Normal file
121
packages/browseros-agent/apps/eval/tests/collectors/validator.test.ts
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { validateOutput } from '../../src/collectors/validator'
|
||||
import type { CollectedRecord } from '../../src/types/collection-target'
|
||||
|
||||
const TINY_PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
|
||||
|
||||
async function setupValidRecord(outDir: string, id: string = 'hn_abcdef12') {
|
||||
await mkdir(join(outDir, 'screenshots'), { recursive: true })
|
||||
await mkdir(join(outDir, 'raw'), { recursive: true })
|
||||
const pngPath = join(outDir, 'screenshots', `${id}.png`)
|
||||
await writeFile(pngPath, Buffer.from(TINY_PNG_BASE64, 'base64'))
|
||||
const record: CollectedRecord = {
|
||||
id,
|
||||
url: 'https://example.com/',
|
||||
site: 'hn',
|
||||
viewport: { width: 1280, height: 800 },
|
||||
scroll_y: 0,
|
||||
screenshot_path: `screenshots/${id}.png`,
|
||||
snapshot: '[1] button "a"\n[2] link "b"',
|
||||
elements: [
|
||||
{
|
||||
backend_id: 1,
|
||||
role: 'button',
|
||||
name: 'a',
|
||||
bbox: [0, 0, 10, 10],
|
||||
snapshot_line: '[1] button "a"',
|
||||
in_viewport: true,
|
||||
},
|
||||
{
|
||||
backend_id: 2,
|
||||
role: 'link',
|
||||
name: 'b',
|
||||
bbox: [20, 20, 30, 30],
|
||||
snapshot_line: '[2] link "b"',
|
||||
in_viewport: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
await writeFile(
|
||||
join(outDir, 'raw', `${id}.json`),
|
||||
JSON.stringify(record, null, 2),
|
||||
)
|
||||
return { record, id }
|
||||
}
|
||||
|
||||
describe('validateOutput', () => {
|
||||
let outDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
outDir = await mkdtemp(join(tmpdir(), 'vl-validate-'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
await rm(outDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns empty errors for a valid record', async () => {
|
||||
await setupValidRecord(outDir)
|
||||
const errors = await validateOutput(outDir, outDir)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
it('flags mismatched snapshot vs elements count', async () => {
|
||||
const { id } = await setupValidRecord(outDir)
|
||||
const recordPath = join(outDir, 'raw', `${id}.json`)
|
||||
const r = JSON.parse(await Bun.file(recordPath).text()) as CollectedRecord
|
||||
r.snapshot = '[1] button "a"'
|
||||
await writeFile(recordPath, JSON.stringify(r, null, 2))
|
||||
const errors = await validateOutput(outDir, outDir)
|
||||
expect(errors.join(' ')).toMatch(/snapshot has 1 lines but elements has 2/)
|
||||
})
|
||||
|
||||
it('flags duplicate backend_id', async () => {
|
||||
const { id } = await setupValidRecord(outDir)
|
||||
const recordPath = join(outDir, 'raw', `${id}.json`)
|
||||
const r = JSON.parse(await Bun.file(recordPath).text()) as CollectedRecord
|
||||
r.elements[1].backend_id = 1
|
||||
r.elements[1].snapshot_line = '[1] link "b"'
|
||||
r.snapshot = '[1] button "a"\n[1] link "b"'
|
||||
await writeFile(recordPath, JSON.stringify(r, null, 2))
|
||||
const errors = await validateOutput(outDir, outDir)
|
||||
expect(errors.join(' ')).toMatch(/duplicate backend_id 1/)
|
||||
})
|
||||
|
||||
it('flags bad bbox (x1 > x2)', async () => {
|
||||
const { id } = await setupValidRecord(outDir)
|
||||
const recordPath = join(outDir, 'raw', `${id}.json`)
|
||||
const r = JSON.parse(await Bun.file(recordPath).text()) as CollectedRecord
|
||||
r.elements[0].bbox = [100, 0, 50, 10]
|
||||
await writeFile(recordPath, JSON.stringify(r, null, 2))
|
||||
const errors = await validateOutput(outDir, outDir)
|
||||
expect(errors.join(' ')).toMatch(/bad bbox/)
|
||||
})
|
||||
|
||||
it('flags missing screenshot file', async () => {
|
||||
const { id } = await setupValidRecord(outDir)
|
||||
await rm(join(outDir, 'screenshots', `${id}.png`))
|
||||
const errors = await validateOutput(outDir, outDir)
|
||||
expect(errors.join(' ')).toMatch(/screenshot_path missing/)
|
||||
})
|
||||
|
||||
it('flags id that does not match filename stem', async () => {
|
||||
const { id } = await setupValidRecord(outDir, 'hn_deadbeef')
|
||||
const recordPath = join(outDir, 'raw', `${id}.json`)
|
||||
const r = JSON.parse(await Bun.file(recordPath).text()) as CollectedRecord
|
||||
r.id = 'hn_11111111'
|
||||
await writeFile(recordPath, JSON.stringify(r, null, 2))
|
||||
const errors = await validateOutput(outDir, outDir)
|
||||
expect(errors.join(' ')).toMatch(/does not match filename stem/)
|
||||
})
|
||||
|
||||
it('returns an error when raw dir has no json records', async () => {
|
||||
await mkdir(join(outDir, 'raw'), { recursive: true })
|
||||
await mkdir(join(outDir, 'screenshots'), { recursive: true })
|
||||
const errors = await validateOutput(outDir, outDir)
|
||||
expect(errors.join(' ')).toMatch(/no \.json records/)
|
||||
})
|
||||
})
|
||||
@@ -911,6 +911,39 @@ export class Browser {
|
||||
}
|
||||
}
|
||||
|
||||
async setViewport(
|
||||
page: number,
|
||||
width: number,
|
||||
height: number,
|
||||
deviceScaleFactor: number = 1,
|
||||
): Promise<void> {
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new Error(
|
||||
`Invalid viewport: width=${width} height=${height} must both be > 0`,
|
||||
)
|
||||
}
|
||||
const session = await this.resolveSession(page)
|
||||
await session.Emulation.setDeviceMetricsOverride({
|
||||
width,
|
||||
height,
|
||||
deviceScaleFactor,
|
||||
mobile: false,
|
||||
})
|
||||
}
|
||||
|
||||
async clearViewport(page: number): Promise<void> {
|
||||
const session = await this.resolveSession(page)
|
||||
await session.Emulation.clearDeviceMetricsOverride()
|
||||
}
|
||||
|
||||
async getElementBbox(
|
||||
page: number,
|
||||
backendNodeId: number,
|
||||
): Promise<elements.Bbox> {
|
||||
const session = await this.resolveSession(page)
|
||||
return elements.getElementBbox(session, backendNodeId)
|
||||
}
|
||||
|
||||
async evaluate(
|
||||
page: number,
|
||||
expression: string,
|
||||
|
||||
@@ -6,6 +6,36 @@ function quadCenter(q: number[]): { x: number; y: number } {
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
export interface Bbox {
|
||||
x1: number
|
||||
y1: number
|
||||
x2: number
|
||||
y2: number
|
||||
}
|
||||
|
||||
function quadBounds(q: number[]): Bbox {
|
||||
const xs = [q[0], q[2], q[4], q[6]]
|
||||
const ys = [q[1], q[3], q[5], q[7]]
|
||||
const x1 = Math.min(...xs)
|
||||
const y1 = Math.min(...ys)
|
||||
const x2 = Math.max(...xs)
|
||||
const y2 = Math.max(...ys)
|
||||
if (
|
||||
!Number.isFinite(x1) ||
|
||||
!Number.isFinite(y1) ||
|
||||
!Number.isFinite(x2) ||
|
||||
!Number.isFinite(y2)
|
||||
) {
|
||||
throw new Error('Quad contains non-finite coordinates')
|
||||
}
|
||||
return {
|
||||
x1: Math.floor(x1),
|
||||
y1: Math.floor(y1),
|
||||
x2: Math.ceil(x2),
|
||||
y2: Math.ceil(y2),
|
||||
}
|
||||
}
|
||||
|
||||
/** 3-tier fallback: getContentQuads -> getBoxModel -> getBoundingClientRect */
|
||||
export async function getElementCenter(
|
||||
session: ProtocolApi,
|
||||
@@ -51,6 +81,56 @@ export async function getElementCenter(
|
||||
return { x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 }
|
||||
}
|
||||
|
||||
/** Axis-aligned bbox in viewport pixels via the same 3-tier fallback as getElementCenter. */
|
||||
export async function getElementBbox(
|
||||
session: ProtocolApi,
|
||||
backendNodeId: number,
|
||||
): Promise<Bbox> {
|
||||
try {
|
||||
const quadsResult = await session.DOM.getContentQuads({ backendNodeId })
|
||||
if (quadsResult.quads?.length) {
|
||||
const q = quadsResult.quads[0] as unknown as number[]
|
||||
if (q && q.length >= 8) return quadBounds(q)
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
try {
|
||||
const boxResult = await session.DOM.getBoxModel({ backendNodeId })
|
||||
const content = boxResult.model?.content as unknown as number[] | undefined
|
||||
if (content && content.length >= 8) return quadBounds(content)
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
const resolved = await session.DOM.resolveNode({ backendNodeId })
|
||||
const objectId = resolved.object?.objectId
|
||||
if (!objectId) {
|
||||
throw new Error(
|
||||
'Could not resolve element — it may have been removed from the page.',
|
||||
)
|
||||
}
|
||||
|
||||
const boundsResult = await session.Runtime.callFunctionOn({
|
||||
functionDeclaration:
|
||||
'function(){var r=this.getBoundingClientRect();return{x:r.left,y:r.top,w:r.width,h:r.height}}',
|
||||
objectId,
|
||||
returnByValue: true,
|
||||
})
|
||||
|
||||
const rect = boundsResult.result?.value as
|
||||
| { x: number; y: number; w: number; h: number }
|
||||
| undefined
|
||||
if (!rect) throw new Error('Could not get element bounds.')
|
||||
return {
|
||||
x1: Math.floor(rect.x),
|
||||
y1: Math.floor(rect.y),
|
||||
x2: Math.ceil(rect.x + rect.w),
|
||||
y2: Math.ceil(rect.y + rect.h),
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollIntoView(
|
||||
session: ProtocolApi,
|
||||
backendNodeId: number,
|
||||
|
||||
85
packages/browseros-agent/scripts/copy-browseros-profile.sh
Executable file
85
packages/browseros-agent/scripts/copy-browseros-profile.sh
Executable file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Copy a BrowserOS profile into a seed directory suitable for passing
|
||||
# as --user-data-dir to a child Chromium instance (e.g. the VL collector).
|
||||
#
|
||||
# Usage:
|
||||
# .scripts/copy-browseros-profile.sh <profile-name> <dest-dir>
|
||||
#
|
||||
# Example:
|
||||
# .scripts/copy-browseros-profile.sh Work /tmp/vl-seed-profile
|
||||
#
|
||||
# Result: <dest-dir>/Default/<profile files> plus a stub Local State.
|
||||
#
|
||||
# Requires: jq, macOS (uses APFS clone via `cp -c`).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROFILE_NAME="${1:-}"
|
||||
DEST_DIR="${2:-}"
|
||||
|
||||
if [[ -z "$PROFILE_NAME" || -z "$DEST_DIR" ]]; then
|
||||
echo "usage: $0 <profile-name> <dest-dir>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SRC_ROOT="$HOME/Library/Application Support/BrowserOS"
|
||||
LOCAL_STATE="$SRC_ROOT/Local State"
|
||||
|
||||
if [[ ! -f "$LOCAL_STATE" ]]; then
|
||||
echo "error: BrowserOS Local State not found at: $LOCAL_STATE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if pgrep -qf "BrowserOS.app/Contents/MacOS/BrowserOS"; then
|
||||
echo "error: BrowserOS is running. Quit it first so the profile SQLite files aren't mid-write." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PROFILE_FOLDER=$(
|
||||
jq -r --arg name "$PROFILE_NAME" \
|
||||
'.profile.info_cache | to_entries[] | select(.value.name == $name) | .key' \
|
||||
"$LOCAL_STATE"
|
||||
)
|
||||
|
||||
if [[ -z "$PROFILE_FOLDER" ]]; then
|
||||
echo "error: no profile named '$PROFILE_NAME' found. Available profiles:" >&2
|
||||
jq -r '.profile.info_cache | to_entries | map(" \(.key)\t\(.value.name)") | .[]' \
|
||||
"$LOCAL_STATE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SRC_PROFILE="$SRC_ROOT/$PROFILE_FOLDER"
|
||||
if [[ ! -d "$SRC_PROFILE" ]]; then
|
||||
echo "error: profile directory missing on disk: $SRC_PROFILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "source: $SRC_PROFILE (name: $PROFILE_NAME)"
|
||||
echo "dest: $DEST_DIR/Default"
|
||||
|
||||
if [[ -e "$DEST_DIR" ]]; then
|
||||
echo "error: $DEST_DIR already exists. Remove it or pick a new path." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DEST_DIR"
|
||||
|
||||
# APFS clone is O(1) and uses no extra disk space until files diverge.
|
||||
cp -c -R "$SRC_PROFILE" "$DEST_DIR/Default"
|
||||
|
||||
# Strip singleton locks from the source instance.
|
||||
rm -f \
|
||||
"$DEST_DIR/Default/SingletonLock" \
|
||||
"$DEST_DIR/Default/SingletonSocket" \
|
||||
"$DEST_DIR/Default/SingletonCookie"
|
||||
|
||||
# Minimal Local State so Chrome doesn't complain on first launch.
|
||||
echo '{}' > "$DEST_DIR/Local State"
|
||||
|
||||
BYTES=$(du -sh "$DEST_DIR" | awk '{print $1}')
|
||||
echo "done: $BYTES at $DEST_DIR"
|
||||
echo
|
||||
echo "next:"
|
||||
echo " launch Chromium with --user-data-dir=$DEST_DIR"
|
||||
echo " (and drop --use-mock-keychain so encrypted cookies decrypt)"
|
||||
Reference in New Issue
Block a user