diff --git a/bun.lock b/bun.lock index 0ed2bc31c0f..5d2fd96fd8f 100644 --- a/bun.lock +++ b/bun.lock @@ -417,6 +417,8 @@ "electron-store": "11.0.2", "electron-updater": "6.8.9", "electron-window-state": "^5.0.3", + "lighthouse": "13.4.1", + "puppeteer-core": "25.9.0", }, "devDependencies": { "@brendonovich/vite-plugin-opencode": "0.1.3", @@ -435,6 +437,7 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "app-builder-lib": "26.15.7", + "devtools-protocol": "0.0.1687809", "drizzle-orm": "catalog:", "effect": "catalog:", "electron": "42.10.1", @@ -1841,6 +1844,16 @@ "@fontsource/inter": ["@fontsource/inter@5.3.0", "", {}, "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g=="], + "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="], + + "@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="], + + "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.4", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw=="], + + "@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ=="], + + "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="], + "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], @@ -2227,6 +2240,8 @@ "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw=="], + "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw=="], + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-transformer": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg=="], "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="], @@ -2237,6 +2252,8 @@ "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], + "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ=="], + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.6.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw=="], @@ -2509,6 +2526,8 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], + "@paulirish/trace_engine": ["@paulirish/trace_engine@0.0.65", "", { "dependencies": { "legacy-javascript": "latest", "third-party-web": "latest" } }, "sha512-Qsm6F5C8xf6ZzQXbQc2+wcpe6sggfs/gvc/ytqSurdvYg3kyW0ECHCqE0CWBKZpqgjVfPNX9c7SCS3r2nEIRGg=="], + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.9.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-AKvPMOM7LfK0uFe1m7o7+veOa8xQGPqsqOrKi3QKgCElzwjGp39mbhr2g7mt/v/mXQHiIvJmDj5cJS173x8Q9Q=="], "@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="], @@ -2565,6 +2584,8 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + "@puppeteer/browsers": ["@puppeteer/browsers@3.2.1", "", { "dependencies": { "modern-tar": "^0.8.0", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A=="], + "@radix-ui/colors": ["@radix-ui/colors@1.0.1", "", {}, "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw=="], @@ -2763,10 +2784,18 @@ "@sentry/feedback": ["@sentry/feedback@10.71.0", "", { "dependencies": { "@sentry/core": "10.71.0" } }, "sha512-4+zMAmn1DcbYBtFE0pWt6zo8vWoBHTuP/UhtCMTCoGB4sVYvmwRxv9Hc9OpRHSzE9kSl8beD0zKFxuoVHizKQQ=="], + "@sentry/node": ["@sentry/node@10.72.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.72.0", "@sentry/node-core": "10.72.0", "@sentry/opentelemetry": "10.72.0", "@sentry/server-utils": "10.72.0", "import-in-the-middle": "^3.0.0" } }, "sha512-eQHQFxSX26MhG/nB+tAY4QRslnPvJse7pww/hd3zXAqNULRa5lFtjSLALcHx/KUVDCZdTPdUVZEj4nGidcHrow=="], + + "@sentry/node-core": ["@sentry/node-core@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.72.0", "@sentry/opentelemetry": "10.72.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base"] }, "sha512-xYuYWmWEWnN8h3YHa7mds212ANFgrxfrbmLvVg0p0wf9m6MFjLowOTmjaiK0XW20sM/veSelicre650bx8UFtQ=="], + + "@sentry/opentelemetry": ["@sentry/opentelemetry@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.72.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, "sha512-ZVbAM1rGU/awN7cH/jvC87WpQw0NOJx5id6dKnnefStXpP/kUnZXYhtX9gfBnofYvJWa5I5VUSeuKCLUcKL75w=="], + "@sentry/replay": ["@sentry/replay@10.71.0", "", { "dependencies": { "@sentry/browser-utils": "10.71.0", "@sentry/core": "10.71.0" } }, "sha512-ytEgVg7isvavQL6hgsgYeuSCkcA3EyOKm9lMLQOZTLkiUCtFT/ItWwGNhzS46vQ6wsTv3Uc0Y1JlcJHSFKe/Kg=="], "@sentry/replay-canvas": ["@sentry/replay-canvas@10.71.0", "", { "dependencies": { "@sentry/core": "10.71.0", "@sentry/replay": "10.71.0" } }, "sha512-YTqesxBh9arExI49LrTm4Y2/xPX40q2GWi0gWRw6lNfYtyNz7/ZfHi8/1N6JEc8dldTslqFhII6ZFecUyU9iaA=="], + "@sentry/server-utils": ["@sentry/server-utils@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.72.0" } }, "sha512-CWpHMYW81RDBSVBdnxulGUvvBhkBb/OpSr72ubSe1UkKTcgT0NDMCWxE3dLFXqSxzT4DaF5UlUVv9ii5Sse53w=="], + "@sentry/solid": ["@sentry/solid@10.71.0", "", { "dependencies": { "@sentry/browser": "10.71.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.71.0" }, "peerDependencies": { "@solidjs/router": ">=0.13.4 <2.0.0-0", "@tanstack/solid-router": "^1.132.27", "solid-js": "^1.8.4" }, "optionalPeers": ["@solidjs/router", "@tanstack/solid-router"] }, "sha512-/vVy9GmB/RejmomWEMZ+P3xlwrhfpIYJ3F0+UcTE836uuH92r8CSnmucdGQqke8c/6ullQFjZrLd/dTDPXj1aA=="], "@sentry/vite-plugin": ["@sentry/vite-plugin@5.4.0", "", { "dependencies": { "@sentry/bundler-plugins": "^10.64.0" } }, "sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA=="], @@ -3333,6 +3362,8 @@ "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -3561,10 +3592,16 @@ "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "chrome-launcher": ["chrome-launcher@1.2.1", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A=="], + + "chromium-bidi": ["chromium-bidi@17.0.2", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg=="], + "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="], "ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + "cjs-module-lexer": ["cjs-module-lexer@2.2.1", "", {}, "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q=="], + "classnames": ["classnames@2.3.2", "", {}, "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="], "clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="], @@ -3621,6 +3658,8 @@ "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], + "configstore": ["configstore@7.1.0", "", { "dependencies": { "atomically": "^2.0.3", "dot-prop": "^9.0.0", "graceful-fs": "^4.2.11", "xdg-basedir": "^5.1.0" } }, "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg=="], + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], @@ -3657,6 +3696,8 @@ "crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="], + "csp_evaluator": ["csp_evaluator@1.1.8", "", {}, "sha512-EwOnfYuNbTytvbMKsLixTrRgnjOa0WZCxGy8A9nnSYAicrdwn+T/epU/yjgymmOxlgKnvH+8wXt+7p/8ak5Feg=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], "css-selector-parser": ["css-selector-parser@3.3.0", "", {}, "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g=="], @@ -3761,6 +3802,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], @@ -3809,6 +3852,8 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "devtools-protocol": ["devtools-protocol@0.0.1687809", "", {}, "sha512-t7kSb+UKYdyxcmqy98U2P86ne6rCve3DIJ4+lp54i/hhClgFuG35JrLj23rO14Jw3sWDsp7i+22QhNU0+/qk8g=="], + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], "diacritics": ["diacritics@1.3.0", "", {}, "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA=="], @@ -3913,6 +3958,8 @@ "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -3957,7 +4004,7 @@ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], @@ -4259,6 +4306,8 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-link-header": ["http-link-header@1.1.4", "", {}, "sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], @@ -4283,8 +4332,12 @@ "ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="], + "image-ssim": ["image-ssim@0.2.0", "", {}, "sha512-W7+sO6/yhxy83L0G7xR8YAc5Z5QFtYEXXRV6EaE8tuYBZJnA3gVgp3q7X7muhLZVodeb9UfvjSbwt9VJwjIYAg=="], + "immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], + "import-in-the-middle": ["import-in-the-middle@3.3.3", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA=="], + "import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="], "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], @@ -4303,6 +4356,8 @@ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="], + "ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -4421,12 +4476,16 @@ "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="], "js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "js-beautify": "js/bin/js-beautify.js", "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="], "js-cookie": ["js-cookie@3.0.8", "", {}, "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw=="], + "js-library-detector": ["js-library-detector@6.7.0", "", {}, "sha512-c80Qupofp43y4cJ7+8TTDN/AsDwLi5oOm/plBrWI+iQt485vKXCco+yVmOwEgdo9VOdsYTuV0UlTeetVPTriXA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="], @@ -4499,8 +4558,16 @@ "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], + "legacy-javascript": ["legacy-javascript@0.0.1", "", {}, "sha512-lPyntS4/aS7jpuvOlitZDFifBCb4W8L/3QU0PLbUTUj+zYah8rfVjYic88yG7ZKTxhS5h9iz7duT8oUXKszLhg=="], + "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + "lighthouse": ["lighthouse@13.4.1", "", { "dependencies": { "@paulirish/trace_engine": "0.0.65", "@sentry/node": "^10.0.0", "axe-core": "^4.12.1", "chrome-launcher": "^1.2.1", "configstore": "^7.0.0", "csp_evaluator": "1.1.8", "devtools-protocol": "0.0.1663043", "enquirer": "^2.3.6", "http-link-header": "^1.1.1", "intl-messageformat": "^10.5.3", "jpeg-js": "^0.4.4", "js-library-detector": "^6.7.0", "lighthouse-logger": "^2.0.2", "lighthouse-stack-packs": "1.12.3", "lodash-es": "^4.17.21", "lookup-closest-locale": "6.2.0", "open": "^8.4.0", "puppeteer-core": "^25.3.0", "robots-parser": "^3.0.1", "speedline-core": "^1.4.3", "third-party-web": "^0.29.2", "tldts-icann": "^7.4.9", "web-features": "^3.34.0", "ws": "^7.0.0", "yargs": "^17.3.1", "yargs-parser": "^21.0.0" }, "bin": { "lighthouse": "cli/index.js", "smokehouse": "cli/test/smokehouse/frontends/smokehouse-bin.js", "chrome-debug": "core/scripts/manual-chrome-launcher.js" } }, "sha512-fDu8lt3QLK/lTqIxtp1HkzQNJ32rsFHhbadYOepcMZFLgA8oINhxutMbMv8XXnpTOvZ0TXCo4JCk1LDTWaRLnA=="], + + "lighthouse-logger": ["lighthouse-logger@2.0.2", "", { "dependencies": { "debug": "^4.4.1", "marky": "^1.2.2" } }, "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg=="], + + "lighthouse-stack-packs": ["lighthouse-stack-packs@1.12.3", "", {}, "sha512-d8IsOpE83kbANgnM+Tp8+x6HcMpX9o2ITBiUERssgzAIFdZCQzs/f4k6D0DLQTE59enml9mbAOU52Wu35exWtg=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], @@ -4547,6 +4614,8 @@ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "lookup-closest-locale": ["lookup-closest-locale@6.2.0", "", {}, "sha512-/c2kL+Vnp1jnV6K6RpDTHK3dgg0Tu2VVp+elEiJpjfS1UyY7AjOYHohRug6wT0OpoX2qFgNORndE9RqesfVxWQ=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], @@ -4579,6 +4648,8 @@ "marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="], + "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -4739,8 +4810,14 @@ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "modern-tar": ["modern-tar@0.8.4", "", {}, "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g=="], + + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="], "motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="], @@ -5051,6 +5128,8 @@ "pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="], + "puppeteer-core": ["puppeteer-core@25.9.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.1", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1666840", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.3" } }, "sha512-U61rCwSMha62CA/Opy6tCx2Fx+ck7ouiKnbpEApzSoLYMoEu9F71nuFpHL55vmIt33/GYm6eKZVhH2ev0nAIeg=="], + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], @@ -5177,6 +5256,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], @@ -5211,6 +5292,8 @@ "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "robots-parser": ["robots-parser@3.0.1", "", {}, "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ=="], + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], "rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="], @@ -5369,6 +5452,8 @@ "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], + "speedline-core": ["speedline-core@1.4.3", "", { "dependencies": { "@types/node": "*", "image-ssim": "^0.2.0", "jpeg-js": "^0.4.1" } }, "sha512-DI7/OuAUD+GMpR6dmu8lliO2Wg5zfeh+/xsdyJZCzd8o5JgFUjCeLsBDuZjIQJdwXS3J0L/uZYrELKYqx+PXog=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], @@ -5503,6 +5588,8 @@ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "third-party-web": ["third-party-web@0.29.2", "", {}, "sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g=="], + "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], @@ -5525,6 +5612,10 @@ "titleize": ["titleize@4.0.0", "", {}, "sha512-ZgUJ1K83rhdu7uh7EHAC2BgY5DzoX8V5rTvoWI4vFysggi6YjLe5gUXABPWAU7VkvGP7P/0YiWq+dcPeYDsf1g=="], + "tldts-core": ["tldts-core@7.4.11", "", {}, "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg=="], + + "tldts-icann": ["tldts-icann@7.4.11", "", { "dependencies": { "tldts-core": "^7.4.11" } }, "sha512-1p+NDJ7FUYCliESmsQl9EW5Um8JIyFheiy6Y6ZoHho27d+TLAGFa0gMf1VOVy02vNY1aQo6xeKiaGj45+7P+PA=="], + "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], @@ -5587,6 +5678,8 @@ "typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="], + "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], + "typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="], "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], @@ -5747,6 +5840,8 @@ "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + "web-features": ["web-features@3.36.0", "", {}, "sha512-B5W9HbyXT76soRG7Fn3AELZkuDQpmpSxszSmJZLIPUHYxIGkufNz0vcoEhfqnUF1HAfaducWIBaaKuusq9Am2g=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], @@ -5755,6 +5850,8 @@ "webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="], + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], @@ -5831,6 +5928,8 @@ "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], @@ -6303,6 +6402,12 @@ "@opencode-ai/www/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="], + "@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="], + + "@opentelemetry/sdk-trace/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "@opentelemetry/sdk-trace/@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="], + "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], @@ -6335,6 +6440,8 @@ "@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="], + "@puppeteer/browsers/yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@sentry/bundler-plugins/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], @@ -6345,6 +6452,18 @@ "@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "@sentry/node/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@sentry/node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ=="], + + "@sentry/node/@sentry/core": ["@sentry/core@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA=="], + + "@sentry/node-core/@sentry/core": ["@sentry/core@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA=="], + + "@sentry/opentelemetry/@sentry/core": ["@sentry/core@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA=="], + + "@sentry/server-utils/@sentry/core": ["@sentry/core@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA=="], + "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], @@ -6453,6 +6572,10 @@ "builder-util/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], + "chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -6471,6 +6594,8 @@ "config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "configstore/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="], + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], @@ -6515,6 +6640,8 @@ "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "esast-util-from-js/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -6559,6 +6686,8 @@ "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "import-in-the-middle/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], @@ -6567,10 +6696,16 @@ "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "lighthouse/devtools-protocol": ["devtools-protocol@0.0.1663043", "", {}, "sha512-33aOY3ZnBP1dgZsshgaL+/XlsQleiFZgyUaDtdZkEa1nbZhVY1MoDeWjk+wxg25fU924l1ZJfoGNmjjeA/5s1w=="], + + "lighthouse/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + + "lighthouse/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], "md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], "micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], @@ -6635,6 +6770,10 @@ "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "puppeteer-core/devtools-protocol": ["devtools-protocol@0.0.1666840", "", {}, "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg=="], + + "puppeteer-core/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -6703,6 +6842,8 @@ "unplugin/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + "unused-filename/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], "unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], @@ -7221,6 +7362,8 @@ "@opencode-ai/www/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="], + "@opentelemetry/instrumentation/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + "@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], @@ -7229,10 +7372,20 @@ "@pierre/trees/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "@puppeteer/browsers/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + + "@puppeteer/browsers/yargs/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "@puppeteer/browsers/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + "@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="], + "@solidjs/start/shiki/@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="], "@solidjs/start/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="], @@ -7401,6 +7554,8 @@ "builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -7437,6 +7592,8 @@ "electron/@electron/get/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], @@ -7459,6 +7616,12 @@ "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "lighthouse/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], + + "lighthouse/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + + "lighthouse/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], "miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], @@ -8203,6 +8366,8 @@ "@opencode-ai/www/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="], + "@puppeteer/browsers/yargs/cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/packages/app/src/runtime/platform/browser-pane.ts b/packages/app/src/runtime/platform/browser-pane.ts index 8ec0d43b604..0106fb9003f 100644 --- a/packages/app/src/runtime/platform/browser-pane.ts +++ b/packages/app/src/runtime/platform/browser-pane.ts @@ -2,11 +2,17 @@ import type { Browser } from "@opencode-ai/schema/browser" export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }> export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint }> -export type BrowserPaneLayout = { visible: boolean; bounds?: { x: number; y: number; width: number; height: number } } +export type BrowserPaneLayout = { + tabID: Browser.TabID + visible: boolean + bounds?: { x: number; y: number; width: number; height: number } +} export type BrowserPaneCommand = Browser.Action export type BrowserPaneState = Browser.State | null -export type BrowserPaneEvent = { type: "open" } | { type: "state"; state: BrowserPaneState; error?: string } +export type BrowserPaneEvent = + | { type: "focus"; tabID: Browser.TabID } + | { type: "state"; state: BrowserPaneState; error?: string } export type BrowserPaneRegistration = { setLayout(layout?: BrowserPaneLayout): void diff --git a/packages/app/src/session/browser/model.ts b/packages/app/src/session/browser/model.ts index 03c90fcd797..8aca8448b02 100644 --- a/packages/app/src/session/browser/model.ts +++ b/packages/app/src/session/browser/model.ts @@ -1,12 +1,13 @@ -import { createEffect, createMemo, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" +import { batch, createEffect, createMemo, on, onCleanup } from "solid-js" +import type { Browser } from "@opencode-ai/schema/browser" +import { createStore, reconcile } from "solid-js/store" import { useLanguage } from "@/runtime/i18n/language" import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } from "@/runtime/platform/browser-pane" import { usePlatform } from "@/runtime/platform/platform" import { useServer } from "@/runtime/server/current" import { useSettings } from "@/settings/model" import type { SessionModel } from "../model" -import { SESSION_BROWSER_TAB } from "../helpers" +import { isSessionBrowserTab, sessionBrowserTab } from "../helpers" export function createSessionBrowser(session: SessionModel) { const platform = usePlatform() @@ -30,12 +31,44 @@ export function createSessionBrowser(session: SessionModel) { !server.health?.incompatible && !state.unsupported, ) - const opened = () => state.registration !== undefined && session.layout.tabs().all().includes(SESSION_BROWSER_TAB) - const open = () => { + const browserTabs = createMemo(() => state.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ?? []) + const opened = () => state.registration !== undefined && browserTabs().length > 0 + const focus = (tabID: Browser.TabID) => { session.layout.view().reviewPanel.open() - void session.layout.tabs().open(SESSION_BROWSER_TAB) - session.layout.tabs().setActive(SESSION_BROWSER_TAB) + const tabs = session.layout.tabs() + const key = sessionBrowserTab(tabID) + if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key]) + tabs.setActive(key) } + const command = (command: BrowserPaneCommand) => { + setState("error", undefined) + const owner = session.ownership.capture() + void state.registration?.command(command).catch(() => { + if (owner.current()) setState("error", language.t("common.requestFailed")) + }) + } + createEffect( + on( + () => session.layout.tabs().active(), + (active) => { + const tab = state.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === active) + if (tab && tab.id !== state.browser?.focusedTabID) command({ type: "tabs.focus", tabID: tab.id }) + }, + ), + ) + createEffect( + on( + () => session.layout.tabs().all(), + (current, previous) => { + previous + ?.filter((key) => isSessionBrowserTab(key) && !current.includes(key)) + .forEach((key) => { + const tab = state.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === key) + if (tab) command({ type: "tabs.close", tabID: tab.id }) + }) + }, + ), + ) createEffect(() => { const sessionID = session.identity.sessionID() @@ -51,7 +84,7 @@ export function createSessionBrowser(session: SessionModel) { if (registration) return registration = pane.register(target, (event) => owner.run(() => { - if (event.type === "open") return open() + if (event.type === "focus") return focus(event.tabID) if (event.error === "browser.pane.unsupported") return setState("unsupported", true) // The desktop dropped the attachment (server restart, attach race). // Re-register so the agent's browser tool comes back without a reload. @@ -63,7 +96,20 @@ export function createSessionBrowser(session: SessionModel) { return } if (event.state) attempts = 0 - setState({ browser: event.state, error: event.error }) + batch(() => { + const known = new Set(state.browser?.tabs.map((tab) => sessionBrowserTab(tab.id)) ?? []) + setState("browser", reconcile(event.state)) + setState("error", event.error ? language.t("common.requestFailed") : undefined) + const tabs = session.layout.tabs() + const ids = event.state?.tabs.map((tab) => sessionBrowserTab(tab.id)) ?? [] + tabs + .all() + .filter((key) => isSessionBrowserTab(key) && !ids.includes(key)) + .forEach(tabs.close) + const current = tabs.all() + const added = ids.filter((key) => !known.has(key) && !current.includes(key)) + if (added.length) tabs.setAll([...current, ...added]) + }) }), ) setState({ registration, browser: null, error: undefined }) @@ -84,17 +130,14 @@ export function createSessionBrowser(session: SessionModel) { available, opened, state: () => state.browser, + tabs: browserTabs, + active: () => + browserTabs().find((tab) => sessionBrowserTab(tab.id) === session.layout.tabs().active()) ?? + browserTabs().find((tab) => tab.id === state.browser?.focusedTabID) ?? browserTabs()[0], error: () => state.error, registration: () => state.registration, - close: () => session.layout.tabs().close(SESSION_BROWSER_TAB), - open, - command(command: BrowserPaneCommand) { - setState("error", undefined) - const owner = session.ownership.capture() - void state.registration?.command(command).catch((error: unknown) => { - if (!owner.current()) return - setState("error", error instanceof Error ? error.message : language.t("common.requestFailed")) - }) - }, + close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)), + open: () => command({ type: "tabs.open" }), + command, } } diff --git a/packages/app/src/session/browser/pane.tsx b/packages/app/src/session/browser/pane.tsx index e0f3fc331f2..4d54714388c 100644 --- a/packages/app/src/session/browser/pane.tsx +++ b/packages/app/src/session/browser/pane.tsx @@ -19,7 +19,7 @@ export function SessionBrowserPane(props: { const platform = usePlatform() const language = useLanguage() const dialog = useDialog() - const state = props.browser.state + const state = props.browser.active const button = { variant: "ghost", size: "large" } as const const [store, setStore] = createStore({ address: "", @@ -41,6 +41,11 @@ export function SessionBrowserPane(props: { const measure = () => { frame = undefined if (!surface) return + const tab = state() + if (!tab) { + props.registration.setLayout() + return + } const rect = surface.getBoundingClientRect() const zoom = platform.webviewZoom?.() ?? 1 const left = Math.round(rect.left * zoom) @@ -48,10 +53,11 @@ export function SessionBrowserPane(props: { const right = Math.round(rect.right * zoom) const bottom = Math.round(rect.bottom * zoom) const visible = props.visible && store.visible && !dialog.active && !covered(rect) - const next = `${visible}:${left}:${top}:${right}:${bottom}` + const next = `${tab.id}:${visible}:${left}:${top}:${right}:${bottom}` if (next !== layout) { layout = next props.registration.setLayout({ + tabID: tab.id, visible, bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) }, }) @@ -65,8 +71,15 @@ export function SessionBrowserPane(props: { createEffect(() => !store.editing && setStore("address", state()?.url ?? "")) createEffect( - on([() => platform.webviewZoom?.(), () => dialog.active, () => store.visible, () => props.visible], () => - schedule(300), + on( + [ + () => platform.webviewZoom?.(), + () => dialog.active, + () => store.visible, + () => props.visible, + () => state()?.id, + ], + () => schedule(300), ), ) createResizeObserver(() => surface, schedule.bind(null, 0)) @@ -91,7 +104,10 @@ export function SessionBrowserPane(props: { {...button} disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]} aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")} - onClick={() => props.browser.command({ type: direction })} + onClick={() => { + const tab = state() + if (tab) props.browser.command({ type: direction, tabID: tab.id }) + }} icon={} /> )} @@ -100,7 +116,10 @@ export function SessionBrowserPane(props: { {...button} disabled={!state()} aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")} - onClick={() => props.browser.command(state()?.loading ? { type: "stop" } : { type: "reload" })} + onClick={() => { + const tab = state() + if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id }) + }} icon={ }> @@ -111,7 +130,9 @@ export function SessionBrowserPane(props: { class="min-w-0 flex-1" onSubmit={(event) => { event.preventDefault() - if (store.address.trim()) props.browser.command({ type: "navigate", url: store.address }) + const tab = state() + if (tab && store.address.trim()) + props.browser.command({ type: "navigate", tabID: tab.id, url: store.address }) }} > { const active = activeTab() - return active !== "review" && active !== "context" && active !== "empty" && active !== SESSION_BROWSER_TAB + return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active) }) const openFileKeybind = createMemo(() => command.keybindParts("file.open")) const closeTabKeybind = createMemo(() => command.keybindParts("file.close")) @@ -411,28 +412,34 @@ export function SessionSidePanel(props: { )} - - - - - } - hideCloseButton - > -
- - {language.t("session.tab.browser")} -
-
-
+ + {(tab) => ( + props.browser.close(tab.id)} + closeButton={ + + props.browser.close(tab.id)} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + > +
+ + + {tab.title || language.t("session.tab.browser")} + +
+
+ )} +
@@ -527,18 +534,20 @@ export function SessionSidePanel(props: {
{(registration) => ( )} diff --git a/packages/app/src/session/helpers.test.ts b/packages/app/src/session/helpers.test.ts index 895fa032483..cb97bdc2660 100644 --- a/packages/app/src/session/helpers.test.ts +++ b/packages/app/src/session/helpers.test.ts @@ -3,6 +3,7 @@ import { createMemo, createRoot } from "solid-js" import { createStore } from "solid-js/store" import { SESSION_BROWSER_TAB, + sessionBrowserTab, SESSION_OPEN_FILE_TAB, createOpenReviewFile, createOpenSessionFileTab, @@ -252,4 +253,22 @@ describe("createSessionTabs", () => { dispose() }) }) + + test("keeps multiple browser tabs distinct from file tabs", () => { + createRoot((dispose) => { + const first = sessionBrowserTab("first") + const second = sessionBrowserTab("second") + const result = createSessionTabs({ + tabs: () => ({ active: () => second, all: () => [first, "file://src/a.ts", second] }), + pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice(7) : undefined), + normalizeTab: (tab) => tab, + browser: () => true, + }) + expect(result.panelTabs()).toEqual(["file://src/a.ts"]) + expect(result.activeTab()).toBe(second) + expect(result.activeFileTab()).toBeUndefined() + expect(result.closableTab()).toBe(second) + dispose() + }) + }) }) diff --git a/packages/app/src/session/helpers.ts b/packages/app/src/session/helpers.ts index 8185409746e..fbcaebfda35 100644 --- a/packages/app/src/session/helpers.ts +++ b/packages/app/src/session/helpers.ts @@ -2,9 +2,14 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { same } from "@/runtime/persistence/equality" -import { SESSION_BROWSER_TAB, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs" +import { isSessionBrowserTab, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs" -export { SESSION_BROWSER_TAB, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs" +export { + SESSION_BROWSER_TAB, + SESSION_OPEN_FILE_TAB, + sessionBrowserTab, + isSessionBrowserTab, +} from "@/shell/state/session-tabs" const emptyTabs: string[] = [] @@ -45,7 +50,7 @@ export const createSessionTabs = (input: TabsInput) => { .tabs() .all() .flatMap((tab) => { - if (tab === "context" || tab === "review" || tab === SESSION_BROWSER_TAB) return [] + if (tab === "context" || tab === "review" || isSessionBrowserTab(tab)) return [] if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return [] const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab if (seen.has(value)) return [] @@ -63,7 +68,7 @@ export const createSessionTabs = (input: TabsInput) => { const active = input.tabs().active() if (active === "context") return active if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active - if (active === SESSION_BROWSER_TAB && browser()) return active + if (active && isSessionBrowserTab(active) && browser()) return active if (active === "review" && review()) return active if (active && input.pathFromTab(active)) return input.normalizeTab(active) @@ -82,7 +87,7 @@ export const createSessionTabs = (input: TabsInput) => { const active = activeTab() if (active === "context") return active if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active - if (active === SESSION_BROWSER_TAB && browser()) return active + if (active && isSessionBrowserTab(active) && browser()) return active if (!openedTabs().includes(active)) return return active }) diff --git a/packages/app/src/shell/state/session-tabs.ts b/packages/app/src/shell/state/session-tabs.ts index d8de77d84bc..1f3f0ad5b0f 100644 --- a/packages/app/src/shell/state/session-tabs.ts +++ b/packages/app/src/shell/state/session-tabs.ts @@ -1,5 +1,8 @@ export const SESSION_OPEN_FILE_TAB = "open-file" export const SESSION_BROWSER_TAB = "browser" +export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}` +export const isSessionBrowserTab = (tab: string | undefined) => + !!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`)) export type SessionTabs = { active?: string diff --git a/packages/desktop/package.json b/packages/desktop/package.json index f0937cb629c..cb50ac74326 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -28,7 +28,9 @@ "electron-log": "^5", "electron-store": "11.0.2", "electron-updater": "6.8.9", - "electron-window-state": "^5.0.3" + "electron-window-state": "^5.0.3", + "lighthouse": "13.4.1", + "puppeteer-core": "25.9.0" }, "devDependencies": { "@brendonovich/vite-plugin-opencode": "0.1.3", @@ -47,6 +49,7 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "app-builder-lib": "26.15.7", + "devtools-protocol": "0.0.1687809", "drizzle-orm": "catalog:", "effect": "catalog:", "electron": "42.10.1", diff --git a/packages/desktop/src/main/browser-chromium.ts b/packages/desktop/src/main/browser-chromium.ts index 8bf916ad229..35bf26ff26c 100644 --- a/packages/desktop/src/main/browser-chromium.ts +++ b/packages/desktop/src/main/browser-chromium.ts @@ -1,282 +1,777 @@ -import type { Browser } from "@opencode-ai/schema/browser" -import electron, { type BrowserWindow } from "electron" - -type AXNode = { - nodeId: string - backendDOMNodeId?: number - childIds?: string[] - ignored?: boolean - role?: { value?: string } - name?: { value?: unknown } - properties?: Array<{ name: string; value?: { value?: unknown } }> -} +import { Browser } from "@opencode-ai/schema/browser" +import electron, { type BrowserWindow, type WebContents } from "electron" +import type { Protocol } from "devtools-protocol" +import { Schema } from "effect" +import { createCdp, abortError, waitFor } from "./browser/cdp" +import { createBrowserFiles } from "./browser/files" +import { createDiagnostics } from "./browser/diagnostics" +import { createProfiling } from "./browser/profiling" +type Element = { backendID: number; frameID: string; sessionID?: string } +let nextRef = 0 +// Captures and downloads belong to the tab, not whichever document it now shows. +const retainedOperations = new Set([ + "files.list", + "files.get", + "trace.stop", + "trace.analyze", + "cpu.stop", + "cpu.analyze", + "heap.summary", + "heap.query", + "heap.object", + "heap.compare", +]) export type BrowserPage = ReturnType -export function createBrowserPage(win: BrowserWindow, publish: (error?: string) => void, fail: () => void) { +export function createBrowserPage( + win: BrowserWindow, + options: { + id: Browser.TabID + partition: string + publish: (error?: string) => void + fail: () => void + popup: (options: Electron.BrowserWindowConstructorOptions) => WebContents + initialize?: boolean + popupOptions?: Electron.BrowserWindowConstructorOptions + }, +) { const view = new electron.WebContentsView({ + ...options.popupOptions, webPreferences: { - partition: `opencode-browser-${crypto.randomUUID()}`, + partition: options.partition, nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true, webviewTag: false, devTools: false, - disableDialogs: true, + backgroundThrottling: false, }, }) const contents = view.webContents - const refs = new Map() + const cdp = createCdp(contents) + const files = createBrowserFiles() + const diagnostics = createDiagnostics(cdp) + const profiling = createProfiling(contents, cdp, files) + const refs = new Map() + const sessions = new Map() + const parents = new Map() + const contexts = new Map() + const dialogs = new Set<() => void>() + let dialog: { type: string; message: string; defaultValue: string } | null = null let generation = 0 - let nextRef = 0 let closed = false - const state = (): Browser.State => ({ + const state = (): Browser.Tab => ({ + id: options.id, url: contents.getURL().slice(0, 16_384), - title: contents.getTitle().slice(0, 1_024), + title: contents.getTitle().slice(0, 2_048), loading: contents.isLoading(), canGoBack: contents.navigationHistory.canGoBack(), canGoForward: contents.navigationHistory.canGoForward(), generation, }) - const update = () => { - if (!closed) publish() + const publish = () => { + if (!closed) options.publish() } - contents.on("before-input-event", (event, input) => { - if (input.type !== "keyDown" || input.alt || !(process.platform === "darwin" ? input.meta : input.control)) return - const step = - input.key === "=" || input.key === "+" || input.code === "NumpadAdd" - ? 0.5 - : input.key === "-" || input.code === "NumpadSubtract" - ? -0.5 - : 0 - if (!step && input.key !== "0") return - event.preventDefault() - contents.setZoomLevel(input.key === "0" ? 0 : contents.getZoomLevel() + step) + const reset = (event: Electron.Event<{ isMainFrame: boolean; isSameDocument: boolean }>) => { + if (!event.isMainFrame || event.isSameDocument) return + generation++ + refs.clear() + diagnostics.clear() + publish() + } + contents.on("did-start-navigation", reset) + contents.on("did-stop-loading", publish) + contents.on("did-navigate-in-page", publish) + contents.on("page-title-updated", publish) + contents.on("render-process-gone", () => { + if (!closed) options.fail() }) - const session = contents.session - session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)) - session.setPermissionCheckHandler(() => false) - session.setDevicePermissionHandler(() => false) - session.setDisplayMediaRequestHandler((_request, callback) => callback({})) - session.on("will-download", (event) => event.preventDefault()) - contents.setWindowOpenHandler(() => ({ action: "deny" })) + contents.debugger.on("detach", () => { + if (!closed) options.fail() + }) + contents.session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)) + contents.session.setPermissionCheckHandler(() => false) + contents.session.setDevicePermissionHandler(() => false) + contents.session.setDisplayMediaRequestHandler((_request, callback) => callback({})) contents.on("content-bounds-updated", (event) => event.preventDefault()) const guard = (event: Electron.Event<{ url: string }>) => { if (event.url === "about:blank" || destinationOrigin(event.url)) return event.preventDefault() - publish("ERR_BLOCKED_BY_CLIENT") + options.publish("ERR_BLOCKED_BY_CLIENT") } contents.on("will-frame-navigate", guard) contents.on("will-redirect", guard) - contents.on("did-stop-loading", update) - contents.on("did-navigate-in-page", update) - contents.on("page-title-updated", update) - contents.on("did-start-navigation", (event) => { - if (!event.isMainFrame) return - generation++ - refs.clear() - update() + contents.setWindowOpenHandler(({ url }) => + url === "about:blank" || destinationOrigin(url) + ? { + action: "allow", + outlivesOpener: true, + overrideBrowserWindowOptions: { + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + webviewTag: false, + devTools: false, + partition: options.partition, + }, + }, + createWindow: (popupOptions) => options.popup(popupOptions), + } + : { action: "deny" }, + ) + const download = (_event: Electron.Event, item: Electron.DownloadItem, source: WebContents) => { + if (source !== contents) return + try { + const file = files.add(item.getFilename(), item.getMimeType() || "application/octet-stream") + item.setSavePath(file.path) + item.on("updated", () => { + file.bytes = item.getReceivedBytes() + if (file.bytes > Browser.MAX_FILE_BYTES) item.cancel() + }) + item.once("done", (_event, status) => { + file.bytes = item.getReceivedBytes() + file.state = status === "completed" ? "completed" : "failed" + }) + } catch { + item.cancel() + options.publish("download_failed") + } + } + contents.session.on("will-download", download) + cdp.on("Runtime.executionContextCreated", ({ context }, sessionID) => { + const aux = context.auxData as { frameId?: string; isDefault?: boolean } | undefined + if (aux?.frameId && aux.isDefault) contexts.set(aux.frameId, { id: context.id, sessionID }) }) - contents.on("did-fail-load", (_event, code, error, _url, mainFrame) => { - if (!closed && mainFrame && code !== -3) publish(error) + cdp.on("Runtime.executionContextDestroyed", ({ executionContextId }, sessionID) => { + contexts.forEach((context, key) => { + if (context.id === executionContextId && context.sessionID === sessionID) contexts.delete(key) + }) }) - contents.on("render-process-gone", () => { - if (!closed) fail() + cdp.on("Target.attachedToTarget", ({ sessionId, targetInfo }, parentSessionID) => { + if (targetInfo.type !== "iframe") return + const parentID = targetInfo.parentFrameId ?? Array.from(sessions).find(([, id]) => id === parentSessionID)?.[0] + if (parentID) parents.set(targetInfo.targetId, parentID) + sessions.set(targetInfo.targetId, sessionId) + void Promise.all([ + diagnostics.enable(sessionId), + cdp.send("Page.enable", {}, sessionId), + cdp.send( + "Target.setAutoAttach", + { + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true, + filter: [{ type: "iframe", exclude: false }, { exclude: true }], + }, + sessionId, + ), + ]).catch(() => undefined) }) - contents.debugger.on("detach", () => { - if (!closed) fail() + cdp.on("Target.detachedFromTarget", ({ sessionId }) => { + sessions.forEach((id, frameID) => { + if (id === sessionId) { + sessions.delete(frameID) + parents.delete(frameID) + } + }) }) + cdp.on("Page.javascriptDialogOpening", (event) => { + dialog = { + type: event.type, + message: event.message.slice(0, Browser.MAX_TEXT), + defaultValue: event.defaultPrompt ?? "", + } + dialogs.forEach((reject) => reject()) + publish() + }) + cdp.on("Page.javascriptDialogClosed", () => { + dialog = null + publish() + }) + view.setBounds({ x: 0, y: 0, width: 1000, height: 700 }) view.setVisible(false) - // Match the review pane card so the page does not leak past its rounded bottom corners. view.setBorderRadius(10) win.contentView.addChildView(view) + const ready = Promise.all([ + files.ready, + ...(options.initialize === false ? [] : [contents.loadURL("about:blank")]), + diagnostics.enable(), + cdp.send("Page.enable"), + cdp.send("DOM.enable"), + cdp.send("Target.setAutoAttach", { + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true, + filter: [{ type: "iframe", exclude: false }, { exclude: true }], + }), + ]).then(() => undefined) + return { view, + contents, state, - execute, - ready: Promise.resolve().then(() => contents.loadURL("about:blank")), - dispose() { + ready, + async execute(command: Browser.Command, signal: AbortSignal): Promise { + await ready + abortError(signal) + if (closed) throw new Error("Browser tab was closed.") + if ( + command.generation !== undefined && + command.generation !== generation && + !retainedOperations.has(command.action.type) + ) + throw new Error("The document changed. Take a new snapshot and retry deliberately.") + if (dialog && command.action.type !== "dialog") + throw new Error("A JavaScript dialog is open. Use browser.dialog before continuing.") + const modal = Promise.withResolvers() + const cancelled = Promise.withResolvers() + const cancel = () => cancelled.reject(new Error("Browser operation was cancelled.")) + signal.addEventListener("abort", cancel, { once: true }) + const reject = () => + modal.reject(new Error("A JavaScript dialog opened. Use browser.dialog to accept or dismiss it.")) + if (command.action.type !== "dialog") dialogs.add(reject) + try { + return await Promise.race([execute(command.action, command.files, signal), modal.promise, cancelled.promise]) + } finally { + signal.removeEventListener("abort", cancel) + dialogs.delete(reject) + } + }, + async dispose() { if (closed) return closed = true + contents.session.off("will-download", download) + await profiling.dispose() + cdp.dispose() refs.clear() if (!win.isDestroyed()) win.contentView.removeChildView(view) if (!contents.isDestroyed()) contents.close({ waitForBeforeUnload: false }) + await files.dispose() }, } - async function execute(command: Browser.Command, signal: AbortSignal): Promise { - const action = command.action - check() - if (action.type === "navigate") { - await navigate(action.url, signal) - return { type: "state", state: state() } + async function execute( + action: Browser.Action, + transfers: readonly Browser.File[], + signal: AbortSignal, + ): Promise { + const result = (value: unknown, attached: Browser.File[] = []): Browser.Result => { + const json = Schema.decodeUnknownSync(Schema.Json)(value) + if (JSON.stringify(json).length > 512_000) + throw new Error("Result is too large. Request fewer entries or a smaller snapshot.") + return { value: json, files: attached } } - if (["open", "back", "forward", "reload", "stop"].includes(action.type)) { - if (action.type === "stop") contents.stop() - if (action.type === "reload") contents.reload() - if (action.type === "back" && contents.navigationHistory.canGoBack()) contents.navigationHistory.goBack() - if (action.type === "forward" && contents.navigationHistory.canGoForward()) contents.navigationHistory.goForward() - return { type: "state", state: state() } - } - if (action.type === "evaluate") { - const value: unknown = await contents.executeJavaScript(action.script) - check() - return { type: "evaluate", state: state(), content: (JSON.stringify(value) ?? "null").slice(0, 100_000) } - } - if (action.type === "snapshot") { - const tree = (await send("Accessibility.getFullAXTree", { depth: 6 })) as { nodes: AXNode[] } - refs.clear() - const nodes = new Map(tree.nodes.map((node) => [node.nodeId, node])) - const lines = [`Page: ${clean(state().title)}`, `URL: ${state().url}`, ""] - if (tree.nodes[0]) walk(tree.nodes[0], 0) - return { - type: "snapshot", - state: state(), - content: lines.join("\n").slice(0, 40_960), + switch (action.type) { + case "navigate": { + const url = normalizeURL(action.url) + const cancel = () => contents.stop() + signal.addEventListener("abort", cancel, { once: true }) + try { + await contents.loadURL(url) + } finally { + signal.removeEventListener("abort", cancel) + } + abortError(signal) + return result(state()) } - - function walk(node: AXNode, depth: number) { - if (depth > 6 || lines.length >= 503) return - const role = (node.role?.value ?? "node").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40) - const properties = new Map((node.properties ?? []).map((item) => [item.name, item.value?.value])) - const editable = - ["textbox", "searchbox", "combobox", "spinbutton"].includes(role) || !!properties.get("editable") - if (!node.ignored) { - const actionable = properties.get("focusable") || /^(button|link|textbox|combobox)$/.test(role) - const ref = actionable && node.backendDOMNodeId ? `e${++nextRef}` : "" - if (ref && node.backendDOMNodeId) - refs.set(ref, { - id: node.backendDOMNodeId, - editable: editable && !properties.get("disabled") && !properties.get("readonly"), + case "back": + case "forward": + case "reload": + case "stop": { + if (action.type === "back" && contents.navigationHistory.canGoBack()) contents.navigationHistory.goBack() + if (action.type === "forward" && contents.navigationHistory.canGoForward()) + contents.navigationHistory.goForward() + if (action.type === "reload") contents.reload() + if (action.type === "stop") contents.stop() + if (action.type !== "stop") await waitFor(() => !contents.isLoading(), signal, 30_000) + return result(state()) + } + case "frames": + return result({ tab: state(), frames: await frames() }) + case "snapshot": + case "find": + return result({ tab: state(), ...(await snapshot(action)) }) + case "evaluate": { + const context = action.frameID ? contexts.get(action.frameID) : undefined + if (action.frameID && !context) throw new Error("Frame context is unavailable. Call browser.frames again.") + const value = await cdp.send( + "Runtime.evaluate", + { + expression: action.script, + contextId: context?.id, + awaitPromise: true, + returnByValue: true, + userGesture: true, + }, + context?.sessionID, + ) + if (value.exceptionDetails) + throw new Error(value.exceptionDetails.exception?.description ?? value.exceptionDetails.text) + abortError(signal) + return result({ tab: state(), value: value.result.value ?? null }) + } + case "click": + await click(target(action.ref), action.button ?? "left", action.count ?? 1, action.modifiers) + break + case "hover": + await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", ...(await point(target(action.ref))) }) + break + case "drag": { + const source = target(action.from) + const destination = target(action.to) + await point(destination) + const from = await point(source) + const box = await rect(destination) + const to = { x: box.x + box.width / 2, y: box.y + box.height / 2 } + const html5 = await call(source, "function() { return this.draggable; }") + let data: Protocol.Input.DragData | undefined + const off = cdp.on("Input.dragIntercepted", (event) => { + data = event.data + }) + await cdp.send("Input.setInterceptDrags", { enabled: true }) + await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", ...from }) + await cdp.send("Input.dispatchMouseEvent", { + type: "mousePressed", + ...from, + button: "left", + buttons: 1, + clickCount: 1, + }) + try { + for (let i = 1; i <= 10; i++) { + abortError(signal) + await cdp.send("Input.dispatchMouseEvent", { + type: "mouseMoved", + x: from.x + ((to.x - from.x) * i) / 10, + y: from.y + ((to.y - from.y) * i) / 10, + button: "left", + buttons: 1, }) - const flags = ["checked", "disabled", "expanded", "selected"].flatMap((flag) => - properties.has(flag) ? [`${flag}=${properties.get(flag)}`] : [], - ) - lines.push( - `${" ".repeat(depth)}${ref ? `@${ref}` : ""} [${role}] ${JSON.stringify(clean(node.name?.value))} ${flags.join(" ")}`, - ) + } + if (html5) await waitFor(() => data !== undefined, signal, 2_000) + if (data) { + for (const type of ["dragEnter", "dragOver", "drop"]) + await cdp.send("Input.dispatchDragEvent", { type, ...to, data }) + } + } finally { + off() + await cdp.send("Input.setInterceptDrags", { enabled: false }) + await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", ...to, button: "left", clickCount: 1 }) } - // Editable descendants can repeat the field's value as static text. - if (!editable) - node.childIds?.forEach((id) => { - const child = nodes.get(id) - if (child) walk(child, depth + 1) + break + } + case "fill": + await fill(target(action.ref), action.text) + break + case "fill_form": + for (const field of action.fields) { + abortError(signal) + if (field.type === "text") await fill(target(field.ref), field.value) + if (field.type === "select") await select(target(field.ref), field.values) + if (field.type === "check") await check(target(field.ref), field.checked) + } + break + case "select": + await select(target(action.ref), action.values) + break + case "check": + await check(target(action.ref), action.checked) + break + case "press": + await key(action.key) + break + case "scroll": { + const bounds = view.getBounds() + await cdp.send("Input.dispatchMouseEvent", { + type: "mouseWheel", + x: bounds.width / 2, + y: bounds.height / 2, + deltaX: action.deltaX ?? 0, + deltaY: action.deltaY, + }) + break + } + case "wait": { + if (action.condition !== "load" && !action.text) throw new Error("text is required for a text/textGone wait.") + await waitFor( + async () => { + if (action.condition === "load") return !contents.isLoading() + const context = action.frameID ? contexts.get(action.frameID) : undefined + if (action.frameID && !context) throw new Error("Frame context is unavailable.") + const value = await cdp.send( + "Runtime.evaluate", + { + expression: `document.body?.innerText.includes(${JSON.stringify(action.text)}) ?? false`, + returnByValue: true, + contextId: context?.id, + }, + context?.sessionID, + ) + return Boolean(value.result.value) === (action.condition === "text") + }, + signal, + action.timeoutMs, + ) + break + } + case "screenshot": { + if (action.ref && action.fullPage) throw new Error("Choose an element ref or fullPage, not both.") + const metrics = await cdp.send("Page.getLayoutMetrics") + const bounds = action.ref + ? await rect(target(action.ref)) + : action.fullPage + ? metrics.cssContentSize + : { + x: metrics.cssVisualViewport.pageX, + y: metrics.cssVisualViewport.pageY, + width: metrics.cssVisualViewport.clientWidth, + height: metrics.cssVisualViewport.clientHeight, + } + const scale = Math.min(1, (action.maxWidth ?? 2000) / bounds.width) + if (!bounds.width || !bounds.height || bounds.width * bounds.height * scale * scale > 16_000_000) + throw new Error("Screenshot exceeds 16 megapixels; capture an element or use a smaller maxWidth.") + const format = action.format ?? "png" + const capture = await cdp.send("Page.captureScreenshot", { + format, + quality: format === "png" ? undefined : (action.quality ?? 80), + captureBeyondViewport: true, + clip: { ...bounds, scale }, + }) + const id = await files.save(`screenshot.${format}`, `image/${format}`, Buffer.from(capture.data, "base64")) + return result({ tab: state() }, [await files.transfer(id)]) + } + case "dialog": { + if (action.action !== "get") { + if (!dialog) throw new Error("This tab has no JavaScript dialog.") + await cdp.send("Page.handleJavaScriptDialog", { + accept: action.action === "accept", + promptText: action.promptText, }) - } - } - if (action.type === "screenshot") { - const source = await contents.capturePage() - check() - const size = source.getSize() - if (!size.width || !size.height) throw new Error("internal") - const scale = Math.min(1, 2_000 / Math.max(size.width, size.height)) - const image = source.resize({ - width: Math.max(1, Math.round(size.width * scale)), - height: Math.max(1, Math.round(size.height * scale)), - }) - const data = new Uint8Array(image.toPNG()) - if (data.byteLength > 5 * 1_024 * 1_024) throw new Error("result_too_large") - return { type: "screenshot", state: state(), data } - } - if (action.type === "click" || action.type === "fill") { - const target = refs.get(action.ref.replace(/^@/, "")) - if (!target || (action.type === "fill" && !target.editable)) throw new Error("stale_ref") - if (action.type === "fill") { - await send("DOM.focus", { backendNodeId: target.id }) - await key({ key: "a", code: "KeyA", modifiers: process.platform === "darwin" ? 4 : 2 }) - await key({ key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 }) - await send("Input.insertText", { text: action.text }) - } - if (action.type === "click") { - await send("DOM.scrollIntoViewIfNeeded", { backendNodeId: target.id }) - const result = (await send("DOM.getBoxModel", { backendNodeId: target.id })) as { - model: { content: number[] } + dialog = null } - const box = result.model.content - const point = { x: (box[0] + box[4]) / 2, y: (box[1] + box[5]) / 2 } - for (const type of ["mouseMoved", "mousePressed", "mouseReleased"]) { - await send("Input.dispatchMouseEvent", { type, ...point, button: "left", clickCount: 1 }) + return result({ tab: state(), dialog }) + } + case "files.upload": + case "files.drop": { + if (!transfers.length) throw new Error("Upload did not include file bytes from the server.") + const local = await Promise.all( + transfers.map(async (file) => files.get(await files.save(file.name, file.mime, file.data)).path), + ) + const element = target(action.ref) + if (action.type === "files.upload") + await cdp.send("DOM.setFileInputFiles", { files: local, backendNodeId: element.backendID }, element.sessionID) + if (action.type === "files.drop") { + const position = await point(element) + for (const type of ["dragEnter", "dragOver", "drop"]) + await cdp.send("Input.dispatchDragEvent", { + type, + ...position, + data: { items: [], files: local, dragOperationsMask: 1 }, + }) } + break } - } - if (action.type === "press") { - const codes: Record = { - Enter: 13, - Tab: 9, - Escape: 27, - Backspace: 8, - Delete: 46, - ArrowUp: 38, - ArrowDown: 40, - ArrowLeft: 37, - ArrowRight: 39, - PageUp: 33, - PageDown: 34, - Home: 36, - End: 35, - Space: 32, + case "files.list": + return result({ tab: state(), files: files.list() }) + case "files.get": + return result({ tab: state() }, [await files.transfer(action.fileID)]) + case "console": + return result({ tab: state(), ...diagnostics.console(action) }) + case "network.list": + return result({ tab: state(), ...diagnostics.list(action) }) + case "network.get": + return result({ tab: state(), ...(await diagnostics.get(action)) }) + case "trace.start": + await profiling.startTrace(action.durationMs) + return result({ tab: state(), recording: true }) + case "trace.stop": { + const value = await profiling.stopTrace() + return result({ tab: state(), durationMs: value.durationMs, incomplete: value.incomplete }, [ + await files.transfer(value.id), + ]) } - await key({ - key: action.key === "Space" ? " " : action.key, - code: action.key, - windowsVirtualKeyCode: codes[action.key], - }) + case "cpu.start": + await profiling.startCpu() + return result({ tab: state(), recording: true }) + case "cpu.stop": { + const value = await profiling.stopCpu() + return result({ tab: state(), durationMs: value.durationMs }, [await files.transfer(value.id)]) + } + case "heap.snapshot": + return result({ tab: state() }, [await files.transfer(await profiling.heap())]) + case "trace.analyze": + case "cpu.analyze": + case "heap.summary": + case "heap.query": + case "heap.object": + case "heap.compare": + return result({ tab: state(), ...(await profiling.analyze(action)) }) + case "lighthouse": { + const { audit } = await import("./browser/lighthouse") + const report = await audit(contents, files, cdp) + return result( + { tab: state(), scores: report.scores, failures: report.failures }, + await Promise.all(report.files.map((id) => files.transfer(id))), + ) + } + default: + throw new Error("Tab management is handled by the browser session, not a page.") } - if (action.type === "scroll") { - const bounds = view.getBounds() - await send("Input.dispatchMouseEvent", { - type: "mouseWheel", - x: bounds.width / 2, - y: bounds.height / 2, - deltaX: action.direction === "left" ? -action.pixels : action.direction === "right" ? action.pixels : 0, - deltaY: action.direction === "up" ? -action.pixels : action.direction === "down" ? action.pixels : 0, - }) - } - check() - return { type: "state", state: state() } + abortError(signal) + return result(state()) + } - function check() { - if (closed) throw new Error("not_attached") - if (signal.aborted) throw new Error("aborted") - // Opening may create a new document before the command runs. - if (action.type !== "open" && generation !== command.generation) throw new Error("stale_ref") - } + function target(ref: Browser.Ref): Element { + const value = refs.get(ref.replace(/^@/, "")) + if (!value) throw new Error("Element ref is stale or belongs to another tab. Take a new snapshot.") + return value + } - function key(params: Record) { - return send("Input.dispatchKeyEvent", { type: "keyDown", ...params }).finally(() => - contents.debugger.sendCommand("Input.dispatchKeyEvent", { type: "keyUp", ...params }), + async function frames() { + const root = await cdp.send("Page.getFrameTree") + const result: { id: string; parentID?: string; url: string; name: string }[] = [] + const walk = (tree: Protocol.Page.FrameTree, parentID?: string) => { + if (!result.some((frame) => frame.id === tree.frame.id)) + result.push({ + id: tree.frame.id, + ...(tree.frame.parentId || parentID ? { parentID: tree.frame.parentId ?? parentID } : {}), + url: tree.frame.url, + name: tree.frame.name ?? "", + }) + tree.childFrames?.forEach((child) => walk(child, tree.frame.id)) + } + walk(root.frameTree) + const children = await Promise.all( + Array.from(sessions, async ([id, sessionID]) => ({ + id, + tree: await cdp.send("Page.getFrameTree", {}, sessionID).catch(() => undefined), + })), + ) + children.forEach(({ id, tree }) => { + if (tree) walk(tree.frameTree, parents.get(id) ?? root.frameTree.frame.id) + }) + return result + } + + async function call(element: Element, functionDeclaration: string, args: unknown[] = []) { + const object = await cdp.send("DOM.resolveNode", { backendNodeId: element.backendID }, element.sessionID) + const objectId = object.object.objectId + if (!objectId) throw new Error("Element is no longer available.") + try { + const result = await cdp.send( + "Runtime.callFunctionOn", + { + objectId, + functionDeclaration, + arguments: args.map((value) => ({ value })), + returnByValue: true, + awaitPromise: true, + userGesture: true, + }, + element.sessionID, ) - } - - async function send(method: string, params: Record): Promise { - check() - if (!contents.debugger.isAttached()) contents.debugger.attach("1.3") - const result: unknown = await contents.debugger.sendCommand(method, params) - check() - return result + if (result.exceptionDetails) + throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text) + return result.result.value as unknown + } finally { + await cdp.send("Runtime.releaseObject", { objectId }, element.sessionID).catch(() => undefined) } } - async function navigate(input: string, signal: AbortSignal) { - const value = input.trim() || "about:blank" - const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(value) - const url = - value === "about:blank" || /^[a-z][a-z\d+.-]*:\/\//i.test(value) - ? value - : `${local ? "http" : "https"}://${value}` - if (url.length > 16_384 || (url !== "about:blank" && !destinationOrigin(url))) throw new Error("invalid_url") - const cancel = () => { - if (!closed) contents.stop() + async function rect(element: Element, scroll = false) { + if (scroll) { + await cdp.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: element.backendID }, element.sessionID) + // Force a compositor update after scrolling. requestAnimationFrame can + // stall in a hidden WebContentsView, even with background throttling off. + await contents.capturePage(undefined, { stayHidden: false, stayAwake: true }) } - signal.addEventListener("abort", cancel, { once: true }) - await contents.loadURL(url).finally(() => signal.removeEventListener("abort", cancel)) - if (signal.aborted) throw new Error("aborted") + const shape = Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite }) + const value = { + ...Schema.decodeUnknownSync(shape)( + await call( + element, + "function() { const r = this.getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height}; }", + ), + ), + } + const tree = await frames() + let frame = tree.find((frame) => frame.id === element.frameID) + while (frame?.parentID) { + const owner = await cdp.send("DOM.getFrameOwner", { frameId: frame.id }, sessions.get(frame.parentID)) + const offset = Schema.decodeUnknownSync(shape)( + await call( + { backendID: owner.backendNodeId, frameID: frame.parentID, sessionID: sessions.get(frame.parentID) }, + "function() { const r = this.getBoundingClientRect(); return {x:r.x+this.clientLeft,y:r.y+this.clientTop,width:r.width,height:r.height}; }", + ), + ) + value.x += offset.x + value.y += offset.y + frame = tree.find((item) => item.id === frame?.parentID) + } + return value } -} -function clean(value: unknown) { - return typeof value === "string" ? value.replaceAll(/\s+/g, " ").trim().slice(0, 300) : "" + async function point(element: Element) { + const box = await rect(element, true) + return { x: box.x + box.width / 2, y: box.y + box.height / 2 } + } + + async function click(element: Element, button = "left", count = 1, modifiers: readonly string[] = []) { + const position = await point(element) + const flags = modifiers.reduce((mask, key) => mask | ({ Alt: 1, Control: 2, Meta: 4, Shift: 8 }[key] ?? 0), 0) + await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", ...position, modifiers: flags }) + for (let clickCount = 1; clickCount <= count; clickCount++) { + await cdp.send("Input.dispatchMouseEvent", { + type: "mousePressed", + ...position, + button, + clickCount, + modifiers: flags, + }) + await cdp.send("Input.dispatchMouseEvent", { + type: "mouseReleased", + ...position, + button, + clickCount, + modifiers: flags, + }) + } + } + + async function fill(element: Element, value: string) { + const editable = await call( + element, + "function() { return (this instanceof HTMLInputElement || this instanceof HTMLTextAreaElement || this.isContentEditable) && !this.disabled && !this.readOnly; }", + ) + if (!editable) throw new Error("Element is not an editable field.") + await cdp.send("DOM.focus", { backendNodeId: element.backendID }, element.sessionID) + await key(process.platform === "darwin" ? "Meta+A" : "Control+A") + await key("Backspace") + await cdp.send("Input.insertText", { text: value }) + } + + async function select(element: Element, values: readonly string[]) { + await call( + element, + `function(values) { if (!(this instanceof HTMLSelectElement) || this.disabled) throw new Error('Element is not an enabled select.'); if (!this.multiple && values.length !== 1) throw new Error('Select accepts one value.'); for (const value of values) if (!Array.from(this.options).some(option => option.value === value && !option.disabled)) throw new Error('Option value was not found.'); for (const option of this.options) option.selected = values.includes(option.value); this.dispatchEvent(new Event('input',{bubbles:true})); this.dispatchEvent(new Event('change',{bubbles:true})); }`, + [values], + ) + } + + async function check(element: Element, checked: boolean) { + const current = await call( + element, + "function() { if (!(this instanceof HTMLInputElement) || !['checkbox','radio'].includes(this.type) || this.disabled) throw new Error('Element is not an enabled checkbox or radio.'); return this.checked; }", + ) + if (current !== checked) await click(element) + if ((await call(element, "function() { return this.checked; }")) !== checked) + throw new Error("Element did not reach the requested checked state.") + } + + async function key(chord: string) { + const parts = chord.split("+") + const key = parts.pop() ?? "" + const modifiers = parts.reduce((mask, key) => { + const bit = { Alt: 1, Control: 2, Meta: 4, Shift: 8 }[key] + if (!bit) throw new Error(`Unknown key modifier: ${key}`) + return mask | bit + }, 0) + const codes: Record = { + Enter: 13, + Tab: 9, + Escape: 27, + Backspace: 8, + Delete: 46, + ArrowUp: 38, + ArrowDown: 40, + ArrowLeft: 37, + ArrowRight: 39, + PageUp: 33, + PageDown: 34, + Home: 36, + End: 35, + Space: 32, + } + const code = + codes[key] ?? + (key.length === 1 + ? key.toUpperCase().charCodeAt(0) + : /^F([1-9]|1[0-2])$/.test(key) + ? 111 + Number(key.slice(1)) + : undefined) + if (code === undefined) throw new Error(`Unknown key: ${key}`) + const params = { + key: key === "Space" ? " " : key, + windowsVirtualKeyCode: code, + modifiers, + ...(key.length === 1 && !(modifiers & 6) ? { text: key } : {}), + } + await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", ...params }) + await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", ...params }) + } + + async function snapshot(action: Extract) { + const tree = await frames() + const selected = action.type === "snapshot" && action.ref ? target(action.ref) : undefined + const frameID = selected?.frameID ?? action.frameID ?? tree[0]?.id + if (!frameID || !tree.some((frame) => frame.id === frameID)) + throw new Error("Frame is unavailable. Call browser.frames again.") + const sessionID = sessions.get(frameID) + const depth = action.type === "snapshot" ? (action.depth ?? 8) : 8 + const ax = await cdp.send("Accessibility.getFullAXTree", { frameId: frameID, depth }, sessionID) + const nodes = new Map(ax.nodes.map((node) => [node.nodeId, node])) + const root = selected ? ax.nodes.find((node) => node.backendDOMNodeId === selected.backendID) : ax.nodes[0] + if (!root) throw new Error("Element is absent from the accessibility snapshot.") + refs.clear() + const lines: string[] = [] + let truncated = false + const walk = async (node: Protocol.Accessibility.AXNode, level: number): Promise => { + if (level > depth || lines.length >= 500) { + truncated = true + return + } + const role = String(node.role?.value ?? "node") + .replace(/[^a-zA-Z0-9_-]/g, "") + .slice(0, 40) + const properties = new Map(node.properties?.map((property) => [property.name, property.value.value]) ?? []) + if (!node.ignored) { + const actionable = + role !== "RootWebArea" && + (properties.get("focusable") || /^(button|link|textbox|combobox|checkbox|radio|option)$/.test(role)) + const ref = actionable && node.backendDOMNodeId ? `e${++nextRef}` : "" + const element = node.backendDOMNodeId ? { backendID: node.backendDOMNodeId, frameID, sessionID } : undefined + if (ref && element) refs.set(ref, element) + const flags = (["checked", "disabled", "expanded", "selected"] as const).flatMap((name) => + properties.has(name) ? [`${name}=${properties.get(name)}`] : [], + ) + const box = + action.type === "snapshot" && action.boxes && ref && element + ? await rect(element).catch(() => undefined) + : undefined + lines.push( + `${" ".repeat(level)}${ref ? `@${ref} ` : ""}[${role}] ${JSON.stringify( + String(node.name?.value ?? "") + .replace(/\s+/g, " ") + .slice(0, 300), + )} ${flags.join(" ")}${box ? ` box=${JSON.stringify(box)}` : ""}`, + ) + } + if (["textbox", "searchbox"].includes(role) || properties.get("editable")) return + for (const childID of node.childIds ?? []) { + const child = nodes.get(childID) + if (child) await walk(child, level + 1) + } + } + await walk(root, 0) + const content = ( + action.type === "find" ? lines.filter((line) => line.toLowerCase().includes(action.text.toLowerCase())) : lines + ).join("\n") + return { content: content.slice(0, Browser.MAX_TEXT), truncated: truncated || content.length > Browser.MAX_TEXT } + } } export function destinationOrigin(input: string) { @@ -284,3 +779,13 @@ export function destinationOrigin(input: string) { const url = new URL(input) return /^https?:$/.test(url.protocol) && !url.username && !url.password ? url.origin : undefined } + +export function normalizeURL(input: string) { + const value = input.trim() || "about:blank" + const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(value) + const url = + value === "about:blank" || /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `${local ? "http" : "https"}://${value}` + if (url !== "about:blank" && !destinationOrigin(url)) + throw new Error("Only HTTP, HTTPS, and about:blank URLs are supported.") + return url +} diff --git a/packages/desktop/src/main/browser-pane.ts b/packages/desktop/src/main/browser-pane.ts index a94f7952a36..ccdcb60e24b 100644 --- a/packages/desktop/src/main/browser-pane.ts +++ b/packages/desktop/src/main/browser-pane.ts @@ -15,10 +15,13 @@ type Entry = { win: BrowserWindow abort: AbortController registered: PromiseWithResolvers - requests: Map - report?: (event: Extract) => void + requests: Map + report?: (event: BrowserPaneEvent["event"]) => void cleanup?: () => void - page?: BrowserPage + pages: Map + focusedTabID: Browser.TabID | null + partition: string + lastState?: string } export function createBrowserPane() { @@ -39,6 +42,9 @@ export function createBrowserPane() { abort: new AbortController(), registered: Promise.withResolvers(), requests: new Map(), + pages: new Map(), + focusedTabID: null, + partition: `opencode-browser-${crypto.randomUUID()}`, } // "unsupported" means the server has no browser plugin; the renderer stops retrying. let reason: "browser.pane.unsupported" | undefined @@ -49,6 +55,7 @@ export function createBrowserPane() { win.webContents.once("destroyed", stop) win.webContents.on("did-start-navigation", navigate) entry.cleanup = () => { + if (win.isDestroyed()) return win.webContents.off("destroyed", stop) win.webContents.off("did-start-navigation", navigate) } @@ -80,9 +87,10 @@ export function createBrowserPane() { entry.report = (event) => { Queue.offerUnsafe( outbound, - rpc - .state({ ...attachment, state: event.state }, options) - .pipe(Effect.tap(() => Effect.sync(() => publish(entry, event)))), + (event.type === "state" + ? rpc.state({ ...attachment, state: event.state ?? { tabs: [], focusedTabID: null } }, options) + : Effect.void + ).pipe(Effect.andThen(Effect.sync(() => publish(entry, event)))), ) } const receive = client.event.subscribe().pipe( @@ -92,40 +100,61 @@ export function createBrowserPane() { yield* Deferred.succeed(connected, undefined) return } - if (event.type !== "rpc.experimental.browser.control") return - const message = yield* Schema.decodeUnknownEffect(Browser.Control)(event.data) - if (message.connectionID !== attachment.connectionID) return - if (message.type === "attached") return entry.registered.resolve() - if (message.type === "cancel") return entry.requests.get(message.requestID)?.abort() - const abort = new AbortController() - entry.requests.set(message.requestID, abort) - yield* Effect.promise(async () => { - const outcome: Browser.Outcome = await execute(entry, message.command, abort.signal).then( - (result) => ({ type: "success" as const, result }), - (error: unknown) => ({ - type: "failure" as const, - message: (error instanceof Error ? error.message : String(error)).slice(0, 1_024), + if ( + event.type !== "rpc.experimental.browser.control" || + event.data.connectionID !== attachment.connectionID + ) + return + const message = yield* Schema.decodeUnknownEffect(Browser.Control)(event.data).pipe( + Effect.tapError(() => + Effect.sync(() => { + reason = "browser.pane.unsupported" }), - ) - Queue.offerUnsafe( - outbound, - rpc.result( - { - ...attachment, - requestID: message.requestID, - outcome: Schema.encodeSync(Browser.Outcome)(outcome), - }, - options, - ), - ) - }).pipe( + ), + ) + if (message.type === "attached") return entry.registered.resolve() + if (message.type === "cancel") return entry.requests.get(message.requestID)?.abort.abort() + const abort = new AbortController() + entry.requests.set(message.requestID, { abort }) + yield* rpc.command({ ...attachment, requestID: message.requestID }, options).pipe( + Effect.flatMap((command) => + Effect.promise(async () => { + entry.requests.set(message.requestID, { + abort, + ...("tabID" in command.action ? { tabID: command.action.tabID } : {}), + }) + const outcome: Browser.Outcome = await execute(entry, command, abort.signal).then( + (result) => ({ type: "success" as const, result }), + (error: unknown) => ({ + type: "failure" as const, + code: "operation_failed", + message: (error instanceof Error ? error.message : String(error)).slice(0, 1_024), + }), + ) + Queue.offerUnsafe( + outbound, + rpc.result( + { + ...attachment, + requestID: message.requestID, + outcome: Schema.encodeSync(Browser.Outcome)(outcome), + }, + options, + ), + ) + }), + ), Effect.ensuring( Effect.sync(() => { abort.abort() entry.requests.delete(message.requestID) }), ), - Effect.catchCause(() => Effect.sync(stop)), + Effect.catchCause((cause) => + abort.signal.aborted + ? Effect.void + : Effect.logError("Browser command failed", cause).pipe(Effect.andThen(Effect.sync(stop))), + ), Effect.forkScoped, ) }), @@ -134,14 +163,15 @@ export function createBrowserPane() { yield* Effect.raceAllFirst([ receive, Stream.fromQueue(outbound).pipe(Stream.runForEach((send) => send)), - Deferred.await(connected).pipe(Effect.andThen(rpc.attach(attachment, options))), + Deferred.await(connected).pipe(Effect.andThen(rpc.attach({ ...attachment, version: 2 }, options))), ]) }).pipe( Effect.scoped, Effect.tapError((error) => Effect.sync(() => { const type = error instanceof Object && "type" in error ? error.type : undefined - if (type === "rpc.unavailable" || type === "rpc.method_not_found") reason = "browser.pane.unsupported" + if (type === "rpc.unavailable" || type === "rpc.method_not_found" || type === "rpc.invalid_input") + reason = "browser.pane.unsupported" }), ), Effect.ensuring(Effect.sync(stop)), @@ -156,23 +186,23 @@ export function createBrowserPane() { }, layout(win: BrowserWindow, bindingID: string, value?: BrowserPaneLayout) { const entry = owned(win, bindingID) - if (!value) return closePage(entry) + if (!value) return entry.pages.forEach((page) => page.view.setVisible(false)) + const page = entry.pages.get(value.tabID) + if (!page) return const bounds = value.bounds if (!value.visible || !bounds || bounds.width <= 0 || bounds.height <= 0) { - entry.page?.view.setVisible(false) + page.view.setVisible(false) return } - const page = create(entry) + entry.pages.forEach((other) => { + if (other !== page) other.view.setVisible(false) + }) page.view.setBounds(bounds) page.view.setVisible(true) }, async command(win: BrowserWindow, bindingID: string, command: BrowserPaneCommand) { const entry = owned(win, bindingID) - await execute( - entry, - { action: command, generation: entry.page?.state().generation ?? 0 }, - new AbortController().signal, - ) + await execute(entry, { action: command, files: [] }, new AbortController().signal) }, async close(win: BrowserWindow, bindingID: string) { close(owned(win, bindingID)) @@ -198,57 +228,117 @@ export function createBrowserPane() { function close(entry: Entry, reason = "browser.pane.registration.closed") { if (entries.get(entry.bindingID) !== entry) return entry.report = undefined - closePage(entry, reason) + entry.requests.forEach((request) => request.abort.abort()) + entry.requests.clear() + entry.pages.forEach((page) => { + void page.dispose().catch(() => undefined) + }) + entry.pages.clear() + entry.focusedTabID = null + publishState(entry, reason) entries.delete(entry.bindingID) entry.registered.reject(new Error("browser.pane.registration.closed")) entry.cleanup?.() entry.abort.abort() } - function closePage(entry: Entry, error?: string) { - entry.requests.forEach((request) => request.abort()) - entry.requests.clear() - entry.page?.dispose() - entry.page = undefined + async function closePage(entry: Entry, tabID: Browser.TabID, error?: string) { + const page = entry.pages.get(tabID) + if (!page) throw new Error("Browser tab is unavailable.") + const focused = entry.focusedTabID === tabID + entry.requests.forEach((request) => { + if (request.tabID === tabID) request.abort.abort() + }) + entry.pages.delete(tabID) + if (focused) entry.focusedTabID = entry.pages.keys().next().value ?? null + await page.dispose() publishState(entry, error) } function publishState(entry: Entry, error?: string) { const event = { type: "state" as const, - state: entry.page?.state() ?? null, + state: { tabs: Array.from(entry.pages.values(), (page) => page.state()), focusedTabID: entry.focusedTabID }, ...(error === undefined ? {} : { error }), } + const next = JSON.stringify(event) + if (entry.lastState === next) return + entry.lastState = next + report(entry, event) + } + + function report(entry: Entry, event: BrowserPaneEvent["event"]) { if (entry.report) return entry.report(event) publish(entry, event) } - function create(entry: Entry) { - if (entry.page) return entry.page + function create(entry: Entry, initialize = true, popupOptions?: Electron.BrowserWindowConstructorOptions) { + const id = Browser.TabID.make(`tab_${crypto.randomUUID()}`) const fail = () => { - if (entry.page === page) closePage(entry, "page_crashed") + if (entry.pages.has(id)) void closePage(entry, id, "page_crashed").catch(() => undefined) } - const page = createBrowserPage( - entry.win, - (error) => { - if (entry.page === page) publishState(entry, error) - }, + const page = createBrowserPage(entry.win, { + id, + partition: entry.partition, + initialize, + popupOptions, fail, - ) - entry.page = page + publish: (error) => { + if (entry.pages.has(id)) publishState(entry, error) + }, + popup: (popupOptions) => { + const popup = create(entry, false, popupOptions) + focus(entry, popup.state().id) + return popup.contents + }, + }) + entry.pages.set(id, page) void page.ready .then(() => { - if (entry.page === page) publishState(entry) + if (entry.pages.get(id) === page) publishState(entry) }) .catch(fail) return page } async function execute(entry: Entry, command: Browser.Command, signal: AbortSignal) { - if (command.action.type === "open") publish(entry, { type: "open" }) - const page = command.action.type === "open" ? create(entry) : entry.page - if (!page) throw new Error("not_attached") + const action = command.action + const state = () => ({ + tabs: Array.from(entry.pages.values(), (page) => page.state()), + focusedTabID: entry.focusedTabID, + }) + if (signal.aborted) throw new Error("Browser request was cancelled.") + if (action.type === "tabs.list") return { value: state(), files: [] } + if (action.type === "tabs.open") { + const page = create(entry) + if (action.focus !== false) focus(entry, page.state().id) + await page.ready + await page.execute( + { action: { type: "navigate", tabID: page.state().id, url: action.url ?? "about:blank" }, files: [] }, + signal, + ) + publishState(entry) + return { value: page.state(), files: [] } + } + const page = entry.pages.get(action.tabID) + if (!page) throw new Error("Browser tab is unavailable. Call browser.tabs.list.") + if (action.type === "tabs.focus") { + focus(entry, action.tabID) + return { value: page.state(), files: [] } + } + if (action.type === "tabs.close") { + await closePage(entry, action.tabID) + return { value: state(), files: [] } + } await page.ready - return page.execute(command, signal) + const result = await page.execute(command, signal) + publishState(entry) + return result + } + + function focus(entry: Entry, tabID: Browser.TabID) { + entry.focusedTabID = tabID + publishState(entry) + report(entry, { type: "focus", tabID }) } } diff --git a/packages/desktop/src/main/browser/analysis.ts b/packages/desktop/src/main/browser/analysis.ts new file mode 100644 index 00000000000..27c73d3cf45 --- /dev/null +++ b/packages/desktop/src/main/browser/analysis.ts @@ -0,0 +1,193 @@ +import { Schema } from "effect" + +const number = Schema.Finite +const Trace = Schema.Struct({ + traceEvents: Schema.Array( + Schema.Struct({ + name: Schema.optionalKey(Schema.String), + ph: Schema.optionalKey(Schema.String), + pid: Schema.optionalKey(number), + tid: Schema.optionalKey(number), + ts: Schema.optionalKey(number), + dur: Schema.optionalKey(number), + }), + ), +}) +const Cpu = Schema.Struct({ + startTime: number, + endTime: number, + nodes: Schema.Array( + Schema.Struct({ + id: number, + callFrame: Schema.Struct({ functionName: Schema.String, url: Schema.String, lineNumber: number }), + }), + ), + samples: Schema.optionalKey(Schema.Array(number)), + timeDeltas: Schema.optionalKey(Schema.Array(number)), +}) +const Heap = Schema.Struct({ + snapshot: Schema.Struct({ + meta: Schema.Struct({ + node_fields: Schema.Array(Schema.String), + node_types: Schema.Array(Schema.Union([Schema.String, Schema.Array(Schema.String)])), + edge_fields: Schema.Array(Schema.String), + edge_types: Schema.Array(Schema.Union([Schema.String, Schema.Array(Schema.String)])), + }), + }), + nodes: Schema.Array(number), + edges: Schema.Array(number), + strings: Schema.Array(Schema.String), +}) + +export function analyzeTrace(value: unknown, limit = 100) { + const trace = Schema.decodeUnknownSync(Trace)(value) + const events = new Map() + const longTasks: number[] = [] + trace.traceEvents.forEach((event) => { + if (event.ph !== "X" || !event.name || event.dur === undefined) return + const duration = event.dur / 1000 + const item = events.get(event.name) ?? { name: event.name.slice(0, 2_048), count: 0, totalMs: 0, maxMs: 0 } + item.count++ + item.totalMs += duration + item.maxMs = Math.max(item.maxMs, duration) + events.set(event.name, item) + if ((event.name === "RunTask" || event.name === "ThreadControllerImpl::RunTask") && duration > 50) + longTasks.push(duration) + }) + return { + metrics: [ + { name: "recordedEvents", value: trace.traceEvents.length, unit: "count" }, + { name: "longTasks", value: longTasks.length, unit: "count" }, + { name: "longTaskBlocking", value: longTasks.reduce((sum, duration) => sum + duration - 50, 0), unit: "ms" }, + ], + events: Array.from(events.values()) + .sort((a, b) => b.totalMs - a.totalMs) + .slice(0, limit), + insights: [ + "Event durations can overlap and must not be summed into total page time.", + longTasks.length + ? `${longTasks.length} recorded tasks exceeded 50 ms; inspect their stacks in the exported trace.` + : "No tasks over 50 ms were observed in the retained trace. This is not proof that the page has no long tasks.", + "This renderer-process trace does not calculate LCP, INP, CLS, or Lighthouse performance scores.", + ], + } +} + +export function analyzeCpu(value: unknown, limit = 100) { + const profile = Schema.decodeUnknownSync(Cpu)(value) + const times = new Map() + profile.samples?.forEach((id, index) => + times.set(id, (times.get(id) ?? 0) + (profile.timeDeltas?.[index] ?? 0) / 1000), + ) + return { + durationMs: Math.max(0, (profile.endTime - profile.startTime) / 1000), + functions: profile.nodes + .map((node) => ({ + name: node.callFrame.functionName.slice(0, 2_048), + url: node.callFrame.url.slice(0, 100_000), + line: Math.max(0, node.callFrame.lineNumber + 1), + selfMs: times.get(node.id) ?? 0, + })) + .filter((node) => node.selfMs > 0) + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, limit), + } +} + +export function parseHeap(value: unknown) { + const heap = Schema.decodeUnknownSync(Heap)(value) + const fields = heap.snapshot.meta.node_fields + const edgeFields = heap.snapshot.meta.edge_fields + const width = fields.length + const edgeWidth = edgeFields.length + const indexes = { + type: fields.indexOf("type"), + name: fields.indexOf("name"), + id: fields.indexOf("id"), + size: fields.indexOf("self_size"), + count: fields.indexOf("edge_count"), + edgeType: edgeFields.indexOf("type"), + edgeName: edgeFields.indexOf("name_or_index"), + to: edgeFields.indexOf("to_node"), + } + if ( + !width || + !edgeWidth || + Object.values(indexes).some((index) => index < 0) || + heap.nodes.length % width || + heap.edges.length % edgeWidth + ) + throw new Error("Unsupported heap snapshot layout.") + const types = heap.snapshot.meta.node_types[indexes.type] + const edgeTypes = heap.snapshot.meta.edge_types[indexes.edgeType] + if (!Array.isArray(types) || !Array.isArray(edgeTypes)) throw new Error("Unsupported heap snapshot types.") + const node = (offset: number) => ({ + id: heap.nodes[offset + indexes.id], + name: (heap.strings[heap.nodes[offset + indexes.name]] ?? "").slice(0, 100_000), + type: types[heap.nodes[offset + indexes.type]] ?? "unknown", + selfBytes: heap.nodes[offset + indexes.size], + edgeCount: heap.nodes[offset + indexes.count], + }) + const classes = new Map() + let selfBytes = 0 + for (let offset = 0; offset < heap.nodes.length; offset += width) { + const item = node(offset) + const name = item.name.slice(0, 2_048) + const entry = classes.get(name) ?? { name, count: 0, bytes: 0 } + entry.count++ + entry.bytes += item.selfBytes + selfBytes += item.selfBytes + classes.set(name, entry) + } + return { + summary(limit = 100) { + return { + nodes: heap.nodes.length / width, + edges: heap.edges.length / edgeWidth, + selfBytes, + classes: Array.from(classes.values()) + .sort((a, b) => b.bytes - a.bytes) + .slice(0, limit), + } + }, + classes, + query(name = "", limit = 100) { + const found: ReturnType[] = [] + for (let offset = 0; offset < heap.nodes.length; offset += width) { + const item = node(offset) + if (item.name.toLowerCase().includes(name.toLowerCase())) found.push(item) + } + return { nodes: found.sort((a, b) => b.selfBytes - a.selfBytes).slice(0, limit), truncated: found.length > limit } + }, + object(id: number, limit = 100) { + const target = heap.nodes.findIndex((value, index) => index % width === indexes.id && value === id) - indexes.id + if (target < 0) throw new Error("Object ID was not found in this heap snapshot.") + const references: { name: string; node: ReturnType }[] = [] + const retainers: { name: string; node: ReturnType }[] = [] + let edgeOffset = 0 + let truncated = false + for (let offset = 0; offset < heap.nodes.length; offset += width) { + const count = heap.nodes[offset + indexes.count] + for (let index = 0; index < count; index++, edgeOffset += edgeWidth) { + const to = heap.edges[edgeOffset + indexes.to] + if (offset !== target && to !== target) continue + const type = edgeTypes[heap.edges[edgeOffset + indexes.edgeType]] + const raw = heap.edges[edgeOffset + indexes.edgeName] + const name = (type === "element" || type === "hidden" ? String(raw) : (heap.strings[raw] ?? "")).slice( + 0, + 100_000, + ) + if (offset === target) { + if (references.length < limit) references.push({ name, node: node(to) }) + else truncated = true + } + if (to === target) { + if (retainers.length < limit) retainers.push({ name, node: node(offset) }) + else truncated = true + } + } + } + return { node: node(target), references, retainers, truncated } + }, + } +} diff --git a/packages/desktop/src/main/browser/cdp.ts b/packages/desktop/src/main/browser/cdp.ts new file mode 100644 index 00000000000..7d4d27b59b4 --- /dev/null +++ b/packages/desktop/src/main/browser/cdp.ts @@ -0,0 +1,72 @@ +import type { WebContents } from "electron" +import type { ProtocolMapping } from "devtools-protocol/types/protocol-mapping.js" + +export type Cdp = ReturnType + +export function createCdp(contents: WebContents) { + const listeners = new Map void>>() + const sessions = new Set([""]) + const receive = (_event: Electron.Event, name: string, params: unknown, sessionID?: string) => { + if (!sessions.has(sessionID ?? "")) return + if (name === "Target.attachedToTarget") { + const event = params as ProtocolMapping.Events["Target.attachedToTarget"][0] + if (event.targetInfo.type === "iframe") sessions.add(event.sessionId) + } + if (name === "Target.detachedFromTarget") + sessions.delete((params as ProtocolMapping.Events["Target.detachedFromTarget"][0]).sessionId) + listeners.get(name)?.forEach((callback) => callback(params, sessionID || undefined)) + } + contents.debugger.on("message", receive) + return { + async send( + method: Method, + params: object = {}, + sessionID?: string, + ): Promise { + if (contents.isDestroyed()) throw new Error("Browser tab was closed.") + if (!contents.debugger.isAttached()) contents.debugger.attach("1.3") + // Electron is the CDP boundary. Its native response follows the selected protocol method. + const result = contents.debugger.sendCommand(method, params, sessionID) + if (method !== "Page.captureScreenshot") return result + // Keep a hidden view's compositor awake for CDP (including Lighthouse's + // screenshot gatherer) without changing the selected Review tab. + const [capture] = await Promise.all([ + result, + contents.capturePage(undefined, { stayHidden: false, stayAwake: true }), + ]) + return capture + }, + on( + method: Method, + callback: (params: ProtocolMapping.Events[Method][0], sessionID?: string) => void, + ) { + const handler = (params: unknown, sessionID?: string) => + callback(params as ProtocolMapping.Events[Method][0], sessionID) + const handlers = listeners.get(method) ?? new Set() + handlers.add(handler) + listeners.set(method, handlers) + return () => { + handlers.delete(handler) + if (!handlers.size) listeners.delete(method) + } + }, + dispose() { + contents.debugger.off("message", receive) + listeners.clear() + }, + } +} + +export function abortError(signal: AbortSignal) { + if (signal.aborted) throw new Error("Browser operation was cancelled.") +} + +export async function waitFor(check: () => boolean | Promise, signal: AbortSignal, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs + while (!(await check())) { + abortError(signal) + if (Date.now() >= deadline) throw new Error(`Condition was not met within ${timeoutMs} ms.`) + await new Promise((resolve) => setTimeout(resolve, 50)) + } + abortError(signal) +} diff --git a/packages/desktop/src/main/browser/diagnostics.ts b/packages/desktop/src/main/browser/diagnostics.ts new file mode 100644 index 00000000000..d559ec83a30 --- /dev/null +++ b/packages/desktop/src/main/browser/diagnostics.ts @@ -0,0 +1,290 @@ +import { Browser } from "@opencode-ai/schema/browser" +import type { Protocol } from "devtools-protocol" +import type { Cdp } from "./cdp" + +type Request = { + info: Browser.NetworkRequest + nativeID: string + sessionID?: string + started: number + request: Pick + response?: Pick + headersTruncated: boolean + postDataTruncated: boolean + redirected?: boolean +} +const levels = ["debug", "info", "warning", "error"] as const + +export function createDiagnostics(cdp: Cdp) { + const messages: Browser.ConsoleEntry[] = [] + const requests = new Map() + const current = new Map() + const scope = crypto.randomUUID() + let sequence = 0 + let droppedMessages = 0 + let droppedRequests = 0 + const add = ( + level: (typeof levels)[number], + text: string, + timestampMs: number, + source?: Browser.ConsoleEntry["source"], + ) => { + messages.push({ + id: `${scope}:${++sequence}`, + timestampMs, + level, + text: text.slice(0, 2_000), + textTruncated: text.length > 2_000, + ...(source ? { source } : {}), + }) + if (messages.length > 500) { + messages.shift() + droppedMessages++ + } + } + cdp.on("Runtime.consoleAPICalled", (event) => { + const source = event.stackTrace?.callFrames[0] + add( + event.type === "error" || event.type === "assert" + ? "error" + : event.type === "warning" + ? "warning" + : event.type === "debug" + ? "debug" + : "info", + event.args + .map((arg) => + typeof arg.value === "string" + ? arg.value + : arg.value !== undefined + ? JSON.stringify(arg.value) + : (arg.description ?? arg.unserializableValue ?? arg.type), + ) + .join(" "), + event.timestamp, + source + ? { url: source.url.slice(0, Browser.MAX_TEXT), line: source.lineNumber + 1, column: source.columnNumber + 1 } + : undefined, + ) + }) + cdp.on("Runtime.exceptionThrown", (event) => { + const error = event.exceptionDetails + add( + "error", + error.exception?.description ?? error.text, + event.timestamp, + error.url ? { url: error.url, line: error.lineNumber + 1, column: error.columnNumber + 1 } : undefined, + ) + }) + cdp.on("Network.requestWillBeSent", (event, sessionID) => { + const key = `${sessionID ?? ""}:${event.requestId}` + const previous = current.get(key) + if (previous && event.redirectResponse) { + const response = trimHeaders(event.redirectResponse.headers) + previous.response = { mimeType: event.redirectResponse.mimeType, headers: response.headers } + previous.headersTruncated ||= response.truncated + previous.redirected = true + previous.info = { + ...previous.info, + state: "completed", + statusCode: event.redirectResponse.status, + durationMs: Math.max(0, (event.timestamp - previous.started) * 1000), + } + } + const type = (event.type ?? "other").toLowerCase() + const id = `${scope}:${++sequence}` + const headers = trimHeaders(event.request.headers) + const request: Request = { + nativeID: event.requestId, + sessionID, + started: event.timestamp, + request: { + headers: headers.headers, + hasPostData: event.request.hasPostData, + postData: event.request.postData?.slice(0, 20_000), + }, + headersTruncated: headers.truncated, + postDataTruncated: (event.request.postData?.length ?? 0) > 20_000, + info: { + id, + url: event.request.url.slice(0, 16_384), + method: event.request.method, + resourceType: resourceType(type), + timestampMs: event.wallTime * 1000, + state: "pending", + }, + } + requests.set(id, request) + current.set(key, request) + if (requests.size > 500) { + const first = requests.values().next().value + if (first) { + requests.delete(first.info.id) + if (current.get(`${first.sessionID ?? ""}:${first.nativeID}`) === first) + current.delete(`${first.sessionID ?? ""}:${first.nativeID}`) + } + droppedRequests++ + } + }) + cdp.on("Network.responseReceived", (event, sessionID) => { + const request = current.get(`${sessionID ?? ""}:${event.requestId}`) + if (!request) return + const headers = trimHeaders(event.response.headers) + request.response = { mimeType: event.response.mimeType, headers: headers.headers } + request.headersTruncated ||= headers.truncated + request.info = { ...request.info, statusCode: event.response.status } + }) + cdp.on("Network.loadingFinished", (event, sessionID) => { + const request = current.get(`${sessionID ?? ""}:${event.requestId}`) + if (request) + request.info = { + ...request.info, + state: "completed", + durationMs: Math.max(0, (event.timestamp - request.started) * 1000), + } + }) + cdp.on("Network.loadingFailed", (event, sessionID) => { + const request = current.get(`${sessionID ?? ""}:${event.requestId}`) + if (request) + request.info = { + ...request.info, + state: "failed", + failure: event.errorText.slice(0, 2_048), + durationMs: Math.max(0, (event.timestamp - request.started) * 1000), + } + }) + return { + clear() { + messages.length = 0 + requests.clear() + current.clear() + droppedMessages = 0 + droppedRequests = 0 + }, + async enable(sessionID?: string) { + await cdp.send("Runtime.enable", {}, sessionID) + await cdp.send( + "Network.enable", + { maxTotalBufferSize: 5 * 1024 * 1024, maxResourceBufferSize: 1024 * 1024, maxPostDataSize: 20_000 }, + sessionID, + ) + }, + console(input: Extract) { + const matching = messages.filter((entry) => levels.indexOf(entry.level) >= levels.indexOf(input.level ?? "info")) + const result = bounded(matching, input.limit ?? 100) + return { messages: result, truncated: matching.length > result.length, dropped: droppedMessages } + }, + list(input: Extract) { + const matching = Array.from(requests.values()) + .map((request) => request.info) + .filter( + (request) => + (!input.urlContains || request.url.includes(input.urlContains)) && + (!input.resourceType || input.resourceType === request.resourceType), + ) + const result = bounded(matching, input.limit ?? 100) + return { requests: result, truncated: matching.length > result.length, dropped: droppedRequests } + }, + async get(input: Extract) { + const request = requests.get(input.id) + if (!request) throw new Error("Request is no longer retained in this tab. Call browser.network.list.") + const max = input.maxBodyChars ?? 20_000 + const text = (value: string): Browser.Body => ({ + state: "text", + text: value.slice(0, max), + truncated: value.length > max, + }) + const responseBody = async (): Promise => { + if (!input.includeBody) return { state: "notRequested" } + if (request.info.state === "pending") return { state: "pending" } + if (request.redirected || !request.response) return { state: "unavailable", reason: "notCaptured" } + if ( + !/^(text\/|application\/(json|.*\+json|javascript|xml|.*\+xml|x-www-form-urlencoded))/i.test( + request.response.mimeType, + ) + ) + return { state: "unavailable", reason: "binary" } + const body = await cdp + .send("Network.getResponseBody", { requestId: request.nativeID }, request.sessionID) + .catch(() => undefined) + if (!body) return { state: "unavailable", reason: "backendUnavailable" } + const value = body.base64Encoded ? Buffer.from(body.body, "base64").toString("utf8") : body.body + return value.length ? text(value) : { state: "empty" } + } + const requestBody: Browser.Body = !input.includeBody + ? { state: "notRequested" } + : request.request.postData !== undefined + ? { + state: "text", + text: request.request.postData.slice(0, max), + truncated: request.postDataTruncated || request.request.postData.length > max, + } + : request.request.hasPostData + ? { state: "unavailable", reason: "notCaptured" } + : { state: "empty" } + return { + request: request.info, + requestHeaders: headerEntries(request.request.headers), + responseHeaders: headerEntries(request.response?.headers ?? {}), + headersTruncated: request.headersTruncated, + requestBody, + responseBody: await responseBody(), + } + }, + } +} + +function resourceType(type: string): Browser.ResourceType { + switch (type) { + case "document": + case "stylesheet": + case "image": + case "media": + case "font": + case "script": + case "xhr": + case "fetch": + case "eventsource": + case "websocket": + case "manifest": + return type + default: + return "other" + } +} + +function trimHeaders(headers: Protocol.Network.Headers) { + const entries = Object.entries(headers) + const kept: [string, string][] = [] + let size = 0 + let truncated = entries.length > 100 + for (const [key, value] of entries.slice(0, 100)) { + const name = key.slice(0, 2_048) + const text = String(value) + const remaining = Math.max(0, 16_000 - size - name.length) + if (!remaining) { + truncated = true + break + } + const bounded = text.slice(0, Math.min(2_000, remaining)) + truncated ||= bounded.length < text.length || name.length < key.length + kept.push([name, bounded]) + size += name.length + bounded.length + } + return { headers: Object.fromEntries(kept), truncated } +} + +function bounded(items: readonly Item[], limit: number) { + const selected: Item[] = [] + let size = 0 + for (const item of items.slice(-limit).reverse()) { + size += JSON.stringify(item).length + if (size > Browser.MAX_TEXT) break + selected.push(item) + } + return selected.reverse() +} + +function headerEntries(headers: Protocol.Network.Headers) { + return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) })) +} diff --git a/packages/desktop/src/main/browser/files.ts b/packages/desktop/src/main/browser/files.ts new file mode 100644 index 00000000000..792c3988746 --- /dev/null +++ b/packages/desktop/src/main/browser/files.ts @@ -0,0 +1,67 @@ +import { Browser } from "@opencode-ai/schema/browser" +import { mkdir, readFile, stat, writeFile, rm } from "node:fs/promises" +import { mkdirSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" + +export type BrowserFiles = ReturnType + +export function createBrowserFiles() { + const directory = path.join(tmpdir(), `opencode-browser-client-${crypto.randomUUID()}`) + const files = new Map< + Browser.FileID, + { + id: Browser.FileID + name: string + mime: string + bytes: number + state: "pending" | "completed" | "failed" + path: string + } + >() + const ready = mkdir(directory, { recursive: true }) + return { + directory, + ready, + list: () => Array.from(files.values()).map(({ path: _path, ...file }) => file), + add(name: string, mime: string) { + const id = Browser.FileID.make(`file_${crypto.randomUUID()}`) + const target = path.join(directory, id) + // setSavePath must run during Electron's synchronous will-download callback. + mkdirSync(target, { recursive: true }) + const file = { + id, + name: name.slice(0, 2_048), + mime, + bytes: 0, + state: "pending" as "pending" | "completed" | "failed", + path: path.join(target, name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(-120) || "file"), + } + files.set(id, file) + return file + }, + async save(name: string, mime: string, data: Uint8Array) { + if (data.byteLength > Browser.MAX_FILE_BYTES) + throw new Error("File exceeds the 5 MiB transfer limit. Capture a smaller file.") + await ready + const file = this.add(name, mime) + await writeFile(file.path, data) + file.bytes = data.byteLength + file.state = "completed" + return file.id + }, + get(id: Browser.FileID) { + const file = files.get(id) + if (!file || file.state !== "completed") + throw new Error("File is not available in this tab. Call browser.files.list.") + return file + }, + async transfer(id: Browser.FileID): Promise { + const file = this.get(id) + if ((await stat(file.path)).size > Browser.MAX_FILE_BYTES) + throw new Error("File exceeds the 5 MiB transfer limit.") + return { id, name: file.name, mime: file.mime, data: new Uint8Array(await readFile(file.path)) } + }, + dispose: () => ready.then(() => rm(directory, { recursive: true, force: true })), + } +} diff --git a/packages/desktop/src/main/browser/lighthouse.ts b/packages/desktop/src/main/browser/lighthouse.ts new file mode 100644 index 00000000000..81321444961 --- /dev/null +++ b/packages/desktop/src/main/browser/lighthouse.ts @@ -0,0 +1,80 @@ +import type { WebContents } from "electron" +import type { Page } from "puppeteer-core" +import { EventEmitter } from "node:events" +import type { BrowserFiles } from "./files" +import type { Cdp } from "./cdp" + +export async function audit(contents: WebContents, files: BrowserFiles, cdp: Cdp) { + const { snapshot, generateReport } = await import("lighthouse") + const info = (await contents.debugger.sendCommand("Target.getTargetInfo")) as { targetInfo: { targetId: string } } + const sessions = new Map>() + function session(id: string) { + const emitter = new EventEmitter() + const value = Object.assign(emitter, { + id: () => id, + send: (method: string, params?: object) => cdp.send(method as Parameters[0], params, id), + detach: async () => { + sessions.delete(id) + await contents.debugger.sendCommand("Target.detachFromTarget", { sessionId: id }) + }, + }) + sessions.set(id, value) + return value + } + const event = (_event: Electron.Event, method: string, params: unknown, sessionID?: string) => { + const owner = sessionID ? sessions.get(sessionID) : undefined + if (!owner) return + if (method === "Target.attachedToTarget") { + const child = params as { sessionId: string } + owner.emit("sessionattached", session(child.sessionId)) + } + owner.emit(method, params) + owner.emit("*", method, params) + } + contents.debugger.on("message", event) + try { + const attached = (await contents.debugger.sendCommand("Target.attachToTarget", { + targetId: info.targetInfo.targetId, + flatten: true, + })) as { sessionId: string } + const root = session(attached.sessionId) + // Lighthouse's snapshot driver uses only url() and target().createCDPSession(). + // Adapt that narrow boundary to Electron instead of exposing a browser-wide + // Puppeteer connection or emulating unsupported Browser/Target commands. + const page = { + url: () => contents.getURL(), + target: () => ({ createCDPSession: async () => root }), + } as unknown as Page + // Our screenshot tool handles captures separately. Lighthouse's screenshot + // gatherer resizes the viewport and waits for frames that hidden views may not paint. + const result = await snapshot(page, { + flags: { + onlyCategories: ["accessibility", "seo", "best-practices"], + formFactor: "desktop", + screenEmulation: { disabled: true }, + throttlingMethod: "provided", + disableStorageReset: true, + disableFullPageScreenshot: true, + }, + }) + if (!result) throw new Error("Lighthouse did not produce a report.") + return { + scores: Object.values(result.lhr.categories).map((category) => ({ + id: category.id, + title: category.title, + score: category.score, + })), + failures: Object.values(result.lhr.audits) + .filter((audit) => audit.score !== null && audit.score < 1) + .map((audit) => ({ id: audit.id, title: audit.title, description: audit.description })) + .slice(0, 100), + files: await Promise.all([ + files.save("lighthouse.json", "application/json", Buffer.from(generateReport(result.lhr, "json"))), + files.save("lighthouse.html", "text/html", Buffer.from(generateReport(result.lhr, "html"))), + ]), + } + } finally { + contents.debugger.off("message", event) + await Promise.all(Array.from(sessions.values(), (session) => session.detach().catch(() => undefined))) + } +} diff --git a/packages/desktop/src/main/browser/profiling.ts b/packages/desktop/src/main/browser/profiling.ts new file mode 100644 index 00000000000..54e47c07e8b --- /dev/null +++ b/packages/desktop/src/main/browser/profiling.ts @@ -0,0 +1,218 @@ +import type { WebContents } from "electron" +import { Browser } from "@opencode-ai/schema/browser" +import { gzipSync, gunzipSync } from "node:zlib" +import { readFile } from "node:fs/promises" +import { Schema } from "effect" +import type { Cdp } from "./cdp" +import type { BrowserFiles } from "./files" +import { analyzeCpu, analyzeTrace, parseHeap } from "./analysis" + +let recording: + | { + owner: WebContents + pid: number + started: number + timer?: ReturnType + finish: () => Promise<{ id: Browser.FileID; durationMs: number; incomplete: boolean }> + } + | undefined + +export function createProfiling(contents: WebContents, cdp: Cdp, files: BrowserFiles) { + let trace: Promise<{ id: Browser.FileID; durationMs: number; incomplete: boolean }> | undefined + let cpu: + | { + started: number + timer?: ReturnType + result?: Promise<{ id: Browser.FileID; durationMs: number }> + } + | undefined + let takingHeap = false + const json = async (id: Browser.FileID) => { + const file = files.get(id) + const data = await readFile(file.path) + return Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))( + (file.name.endsWith(".gz") ? gunzipSync(data, { maxOutputLength: 128 * 1024 * 1024 }) : data).toString("utf8"), + ) + } + const stopCpu = () => { + if (!cpu) return Promise.reject(new Error("No CPU profile has been started in this tab.")) + if (cpu.result) return cpu.result + clearTimeout(cpu.timer) + cpu.result = cdp.send("Profiler.stop").then(async ({ profile }) => ({ + id: await files.save("profile.cpuprofile", "application/json", Buffer.from(JSON.stringify(profile))), + durationMs: (profile.endTime - profile.startTime) / 1000, + })) + return cpu.result + } + return { + async startTrace(durationMs = 10_000) { + if (recording) throw new Error("Another performance trace is active. Do not stop another tab's recording.") + const complete = Promise.withResolvers<{ stream?: string; dataLossOccurred: boolean }>() + const off = cdp.on("Tracing.tracingComplete", (event) => complete.resolve(event)) + const owner = { + owner: contents, + pid: contents.getOSProcessId(), + started: performance.now(), + timer: undefined as ReturnType | undefined, + finish: () => { + if (trace) return trace + trace = (async () => { + clearTimeout(owner.timer) + const durationMs = performance.now() - owner.started + const deadline = Promise.withResolvers() + const timeout = setTimeout( + () => deadline.reject(new Error("Chromium did not finish flushing the trace within 10 seconds.")), + 10_000, + ) + try { + const result = await Promise.race([ + cdp.send("Tracing.end").then(() => complete.promise), + deadline.promise, + ]) + if (!result.stream) throw new Error("Chromium did not return a trace stream.") + const chunks: Buffer[] = [] + let bytes = 0 + try { + while (true) { + const part = await cdp.send("IO.read", { handle: result.stream, size: 256 * 1024 }) + const buffer = Buffer.from(part.data, part.base64Encoded ? "base64" : "utf8") + bytes += buffer.byteLength + if (bytes > 64 * 1024 * 1024) throw new Error("Trace exceeded its 64 MiB local capture limit.") + chunks.push(buffer) + if (part.eof) break + } + } finally { + await cdp.send("IO.close", { handle: result.stream }) + } + const raw = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ traceEvents: Schema.Array(Schema.Record(Schema.String, Schema.Json)) }), + ), + )(Buffer.concat(chunks).toString("utf8")) + // A CDP trace is browser-wide. Export only this renderer process, never + // the application renderer or another session's process metadata. + const traceEvents = raw.traceEvents.filter((event) => event.pid === owner.pid) + const id = await files.save( + "trace.json.gz", + "application/gzip", + gzipSync( + JSON.stringify({ + traceEvents, + metadata: { source: "opencode", scope: "renderer-process", processId: owner.pid }, + }), + ), + ) + return { + id, + durationMs, + incomplete: + result.dataLossOccurred || contents.isDestroyed() || contents.getOSProcessId() !== owner.pid, + } + } finally { + clearTimeout(timeout) + off() + if (recording === owner) recording = undefined + } + })() + return trace + }, + } + recording = owner + trace = undefined + try { + await cdp.send("Tracing.start", { + transferMode: "ReturnAsStream", + traceConfig: { + recordMode: "recordUntilFull", + traceBufferSizeInKb: 8192, + includedCategories: [ + "devtools.timeline", + "disabled-by-default-devtools.timeline", + "disabled-by-default-devtools.timeline.stack", + "v8.execute", + "blink.user_timing", + "disabled-by-default-v8.cpu_profiler", + ], + excludedCategories: ["*"], + }, + }) + owner.timer = setTimeout(() => { + void owner.finish().catch(() => undefined) + }, durationMs) + } catch (error) { + off() + if (recording === owner) recording = undefined + throw error + } + }, + stopTrace() { + if (recording?.owner === contents) return recording.finish() + if (trace) return trace + return Promise.reject(new Error("This tab has no performance trace to stop.")) + }, + async startCpu() { + if (cpu && !cpu.result) throw new Error("A CPU profile is already active in this tab.") + await cdp.send("Profiler.enable") + await cdp.send("Profiler.start") + cpu = { started: Date.now() } + cpu.timer = setTimeout(() => { + void stopCpu().catch(() => undefined) + }, 30_000) + }, + stopCpu, + async heap() { + if (takingHeap) throw new Error("A heap snapshot is already being captured in this tab.") + takingHeap = true + const chunks: string[] = [] + let size = 0 + let overflow = false + const off = cdp.on("HeapProfiler.addHeapSnapshotChunk", ({ chunk }) => { + size += Buffer.byteLength(chunk) + if (size > 128 * 1024 * 1024) overflow = true + if (!overflow) chunks.push(chunk) + }) + try { + await cdp.send("HeapProfiler.enable") + await cdp.send("HeapProfiler.takeHeapSnapshot", { reportProgress: false }) + } finally { + takingHeap = false + off() + } + if (overflow) throw new Error("Heap snapshot exceeded the 128 MiB local capture limit.") + return files.save("heap.heapsnapshot.gz", "application/gzip", gzipSync(chunks.join(""))) + }, + async analyze( + action: Extract< + Browser.Action, + { type: "trace.analyze" | "cpu.analyze" | "heap.summary" | "heap.query" | "heap.object" | "heap.compare" } + >, + ) { + if (action.type === "heap.compare") { + const before = parseHeap(await json(action.before)).classes + const after = parseHeap(await json(action.after)).classes + return { + classes: Array.from(new Set([...before.keys(), ...after.keys()])) + .map((name) => ({ + name, + countDelta: (after.get(name)?.count ?? 0) - (before.get(name)?.count ?? 0), + bytesDelta: (after.get(name)?.bytes ?? 0) - (before.get(name)?.bytes ?? 0), + })) + .filter((item) => item.countDelta || item.bytesDelta) + .sort((a, b) => Math.abs(b.bytesDelta) - Math.abs(a.bytesDelta)) + .slice(0, action.limit ?? 100), + } + } + const value = await json(action.fileID) + if (action.type === "trace.analyze") return analyzeTrace(value, action.limit) + if (action.type === "cpu.analyze") return analyzeCpu(value, action.limit) + const heap = parseHeap(value) + if (action.type === "heap.summary") return heap.summary(action.limit) + if (action.type === "heap.query") return heap.query(action.name, action.limit) + return heap.object(action.id, action.limit) + }, + async dispose() { + if (recording?.owner === contents) await recording.finish().catch(() => undefined) + if (cpu && !cpu.result) await stopCpu().catch(() => undefined) + }, + } +} diff --git a/packages/desktop/src/renderer/api.ts b/packages/desktop/src/renderer/api.ts index 44fe3ab9676..ce3a39965e1 100644 --- a/packages/desktop/src/renderer/api.ts +++ b/packages/desktop/src/renderer/api.ts @@ -28,7 +28,7 @@ export const api: ElectronAPI = { browserPane: { request: (request) => invoke("BrowserPane", { request }), send: (request) => send("BrowserPane", { request }), - onEvent: (callback) => listen("BrowserPaneEvent", (value) => callback(mutable(value))), + onEvent: (callback) => listen("BrowserPaneEvent", (value) => callback(value)), }, wslServers: { getState: () => invoke("WslGetState").then(mutable), diff --git a/packages/desktop/src/shared/ipc-rpc/browser.ts b/packages/desktop/src/shared/ipc-rpc/browser.ts index db5aca70d5b..2546447cfb1 100644 --- a/packages/desktop/src/shared/ipc-rpc/browser.ts +++ b/packages/desktop/src/shared/ipc-rpc/browser.ts @@ -11,7 +11,7 @@ const endpoint = Schema.Struct({ }) const target = Schema.Struct({ sessionID: text(256).check(Schema.isStartsWith("ses")), endpoint }) const bounds = Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite }) -const layout = Schema.Struct({ visible: Schema.Boolean, bounds: Schema.optionalKey(bounds) }) +const layout = Schema.Struct({ tabID: Browser.TabID, visible: Schema.Boolean, bounds: Schema.optionalKey(bounds) }) export const BrowserPaneRequestSchema = Schema.Union([ Schema.Struct({ type: Schema.Literal("register"), bindingID, target }), Schema.Struct({ type: Schema.Literal("layout"), bindingID, layout: Schema.optionalKey(layout) }), @@ -21,7 +21,7 @@ export const BrowserPaneRequestSchema = Schema.Union([ export type BrowserPaneRequest = Schema.Schema.Type export const BrowserPaneEventSchema = Schema.Union([ - Schema.Struct({ type: Schema.Literal("open") }), + Schema.Struct({ type: Schema.Literal("focus"), tabID: Browser.TabID }), Schema.Struct({ type: Schema.Literal("state"), state: Schema.NullOr(Browser.State), diff --git a/packages/desktop/test/browser-analysis.test.ts b/packages/desktop/test/browser-analysis.test.ts new file mode 100644 index 00000000000..974e01be2d6 --- /dev/null +++ b/packages/desktop/test/browser-analysis.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test" +import { analyzeCpu, analyzeTrace, parseHeap } from "../src/main/browser/analysis" + +test("trace analysis reports observed durations without inventing Web Vitals", () => { + const result = analyzeTrace({ + traceEvents: [ + { name: "RunTask", ph: "X", dur: 75_000 }, + { name: "Paint", ph: "X", dur: 2_500 }, + { name: "Paint", ph: "X", dur: 1_000 }, + { name: "marker", ph: "i" }, + ], + }) + expect(result.metrics).toEqual([ + { name: "recordedEvents", value: 4, unit: "count" }, + { name: "longTasks", value: 1, unit: "count" }, + { name: "longTaskBlocking", value: 25, unit: "ms" }, + ]) + expect(result.events).toContainEqual({ name: "Paint", count: 2, totalMs: 3.5, maxMs: 2.5 }) +}) + +test("CPU analysis attributes sample intervals to their functions", () => { + const result = analyzeCpu({ + startTime: 0, + endTime: 6000, + nodes: [ + { id: 1, callFrame: { functionName: "first", url: "a.js", lineNumber: 0 } }, + { id: 2, callFrame: { functionName: "second", url: "b.js", lineNumber: 2 } }, + ], + samples: [1, 2, 1], + timeDeltas: [1000, 2000, 3000], + }) + expect(result.durationMs).toBe(6) + expect(result.functions).toEqual([ + { name: "first", url: "a.js", line: 1, selfMs: 4 }, + { name: "second", url: "b.js", line: 3, selfMs: 2 }, + ]) +}) + +test("heap queries and object links use snapshot IDs and shallow sizes", () => { + const heap = parseHeap({ + snapshot: { + meta: { + node_fields: ["type", "name", "id", "self_size", "edge_count"], + node_types: [["object"], "string", "number", "number", "number"], + edge_fields: ["type", "name_or_index", "to_node"], + edge_types: [["property"], "string", "node"], + }, + }, + nodes: [0, 0, 1, 10, 1, 0, 1, 3, 20, 0], + edges: [0, 2, 5], + strings: ["root", "child", "next"], + }) + expect(heap.summary()).toMatchObject({ nodes: 2, edges: 1, selfBytes: 30 }) + expect(heap.query("", 1)).toMatchObject({ nodes: [{ id: 3, selfBytes: 20 }], truncated: true }) + expect(heap.object(1).references).toMatchObject([{ name: "next", node: { id: 3 } }]) + expect(heap.object(3).retainers).toMatchObject([{ name: "next", node: { id: 1 } }]) + expect(() => heap.object(99)).toThrow("Object ID was not found") +}) diff --git a/packages/desktop/test/browser-native.test.ts b/packages/desktop/test/browser-native.test.ts new file mode 100644 index 00000000000..c5a64331318 --- /dev/null +++ b/packages/desktop/test/browser-native.test.ts @@ -0,0 +1,105 @@ +import { test, expect } from "bun:test" +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import path from "node:path" +import { tmpdir } from "node:os" + +test("browser suite over authenticated HTTP RPC with separate server and desktop storage", async () => { + const root = await mkdtemp(path.join(tmpdir(), "opencode-browser-suite-")) + const output = await mkdtemp(path.resolve(import.meta.dir, "../node_modules/.browser-suite-")) + const names = ["home", "config", "data", "cache", "state", "server-files", "client-files", "electron-data"] + await Promise.all(names.map((name) => mkdir(path.join(root, name), { recursive: true }))) + const built = await Bun.build({ + entrypoints: [path.join(import.meta.dir, "browser/native.ts")], + outdir: output, + naming: "native.mjs", + target: "node", + format: "esm", + external: ["electron", "lighthouse", "puppeteer-core"], + }) + if (!built.success) throw new AggregateError(built.logs) + const environment = { + ...process.env, + OPENCODE_TEST_HOME: path.join(root, "home"), + OPENCODE_CONFIG_DIR: path.join(root, "config"), + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_STATE_HOME: path.join(root, "state"), + SMOKE_PASSWORD: crypto.randomUUID(), + SMOKE_SERVER_FILES: path.join(root, "server-files"), + SMOKE_ROOT: root, + ELECTRON_RUN_AS_NODE: undefined, + } + const ready = Promise.withResolvers() + const server = Bun.spawn([process.execPath, path.join(import.meta.dir, "browser/server.ts")], { + cwd: root, + env: { ...environment, TMP: path.join(root, "server-files"), TEMP: path.join(root, "server-files") }, + stdout: "inherit", + stderr: "inherit", + ipc(message: unknown) { + if (typeof message === "string") ready.resolve(message) + }, + }) + void server.exited.then((code) => ready.reject(new Error(`Fixture server exited: ${code}`))) + let native: ReturnType | undefined + let proxy: Bun.Server | undefined + try { + const url = await Promise.race([ + ready.promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Fixture server startup timed out")), 30_000).unref(), + ), + ]) + // Exercise the real HTTP boundary with delayed state acknowledgments, as on + // a remote server. Neither endpoint can rely on synchronous UI/state updates. + proxy = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + idleTimeout: 0, + async fetch(request) { + const incoming = new URL(request.url) + if (incoming.pathname.endsWith("/experimental.browser/state")) + await new Promise((resolve) => setTimeout(resolve, 75)) + return fetch(new Request(new URL(incoming.pathname + incoming.search, url), request), { decompress: false }) + }, + }) + const electron: unknown = (await import("electron")).default + if (typeof electron !== "string") throw new Error("Electron binary path is unavailable.") + native = Bun.spawn([electron, path.join(output, "native.mjs")], { + cwd: output, + env: { + ...environment, + SMOKE_URL: proxy.url.href, + TMP: path.join(root, "client-files"), + TEMP: path.join(root, "client-files"), + }, + stdout: "inherit", + stderr: "inherit", + }) + expect( + await Promise.race([ + native.exited, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Native browser suite timed out")), 140_000).unref(), + ), + ]), + ).toBe(0) + } finally { + if (native?.exitCode === null) { + native.kill() + await native.exited + } + if (server.exitCode === null) server.send("stop") + proxy?.stop(true) + await Promise.race([ + server.exited, + new Promise((resolve) => + setTimeout(() => { + if (server.exitCode === null) server.kill() + resolve() + }, 10_000).unref(), + ), + ]) + await Promise.all([rm(root, { recursive: true, force: true }), rm(output, { recursive: true, force: true })]) + } +}, 180_000) diff --git a/packages/desktop/test/browser/contract.ts b/packages/desktop/test/browser/contract.ts new file mode 100644 index 00000000000..7b7f8a95925 --- /dev/null +++ b/packages/desktop/test/browser/contract.ts @@ -0,0 +1,16 @@ +import { Rpc } from "@opencode-ai/schema/rpc" +import { Session } from "@opencode-ai/schema/session" +import { Schema } from "effect" + +export const Smoke = Rpc.define({ + id: "browser.smoke", + methods: { + execute: { + input: Schema.Struct({ sessionID: Session.ID, code: Schema.String }), + output: Schema.Struct({ output: Schema.String, error: Schema.optionalKey(Schema.Boolean) }), + }, + write: { input: Schema.Struct({ text: Schema.String }), output: Schema.String }, + read: { input: Schema.Struct({ path: Schema.String }), output: Schema.String }, + }, + events: {}, +}) diff --git a/packages/desktop/test/browser/native.ts b/packages/desktop/test/browser/native.ts new file mode 100644 index 00000000000..44cad4cada7 --- /dev/null +++ b/packages/desktop/test/browser/native.ts @@ -0,0 +1,313 @@ +import assert from "node:assert/strict" +import { createServer } from "node:http" +import { once } from "node:events" +import path from "node:path" +import { app, BrowserWindow } from "electron" +import { Browser } from "@opencode-ai/schema/browser" +import { OpenCode } from "@opencode-ai/client" +import { Effect, Fiber, Schema, Stream } from "effect" +import { createBrowserPane } from "../../src/main/browser-pane" +import { bindIpcEvents, ipcEventStream } from "../../src/main/ipc-events" +import { Smoke } from "./contract" + +type Output = Schema.Schema.Type["output"]> + +// Fail the disposable fixture in stderr instead of opening Electron's error dialog. +process.on("uncaughtException", (error) => { + console.error(error) + app.exit(1) +}) +process.on("unhandledRejection", (error) => { + console.error(error) + app.exit(1) +}) + +async function main() { + const root = process.env.SMOKE_ROOT! + app.setPath("userData", path.join(root, "electron-data")) + app.on("window-all-closed", () => {}) + await app.whenReady() + const web = createServer((request, response) => { + if (request.url === "/api/test") { + response.setHeader("content-type", "application/json") + response.end('{"message":"network body"}') + return + } + if (request.url === "/missing") { + response.writeHead(404, { "content-type": "text/plain" }) + response.end("not found") + return + } + if (request.url === "/download") { + response.setHeader("content-disposition", 'attachment; filename="report.txt"') + response.setHeader("content-type", "text/plain") + response.end("desktop download bytes") + return + } + if (request.url === "/frame") { + response.setHeader("content-type", "text/html") + response.end("") + return + } + response.setHeader("content-type", "text/html") + response.end( + `Browser suite

Browser suite

WaitingDownloadPopup
Scroll content
`, + ) + }) + await once(web.listen(0, "127.0.0.1"), "listening") + const address = web.address() + assert(address && typeof address !== "string") + const fixture = `http://127.0.0.1:${address.port}` + const client = OpenCode.make({ + baseUrl: process.env.SMOKE_URL!, + headers: { authorization: `Basic ${Buffer.from(`opencode:${process.env.SMOKE_PASSWORD}`).toString("base64")}` }, + }) + const location = { directory: process.env.SMOKE_SERVER_FILES! } + const rpc = client.rpc(Smoke) + const session = await client.session.create({ title: "Browser suite", location }) + const pane = createBrowserPane() + const win = new BrowserWindow({ show: false, width: 1100, height: 800, webPreferences: { sandbox: true } }) + const readyToShow = once(win, "ready-to-show") + await win.loadURL("about:blank") + await readyToShow + win.setTitle("Browser suite (isolated test)") + win.showInactive() + await win.webContents.executeJavaScript( + "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(null))))", + ) + const ipcErrors: string[] = [] + const inventories = new Map() + const unbind = await Effect.runPromise(bindIpcEvents(win.webContents.id)) + const events = Effect.runFork( + ipcEventStream(win.webContents.id).pipe( + Stream.runForEach((event) => + Effect.sync(() => { + if (event._tag !== "BrowserPaneEvent") return + if (event.event.type === "state") { + inventories.set(event.bindingID, event.event.state) + return + } + if (!inventories.get(event.bindingID)?.tabs.some((tab) => tab.id === event.event.tabID)) + ipcErrors.push("Focus arrived before its tab inventory") + }), + ), + ), + ) + const visited = new Set() + async function call( + name: Name, + input: Omit, "type">, + ): Promise> { + console.log(`BROWSER ${name}`) + const result = await rpc.execute( + { sessionID: session.id, code: `return await tools.browser.${name}(${JSON.stringify(input)})` }, + { location }, + ) + assert(!result.error, result.output) + const operation = Browser.Operations.find((operation) => operation.name === name) + assert(operation) + visited.add(name) + return Schema.decodeUnknownSync(operation.output)(JSON.parse(result.output)) as Output + } + try { + await pane.register(win, "suite", { + sessionID: session.id, + endpoint: { url: process.env.SMOKE_URL!, password: process.env.SMOKE_PASSWORD }, + }) + const first = await call("tabs.open", { url: fixture }) + const second = await call("tabs.open", { url: `${fixture}/other`, focus: false }) + assert.equal((await call("tabs.list", {})).tabs.length, 2) + const tabID = first.id + await call("evaluate", { + tabID, + script: `(async () => { + const source=document.createElement('div'); source.draggable=true; source.tabIndex=0; source.setAttribute('role','button'); source.textContent='Drag source'; source.ondragstart=e=>e.dataTransfer.setData('text/plain','element dropped'); document.body.prepend(source); + const drop=document.createElement('div'); drop.tabIndex=0; drop.setAttribute('role','button'); drop.textContent='Drop target'; drop.style.cssText='height:40px;width:300px;background:#ddd'; drop.ondragover=e=>e.preventDefault(); drop.ondrop=async e=>{e.preventDefault();document.querySelector('output').textContent=e.dataTransfer.files.length?await e.dataTransfer.files[0].text():e.dataTransfer.getData('text/plain')}; document.body.prepend(drop); + await new Promise(resolve=>{const frame=document.querySelector('iframe'); frame.onload=()=>resolve(null); frame.src=${JSON.stringify(`http://localhost:${address.port}/frame`)};}); + })()`, + }) + pane.layout(win, "suite", { tabID, visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } }) + await call("tabs.focus", { tabID: second.id }) + pane.layout(win, "suite", { tabID: second.id, visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } }) + const snap = await call("snapshot", { tabID, boxes: true }) + const ref = (text: string) => { + const match = snap.content + .split("\n") + .find((line) => /@e\d+/.test(line) && line.includes(`"${text}"`)) + ?.match(/@e\d+/)?.[0] + assert(match, `Missing ${text}: ${snap.content}`) + return Browser.Ref.make(match) + } + await call("fill", { tabID, ref: ref("Name"), text: "remote browser" }) + await call("hover", { tabID, ref: ref("Apply") }) + await call("click", { tabID, ref: ref("Apply") }) + assert.equal( + (await call("evaluate", { tabID, script: "document.querySelector('output').textContent" })).value, + "remote browser", + ) + assert.equal( + (await call("evaluate", { tabID: second.id, script: "document.querySelector('input').value" })).value, + "", + ) + assert.equal((await call("tabs.list", {})).focusedTabID, second.id) + const wrongTab = await rpc.execute( + { + sessionID: session.id, + code: `return await tools.browser.click({tabID:${JSON.stringify(second.id)},ref:${JSON.stringify(ref("Apply"))}})`, + }, + { location }, + ) + assert(wrongTab.error && wrongTab.output.includes("another tab")) + await call("check", { tabID, ref: ref("Remember"), checked: true }) + await call("select", { tabID, ref: ref("Color"), values: ["blue"] }) + await call("fill_form", { + tabID, + fields: [ + { type: "text", ref: ref("Name"), value: "batch" }, + { type: "check", ref: ref("Remember"), checked: false }, + ], + }) + await call("press", { tabID, key: "Tab" }) + await call("scroll", { tabID, deltaY: 100 }) + await call("wait", { tabID, condition: "text", text: "Scroll content" }) + await call("drag", { tabID, from: ref("Drag source"), to: ref("Drop target") }) + assert.equal( + (await call("evaluate", { tabID, script: "document.querySelector('output').textContent" })).value, + "element dropped", + ) + const frames = await call("frames", { tabID }) + const child = frames.frames.find((frame) => frame.parentID) + assert(child) + assert.equal( + (await call("evaluate", { tabID, frameID: child.id, script: "document.querySelector('button').textContent" })) + .value, + "Frame button", + ) + const childSnapshot = await call("snapshot", { tabID, frameID: child.id }) + const childRef = childSnapshot.content + .split("\n") + .find((line) => line.includes('[button] "Frame button"')) + ?.match(/@e\d+/)?.[0] + assert(childRef, childSnapshot.content) + await call("click", { tabID, ref: Browser.Ref.make(childRef) }) + assert.equal( + (await call("evaluate", { tabID, frameID: child.id, script: "document.querySelector('button').textContent" })) + .value, + "Frame clicked", + ) + const found = await call("find", { tabID, text: "Apply" }) + assert(found.content.includes("Apply")) + const screenshot = await call("screenshot", { tabID, fullPage: true, maxWidth: 1000 }) + const screenshotBytes = await rpc.read({ path: screenshot.files[0].path }, { location }) + assert( + Buffer.from(screenshotBytes, "base64") + .subarray(0, 8) + .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), + ) + assert(screenshot.files[0].path.startsWith(process.env.SMOKE_SERVER_FILES!)) + const logs = await call("console", { tabID, level: "debug" }) + assert(logs.messages.some((message) => message.text.includes("fixture error"))) + const network = await call("network.list", { tabID, urlContains: "/api/test" }) + assert(network.requests.length) + const detail = await call("network.get", { tabID, id: network.requests[0].id, includeBody: true }) + assert.equal(detail.responseBody.state, "text") + const upload = await rpc.write({ text: "server upload bytes" }, { location }) + const fileSnap = await call("snapshot", { tabID }) + const input = fileSnap.content + .split("\n") + .find((line) => line.includes('"Upload"')) + ?.match(/@e\d+/)?.[0] + assert(input, fileSnap.content) + await call("files.upload", { tabID, ref: Browser.Ref.make(input), paths: [upload] }) + assert.equal( + (await call("evaluate", { tabID, script: "document.querySelector('input[type=file]').files[0].name" })).value, + "upload.txt", + ) + assert.equal( + (await call("evaluate", { tabID, script: "document.querySelector('input[type=file]').files[0].text()" })).value, + "server upload bytes", + ) + const drop = fileSnap.content + .split("\n") + .find((line) => line.includes('[button] "Drop target"')) + ?.match(/@e\d+/)?.[0] + assert(drop, fileSnap.content) + await call("files.drop", { tabID, ref: Browser.Ref.make(drop), paths: [upload] }) + await call("wait", { tabID, condition: "text", text: "server upload bytes" }) + await call("evaluate", { tabID, script: "document.querySelector('a[href=\"/download\"]').click()" }) + const downloads = await until(async () => { + const result = await call("files.list", { tabID }) + return result.files.find((file) => file.name === "report.txt" && file.state === "completed") + }) + const download = await call("files.get", { tabID, fileID: downloads.id }) + assert.equal( + Buffer.from(await rpc.read({ path: download.files[0].path }, { location }), "base64").toString(), + "desktop download bytes", + ) + await call("evaluate", { tabID, script: "setTimeout(()=>alert('hello dialog'),0); null" }) + await until(async () => (await call("dialog", { tabID, action: "get" })).dialog) + await call("dialog", { tabID, action: "dismiss" }) + await call("evaluate", { tabID, script: "window.open('/frame'); null" }) + await until(async () => (await call("tabs.list", {})).tabs.length === 3) + await call("navigate", { tabID, url: `${fixture}/next` }) + await call("back", { tabID }) + await call("forward", { tabID }) + await call("trace.start", { tabID, durationMs: 30_000 }) + await call("reload", { tabID }) + await call("evaluate", { + tabID, + script: "performance.mark('browser-suite-marker'); let n=0; for(let i=0;i<100000;i++) n+=i; n", + }) + const trace = await call("trace.stop", { tabID }) + const traceAnalysis = await call("trace.analyze", { tabID, fileID: trace.files[0].id }) + assert(traceAnalysis.metrics[0].value > 0) + await call("cpu.start", { tabID }) + await call("evaluate", { tabID, script: "Array.from({length:100000},(_,i)=>Math.sqrt(i)).reduce((a,b)=>a+b,0)" }) + const cpu = await call("cpu.stop", { tabID }) + await call("cpu.analyze", { tabID, fileID: cpu.files[0].id }) + const heap = await call("heap.snapshot", { tabID }) + const heapID = heap.files[0].id + assert((await call("heap.summary", { tabID, fileID: heapID })).nodes > 0) + const query = await call("heap.query", { tabID, fileID: heapID, name: "Object", limit: 3 }) + assert(query.nodes.length) + await call("heap.object", { tabID, fileID: heapID, id: query.nodes[0].id }) + assert.equal((await call("heap.compare", { tabID, before: heapID, after: heapID })).classes.length, 0) + const audit = await call("lighthouse", { tabID }) + assert(audit.scores.some((score) => score.id === "accessibility")) + await call("stop", { tabID }) + await call("tabs.close", { tabID: second.id }) + assert.equal((await call("tabs.list", {})).tabs.length, 2) + assert.deepEqual( + Browser.Operations.map((operation) => operation.name).filter((name) => !visited.has(name)), + [], + ) + assert.deepEqual(ipcErrors, []) + console.log( + `PASS ${visited.size} browser operations over physical authenticated HTTP, including file bytes in both directions`, + ) + } finally { + await pane.close(win, "suite") + await pane.dispose() + await Effect.runPromise(Fiber.interrupt(events)) + await Effect.runPromise(unbind) + win.destroy() + web.closeAllConnections() + await new Promise((resolve) => web.close(() => resolve())) + app.quit() + } +} + +async function until(read: () => Promise) { + const deadline = Date.now() + 10_000 + while (true) { + const value = await read() + if (value) return value + if (Date.now() > deadline) throw new Error("Condition timed out") + await new Promise((resolve) => setTimeout(resolve, 50)) + } +} + +main().catch((error) => { + console.error(error) + app.exit(1) +}) diff --git a/packages/desktop/test/browser/plugin/index.ts b/packages/desktop/test/browser/plugin/index.ts new file mode 100644 index 00000000000..c42e8d0c6f5 --- /dev/null +++ b/packages/desktop/test/browser/plugin/index.ts @@ -0,0 +1,55 @@ +import { Plugin } from "@opencode-ai/plugin/effect" +import { Agent } from "@opencode-ai/schema/agent" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Tool } from "@opencode-ai/schema/tool" +import { CodeModeTool } from "../../../../core/src/codemode/tool" +import { execute } from "../../../../core/src/tool/runtime" +import { Effect, Schema } from "effect" +import path from "node:path" +import { readFile, writeFile } from "node:fs/promises" +import { Smoke } from "../contract" + +export default Plugin.define({ + id: "browser.smoke", + effect: (ctx) => + Effect.gen(function* () { + const tools = new Map() + yield* ctx.tool + .transform((editor) => { + editor + .list() + .filter((tool) => tool.options?.namespace?.startsWith("browser")) + .forEach((tool) => tools.set(tool.id, tool)) + }) + .pipe(Effect.orDie) + yield* ctx.rpc + .register(Smoke, { + execute: (input) => + Effect.gen(function* () { + const result = yield* CodeModeTool.create({ tools }, (name, tool, input, context) => + execute(tool, input, context), + ).execute( + { code: input.code }, + { + sessionID: input.sessionID, + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.create(), + id: Tool.CallID.make(crypto.randomUUID()), + progress: () => Effect.void, + }, + ) + return Schema.decodeUnknownSync( + Schema.Struct({ output: Schema.String, error: Schema.optionalKey(Schema.Boolean) }), + )(result.output) + }).pipe(Effect.orDie), + write: ({ text }) => + Effect.promise(async () => { + const file = path.join(process.env.SMOKE_SERVER_FILES!, "upload.txt") + await writeFile(file, text) + return file + }), + read: ({ path }) => Effect.promise(async () => (await readFile(path)).toString("base64")), + }) + .pipe(Effect.orDie) + }), +}) diff --git a/packages/desktop/test/browser/server.ts b/packages/desktop/test/browser/server.ts new file mode 100644 index 00000000000..8224d73c075 --- /dev/null +++ b/packages/desktop/test/browser/server.ts @@ -0,0 +1,31 @@ +import path from "node:path" +import { Effect } from "effect" +import { HttpServer } from "effect/unstable/http" +import { ServerProcess } from "../../../server/src/process" + +const stopped = Promise.withResolvers() +process.on("message", (message) => { + if (message === "stop") stopped.resolve() +}) +await Effect.gen(function* () { + const server = yield* ServerProcess.start({ + hostname: "127.0.0.1", + port: 0, + password: process.env.SMOKE_PASSWORD, + app: { name: "browser-suite-test", version: "test", channel: "test" }, + database: { path: ":memory:" }, + models: { fetch: false }, + fs: { filewatcher: false, fff: false }, + config: { + directory: process.env.OPENCODE_CONFIG_DIR!, + project: false, + content: JSON.stringify({ + plugins: ["-opencode.provider.*", path.join(import.meta.dir, "plugin")], + permissions: [{ action: "*", resource: "*", effect: "allow" }], + }), + }, + }) + process.send?.(HttpServer.formatAddress(server.address)) + yield* Effect.promise(() => stopped.promise) +}).pipe(Effect.scoped, Effect.runPromise) +process.disconnect?.()