Compare commits

...

5 Commits

Author SHA1 Message Date
Nikhil Sonti
c222d85a89 feat: evals data 2026-04-20 09:19:58 -07:00
Nikhil Sonti
bf14bcab7b feat: --profile-seed flag for VL collector
Threads an optional profile-seed path through BrowserOSAppManager so
each worker clones <seed>/Default into its user-data-dir tempdir before
Chrome launches (APFS cp -c -R). When set, the launcher drops
--use-mock-keychain so cookies encrypted against the macOS Keychain
decrypt correctly — i.e. the seed's logged-in state survives.

Build the seed via scripts/copy-browseros-profile.sh.

Usage: bun run collect -- --seeds ... --profile-seed /tmp/vl-seed-work
2026-04-18 14:46:55 -07:00
Nikhil Sonti
76e9f33f59 chore: add copy-browseros-profile.sh
Snapshot a named BrowserOS profile into <dest>/Default via APFS clone,
strip SingletonLock/Socket/Cookie, and emit a stub Local State. Suitable
as a --user-data-dir seed for a headless/ephemeral Chromium launch.

Usage: scripts/copy-browseros-profile.sh <profile-name> <dest-dir>
2026-04-18 14:46:46 -07:00
Nikhil Sonti
e0caa87172 fix: address review comments for VL collector
- Move scroll_y/url/screenshot adjacent in captureOne so they reflect
  the same page state as the snapshot (bbox resolution can take seconds
  during which scroll drifts).
- Drop unreachable idempotency skip branch in RecordWriter; IDs are
  random UUIDs so the branch never fired. Rename test to match reality.
- Write atomically (temp file + rename) to avoid orphan PNGs.
- Use VL_VIEWPORT_WIDTH/HEIGHT constants in manifest (no magic literals).
- Add Browser.clearViewport companion to setViewport + reject
  non-positive dims.
- Guard quadBounds against non-finite coordinates (throw instead of
  propagating NaN).
- Validator: per-issue Zod error formatting matching target-loader.
- CLI: validate --workers / --limit are positive integers; fail fast.
- Runner: safer signal handler that sets a stop flag and kicks app
  managers instead of process.exit(130) (which abandons in-flight
  cleanup). Drop [W${n}] log prefix.
2026-04-18 14:31:10 -07:00
Nikhil Sonti
b06838ff75 feat: add VL training-data collector
Deterministic collector that drives BrowserOS through seed URLs and
produces Stage-1 VL-training records: 1280x800 PNG + JSON with snapshot
text and per-element axis-aligned bounding boxes.

Server-side additions:
- getElementBbox in apps/server/src/browser/elements.ts (3-tier CDP
  fallback: getContentQuads -> getBoxModel -> getBoundingClientRect)
- Browser.setViewport + Browser.getElementBbox public methods

Eval-side additions:
- Zod schemas for CollectionTarget / CollectedRecord
- snapshot-parser, target-loader, record-writer, validator, vl-collector
- collection-runner reusing BrowserOSAppManager port-offset pattern
- CLI entry: bun run collect --seeds <path.jsonl>
- Default seed file (10 sites x 3 states)
- 28 unit tests across 4 collector modules
2026-04-18 14:28:02 -07:00
21 changed files with 1651 additions and 3 deletions

View 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}]}

View 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}]}

View File

@@ -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": {

View 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)
})

View 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)
}

View 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,
}
})
}

View 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
}

View 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)
}

View 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)
}

View File

@@ -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

View File

@@ -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) {

View 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'
}
}

View 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>

View File

@@ -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,

View 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)
})
})

View 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)
})
})

View 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)
})
})

View 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/)
})
})

View File

@@ -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,

View File

@@ -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,

View 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)"