From 9ddbc132a6bcbad154d0782721f8598a947e6ddd Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 19 Mar 2026 13:21:51 -0700 Subject: [PATCH 01/34] feat: dev-browser CLI --- .../use-bun-instead-of-node-vite-npm-pnpm.mdc | 111 - .github/workflows/ci.yml | 95 +- .github/workflows/release.yml | 90 + .gitignore | 68 +- README.md | 109 +- RELEASING.md | 87 + bin/dev-browser.js | 129 + bun.lock | 101 - cli/Cargo.lock | 413 + cli/Cargo.toml | 17 + cli/src/connection.rs | 43 + cli/src/daemon.rs | 240 + cli/src/main.rs | 546 + daemon/package.json | 26 + daemon/pnpm-lock.yaml | 1166 + daemon/scripts/bundle-sandbox-client.ts | 20 + daemon/src/browser-api.ts | 39 + daemon/src/browser-manager-pages.test.ts | 126 + daemon/src/browser-manager.ts | 776 + daemon/src/daemon.ts | 445 + daemon/src/protocol.ts | 136 + .../sandbox/__tests__/auto-connect.test.ts | 667 + .../__tests__/forked-client-bundle.test.ts | 59 + .../src/sandbox/__tests__/named-pages.test.ts | 222 + .../sandbox/__tests__/playwright-api.test.ts | 809 + .../sandbox/__tests__/protocol-bridge.test.ts | 59 + .../sandbox/__tests__/quickjs-host.test.ts | 159 + .../sandbox/__tests__/sandbox-file-io.test.ts | 238 + .../__tests__/sandbox-integration.test.ts | 177 + .../__tests__/sandbox-security.test.ts | 193 + daemon/src/sandbox/forked-client/README.md | 375 + .../src/sandbox/forked-client/bundle-entry.ts | 9 + daemon/src/sandbox/forked-client/package.json | 3 + .../sandbox/forked-client/quickjs-platform.ts | 56 + .../forked-client/src/client/android.ts | 28 + .../sandbox/forked-client/src/client/api.ts | 49 + .../forked-client/src/client/artifact.ts | 36 + .../forked-client/src/client/browser.ts | 207 + .../src/client/browserContext.ts | 652 + .../forked-client/src/client/browserType.ts | 148 + .../forked-client/src/client/cdpSession.ts | 59 + .../forked-client/src/client/channelOwner.ts | 244 + .../forked-client/src/client/clientHelper.ts | 55 + .../src/client/clientInstrumentation.ts | 91 + .../src/client/clientStackTrace.ts | 77 + .../sandbox/forked-client/src/client/clock.ts | 72 + .../forked-client/src/client/connect.ts | 165 + .../forked-client/src/client/connection.ts | 354 + .../src/client/consoleMessage.ts | 73 + .../forked-client/src/client/coverage.ts | 43 + .../forked-client/src/client/debugger.ts | 60 + .../forked-client/src/client/dialog.ts | 62 + .../forked-client/src/client/disposable.ts | 77 + .../forked-client/src/client/download.ts | 71 + .../forked-client/src/client/electron.ts | 14 + .../forked-client/src/client/elementHandle.ts | 335 + .../forked-client/src/client/errors.ts | 66 + .../forked-client/src/client/eventEmitter.ts | 399 + .../forked-client/src/client/events.ts | 103 + .../sandbox/forked-client/src/client/fetch.ts | 36 + .../forked-client/src/client/fileChooser.ts | 50 + .../forked-client/src/client/fileUtils.ts | 11 + .../sandbox/forked-client/src/client/frame.ts | 481 + .../forked-client/src/client/harRouter.ts | 20 + .../sandbox/forked-client/src/client/input.ts | 95 + .../forked-client/src/client/jsHandle.ts | 112 + .../forked-client/src/client/jsonPipe.ts | 8 + .../forked-client/src/client/localUtils.ts | 40 + .../forked-client/src/client/locator.ts | 476 + .../forked-client/src/client/network.ts | 953 + .../sandbox/forked-client/src/client/page.ts | 902 + .../forked-client/src/client/platform.ts | 131 + .../forked-client/src/client/playwright.ts | 85 + .../forked-client/src/client/screencast.ts | 12 + .../forked-client/src/client/selectors.ts | 60 + .../forked-client/src/client/stream.ts | 12 + .../src/client/timeoutSettings.ts | 85 + .../forked-client/src/client/tracing.ts | 22 + .../sandbox/forked-client/src/client/types.ts | 146 + .../sandbox/forked-client/src/client/video.ts | 29 + .../forked-client/src/client/waiter.ts | 148 + .../forked-client/src/client/webError.ts | 37 + .../forked-client/src/client/worker.ts | 92 + .../src/client/writableStream.ts | 12 + .../forked-client/src/protocol/channels.d.ts | 175 + .../forked-client/src/protocol/serializers.ts | 210 + .../forked-client/src/protocol/validator.ts | 3005 ++ .../src/protocol/validatorPrimitives.ts | 157 + .../src/utils/isomorphic/ariaSnapshot.ts | 580 + .../src/utils/isomorphic/assert.ts | 21 + .../src/utils/isomorphic/colors.ts | 67 + .../src/utils/isomorphic/cssParser.ts | 268 + .../src/utils/isomorphic/cssTokenizer.ts | 967 + .../src/utils/isomorphic/headers.ts | 45 + .../src/utils/isomorphic/imageUtils.ts | 139 + .../src/utils/isomorphic/locatorGenerators.ts | 734 + .../src/utils/isomorphic/locatorParser.ts | 249 + .../src/utils/isomorphic/locatorUtils.ts | 79 + .../src/utils/isomorphic/lruCache.ts | 50 + .../src/utils/isomorphic/manualPromise.ts | 123 + .../src/utils/isomorphic/mimeType.ts | 451 + .../src/utils/isomorphic/multimap.ts | 83 + .../src/utils/isomorphic/protocolFormatter.ts | 77 + .../src/utils/isomorphic/protocolMetainfo.ts | 335 + .../src/utils/isomorphic/rtti.ts | 30 + .../src/utils/isomorphic/selectorParser.ts | 442 + .../src/utils/isomorphic/semaphore.ts | 51 + .../src/utils/isomorphic/stackTrace.ts | 202 + .../src/utils/isomorphic/stringUtils.ts | 196 + .../src/utils/isomorphic/time.ts | 47 + .../src/utils/isomorphic/timeoutRunner.ts | 62 + .../src/utils/isomorphic/types.ts | 23 + .../src/utils/isomorphic/urlMatch.ts | 272 + .../isomorphic/utilityScriptSerializers.ts | 304 + .../src/utils/isomorphic/yaml.ts | 95 + .../sandbox/forked-client/types/protocol.d.ts | 23824 ++++++++++++++++ .../forked-client/types/recorder-actions.d.ts | 2 + .../sandbox/forked-client/types/structs.d.ts | 45 + .../sandbox/forked-client/types/types.d.ts | 22843 +++++++++++++++ daemon/src/sandbox/host-bridge.ts | 75 + daemon/src/sandbox/playwright-internals.ts | 98 + daemon/src/sandbox/protocol-bridge.ts | 81 + daemon/src/sandbox/quickjs-host.ts | 627 + daemon/src/sandbox/quickjs-sandbox.ts | 728 + daemon/src/sandbox/sandbox-transport.ts | 46 + daemon/src/sandbox/script-runner-quickjs.ts | 31 + daemon/src/script-runner.ts | 79 + daemon/src/temp-files.ts | 189 + daemon/tsconfig.json | 15 + daemon/vitest.config.ts | 8 + extension/__tests__/CDPRouter.test.ts | 211 - extension/__tests__/StateManager.test.ts | 45 - extension/__tests__/TabManager.test.ts | 170 - extension/__tests__/logger.test.ts | 119 - extension/entrypoints/background.ts | 174 - extension/entrypoints/popup/index.html | 23 - extension/entrypoints/popup/main.ts | 52 - extension/entrypoints/popup/style.css | 96 - extension/package-lock.json | 5902 ---- extension/package.json | 21 - extension/public/icons/icon-128.png | Bin 28202 -> 0 bytes extension/public/icons/icon-16.png | Bin 730 -> 0 bytes extension/public/icons/icon-32.png | Bin 2060 -> 0 bytes extension/public/icons/icon-48.png | Bin 4333 -> 0 bytes extension/scripts/generate-icons.mjs | 152 - extension/services/CDPRouter.ts | 211 - extension/services/ConnectionManager.ts | 214 - extension/services/StateManager.ts | 28 - extension/services/TabManager.ts | 218 - extension/tsconfig.json | 3 - extension/utils/logger.ts | 63 - extension/utils/types.ts | 94 - extension/vitest.config.ts | 10 - extension/wxt.config.ts | 16 - install-dev.sh | 88 +- package.json | 47 +- scripts/postinstall.js | 286 + scripts/sync-version.js | 48 + skills/dev-browser/SKILL.md | 206 +- skills/dev-browser/bun.lock | 443 - skills/dev-browser/package-lock.json | 2988 -- skills/dev-browser/package.json | 31 - skills/dev-browser/references/scraping.md | 155 - skills/dev-browser/scripts/start-relay.ts | 32 - skills/dev-browser/scripts/start-server.ts | 117 - skills/dev-browser/server.sh | 24 - skills/dev-browser/src/client.ts | 474 - skills/dev-browser/src/index.ts | 287 - skills/dev-browser/src/relay.ts | 731 - .../src/snapshot/__tests__/snapshot.test.ts | 223 - .../src/snapshot/browser-script.ts | 877 - skills/dev-browser/src/snapshot/index.ts | 14 - skills/dev-browser/src/snapshot/inject.ts | 13 - skills/dev-browser/src/types.ts | 34 - skills/dev-browser/tsconfig.json | 36 - skills/dev-browser/vitest.config.ts | 12 - 176 files changed, 73678 insertions(+), 14944 deletions(-) delete mode 100644 .cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc create mode 100644 .github/workflows/release.yml create mode 100644 RELEASING.md create mode 100755 bin/dev-browser.js delete mode 100644 bun.lock create mode 100644 cli/Cargo.lock create mode 100644 cli/Cargo.toml create mode 100644 cli/src/connection.rs create mode 100644 cli/src/daemon.rs create mode 100644 cli/src/main.rs create mode 100644 daemon/package.json create mode 100644 daemon/pnpm-lock.yaml create mode 100644 daemon/scripts/bundle-sandbox-client.ts create mode 100644 daemon/src/browser-api.ts create mode 100644 daemon/src/browser-manager-pages.test.ts create mode 100644 daemon/src/browser-manager.ts create mode 100644 daemon/src/daemon.ts create mode 100644 daemon/src/protocol.ts create mode 100644 daemon/src/sandbox/__tests__/auto-connect.test.ts create mode 100644 daemon/src/sandbox/__tests__/forked-client-bundle.test.ts create mode 100644 daemon/src/sandbox/__tests__/named-pages.test.ts create mode 100644 daemon/src/sandbox/__tests__/playwright-api.test.ts create mode 100644 daemon/src/sandbox/__tests__/protocol-bridge.test.ts create mode 100644 daemon/src/sandbox/__tests__/quickjs-host.test.ts create mode 100644 daemon/src/sandbox/__tests__/sandbox-file-io.test.ts create mode 100644 daemon/src/sandbox/__tests__/sandbox-integration.test.ts create mode 100644 daemon/src/sandbox/__tests__/sandbox-security.test.ts create mode 100644 daemon/src/sandbox/forked-client/README.md create mode 100644 daemon/src/sandbox/forked-client/bundle-entry.ts create mode 100644 daemon/src/sandbox/forked-client/package.json create mode 100644 daemon/src/sandbox/forked-client/quickjs-platform.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/android.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/api.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/artifact.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/browser.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/browserContext.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/browserType.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/cdpSession.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/channelOwner.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/clientHelper.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/clientInstrumentation.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/clientStackTrace.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/clock.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/connect.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/connection.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/consoleMessage.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/coverage.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/debugger.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/dialog.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/disposable.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/download.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/electron.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/elementHandle.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/errors.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/eventEmitter.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/events.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/fetch.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/fileChooser.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/fileUtils.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/frame.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/harRouter.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/input.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/jsHandle.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/jsonPipe.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/localUtils.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/locator.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/network.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/page.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/platform.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/playwright.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/screencast.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/selectors.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/stream.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/timeoutSettings.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/tracing.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/types.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/video.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/waiter.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/webError.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/worker.ts create mode 100644 daemon/src/sandbox/forked-client/src/client/writableStream.ts create mode 100644 daemon/src/sandbox/forked-client/src/protocol/channels.d.ts create mode 100644 daemon/src/sandbox/forked-client/src/protocol/serializers.ts create mode 100644 daemon/src/sandbox/forked-client/src/protocol/validator.ts create mode 100644 daemon/src/sandbox/forked-client/src/protocol/validatorPrimitives.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/ariaSnapshot.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/assert.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/colors.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/cssParser.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/cssTokenizer.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/headers.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/imageUtils.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorGenerators.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorParser.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorUtils.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/lruCache.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/manualPromise.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/mimeType.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/multimap.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/protocolFormatter.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/protocolMetainfo.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/rtti.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/selectorParser.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/semaphore.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/stackTrace.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/stringUtils.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/time.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/timeoutRunner.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/types.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/urlMatch.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/utilityScriptSerializers.ts create mode 100644 daemon/src/sandbox/forked-client/src/utils/isomorphic/yaml.ts create mode 100644 daemon/src/sandbox/forked-client/types/protocol.d.ts create mode 100644 daemon/src/sandbox/forked-client/types/recorder-actions.d.ts create mode 100644 daemon/src/sandbox/forked-client/types/structs.d.ts create mode 100644 daemon/src/sandbox/forked-client/types/types.d.ts create mode 100644 daemon/src/sandbox/host-bridge.ts create mode 100644 daemon/src/sandbox/playwright-internals.ts create mode 100644 daemon/src/sandbox/protocol-bridge.ts create mode 100644 daemon/src/sandbox/quickjs-host.ts create mode 100644 daemon/src/sandbox/quickjs-sandbox.ts create mode 100644 daemon/src/sandbox/sandbox-transport.ts create mode 100644 daemon/src/sandbox/script-runner-quickjs.ts create mode 100644 daemon/src/script-runner.ts create mode 100644 daemon/src/temp-files.ts create mode 100644 daemon/tsconfig.json create mode 100644 daemon/vitest.config.ts delete mode 100644 extension/__tests__/CDPRouter.test.ts delete mode 100644 extension/__tests__/StateManager.test.ts delete mode 100644 extension/__tests__/TabManager.test.ts delete mode 100644 extension/__tests__/logger.test.ts delete mode 100644 extension/entrypoints/background.ts delete mode 100644 extension/entrypoints/popup/index.html delete mode 100644 extension/entrypoints/popup/main.ts delete mode 100644 extension/entrypoints/popup/style.css delete mode 100644 extension/package-lock.json delete mode 100644 extension/package.json delete mode 100644 extension/public/icons/icon-128.png delete mode 100644 extension/public/icons/icon-16.png delete mode 100644 extension/public/icons/icon-32.png delete mode 100644 extension/public/icons/icon-48.png delete mode 100644 extension/scripts/generate-icons.mjs delete mode 100644 extension/services/CDPRouter.ts delete mode 100644 extension/services/ConnectionManager.ts delete mode 100644 extension/services/StateManager.ts delete mode 100644 extension/services/TabManager.ts delete mode 100644 extension/tsconfig.json delete mode 100644 extension/utils/logger.ts delete mode 100644 extension/utils/types.ts delete mode 100644 extension/vitest.config.ts delete mode 100644 extension/wxt.config.ts create mode 100755 scripts/postinstall.js create mode 100755 scripts/sync-version.js delete mode 100644 skills/dev-browser/bun.lock delete mode 100644 skills/dev-browser/package-lock.json delete mode 100644 skills/dev-browser/package.json delete mode 100644 skills/dev-browser/references/scraping.md delete mode 100644 skills/dev-browser/scripts/start-relay.ts delete mode 100644 skills/dev-browser/scripts/start-server.ts delete mode 100755 skills/dev-browser/server.sh delete mode 100644 skills/dev-browser/src/client.ts delete mode 100644 skills/dev-browser/src/index.ts delete mode 100644 skills/dev-browser/src/relay.ts delete mode 100644 skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts delete mode 100644 skills/dev-browser/src/snapshot/browser-script.ts delete mode 100644 skills/dev-browser/src/snapshot/index.ts delete mode 100644 skills/dev-browser/src/snapshot/inject.ts delete mode 100644 skills/dev-browser/src/types.ts delete mode 100644 skills/dev-browser/tsconfig.json delete mode 100644 skills/dev-browser/vitest.config.ts diff --git a/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc deleted file mode 100644 index b8100b77..00000000 --- a/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +++ /dev/null @@ -1,111 +0,0 @@ ---- -description: Use Bun instead of Node.js, npm, pnpm, or vite. -globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" -alwaysApply: false ---- - -Default to using Bun instead of Node.js. - -- Use `bun ` instead of `node ` or `ts-node ` -- Use `bun test` instead of `jest` or `vitest` -- Use `bun build ` instead of `webpack` or `esbuild` -- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` -- Use `bun run - - -``` - -With the following `frontend.tsx`: - -```tsx#frontend.tsx -import React from "react"; - -// import .css files directly and it works -import './index.css'; - -import { createRoot } from "react-dom/client"; - -const root = createRoot(document.body); - -export default function Frontend() { - return

Hello, world!

; -} - -root.render(); -``` - -Then, run index.ts - -```sh -bun --hot ./index.ts -``` - -For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94621dcf..62f0070a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,65 +11,58 @@ concurrency: cancel-in-progress: true jobs: - typecheck: - name: TypeScript + build-daemon: + name: Build Daemon Bundles runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 with: - node-version: "20" - - - name: Install dependencies - working-directory: skills/dev-browser - run: npm install - - - name: TypeScript check - working-directory: skills/dev-browser - run: npx tsc --noEmit + version: latest + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: cd daemon && pnpm install + - run: cd daemon && pnpm run bundle + - run: cd daemon && pnpm run bundle:sandbox-client + - uses: actions/upload-artifact@v4 + with: + name: daemon-bundles + path: | + daemon/dist/daemon.bundle.mjs + daemon/dist/sandbox-client.js - test: - name: Tests + daemon-tests: + name: Daemon (TypeScript + Tests) + needs: build-daemon runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 with: - node-version: "20" - - - name: Install dependencies - working-directory: skills/dev-browser - run: npm install - - - name: Install Playwright browsers - working-directory: skills/dev-browser - run: npx playwright install --with-deps chromium - - - name: Run tests - working-directory: skills/dev-browser - run: npm test + version: latest + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/download-artifact@v4 + with: + name: daemon-bundles + path: daemon/dist/ + - run: cd daemon && pnpm install + - run: cd daemon && pnpm exec tsc --noEmit + - run: cd daemon && pnpm exec playwright install chromium + - run: cd daemon && pnpm vitest run - format: - name: Formatting + cli: + name: CLI (Rust) + needs: build-daemon runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: - node-version: "20" - cache: "npm" - - - name: Install dependencies - run: npm ci - - - name: Check formatting - run: npm run format:check + name: daemon-bundles + path: daemon/dist/ + - uses: dtolnay/rust-toolchain@stable + - run: cd cli && cargo build + - run: cd cli && cargo check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..171e3904 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,90 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + build-binaries: + strategy: + matrix: + include: + - target: aarch64-apple-darwin + os: macos-latest + binary: dev-browser-darwin-arm64 + - target: x86_64-apple-darwin + os: macos-latest + binary: dev-browser-darwin-x64 + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + binary: dev-browser-linux-x64 + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + binary: dev-browser-linux-arm64 + cross: true + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + binary: dev-browser-linux-musl-x64 + cross: true + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - name: Install cross (if needed) + if: matrix.cross + run: cargo install cross + - name: Build + run: | + if [ "${{ matrix.cross }}" = "true" ]; then + cross build --release --target ${{ matrix.target }} --manifest-path cli/Cargo.toml + else + cargo build --release --target ${{ matrix.target }} --manifest-path cli/Cargo.toml + fi + - name: Rename binary + run: cp cli/target/${{ matrix.target }}/release/dev-browser ${{ matrix.binary }} + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.binary }} + path: ${{ matrix.binary }} + + publish: + needs: build-binaries + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + path: artifacts/ + - uses: pnpm/action-setup@v4 + with: + version: latest + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: 'https://registry.npmjs.org' + - run: cd daemon && pnpm install && pnpm run bundle && pnpm run bundle:sandbox-client + - run: | + mkdir -p dist/bin dist/scripts dist/daemon/dist + cp bin/dev-browser.js dist/bin/ + cp scripts/postinstall.js dist/scripts/ + cp scripts/sync-version.js dist/scripts/ 2>/dev/null || true + cp daemon/dist/daemon.bundle.mjs dist/daemon/dist/ + cp daemon/dist/sandbox-client.js dist/daemon/dist/ + cp package.json dist/ + cp README.md dist/ + cp LICENSE dist/ 2>/dev/null || true + - run: cd dist && npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - uses: softprops/action-gh-release@v2 + with: + files: artifacts/**/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 1fd9cd38..204a7e74 100644 --- a/.gitignore +++ b/.gitignore @@ -1,44 +1,44 @@ -# Dependencies -node_modules/ - -# Build output -dist/ -build/ -# Environment files +_scratch/ +!bin/dev-browser.js +!daemon/dist/daemon.bundle.mjs +!daemon/dist/sandbox-client.js +.dev-browser/ +.DS_Store .env -.env.local .env.*.local - -# IDE +.env.local .idea/ +.output/ .vscode/ -*.swp +.wxt/ +*.log *.swo - -# OS -.DS_Store -Thumbs.db - +*.swp +*.tsbuildinfo +# Browser profiles +# Build output +# Dependencies +# Environment files +# IDE # Logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - +# OS +# Temporary files # Test coverage -coverage/ - # TypeScript cache -*.tsbuildinfo - -# Temporary files -tmp/ -temp/ - -# Browser profiles -profiles/ - # WXT generated files -.wxt/ -.output/ +bin/dev-browser-* +build/ +**/target/ +coverage/ +daemon/dist/* +daemon/node_modules/ +dist/ +node_modules/ +npm-debug.log* +profiles/ +temp/ +Thumbs.db +tmp/ +yarn-debug.log* +yarn-error.log* diff --git a/README.md b/README.md index a5998263..6e4df39f 100644 --- a/README.md +++ b/README.md @@ -2,20 +2,46 @@ Dev Browser - Browser automation for Claude Code

-A browser automation plugin for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) that lets Claude control your browser to test and verify your work as you develop. +A browser automation tool that lets AI agents and developers control browsers with sandboxed JavaScript scripts. **Key features:** +- **Sandboxed execution** - Scripts run in a QuickJS WASM sandbox with no host access - **Persistent pages** - Navigate once, interact across multiple scripts -- **Flexible execution** - Full scripts when possible, step-by-step when exploring -- **LLM-friendly DOM snapshots** - Structured page inspection optimized for AI +- **Auto-connect** - Connect to your running Chrome or launch a fresh Chromium +- **Full Playwright API** - goto, click, fill, locators, evaluate, screenshots, and more +- **Zero startup cost** - Rust CLI binary, Node daemon runs in the background -## Prerequisites +## CLI Installation -- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI installed -- [Node.js](https://nodejs.org) (v18 or later) with npm +```bash +npm install -g dev-browser +dev-browser install # installs Playwright + Chromium +``` + +### Quick start + +```bash +# Launch a headless browser and run a script +dev-browser --headless <<'EOF' +const page = await browser.getPage("main"); +await page.goto("https://example.com"); +console.log(await page.title()); +EOF + +# Connect to your running Chrome (enable at chrome://inspect/#remote-debugging) +dev-browser --connect <<'EOF' +const tabs = await browser.listPages(); +console.log(JSON.stringify(tabs, null, 2)); +EOF +``` -## Installation +### Using with AI agents + +After installing, just tell your agent to run `dev-browser --help` — the help output includes a full LLM usage guide with examples and API reference. No plugin or skill installation needed. + +
+Legacy plugin installation (Claude Code / Amp / Codex) ### Claude Code @@ -40,53 +66,40 @@ cp -r /tmp/dev-browser-skill/skills/dev-browser $SKILLS_DIR/dev-browser rm -rf /tmp/dev-browser-skill ``` -**Amp only:** Start the server manually before use: - -```bash -cd ~/.claude/skills/dev-browser && npm install && npm run start-server -``` +
-### Chrome Extension (Optional) +## Script API -The Chrome extension allows Dev Browser to control your existing Chrome browser instead of launching a separate Chromium instance. This gives you access to your logged-in sessions, bookmarks, and extensions. +Scripts run in a sandboxed QuickJS runtime (not Node.js). Available globals: -**Installation:** +```javascript +// Browser control +browser.getPage(nameOrId) // Get/create named page, or connect to tab by targetId +browser.newPage() // Create anonymous page (cleaned up after script) +browser.listPages() // List all tabs: [{id, url, title, name}] +browser.closePage(name) // Close a named page -1. Download `extension.zip` from the [latest release](https://github.com/sawyerhood/dev-browser/releases/latest) -2. Unzip the file to a permanent location (e.g., `~/.dev-browser-extension`) -3. Open Chrome and go to `chrome://extensions` -4. Enable "Developer mode" (toggle in top right) -5. Click "Load unpacked" and select the unzipped extension folder +// File I/O (restricted to ~/.dev-browser/tmp/) +await saveScreenshot(buf, name) // Save screenshot buffer, returns path +await writeFile(name, data) // Write file, returns path +await readFile(name) // Read file, returns content -**Using the extension:** - -1. Click the Dev Browser extension icon in Chrome's toolbar -2. Toggle it to "Active" - this enables browser control -3. Ask Claude to connect to your browser (e.g., "connect to my Chrome" or "use the extension") - -When active, Claude can control your existing Chrome tabs with all your logged-in sessions, cookies, and extensions intact. - -## Permissions - -To skip permission prompts, add to `~/.claude/settings.json`: - -```json -{ - "permissions": { - "allow": ["Skill(dev-browser:dev-browser)", "Bash(npx tsx:*)"] - } -} +// Output +console.log/warn/error/info // Routed to CLI stdout/stderr ``` -Or run with `claude --dangerously-skip-permissions` (skips all prompts). +Pages are full [Playwright Page objects](https://playwright.dev/docs/api/class-page) — `goto`, `click`, `fill`, `locator`, `evaluate`, `screenshot`, and everything else. -## Usage +## Architecture -Just ask Claude to interact with your browser: - -> "Open localhost:3000 and verify the signup flow works" +``` +Rust CLI → Unix socket → Node.js daemon → QuickJS WASM sandbox → Playwright → Browser +``` -> "Go to the settings page and figure out why the save button isn't working" +- **Rust CLI** — near-instant startup, auto-starts the daemon +- **Node.js daemon** — manages browser instances, persists between runs +- **QuickJS sandbox** — scripts can't access filesystem, network, or host process +- **Playwright** — drives Chromium via CDP ## Benchmarks @@ -99,14 +112,6 @@ Just ask Claude to interact with your browser: _See [dev-browser-eval](https://github.com/SawyerHood/dev-browser-eval) for methodology._ -### How It's Different - -| Approach | How It Works | Tradeoff | -| ---------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------ | -| [Playwright MCP](https://github.com/microsoft/playwright-mcp) | Observe-think-act loop with individual tool calls | Simple but slow; each action is a separate round-trip | -| [Playwright Skill](https://github.com/lackeyjb/playwright-skill) | Full scripts that run end-to-end | Fast but fragile; scripts start fresh every time | -| **Dev Browser** | Stateful server + agentic script execution | Best of both: persistent state with flexible execution | - ## License MIT diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..99ae89ee --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,87 @@ +# Releasing dev-browser + +## First Time Setup + +### 1. npm authentication +```bash +npm login +``` + +### 2. GitHub secrets +Go to **GitHub repo → Settings → Secrets and variables → Actions** and add: +- `NPM_TOKEN` — your npm access token (create at https://www.npmjs.com/settings/tokens) + +## Publishing a New Version + +### 1. Bump the version +```bash +node scripts/sync-version.js 0.2.0 +``` +This updates both `package.json` and `cli/Cargo.toml`. + +### 2. Commit +```bash +git add -A && git commit -m "release: v0.2.0" +``` + +### 3. Tag and push +```bash +git tag v0.2.0 +git push && git push --tags +``` + +The GitHub Actions release workflow triggers automatically and: +1. Cross-compiles the Rust CLI for 5 platforms (macOS ARM64/x64, Linux x64/ARM64/musl) +2. Bundles the daemon and sandbox client +3. Creates a GitHub release with all binaries attached +4. Publishes to npm + +### 4. Verify +```bash +npm info dev-browser version # should show 0.2.0 +npm install -g dev-browser # test the install +dev-browser --help # verify it works +``` + +## Quick Patch Release + +Same flow, just use a patch version: +```bash +node scripts/sync-version.js 0.1.1 +git add -A && git commit -m "release: v0.1.1" +git tag v0.1.1 +git push && git push --tags +``` + +## What the CI Does + +See `.github/workflows/release.yml`. On tag push (`v*`): + +| Step | What happens | +|------|-------------| +| **Build** | Cross-compiles Rust CLI for each platform target | +| **Bundle** | Runs `pnpm run bundle` and `pnpm run bundle:sandbox-client` in `daemon/` | +| **Assemble** | Copies bin wrapper, postinstall, daemon bundles, README, LICENSE into publish dir | +| **Publish npm** | `npm publish` from the assembled directory | +| **GitHub Release** | Creates a release with platform binaries attached | + +## Platform Binaries + +| Platform | Rust Target | Binary Name | +|----------|------------|-------------| +| macOS Apple Silicon | `aarch64-apple-darwin` | `dev-browser-darwin-arm64` | +| macOS Intel | `x86_64-apple-darwin` | `dev-browser-darwin-x64` | +| Linux x64 | `x86_64-unknown-linux-gnu` | `dev-browser-linux-x64` | +| Linux ARM64 | `aarch64-unknown-linux-gnu` | `dev-browser-linux-arm64` | +| Linux x64 (musl) | `x86_64-unknown-linux-musl` | `dev-browser-linux-musl-x64` | + +## How Users Install + +```bash +# Global install (postinstall downloads binary, patches shims for zero Node startup) +npm install -g dev-browser +dev-browser install # installs Playwright + Chromium + +# Or one-off via npx (uses Node wrapper, slightly slower startup) +npx dev-browser --help +``` diff --git a/bin/dev-browser.js b/bin/dev-browser.js new file mode 100755 index 00000000..95760355 --- /dev/null +++ b/bin/dev-browser.js @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +import { execSync, spawn } from 'child_process'; +import { accessSync, chmodSync, constants, existsSync } from 'fs'; +import { arch, platform } from 'os'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function isMusl() { + if (platform() !== 'linux') { + return false; + } + + try { + const report = process.report?.getReport?.(); + if (report?.header?.glibcVersionRuntime) { + return false; + } + } catch { + // Fall through to the ldd probe. + } + + try { + const output = execSync('ldd --version 2>&1', { encoding: 'utf8' }); + return output.toLowerCase().includes('musl'); + } catch { + return existsSync('/lib/ld-musl-x86_64.so.1') || existsSync('/lib/ld-musl-aarch64.so.1'); + } +} + +function getBinaryName() { + const os = platform(); + const cpuArch = arch(); + + let osKey; + switch (os) { + case 'darwin': + osKey = 'darwin'; + break; + case 'linux': + osKey = isMusl() ? 'linux-musl' : 'linux'; + break; + case 'win32': + osKey = 'win32'; + break; + default: + return null; + } + + let archKey; + switch (cpuArch) { + case 'x64': + case 'x86_64': + archKey = 'x64'; + break; + case 'arm64': + case 'aarch64': + archKey = 'arm64'; + break; + default: + return null; + } + + const extension = os === 'win32' ? '.exe' : ''; + return `dev-browser-${osKey}-${archKey}${extension}`; +} + +function ensureExecutable(binaryPath) { + if (platform() === 'win32') { + return; + } + + try { + accessSync(binaryPath, constants.X_OK); + } catch { + chmodSync(binaryPath, 0o755); + } +} + +function main() { + const binaryName = getBinaryName(); + + if (!binaryName) { + console.error(`Error: Unsupported platform: ${platform()}-${arch()}`); + process.exit(1); + } + + const binaryPath = join(__dirname, binaryName); + + if (!existsSync(binaryPath)) { + console.error(`Error: Native binary not found for ${platform()}-${arch()}`); + console.error(`Expected: ${binaryPath}`); + console.error(''); + console.error('The postinstall step downloads this binary from GitHub releases.'); + console.error('Reinstall the package to retry the download, or verify this release includes'); + console.error(`the asset "${binaryName}" for your platform.`); + process.exit(1); + } + + try { + ensureExecutable(binaryPath); + } catch (error) { + console.error(`Error: Cannot make the native binary executable: ${error.message}`); + process.exit(1); + } + + const child = spawn(binaryPath, process.argv.slice(2), { + stdio: 'inherit', + windowsHide: false, + }); + + child.on('error', (error) => { + console.error(`Error executing native binary: ${error.message}`); + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 1); + }); +} + +main(); diff --git a/bun.lock b/bun.lock deleted file mode 100644 index 04dcd799..00000000 --- a/bun.lock +++ /dev/null @@ -1,101 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "browser-skill", - "devDependencies": { - "@types/bun": "latest", - "husky": "^9.1.7", - "lint-staged": "^16.2.7", - "prettier": "^3.7.4", - "typescript": "^5", - }, - }, - }, - "packages": { - "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], - - "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], - - "ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="], - - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - - "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], - - "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], - - "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], - - "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], - - "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], - - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], - - "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - - "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], - - "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], - - "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - - "lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="], - - "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], - - "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], - - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - - "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], - - "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], - - "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - - "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - - "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], - - "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], - - "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], - - "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - - "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], - - "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], - - "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], - - "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - - "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], - - "wrap-ansi/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=="], - } -} diff --git a/cli/Cargo.lock b/cli/Cargo.lock new file mode 100644 index 00000000..0de27c18 --- /dev/null +++ b/cli/Cargo.lock @@ -0,0 +1,413 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "dev-browser" +version = "0.1.0" +dependencies = [ + "clap", + "dirs", + "libc", + "serde", + "serde_json", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 00000000..26685e28 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "dev-browser" +version = "0.1.0" +edition = "2021" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "5" +libc = "0.2" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/cli/src/connection.rs b/cli/src/connection.rs new file mode 100644 index 00000000..93f5b987 --- /dev/null +++ b/cli/src/connection.rs @@ -0,0 +1,43 @@ +use std::io::{self, BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::time::Duration; + +pub fn socket_path() -> io::Result { + dirs::home_dir() + .map(|path| path.join(".dev-browser").join("daemon.sock")) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "Could not determine home directory", + ) + }) +} + +pub fn connect_to_daemon() -> io::Result { + let stream = UnixStream::connect(socket_path()?)?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + Ok(stream) +} + +pub fn send_message(stream: &mut UnixStream, msg: &serde_json::Value) -> io::Result<()> { + let json = serde_json::to_string(msg) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + stream.write_all(json.as_bytes())?; + stream.write_all(b"\n")?; + stream.flush() +} + +pub fn read_line(reader: &mut BufReader) -> io::Result { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line)?; + + if bytes_read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Daemon connection closed unexpectedly", + )); + } + + Ok(line) +} diff --git a/cli/src/daemon.rs b/cli/src/daemon.rs new file mode 100644 index 00000000..c16b6241 --- /dev/null +++ b/cli/src/daemon.rs @@ -0,0 +1,240 @@ +use crate::connection::connect_to_daemon; +use std::env; +use std::error::Error; +use std::ffi::OsStr; +use std::fs; +use std::io; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +const DEV_BROWSER_DIR: &str = ".dev-browser"; +const EMBEDDED_DAEMON: &str = include_str!("../../daemon/dist/daemon.bundle.mjs"); +const EMBEDDED_SANDBOX_CLIENT: &str = include_str!("../../daemon/dist/sandbox-client.js"); +const EMBEDDED_PACKAGE_JSON: &str = r#"{"name":"dev-browser-runtime","private":true,"type":"module","packageManager":"pnpm@10.30.1","dependencies":{"playwright":"^1.52.0","playwright-core":"^1.52.0","quickjs-emscripten":"^0.32.0"}}"#; + +struct DaemonCommand { + program: String, + args: Vec, + current_dir: PathBuf, + requires_runtime_install: bool, +} + +pub fn ensure_daemon() -> Result<(), Box> { + if is_daemon_running() { + return Ok(()); + } + + let command = find_daemon_command()?; + if command.requires_runtime_install && !embedded_playwright_installed(&command.current_dir) { + return Err( + "Embedded daemon dependencies are missing. Run `dev-browser install` first.".into(), + ); + } + + spawn_daemon(&command)?; + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + thread::sleep(Duration::from_millis(100)); + if is_daemon_running() { + return Ok(()); + } + } + + Err("Daemon failed to start within 5 seconds".into()) +} + +pub fn ensure_daemon_extracted() -> Result> { + let base_dir = daemon_base_dir()?; + let daemon_path = base_dir.join("daemon.mjs"); + let package_json_path = base_dir.join("package.json"); + + fs::create_dir_all(&base_dir)?; + let sandbox_client_path = base_dir.join("sandbox-client.js"); + sync_text_file(&daemon_path, EMBEDDED_DAEMON)?; + sync_text_file(&sandbox_client_path, EMBEDDED_SANDBOX_CLIENT)?; + sync_text_file(&package_json_path, EMBEDDED_PACKAGE_JSON)?; + + Ok(daemon_path) +} + +pub fn install_daemon_runtime() -> Result<(), Box> { + let base_dir = daemon_base_dir()?; + ensure_daemon_extracted()?; + run_install_command("pnpm", &["install"], &base_dir)?; + run_install_command( + "pnpm", + &["exec", "playwright", "install", "chromium"], + &base_dir, + )?; + Ok(()) +} + +pub fn is_daemon_running() -> bool { + let Some(pid) = daemon_pid() else { + return false; + }; + + process_is_alive(pid) && connect_to_daemon().is_ok() +} + +fn spawn_daemon(command: &DaemonCommand) -> io::Result<()> { + let mut process = Command::new(&command.program); + process.args(&command.args); + process.current_dir(&command.current_dir); + process.stdin(Stdio::null()); + process.stdout(Stdio::null()); + process.stderr(Stdio::null()); + + unsafe { + process.pre_exec(|| { + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + + let _child = process.spawn()?; + Ok(()) +} + +fn daemon_pid() -> Option { + let pid_path = dirs::home_dir()?.join(".dev-browser").join("daemon.pid"); + let pid = fs::read_to_string(pid_path).ok()?; + pid.trim().parse::().ok() +} + +fn process_is_alive(pid: i32) -> bool { + let result = unsafe { libc::kill(pid, 0) }; + if result == 0 { + return true; + } + + io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +fn find_daemon_command() -> Result> { + if let Some(entry) = env::var_os("DEV_BROWSER_DAEMON") { + return command_from_entry(PathBuf::from(entry)); + } + + let daemon_path = ensure_daemon_extracted()?; + Ok(DaemonCommand { + program: "node".to_string(), + args: vec![daemon_path.to_string_lossy().into_owned()], + current_dir: daemon_base_dir()?, + requires_runtime_install: true, + }) +} + +fn command_from_entry(entry: PathBuf) -> Result> { + let entry = fs::canonicalize(entry)?; + let current_dir = entry + .parent() + .ok_or("Daemon entrypoint has no parent directory")? + .to_path_buf(); + + match entry.extension().and_then(OsStr::to_str) { + Some("js") | Some("mjs") | Some("cjs") => Ok(DaemonCommand { + program: "node".to_string(), + args: vec![entry.to_string_lossy().into_owned()], + current_dir, + requires_runtime_install: false, + }), + Some("ts") | Some("mts") | Some("cts") => { + let tsx_cli = find_tsx_cli(&entry)?; + Ok(DaemonCommand { + program: "node".to_string(), + args: vec![ + tsx_cli.to_string_lossy().into_owned(), + entry.to_string_lossy().into_owned(), + ], + current_dir, + requires_runtime_install: false, + }) + } + _ => Ok(DaemonCommand { + program: entry.to_string_lossy().into_owned(), + args: Vec::new(), + current_dir, + requires_runtime_install: false, + }), + } +} + +fn find_tsx_cli(entry: &Path) -> Result> { + for candidate in entry.ancestors() { + let tsx_cli = candidate + .join("node_modules") + .join("tsx") + .join("dist") + .join("cli.mjs"); + if tsx_cli.is_file() { + return Ok(tsx_cli); + } + } + + Err("Could not locate the tsx runtime required to launch the TypeScript daemon.".into()) +} + +fn daemon_base_dir() -> Result> { + dirs::home_dir() + .map(|path| path.join(DEV_BROWSER_DIR)) + .ok_or_else(|| { + "Could not determine the home directory for the embedded daemon runtime.".into() + }) +} + +fn embedded_playwright_installed(base_dir: &Path) -> bool { + base_dir + .join("node_modules") + .join("playwright") + .join("package.json") + .is_file() +} + +fn sync_text_file(path: &Path, contents: &str) -> Result<(), Box> { + let needs_update = match fs::read_to_string(path) { + Ok(existing) => existing != contents, + Err(error) if error.kind() == io::ErrorKind::NotFound => true, + Err(error) => return Err(error.into()), + }; + + if needs_update { + fs::write(path, contents)?; + } + + Ok(()) +} + +fn run_install_command( + program: &str, + args: &[&str], + current_dir: &Path, +) -> Result<(), Box> { + let status = Command::new(program) + .args(args) + .current_dir(current_dir) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status()?; + + if status.success() { + return Ok(()); + } + + let reason = match status.code() { + Some(code) => format!( + "`{program} {}` failed with exit code {code}", + args.join(" ") + ), + None => format!("`{program} {}` terminated by signal", args.join(" ")), + }; + + Err(reason.into()) +} diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 00000000..ced39fe3 --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,546 @@ +mod connection; +mod daemon; + +use clap::{CommandFactory, Parser, Subcommand}; +use connection::{connect_to_daemon, read_line, send_message}; +use daemon::{ensure_daemon, install_daemon_runtime, is_daemon_running}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::error::Error; +use std::fs; +use std::io::{self, BufReader, Read, Write}; +use std::os::unix::net::UnixStream; +use std::process; +use std::time::{SystemTime, UNIX_EPOCH}; + +const CLI_LONG_ABOUT: &str = r###"Dev Browser is a CLI for controlling local or external browsers with JavaScript scripts. +Scripts run in a sandboxed QuickJS runtime (not Node.js). Top-level `await` is +available, along with a preconnected `browser` global and standard `console` output. +A background daemon starts automatically when needed and manages browser instances, +named pages, and CDP connections. + +SANDBOX ENVIRONMENT: + Scripts execute inside a QuickJS WASM sandbox with no arbitrary access to the host system. + This is NOT Node.js — the following are NOT available: + - require() / import() No module loading + - process No process access + - fs / path / os No direct filesystem access + - fetch / WebSocket No direct network access + - __dirname / __filename No path globals + + Available globals: + browser Pre-connected browser handle (see API below) + console log, warn, error, info (routed to CLI output) + setTimeout / clearTimeout Basic timers + saveScreenshot(buf, name) Save a screenshot buffer (async, must be awaited) + writeFile(name, data) Write a file to temp dir (async, must be awaited) + readFile(name) Read a file from temp dir (async, must be awaited) + + Memory and CPU limits are enforced. Infinite loops will be interrupted. + +Primary invocation styles: + dev-browser <<'EOF' + const page = await browser.getPage("main"); + await page.goto("https://example.com"); + console.log(await page.title()); + EOF + + dev-browser run script.js + dev-browser --browser my-project < script.js + dev-browser --connect http://localhost:9222 <<'EOF' + const page = await browser.getPage("main"); + await page.goto("https://example.com"); + EOF + dev-browser --connect <<'EOF' + const page = await browser.getPage("main"); + console.log(await page.title()); + EOF + +Script API available inside every script: + browser.getPage(nameOrId) Get a page by name (creates if new) or connect to an existing + tab by its targetId from listPages(). + browser.newPage() Create an anonymous page. Anonymous pages are cleaned up after the script exits. + browser.listPages() List all tabs: named pages and existing browser tabs. + Returns [{id, url, title, name}]. + browser.closePage(name) Close and remove a named page. + await saveScreenshot(buf: Buffer, name: string): Promise + Save a screenshot buffer to ~/.dev-browser/tmp/. + Returns the full path to the saved file. + Example: const path = await saveScreenshot(await page.screenshot(), "home.png"); + + await writeFile(name: string, data: string): Promise + Write data to ~/.dev-browser/tmp/. + Returns the full path to the written file. + Example: const path = await writeFile("results.json", JSON.stringify(data)); + + await readFile(name: string): Promise + Read a file from ~/.dev-browser/tmp/. + Returns the file content as a string. + Example: const data = JSON.parse(await readFile("results.json")); + + console.log/info(...) Write output to stdout. + console.warn/error(...) Write output to stderr. + + All file I/O functions are async and must be awaited. + All paths are restricted to ~/.dev-browser/tmp/ — no filesystem escape. + +Pages returned by `browser.getPage()` and `browser.newPage()` are full Playwright +Page objects — you get the same API (goto, click, fill, locator, evaluate, etc.): + https://playwright.dev/docs/api/class-page"###; + +const CLI_AFTER_LONG_HELP: &str = r####"LLM USAGE GUIDE: + Prefer SHORT, focused scripts for exploring pages — one action at a time. + Use LONGER scripts only when automating repetitive or multi-step workflows. + + Short exploration scripts (preferred): + dev-browser --connect <<'EOF' + const tabs = await browser.listPages(); + console.log(JSON.stringify(tabs, null, 2)); + EOF + + dev-browser --connect <<'EOF' + const page = await browser.getPage("TARGET_ID_HERE"); + console.log(await page.title()); + console.log(await page.textContent("body")); + EOF + + Longer automation scripts (when needed): + When using dev-browser to automate a browser, prefer piping a script via heredoc: + + dev-browser <<'EOF' + const page = await browser.getPage("main"); + await page.goto("https://example.com"); + + // Get page content + const title = await page.title(); + const text = await page.textContent("body"); + console.log(JSON.stringify({ title, text })); + + // Interact with elements + await page.fill("#search", "query"); + await page.click("button[type=submit]"); + + // Wait for navigation or results + await page.waitForSelector(".results"); + const results = await page.$$eval(".result", (els) => + els.map((e) => e.textContent) + ); + console.log(JSON.stringify(results)); + EOF + + Common Playwright Page methods: + page.goto(url) Navigate to a URL + page.title() Get the current page title + page.textContent(selector) Get the text content of an element + page.innerHTML(selector) Get the inner HTML of an element + page.fill(selector, value) Fill an input field + page.click(selector) Click an element + page.type(selector, text) Type text character by character + page.press(selector, key) Press a key such as Enter or Tab + page.waitForSelector(selector) Wait for an element to appear + page.waitForURL(url) Wait for navigation to a URL + page.screenshot({ path }) Save a screenshot under ~/.dev-browser/tmp/ + page.$$eval(selector, fn) Run a function on all matching elements + page.$eval(selector, fn) Run a function on the first matching element + page.evaluate(fn) Run JavaScript in the page context + page.locator(selector) Create a locator for chained actions + + Connecting to a running Chrome instance: + Auto-discover Chrome with debugging enabled: + dev-browser --connect <<'EOF' + const page = await browser.getPage("main"); + console.log(await page.title()); + EOF + + Connect to a specific CDP endpoint: + dev-browser --connect http://localhost:9222 <<'EOF' + const page = await browser.getPage("main"); + console.log(await page.title()); + EOF + + To launch Chrome with debugging enabled: + /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222 + + Or visit chrome://inspect/#remote-debugging to configure. + + Tips: + - Use console.log(JSON.stringify(...)) for structured output. + - Named pages from browser.getPage("name") persist between script runs. + - Each --browser name maps to a separate daemon-managed browser instance. + - Use --connect to attach to an existing browser; omit the URL to auto-discover Chrome with debugging enabled. + - Add --headless for unattended automation; omit it when you want to watch the browser window."####; + +#[derive(Parser)] +#[command(name = "dev-browser")] +#[command(about = "Control browsers with JavaScript automation scripts")] +#[command(long_about = CLI_LONG_ABOUT)] +#[command(after_long_help = CLI_AFTER_LONG_HELP)] +struct Cli { + #[arg( + long, + default_value = "default", + value_name = "NAME", + help = "Use a named daemon-managed browser instance", + long_help = "Select the named browser instance to run against.\n\nThe daemon keeps separate browser state for each name. Named pages created with `browser.getPage(\"name\")` persist within that browser between script runs.\n\nDefaults to `default`." + )] + browser: String, + + #[arg( + long, + num_args = 0..=1, + default_missing_value = "auto", + value_name = "URL", + help = "Connect to a running Chrome instance", + long_help = "Connect to a running Chrome instance.\n\nWithout a URL: auto-discovers Chrome with debugging enabled.\nWorks with Chrome's built-in remote debugging\n(chrome://inspect/#remote-debugging) and classic\n--remote-debugging-port mode.\n\nWith a URL: connects to the specified CDP endpoint.\nAccepts HTTP or WebSocket CDP endpoints such as `http://localhost:9222` or `ws://host:9222/devtools/browser/...`.\n\nTo launch Chrome with debugging:\n /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222\n\nOr visit chrome://inspect/#remote-debugging to configure." + )] + connect: Option, + + #[arg( + long, + help = "Launch daemon-managed Chromium without a visible window", + long_help = "Launch or relaunch daemon-managed Chromium in headless mode.\n\nThis only affects daemon-launched browsers. It has no effect when `--connect` attaches to an already-running external browser." + )] + headless: bool, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Command { + #[command( + about = "Run a script file against the browser", + long_about = "Run a script file against the browser.\n\nThe file is executed the same way as stdin input: as top-level JavaScript with `await`, `browser`, and `console` available.\n\nUse top-level flags before `run`, for example `dev-browser --browser my-project run script.js`." + )] + Run { + #[arg( + value_name = "FILE", + help = "Path to a JavaScript file to execute", + long_help = "Path to the JavaScript file to execute.\n\nThis is equivalent to `dev-browser < script.js`, but can be easier to script or combine with shell tooling." + )] + file: String, + }, + #[command( + about = "Install Playwright browsers (Chromium)", + long_about = "Install Playwright browsers (Chromium).\n\nDownloads the Chromium build used for daemon-managed browser instances." + )] + Install, + #[command( + about = "List all managed browser instances", + long_about = "List all managed browser instances.\n\nShows the browser name, whether it is daemon-launched or externally connected, its status, and any named pages currently registered." + )] + Browsers, + #[command( + about = "Show daemon status", + long_about = "Show daemon status.\n\nPrints daemon process details, socket path, uptime, and the current set of managed browsers." + )] + Status, + #[command( + about = "Stop the daemon and all browsers", + long_about = "Stop the daemon and all browsers.\n\nThis stops the background daemon process and closes every browser instance it currently manages." + )] + Stop, +} + +#[derive(Debug, Deserialize)] +struct BrowserSummary { + name: String, + #[serde(rename = "type")] + kind: String, + status: String, + pages: Vec, +} + +#[derive(Debug, Deserialize)] +struct StatusSummary { + pid: i32, + #[serde(rename = "uptimeMs")] + uptime_ms: u64, + #[serde(rename = "browserCount")] + browser_count: usize, + #[serde(rename = "socketPath")] + socket_path: String, + browsers: Vec, +} + +enum ResultMode { + None, + Json, + Browsers, + Status, +} + +fn main() { + let exit_code = match run() { + Ok(code) => code, + Err(error) => { + eprintln!("Error: {error}"); + 1 + } + }; + + process::exit(exit_code); +} + +fn run() -> Result> { + let cli = Cli::parse(); + + match &cli.command { + Some(Command::Run { file }) => { + let script = fs::read_to_string(file)?; + run_script(&cli, script) + } + Some(Command::Browsers) => { + ensure_daemon()?; + send_request( + json!({ + "id": request_id("browsers"), + "type": "browsers", + }), + ResultMode::Browsers, + ) + } + Some(Command::Install) => { + install_daemon_runtime()?; + Ok(0) + } + Some(Command::Status) => { + ensure_daemon()?; + send_request( + json!({ + "id": request_id("status"), + "type": "status", + }), + ResultMode::Status, + ) + } + Some(Command::Stop) => { + if !is_daemon_running() { + println!("Daemon is not running."); + return Ok(0); + } + + let exit_code = send_request( + json!({ + "id": request_id("stop"), + "type": "stop", + }), + ResultMode::None, + )?; + + if exit_code == 0 { + println!("Daemon stopped."); + } + + Ok(exit_code) + } + None => { + if stdin_is_tty() { + let mut command = Cli::command(); + command.print_help()?; + println!(); + return Ok(2); + } + + let script = read_script_from_stdin()?; + run_script(&cli, script) + } + } +} + +fn run_script(cli: &Cli, script: String) -> Result> { + ensure_daemon()?; + + let mut request = json!({ + "id": request_id("execute"), + "type": "execute", + "browser": cli.browser, + "script": script, + }); + + if cli.headless { + request["headless"] = Value::Bool(true); + } + + if let Some(endpoint) = &cli.connect { + request["connect"] = Value::String(endpoint.clone()); + } + + send_request(request, ResultMode::Json) +} + +fn send_request(message: Value, result_mode: ResultMode) -> Result> { + let mut stream = connect_to_daemon()?; + let reader_stream = stream.try_clone()?; + let mut reader = BufReader::new(reader_stream); + + send_message(&mut stream, &message)?; + stream_responses(&mut reader, result_mode) +} + +fn stream_responses( + reader: &mut BufReader, + result_mode: ResultMode, +) -> Result> { + loop { + let line = read_line(reader)?; + let message: Value = serde_json::from_str(line.trim_end())?; + + match message.get("type").and_then(Value::as_str) { + Some("stdout") => { + if let Some(data) = message.get("data").and_then(Value::as_str) { + print!("{data}"); + io::stdout().flush()?; + } + } + Some("stderr") => { + if let Some(data) = message.get("data").and_then(Value::as_str) { + eprint!("{data}"); + io::stderr().flush()?; + } + } + Some("result") => { + if let Some(data) = message.get("data") { + render_result(data, &result_mode)?; + } + } + Some("complete") => return Ok(0), + Some("error") => { + let error_message = message + .get("message") + .and_then(Value::as_str) + .unwrap_or("Unknown daemon error"); + eprintln!("{error_message}"); + return Ok(1); + } + _ => {} + } + } +} + +fn render_result(data: &Value, result_mode: &ResultMode) -> Result<(), Box> { + match result_mode { + ResultMode::None => {} + ResultMode::Json => { + if data.is_null() { + return Ok(()); + } + + if let Some(text) = data.as_str() { + println!("{text}"); + } else { + println!("{}", serde_json::to_string_pretty(data)?); + } + } + ResultMode::Browsers => print_browsers(data)?, + ResultMode::Status => print_status(data)?, + } + + Ok(()) +} + +fn print_browsers(data: &Value) -> Result<(), Box> { + let browsers: Vec = serde_json::from_value(data.clone())?; + if browsers.is_empty() { + println!("No browsers."); + return Ok(()); + } + + let page_values: Vec = browsers + .iter() + .map(|browser| { + if browser.pages.is_empty() { + "-".to_string() + } else { + browser.pages.join(", ") + } + }) + .collect(); + + let name_width = browsers + .iter() + .map(|browser| browser.name.len()) + .max() + .unwrap_or(4) + .max("NAME".len()); + let type_width = browsers + .iter() + .map(|browser| browser.kind.len()) + .max() + .unwrap_or(4) + .max("TYPE".len()); + let status_width = browsers + .iter() + .map(|browser| browser.status.len()) + .max() + .unwrap_or(6) + .max("STATUS".len()); + + println!( + "{: Result<(), Box> { + let status: StatusSummary = serde_json::from_value(data.clone())?; + + println!("PID: {}", status.pid); + println!("Uptime: {}", format_duration_ms(status.uptime_ms)); + println!("Browsers: {}", status.browser_count); + println!("Socket: {}", status.socket_path); + + if !status.browsers.is_empty() { + let managed = status + .browsers + .iter() + .map(|browser| format!("{} ({}, {})", browser.name, browser.kind, browser.status)) + .collect::>() + .join(", "); + println!("Managed: {managed}"); + } + + Ok(()) +} + +fn read_script_from_stdin() -> io::Result { + let mut script = String::new(); + io::stdin().read_to_string(&mut script)?; + Ok(script) +} + +fn stdin_is_tty() -> bool { + unsafe { libc::isatty(libc::STDIN_FILENO) == 1 } +} + +fn request_id(prefix: &str) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + format!("{prefix}-{now}-{}", process::id()) +} + +fn format_duration_ms(duration_ms: u64) -> String { + if duration_ms < 1_000 { + return format!("{duration_ms}ms"); + } + + if duration_ms < 60_000 { + return format!("{:.1}s", duration_ms as f64 / 1_000.0); + } + + let total_seconds = duration_ms / 1_000; + let minutes = total_seconds / 60; + let seconds = total_seconds % 60; + format!("{minutes}m {seconds}s") +} diff --git a/daemon/package.json b/daemon/package.json new file mode 100644 index 00000000..896118e3 --- /dev/null +++ b/daemon/package.json @@ -0,0 +1,26 @@ +{ + "name": "dev-browser-daemon", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "pnpm@10.30.1", + "scripts": { + "build": "tsc", + "bundle": "esbuild src/daemon.ts --bundle --platform=node --format=esm --outfile=dist/daemon.bundle.mjs --external:playwright --external:quickjs-emscripten --external:@jitl/*", + "bundle:sandbox-client": "tsx scripts/bundle-sandbox-client.ts", + "dev": "tsx src/daemon.ts" + }, + "dependencies": { + "playwright": "^1.52.0", + "playwright-core": "^1.52.0", + "quickjs-emscripten": "^0.32.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "esbuild": "^0.27.4", + "tsx": "^4.20.0", + "typescript": "^5.8.0", + "vitest": "^4.1.0" + } +} diff --git a/daemon/pnpm-lock.yaml b/daemon/pnpm-lock.yaml new file mode 100644 index 00000000..89179c36 --- /dev/null +++ b/daemon/pnpm-lock.yaml @@ -0,0 +1,1166 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + playwright: + specifier: ^1.52.0 + version: 1.58.2 + playwright-core: + specifier: ^1.52.0 + version: 1.58.2 + quickjs-emscripten: + specifier: ^0.32.0 + version: 0.32.0 + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.12.0 + esbuild: + specifier: ^0.27.4 + version: 0.27.4 + tsx: + specifier: ^4.20.0 + version: 4.21.0 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.0(@types/node@24.12.0)(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(tsx@4.21.0)) + +packages: + + '@emnapi/core@1.9.0': + resolution: {integrity: sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==} + + '@emnapi/runtime@1.9.0': + resolution: {integrity: sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jitl/quickjs-ffi-types@0.32.0': + resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + resolution: {integrity: sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==} + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + resolution: {integrity: sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==} + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + resolution: {integrity: sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==} + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + resolution: {integrity: sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + + '@oxc-project/types@0.120.0': + resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==} + + '@rolldown/binding-android-arm64@1.0.0-rc.10': + resolution: {integrity: sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.10': + resolution: {integrity: sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.10': + resolution: {integrity: sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.10': + resolution: {integrity: sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10': + resolution: {integrity: sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.10': + resolution: {integrity: sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.10': + resolution: {integrity: sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.10': + resolution: {integrity: sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.10': + resolution: {integrity: sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10': + resolution: {integrity: sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.10': + resolution: {integrity: sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.10': + resolution: {integrity: sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + + '@vitest/expect@4.1.0': + resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} + + '@vitest/mocker@4.1.0': + resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.0': + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} + + '@vitest/runner@4.1.0': + resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} + + '@vitest/snapshot@4.1.0': + resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} + + '@vitest/spy@4.1.0': + resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} + + '@vitest/utils@4.1.0': + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + quickjs-emscripten-core@0.32.0: + resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==} + + quickjs-emscripten@0.32.0: + resolution: {integrity: sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==} + engines: {node: '>=16.0.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown@1.0.0-rc.10: + resolution: {integrity: sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + vite@8.0.1: + resolution: {integrity: sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.0: + resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.0 + '@vitest/browser-preview': 4.1.0 + '@vitest/browser-webdriverio': 4.1.0 + '@vitest/ui': 4.1.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@emnapi/core@1.9.0': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@jitl/quickjs-ffi-types@0.32.0': {} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.9.0 + '@emnapi/runtime': 1.9.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.120.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.10': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.10': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.10': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.10': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + + '@vitest/expect@4.1.0': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.0(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.1.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(tsx@4.21.0) + + '@vitest/pretty-format@4.1.0': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.0': + dependencies: + '@vitest/utils': 4.1.0 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.0': + dependencies: + '@vitest/pretty-format': 4.1.0 + '@vitest/utils': 4.1.0 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.0': {} + + '@vitest/utils@4.1.0': + dependencies: + '@vitest/pretty-format': 4.1.0 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.0.0: {} + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.11: {} + + obug@2.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + quickjs-emscripten-core@0.32.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + quickjs-emscripten@0.32.0: + dependencies: + '@jitl/quickjs-wasmfile-debug-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-debug-sync': 0.32.0 + '@jitl/quickjs-wasmfile-release-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-release-sync': 0.32.0 + quickjs-emscripten-core: 0.32.0 + + resolve-pkg-maps@1.0.0: {} + + rolldown@1.0.0-rc.10: + dependencies: + '@oxc-project/types': 0.120.0 + '@rolldown/pluginutils': 1.0.0-rc.10 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.10 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.10 + '@rolldown/binding-darwin-x64': 1.0.0-rc.10 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.10 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.10 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.10 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.10 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.10 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.10 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.10 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.10 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.0.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.0.4: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(tsx@4.21.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.3 + postcss: 8.5.8 + rolldown: 1.0.0-rc.10 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.12.0 + esbuild: 0.27.4 + fsevents: 2.3.3 + tsx: 4.21.0 + + vitest@4.1.0(@types/node@24.12.0)(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(tsx@4.21.0)): + dependencies: + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.0 + '@vitest/runner': 4.1.0 + '@vitest/snapshot': 4.1.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + zod@3.25.76: {} diff --git a/daemon/scripts/bundle-sandbox-client.ts b/daemon/scripts/bundle-sandbox-client.ts new file mode 100644 index 00000000..30a0c48a --- /dev/null +++ b/daemon/scripts/bundle-sandbox-client.ts @@ -0,0 +1,20 @@ +import { mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +import { build } from "esbuild"; + +const daemonDir = resolve(import.meta.dirname, ".."); +const entryPoint = resolve(daemonDir, "src/sandbox/forked-client/bundle-entry.ts"); +const outfile = resolve(daemonDir, "dist/sandbox-client.js"); + +await mkdir(dirname(outfile), { recursive: true }); + +await build({ + entryPoints: [entryPoint], + bundle: true, + format: "iife", + globalName: "__PlaywrightClient", + outfile, + platform: "neutral", + target: "es2022", +}); diff --git a/daemon/src/browser-api.ts b/daemon/src/browser-api.ts new file mode 100644 index 00000000..267e1b54 --- /dev/null +++ b/daemon/src/browser-api.ts @@ -0,0 +1,39 @@ +import type { Page } from "playwright"; +import type { BrowserManager, BrowserPageSummary } from "./browser-manager.js"; + +export interface CleanupTracker { + anonymousPages: Page[]; +} + +export interface ScriptBrowserAPI { + getPage(name: string): Promise; + newPage(): Promise; + listPages(): Promise; + closePage(name: string): Promise; +} + +export function createBrowserAPI( + manager: BrowserManager, + browserName: string, + cleanupTracker: CleanupTracker +): ScriptBrowserAPI { + return { + getPage(name: string): Promise { + return manager.getPage(browserName, name); + }, + + async newPage(): Promise { + const page = await manager.newPage(browserName); + cleanupTracker.anonymousPages.push(page); + return page; + }, + + async listPages(): Promise { + return manager.listPages(browserName); + }, + + closePage(name: string): Promise { + return manager.closePage(browserName, name); + }, + }; +} diff --git a/daemon/src/browser-manager-pages.test.ts b/daemon/src/browser-manager-pages.test.ts new file mode 100644 index 00000000..8af66beb --- /dev/null +++ b/daemon/src/browser-manager-pages.test.ts @@ -0,0 +1,126 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { BrowserManager } from "./browser-manager.js"; + +const browserName = "browser-manager-pages"; + +function createDataUrl(title: string, body: string): string { + return `data:text/html,${encodeURIComponent(`${title}${body}`)}`; +} + +describe.sequential("BrowserManager page discovery", () => { + let browserRootDir = ""; + let manager: BrowserManager; + + beforeAll(async () => { + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-manager-pages-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + }, 180_000); + + afterEach(async () => { + await manager.stopBrowser(browserName); + }, 180_000); + + afterAll(async () => { + await manager.stopAll(); + await rm(browserRootDir, { + recursive: true, + force: true, + }); + }, 180_000); + + async function ensureBrowser(): Promise { + await manager.ensureBrowser(browserName, { + headless: true, + }); + } + + it("listPages returns objects with id, url, title, and name fields", async () => { + await ensureBrowser(); + + const anonymousPage = await manager.newPage(browserName); + await anonymousPage.goto(createDataUrl("Anonymous Tab", "

anonymous

")); + + const pages = await manager.listPages(browserName); + const anonymousSummary = pages.find( + (page) => page.name === null && page.title === "Anonymous Tab", + ); + + expect(anonymousSummary).toBeDefined(); + expect(anonymousSummary).toEqual( + expect.objectContaining({ + id: expect.stringMatching(/^[a-f0-9]+$/i), + name: null, + title: "Anonymous Tab", + url: expect.stringContaining("data:text/html"), + }), + ); + + for (const page of pages) { + expect(typeof page.id).toBe("string"); + expect(typeof page.url).toBe("string"); + expect(typeof page.title).toBe("string"); + expect(page.name === null || typeof page.name === "string").toBe(true); + } + }, 120_000); + + it("listPages includes pages created via getPage with their name", async () => { + await ensureBrowser(); + + const namedPage = await manager.getPage(browserName, "dashboard"); + await namedPage.goto(createDataUrl("Dashboard", "
named page
")); + + await expect(manager.listPages(browserName)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.stringMatching(/^[a-f0-9]+$/i), + name: "dashboard", + title: "Dashboard", + url: expect.stringContaining("data:text/html"), + }), + ]), + ); + }, 120_000); + + it("getPage accepts a targetId for an existing tab", async () => { + await ensureBrowser(); + + const existingPage = await manager.newPage(browserName); + await existingPage.goto(createDataUrl("Target Tab", "

existing tab

")); + + const targetSummary = (await manager.listPages(browserName)).find( + (page) => page.name === null && page.title === "Target Tab", + ); + + expect(targetSummary).toBeDefined(); + + const connectedPage = await manager.getPage(browserName, targetSummary!.id); + + expect(connectedPage).toBe(existingPage); + expect( + (await manager.listPages(browserName)).filter((page) => page.title === "Target Tab"), + ).toHaveLength(1); + }, 120_000); + + it("getPage with a name still returns the existing named page", async () => { + await ensureBrowser(); + + const firstPage = await manager.getPage(browserName, "persist"); + await firstPage.goto(createDataUrl("Persist", "
same page
")); + await firstPage.evaluate(() => { + window.name = "persisted-state"; + }); + + const secondPage = await manager.getPage(browserName, "persist"); + + expect(secondPage).toBe(firstPage); + await expect(secondPage.evaluate(() => window.name)).resolves.toBe("persisted-state"); + expect( + (await manager.listPages(browserName)).filter((page) => page.name === "persist"), + ).toHaveLength(1); + }, 120_000); +}); diff --git a/daemon/src/browser-manager.ts b/daemon/src/browser-manager.ts new file mode 100644 index 00000000..95d5743e --- /dev/null +++ b/daemon/src/browser-manager.ts @@ -0,0 +1,776 @@ +import { mkdir, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; + +export interface BrowserEntry { + name: string; + type: "launched" | "connected"; + browser: Browser; + context: BrowserContext; + pages: Map; + profileDir?: string; + endpoint?: string; + headless: boolean; +} + +export interface BrowserSummary { + name: string; + type: BrowserEntry["type"]; + status: "running" | "connected" | "disconnected"; + pages: string[]; +} + +export interface BrowserPageSummary { + id: string; + url: string; + title: string; + name: string | null; +} + +type BrowserManagerDependencies = { + connectOverCDP: typeof chromium.connectOverCDP; + fetch: typeof globalThis.fetch; + homedir: () => string; + launchPersistentContext: typeof chromium.launchPersistentContext; + mkdir: typeof mkdir; + platform: NodeJS.Platform; + readFile: typeof readFile; +}; + +type DebuggerWebSocketLookupResult = + | { + status: "ok"; + webSocketDebuggerUrl: string; + } + | { + status: "not-found" | "unavailable"; + }; + +const DISCOVERY_PORTS = [9222, 9223, 9224, 9225, 9226, 9227, 9228, 9229]; +const PROBE_TIMEOUT_MS = 750; +const MANUAL_CONNECT_TIMEOUT_MS = 5_000; +const TARGET_ID_PATTERN = /^[a-f0-9]{16,}$/i; + +function isIgnorableFileError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === "ENOENT" || code === "ENOTDIR" || code === "EACCES"; +} + +function isHttpEndpoint(endpoint: string): boolean { + return endpoint.startsWith("http://") || endpoint.startsWith("https://"); +} + +export class BrowserManager { + private readonly browsers = new Map(); + private readonly baseDir: string; + private readonly dependencies: BrowserManagerDependencies; + + constructor( + baseDir = path.join(os.homedir(), ".dev-browser", "browsers"), + dependencies: Partial = {}, + ) { + this.baseDir = baseDir; + this.dependencies = { + connectOverCDP: chromium.connectOverCDP.bind(chromium) as typeof chromium.connectOverCDP, + fetch: globalThis.fetch, + homedir: os.homedir, + launchPersistentContext: + chromium.launchPersistentContext.bind(chromium) as typeof chromium.launchPersistentContext, + mkdir, + platform: process.platform, + readFile, + ...dependencies, + }; + } + + async ensureBrowser( + name: string, + options: { + headless?: boolean; + } = {} + ): Promise { + await this.ensureBaseDir(); + const requestedHeadless = options.headless ?? false; + + const existing = this.browsers.get(name); + if (existing) { + const needsRelaunch = + existing.type !== "launched" || + !existing.browser.isConnected() || + (options.headless !== undefined && existing.headless !== requestedHeadless); + + if (!needsRelaunch) { + return existing; + } + + await this.stopBrowser(name); + } + + return this.launchBrowser(name, requestedHeadless); + } + + async autoConnect(name: string): Promise { + await this.ensureBaseDir(); + + const existing = this.browsers.get(name); + if (existing?.type === "connected" && existing.browser.isConnected()) { + return existing; + } + + if (existing) { + await this.stopBrowser(name); + } + + const attemptedEndpoints = new Set(); + let lastError: unknown; + + const tryEndpoint = async (endpoint: string | null): Promise => { + if (!endpoint || attemptedEndpoints.has(endpoint)) { + return null; + } + + attemptedEndpoints.add(endpoint); + + try { + return await this.openConnectedBrowser(name, endpoint); + } catch (error) { + lastError = error; + return null; + } + }; + + const devToolsEndpoint = await this.readDevToolsActivePort(); + const devToolsBrowser = await tryEndpoint(devToolsEndpoint); + if (devToolsBrowser) { + return devToolsBrowser; + } + + for (const port of DISCOVERY_PORTS) { + const endpoint = await this.probePort(port); + const connectedBrowser = await tryEndpoint(endpoint); + if (connectedBrowser) { + return connectedBrowser; + } + } + + throw new Error(this.buildAutoConnectError(lastError)); + } + + async connectBrowser(name: string, endpoint: string): Promise { + if (endpoint === "auto") { + return this.autoConnect(name); + } + + await this.ensureBaseDir(); + const resolvedEndpoint = await this.resolveEndpoint(endpoint); + + const existing = this.browsers.get(name); + if (existing) { + const isSameConnection = + existing.type === "connected" && + existing.endpoint === resolvedEndpoint && + existing.browser.isConnected(); + + if (isSameConnection) { + return existing; + } + + await this.stopBrowser(name); + } + + return this.openConnectedBrowser(name, resolvedEndpoint); + } + + getBrowser(name: string): BrowserEntry | undefined { + const entry = this.browsers.get(name); + if (!entry || !entry.browser.isConnected()) { + return undefined; + } + + return entry; + } + + async getPage(browserName: string, pageNameOrId: string): Promise { + const entry = this.getBrowserEntry(browserName); + const existingPage = entry.pages.get(pageNameOrId); + + if (existingPage && !existingPage.isClosed()) { + return existingPage; + } + + entry.pages.delete(pageNameOrId); + + if (TARGET_ID_PATTERN.test(pageNameOrId)) { + const page = await this.findPageByTargetId(entry, pageNameOrId); + if (page) { + return page; + } + } + + const page = await entry.context.newPage(); + this.registerNamedPage(entry, pageNameOrId, page); + return page; + } + + async newPage(browserName: string): Promise { + const entry = this.getBrowserEntry(browserName); + return entry.context.newPage(); + } + + async listPages(browserName: string): Promise { + const entry = this.browsers.get(browserName); + if (!entry || !entry.browser.isConnected()) { + return []; + } + + this.pruneClosedPages(entry); + const namesByPage = this.getNamedPagesByPage(entry); + const summaries: BrowserPageSummary[] = []; + + for (const { context, page } of this.getContextPages(entry)) { + const id = await this.getPageTargetId(context, page); + if (!id) { + continue; + } + + let title = ""; + try { + title = await page.title(); + } catch (error) { + if (page.isClosed()) { + continue; + } + + throw error; + } + + summaries.push({ + id, + url: page.url(), + title, + name: namesByPage.get(page) ?? null, + }); + } + + return summaries; + } + + async closePage(browserName: string, pageName: string): Promise { + const entry = this.getBrowserEntry(browserName); + const page = entry.pages.get(pageName); + + if (!page || page.isClosed()) { + entry.pages.delete(pageName); + throw new Error(`Page "${browserName}/${pageName}" not found`); + } + + entry.pages.delete(pageName); + + if (!page.isClosed()) { + await page.close(); + } + } + + listBrowsers(): BrowserSummary[] { + return Array.from(this.browsers.values()) + .map((entry) => { + this.pruneClosedPages(entry); + + const status: BrowserSummary["status"] = + entry.type === "connected" + ? entry.browser.isConnected() + ? "connected" + : "disconnected" + : entry.browser.isConnected() + ? "running" + : "disconnected"; + + return { + name: entry.name, + type: entry.type, + status, + pages: this.listNamedPages(entry), + }; + }) + .sort((left, right) => left.name.localeCompare(right.name)); + } + + async stopBrowser(name: string): Promise { + const entry = this.browsers.get(name); + if (!entry) { + return; + } + + this.browsers.delete(name); + + try { + if (entry.type === "launched") { + await entry.context.close(); + } else { + await entry.browser.close(); + } + } catch { + // Best effort during shutdown or reconnect. + } + } + + async stopAll(): Promise { + const names = Array.from(this.browsers.keys()); + for (const name of names) { + await this.stopBrowser(name); + } + } + + browserCount(): number { + return this.browsers.size; + } + + private async ensureBaseDir(): Promise { + await this.dependencies.mkdir(this.baseDir, { recursive: true }); + } + + private getBrowserEntry(name: string): BrowserEntry { + const entry = this.browsers.get(name); + if (!entry || !entry.browser.isConnected()) { + throw new Error(`Browser "${name}" is not running`); + } + + return entry; + } + + private async launchBrowser(name: string, headless: boolean): Promise { + const profileDir = path.join(this.baseDir, name, "chromium-profile"); + await this.dependencies.mkdir(profileDir, { recursive: true }); + + const context = await this.dependencies.launchPersistentContext(profileDir, { + headless, + handleSIGINT: false, + handleSIGTERM: false, + handleSIGHUP: false, + }); + const browser = context.browser(); + + if (!browser) { + await context.close(); + throw new Error(`Playwright did not expose a browser handle for "${name}"`); + } + + const entry: BrowserEntry = { + name, + type: "launched", + browser, + context, + pages: new Map(), + profileDir, + headless, + }; + + this.attachBrowserLifecycle(entry); + this.browsers.set(name, entry); + return entry; + } + + private async openConnectedBrowser(name: string, endpoint: string): Promise { + const browser = await this.dependencies.connectOverCDP(endpoint); + const contexts = browser.contexts(); + + // Enumerate existing tabs for connected browsers, but leave them unnamed so getPage(name) + // still opens a fresh tab unless a targetId is provided. + for (const browserContext of contexts) { + browserContext.pages(); + } + + const context = contexts[0] ?? (await browser.newContext()); + + const entry: BrowserEntry = { + name, + type: "connected", + browser, + context, + pages: new Map(), + endpoint, + headless: false, + }; + + this.attachBrowserLifecycle(entry); + this.browsers.set(name, entry); + return entry; + } + + private attachBrowserLifecycle(entry: BrowserEntry): void { + entry.browser.on("disconnected", () => { + const current = this.browsers.get(entry.name); + if (current !== entry) { + return; + } + + entry.pages.clear(); + + if (entry.type === "launched") { + this.browsers.delete(entry.name); + } + }); + } + + private async discoverChrome(): Promise { + const devToolsEndpoint = await this.readDevToolsActivePort(); + if (devToolsEndpoint) { + return devToolsEndpoint; + } + + for (const port of DISCOVERY_PORTS) { + const endpoint = await this.probePort(port); + if (endpoint) { + return endpoint; + } + } + + return null; + } + + private async readDevToolsActivePort(expectedPort?: number): Promise { + for (const candidate of this.getDevToolsActivePortCandidates()) { + let contents: string; + + try { + contents = await this.dependencies.readFile(candidate, "utf8"); + } catch (error) { + if (isIgnorableFileError(error)) { + continue; + } + + throw error; + } + + const endpoint = this.parseDevToolsActivePort(contents, expectedPort); + if (endpoint) { + return endpoint; + } + } + + return null; + } + + private async probePort(port: number): Promise { + const endpoint = `http://127.0.0.1:${port}`; + const result = await this.fetchDebuggerWebSocketUrl(endpoint, PROBE_TIMEOUT_MS); + + if (result.status === "ok") { + return result.webSocketDebuggerUrl; + } + + if (result.status === "not-found") { + return this.readDevToolsActivePort(port); + } + + return null; + } + + private getDevToolsActivePortCandidates(): string[] { + const homeDir = this.dependencies.homedir(); + + switch (this.dependencies.platform) { + case "darwin": + return [ + path.join(homeDir, "Library", "Application Support", "Google", "Chrome", "DevToolsActivePort"), + path.join( + homeDir, + "Library", + "Application Support", + "Google", + "Chrome Canary", + "DevToolsActivePort", + ), + path.join(homeDir, "Library", "Application Support", "Chromium", "DevToolsActivePort"), + path.join( + homeDir, + "Library", + "Application Support", + "BraveSoftware", + "Brave-Browser", + "DevToolsActivePort", + ), + ]; + case "linux": + return [ + path.join(homeDir, ".config", "google-chrome", "DevToolsActivePort"), + path.join(homeDir, ".config", "chromium", "DevToolsActivePort"), + path.join(homeDir, ".config", "google-chrome-beta", "DevToolsActivePort"), + path.join(homeDir, ".config", "google-chrome-unstable", "DevToolsActivePort"), + path.join(homeDir, ".config", "BraveSoftware", "Brave-Browser", "DevToolsActivePort"), + ]; + default: + return []; + } + } + + private async resolveEndpoint(endpoint: string): Promise { + if (endpoint === "auto") { + const discoveredEndpoint = await this.discoverChrome(); + if (discoveredEndpoint) { + return discoveredEndpoint; + } + + throw new Error(this.buildAutoConnectError()); + } + + if (isHttpEndpoint(endpoint)) { + const discoveredEndpoint = await this.resolveHttpEndpoint(endpoint, MANUAL_CONNECT_TIMEOUT_MS); + + if (!discoveredEndpoint) { + throw new Error(this.buildManualConnectError(endpoint)); + } + + return discoveredEndpoint; + } + + return endpoint; + } + + private async fetchDebuggerWebSocketUrl( + endpoint: string, + timeoutMs: number, + ): Promise { + let response: Response; + + try { + response = await this.dependencies.fetch(this.toJsonVersionUrl(endpoint), { + headers: { + accept: "application/json", + }, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch { + return { status: "unavailable" }; + } + + if (response.status === 404) { + return { status: "not-found" }; + } + + if (!response.ok) { + return { status: "unavailable" }; + } + + let payload: unknown; + + try { + payload = await response.json(); + } catch { + return { status: "unavailable" }; + } + + const webSocketDebuggerUrl = + typeof payload === "object" && payload !== null + ? (payload as { webSocketDebuggerUrl?: unknown }).webSocketDebuggerUrl + : undefined; + + return typeof webSocketDebuggerUrl === "string" && webSocketDebuggerUrl.length > 0 + ? { + status: "ok", + webSocketDebuggerUrl, + } + : { status: "unavailable" }; + } + + private toJsonVersionUrl(endpoint: string): URL { + const url = new URL(endpoint); + if (url.pathname !== "/json/version") { + url.pathname = "/json/version"; + url.search = ""; + url.hash = ""; + } + + return url; + } + + private buildAutoConnectError(lastError?: unknown): string { + const launchCommand = + this.dependencies.platform === "darwin" + ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222" + : "google-chrome --remote-debugging-port=9222"; + + const details = [ + "Could not auto-discover a running Chrome instance with remote debugging enabled.", + "Enable Chrome remote debugging at chrome://inspect/#remote-debugging", + `or launch Chrome with: ${launchCommand}`, + ]; + const lastErrorMessage = + lastError instanceof Error + ? lastError.message + : typeof lastError === "string" && lastError.length > 0 + ? lastError + : null; + + if (lastErrorMessage) { + details.push(`Last connection error: ${lastErrorMessage}`); + } + + return details.join("\n"); + } + + private async resolveHttpEndpoint(endpoint: string, timeoutMs: number): Promise { + const result = await this.fetchDebuggerWebSocketUrl(endpoint, timeoutMs); + if (result.status === "ok") { + return result.webSocketDebuggerUrl; + } + + if (result.status === "not-found") { + const port = this.getEndpointPort(endpoint); + if (port !== null) { + return this.readDevToolsActivePort(port); + } + } + + return null; + } + + private parseDevToolsActivePort(contents: string, expectedPort?: number): string | null { + const lines = contents + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const port = Number.parseInt(lines[0] ?? "", 10); + const webSocketPath = lines[1] ?? ""; + + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + return null; + } + + if (expectedPort !== undefined && port !== expectedPort) { + return null; + } + + if (!webSocketPath.startsWith("/devtools/browser/")) { + return null; + } + + return `ws://127.0.0.1:${port}${webSocketPath}`; + } + + private getEndpointPort(endpoint: string): number | null { + let url: URL; + + try { + url = new URL(endpoint); + } catch { + return null; + } + + const rawPort = + url.port || (url.protocol === "https:" ? "443" : url.protocol === "http:" ? "80" : ""); + const port = Number.parseInt(rawPort, 10); + + return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null; + } + + private buildManualConnectError(endpoint: string): string { + return [ + `Could not resolve a CDP WebSocket endpoint from ${endpoint}.`, + "If Chrome is using built-in remote debugging, run `dev-browser --connect` without a URL so DevToolsActivePort can be auto-discovered.", + "Or connect with the exact ws://127.0.0.1:/devtools/browser/... URL from DevToolsActivePort, or launch Chrome with --remote-debugging-port=9222.", + ].join("\n"); + } + + private registerNamedPage(entry: BrowserEntry, pageName: string, page: Page): void { + entry.pages.set(pageName, page); + + page.on("close", () => { + const current = entry.pages.get(pageName); + if (current === page) { + entry.pages.delete(pageName); + } + }); + } + + private pruneClosedPages(entry: BrowserEntry): void { + for (const [pageName, page] of entry.pages.entries()) { + if (page.isClosed()) { + entry.pages.delete(pageName); + } + } + } + + private listNamedPages(entry: BrowserEntry): string[] { + this.pruneClosedPages(entry); + + return Array.from(entry.pages.entries()) + .filter(([, page]) => !page.isClosed()) + .map(([name]) => name) + .sort((left, right) => left.localeCompare(right)); + } + + private getNamedPagesByPage(entry: BrowserEntry): Map { + const namesByPage = new Map(); + + for (const [name, page] of entry.pages.entries()) { + if (!page.isClosed() && !namesByPage.has(page)) { + namesByPage.set(page, name); + } + } + + return namesByPage; + } + + private getBrowserContexts(entry: BrowserEntry): BrowserContext[] { + return [...new Set([entry.context, ...entry.browser.contexts()])]; + } + + private getContextPages(entry: BrowserEntry): Array<{ context: BrowserContext; page: Page }> { + const pages: Array<{ context: BrowserContext; page: Page }> = []; + + for (const context of this.getBrowserContexts(entry)) { + for (const page of context.pages()) { + if (!page.isClosed()) { + pages.push({ context, page }); + } + } + } + + return pages; + } + + private async findPageByTargetId(entry: BrowserEntry, targetId: string): Promise { + for (const { context, page } of this.getContextPages(entry)) { + const pageTargetId = await this.getPageTargetId(context, page); + if (pageTargetId === targetId) { + return page; + } + } + + return null; + } + + private async getPageTargetId(context: BrowserContext, page: Page): Promise { + let session: Awaited> | undefined; + + try { + session = await context.newCDPSession(page); + const result = await session.send("Target.getTargetInfo"); + const targetId = + typeof result === "object" && + result !== null && + "targetInfo" in result && + typeof result.targetInfo === "object" && + result.targetInfo !== null && + "targetId" in result.targetInfo + ? result.targetInfo.targetId + : undefined; + + if (typeof targetId !== "string" || targetId.length === 0) { + throw new Error("CDP target info did not include a targetId"); + } + + return targetId; + } catch (error) { + if (page.isClosed()) { + return null; + } + + throw error; + } finally { + await session?.detach().catch(() => undefined); + } + } +} diff --git a/daemon/src/daemon.ts b/daemon/src/daemon.ts new file mode 100644 index 00000000..2e262580 --- /dev/null +++ b/daemon/src/daemon.ts @@ -0,0 +1,445 @@ +import { spawn } from "node:child_process"; +import { mkdir, unlink, writeFile } from "node:fs/promises"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { BrowserManager } from "./browser-manager.js"; +import { parseRequest, serialize, type ExecuteRequest, type Response } from "./protocol.js"; +import { runScript } from "./sandbox/script-runner-quickjs.js"; +import { ensureDevBrowserTempDir } from "./temp-files.js"; + +const BASE_DIR = path.join(os.homedir(), ".dev-browser"); +const SOCKET_PATH = path.join(BASE_DIR, "daemon.sock"); +const PID_PATH = path.join(BASE_DIR, "daemon.pid"); +const BROWSERS_DIR = path.join(BASE_DIR, "browsers"); +const EMBEDDED_PACKAGE_JSON = JSON.stringify({ + name: "dev-browser-runtime", + private: true, + type: "module", + packageManager: "pnpm@10.30.1", + dependencies: { + playwright: "^1.52.0", + "playwright-core": "^1.52.0", + "quickjs-emscripten": "^0.32.0", + }, +}); + +const manager = new BrowserManager(BROWSERS_DIR); +const startedAt = Date.now(); +const browserLocks = new Map>(); +const clients = new Set(); + +let server: net.Server | null = null; +let shuttingDown: Promise | null = null; + +function formatError(error: unknown): string { + if (error instanceof Error) { + return error.stack ?? error.message; + } + + return String(error); +} + +async function writeMessage(socket: net.Socket, message: Response): Promise { + if (socket.destroyed) { + return; + } + + await new Promise((resolve, reject) => { + const payload = serialize(message); + socket.write(payload, (error?: Error | null) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); +} + +async function unlinkIfExists(filePath: string): Promise { + try { + await unlink(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } +} + +async function withBrowserLock(browserName: string, action: () => Promise): Promise { + const previous = browserLocks.get(browserName) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.catch(() => undefined).then(() => current); + browserLocks.set(browserName, tail); + + await previous.catch(() => undefined); + + try { + return await action(); + } finally { + release(); + if (browserLocks.get(browserName) === tail) { + browserLocks.delete(browserName); + } + } +} + +function createMessageQueue(socket: net.Socket) { + let queue = Promise.resolve(); + + return { + push(message: Response): Promise { + queue = queue + .then(() => writeMessage(socket, message)) + .catch(() => undefined); + return queue; + }, + async drain(): Promise { + await queue; + }, + }; +} + +async function handleExecute(socket: net.Socket, request: ExecuteRequest): Promise { + await withBrowserLock(request.browser, async () => { + if (request.connect === "auto") { + await manager.autoConnect(request.browser); + } else if (request.connect) { + await manager.connectBrowser(request.browser, request.connect); + } else { + await manager.ensureBrowser(request.browser, { + headless: request.headless, + }); + } + + const output = createMessageQueue(socket); + + try { + await runScript( + request.script, + manager, + request.browser, + { + onStdout: (data) => { + void output.push({ + id: request.id, + type: "stdout", + data, + }); + }, + onStderr: (data) => { + void output.push({ + id: request.id, + type: "stderr", + data, + }); + }, + }, + ); + + await output.drain(); + await writeMessage(socket, { + id: request.id, + type: "complete", + success: true, + }); + } catch (error) { + await output.drain().catch(() => undefined); + await writeMessage(socket, { + id: request.id, + type: "error", + message: formatError(error), + }); + } + }); +} + +async function handleInstall(socket: net.Socket, request: { id: string }): Promise { + const output = createMessageQueue(socket); + try { + await mkdir(BASE_DIR, { recursive: true }); + await writeFile(path.join(BASE_DIR, "package.json"), EMBEDDED_PACKAGE_JSON); + await runInstallCommand(output, request.id, "pnpm", ["install"], BASE_DIR, "pnpm install"); + await runInstallCommand( + output, + request.id, + "pnpm", + ["exec", "playwright", "install", "chromium"], + BASE_DIR, + "Playwright install" + ); + await writeMessage(socket, { + id: request.id, + type: "complete", + success: true, + }); + } catch (error) { + await output.drain().catch(() => undefined); + await writeMessage(socket, { + id: request.id, + type: "error", + message: formatError(error), + }); + } +} + +async function runInstallCommand( + output: ReturnType, + requestId: string, + program: string, + args: string[], + cwd: string, + label: string +): Promise { + const child = spawn(program, args, { + cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (data: string) => { + void output.push({ + id: requestId, + type: "stdout", + data, + }); + }); + + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (data: string) => { + void output.push({ + id: requestId, + type: "stderr", + data, + }); + }); + + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => { + resolve({ code, signal }); + }); + } + ); + + await output.drain(); + + if (result.code === 0) { + return; + } + + const reason = + result.signal !== null + ? `${label} terminated by signal ${result.signal}` + : `${label} failed with exit code ${result.code ?? "unknown"}`; + + throw new Error(reason); +} + +async function handleRequest(socket: net.Socket, line: string): Promise { + const parsed = parseRequest(line); + if (!parsed.success) { + await writeMessage(socket, { + id: parsed.id ?? "unknown", + type: "error", + message: parsed.error, + }); + return; + } + + const { request } = parsed; + + switch (request.type) { + case "execute": + await handleExecute(socket, request); + return; + + case "browsers": + await writeMessage(socket, { + id: request.id, + type: "result", + data: manager.listBrowsers(), + }); + await writeMessage(socket, { + id: request.id, + type: "complete", + success: true, + }); + return; + + case "browser-stop": + await manager.stopBrowser(request.browser); + await writeMessage(socket, { + id: request.id, + type: "result", + data: { browser: request.browser, stopped: true }, + }); + await writeMessage(socket, { + id: request.id, + type: "complete", + success: true, + }); + return; + + case "status": + await writeMessage(socket, { + id: request.id, + type: "result", + data: { + pid: process.pid, + uptimeMs: Date.now() - startedAt, + browserCount: manager.browserCount(), + browsers: manager.listBrowsers(), + socketPath: SOCKET_PATH, + }, + }); + await writeMessage(socket, { + id: request.id, + type: "complete", + success: true, + }); + return; + + case "install": + await handleInstall(socket, request); + return; + + case "stop": + await writeMessage(socket, { + id: request.id, + type: "result", + data: { stopping: true }, + }); + await writeMessage(socket, { + id: request.id, + type: "complete", + success: true, + }); + setImmediate(() => { + void shutdown(0); + }); + return; + } +} + +async function shutdown(exitCode = 0): Promise { + if (shuttingDown) { + return shuttingDown; + } + + shuttingDown = (async () => { + if (server) { + server.close(); + server = null; + } + + await manager.stopAll(); + await Promise.allSettled([unlinkIfExists(PID_PATH), unlinkIfExists(SOCKET_PATH)]); + + for (const socket of clients) { + if (!socket.destroyed) { + socket.destroy(); + } + } + clients.clear(); + + process.exit(exitCode); + })(); + + return shuttingDown; +} + +async function start(): Promise { + await mkdir(BASE_DIR, { recursive: true }); + await ensureDevBrowserTempDir(); + await unlinkIfExists(SOCKET_PATH); + await writeFile(PID_PATH, `${process.pid}\n`); + + server = net.createServer((socket) => { + clients.add(socket); + socket.setEncoding("utf8"); + + let buffer = ""; + let queue = Promise.resolve(); + + socket.on("data", (chunk: string) => { + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) { + continue; + } + + queue = queue + .then(() => handleRequest(socket, line)) + .catch(async (error) => { + console.error("Request handling error:", error); + if (!socket.destroyed) { + await writeMessage(socket, { + id: "unknown", + type: "error", + message: formatError(error), + }); + } + }); + } + }); + + socket.on("close", () => { + clients.delete(socket); + }); + + socket.on("error", () => { + clients.delete(socket); + }); + }); + + server.on("error", (error) => { + console.error("Daemon server error:", error); + void shutdown(1); + }); + + await new Promise((resolve, reject) => { + server?.once("error", reject); + server?.listen(SOCKET_PATH, () => { + server?.off("error", reject); + resolve(); + }); + }); + + process.stderr.write("daemon ready\n"); +} + +function registerShutdownHandlers(): void { + const handleSignal = () => { + void shutdown(0); + }; + + const handleFatalError = (error: unknown) => { + console.error("Fatal daemon error:", error); + void shutdown(1); + }; + + process.on("SIGINT", handleSignal); + process.on("SIGTERM", handleSignal); + process.on("SIGHUP", handleSignal); + process.on("uncaughtException", handleFatalError); + process.on("unhandledRejection", handleFatalError); +} + +registerShutdownHandlers(); + +start().catch((error) => { + console.error("Failed to start daemon:", error); + void shutdown(1); +}); diff --git a/daemon/src/protocol.ts b/daemon/src/protocol.ts new file mode 100644 index 00000000..9fa3b4dc --- /dev/null +++ b/daemon/src/protocol.ts @@ -0,0 +1,136 @@ +import { z } from "zod"; + +const RequestBaseSchema = z.object({ + id: z.string().min(1), +}); + +export const ExecuteRequestSchema = RequestBaseSchema.extend({ + type: z.literal("execute"), + browser: z.string().min(1).default("default"), + script: z.string(), + headless: z.boolean().optional(), + connect: z.string().min(1).optional(), +}); + +export const BrowsersRequestSchema = RequestBaseSchema.extend({ + type: z.literal("browsers"), +}); + +export const BrowserStopRequestSchema = RequestBaseSchema.extend({ + type: z.literal("browser-stop"), + browser: z.string().min(1), +}); + +export const StatusRequestSchema = RequestBaseSchema.extend({ + type: z.literal("status"), +}); + +export const InstallRequestSchema = RequestBaseSchema.extend({ + type: z.literal("install"), +}); + +export const StopRequestSchema = RequestBaseSchema.extend({ + type: z.literal("stop"), +}); + +export const RequestSchema = z.discriminatedUnion("type", [ + ExecuteRequestSchema, + BrowsersRequestSchema, + BrowserStopRequestSchema, + StatusRequestSchema, + InstallRequestSchema, + StopRequestSchema, +]); + +const ResponseBaseSchema = z.object({ + id: z.string().min(1), +}); + +export const StdoutMessageSchema = ResponseBaseSchema.extend({ + type: z.literal("stdout"), + data: z.string(), +}); + +export const StderrMessageSchema = ResponseBaseSchema.extend({ + type: z.literal("stderr"), + data: z.string(), +}); + +export const CompleteMessageSchema = ResponseBaseSchema.extend({ + type: z.literal("complete"), + success: z.literal(true), +}); + +export const ErrorMessageSchema = ResponseBaseSchema.extend({ + type: z.literal("error"), + message: z.string(), +}); + +export const ResultMessageSchema = ResponseBaseSchema.extend({ + type: z.literal("result"), + data: z.unknown(), +}); + +export const ResponseSchema = z.discriminatedUnion("type", [ + StdoutMessageSchema, + StderrMessageSchema, + CompleteMessageSchema, + ErrorMessageSchema, + ResultMessageSchema, +]); + +export type Request = z.infer; +export type ExecuteRequest = z.infer; +export type Response = z.infer; + +type ParseSuccess = { success: true; request: Request }; +type ParseFailure = { success: false; error: string; id?: string }; + +function describeZodError(error: z.ZodError): string { + return error.issues + .map((issue) => { + const path = issue.path.length > 0 ? issue.path.join(".") : "request"; + return `${path}: ${issue.message}`; + }) + .join("; "); +} + +function extractId(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + const maybeId = (value as { id?: unknown }).id; + return typeof maybeId === "string" && maybeId.length > 0 ? maybeId : undefined; +} + +export function parseRequest(line: string): ParseSuccess | ParseFailure { + let parsed: unknown; + + try { + parsed = JSON.parse(line); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Invalid JSON request", + }; + } + + const result = RequestSchema.safeParse(parsed); + if (!result.success) { + return { + success: false, + error: describeZodError(result.error), + id: extractId(parsed), + }; + } + + return { + success: true, + request: result.data, + }; +} + +export function serialize(message: Response): string { + return `${JSON.stringify(ResponseSchema.parse(message))}\n`; +} diff --git a/daemon/src/sandbox/__tests__/auto-connect.test.ts b/daemon/src/sandbox/__tests__/auto-connect.test.ts new file mode 100644 index 00000000..1db677dc --- /dev/null +++ b/daemon/src/sandbox/__tests__/auto-connect.test.ts @@ -0,0 +1,667 @@ +import { createServer } from "node:http"; +import { EventEmitter } from "node:events"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { BrowserManager } from "../../browser-manager.js"; +import { parseRequest } from "../../protocol.js"; + +class MockCDPSession { + private readonly targetId: string; + + constructor(targetId: string) { + this.targetId = targetId; + } + + async send(method: string): Promise<{ targetInfo: { targetId: string } }> { + expect(method).toBe("Target.getTargetInfo"); + return { + targetInfo: { + targetId: this.targetId, + }, + }; + } + + async detach(): Promise {} +} + +class MockPage extends EventEmitter { + private closed = false; + readonly targetId: string; + readonly pageTitle: string; + readonly pageUrl: string; + + constructor(options: { targetId?: string; title?: string; url?: string } = {}) { + super(); + this.targetId = options.targetId ?? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + this.pageTitle = options.title ?? ""; + this.pageUrl = options.url ?? "about:blank"; + } + + isClosed(): boolean { + return this.closed; + } + + url(): string { + return this.pageUrl; + } + + async title(): Promise { + return this.pageTitle; + } + + async close(): Promise { + this.closed = true; + this.emit("close"); + } +} + +class MockContext { + readonly pagesList: MockPage[]; + newPageCalls = 0; + closeCalls = 0; + + constructor(pages: MockPage[] = []) { + this.pagesList = pages; + } + + pages(): MockPage[] { + return this.pagesList; + } + + async newCDPSession(page: MockPage): Promise { + return new MockCDPSession(page.targetId); + } + + async newPage(): Promise { + this.newPageCalls += 1; + const page = new MockPage({ + targetId: `${this.newPageCalls}`.padStart(32, "0"), + }); + this.pagesList.push(page); + return page; + } + + async close(): Promise { + this.closeCalls += 1; + } +} + +class MockBrowser extends EventEmitter { + readonly contextsList: MockContext[]; + closeCalls = 0; + newContextCalls = 0; + private connected = true; + + constructor(contexts: MockContext[] = []) { + super(); + this.contextsList = contexts; + } + + contexts(): MockContext[] { + return this.contextsList; + } + + async newContext(): Promise { + this.newContextCalls += 1; + const context = new MockContext(); + this.contextsList.push(context); + return context; + } + + isConnected(): boolean { + return this.connected; + } + + async close(): Promise { + this.closeCalls += 1; + this.connected = false; + this.emit("disconnected"); + } + + disconnect(): void { + this.connected = false; + this.emit("disconnected"); + } +} + +type BrowserManagerInternals = { + readDevToolsActivePort(expectedPort?: number): Promise; + discoverChrome(): Promise; + probePort(port: number): Promise; +}; + +function createEnoentError(filePath: string): NodeJS.ErrnoException { + const error = new Error(`ENOENT: no such file or directory, open '${filePath}'`) as NodeJS.ErrnoException; + error.code = "ENOENT"; + return error; +} + +function createManager(options: { + connectOverCDP?: ReturnType; + fetch?: typeof globalThis.fetch; + homedir?: () => string; + platform?: NodeJS.Platform; + readFile?: ReturnType; +} = {}) { + const connectOverCDP = options.connectOverCDP ?? vi.fn(); + const fetch = + options.fetch ?? + (vi.fn(async () => { + throw new Error("unexpected fetch"); + }) as typeof globalThis.fetch); + const readFile = + options.readFile ?? + (vi.fn(async (filePath: string) => { + throw createEnoentError(filePath); + }) as ReturnType); + + const manager = new BrowserManager(path.join("/tmp", "dev-browser-auto-connect-tests"), { + connectOverCDP: connectOverCDP as never, + fetch, + homedir: options.homedir ?? (() => "/Users/tester"), + launchPersistentContext: vi.fn() as never, + mkdir: vi.fn(async () => undefined) as never, + platform: options.platform ?? "darwin", + readFile: readFile as never, + }); + + return { + manager, + connectOverCDP, + fetch, + readFile, + }; +} + +function getInternals(manager: BrowserManager): BrowserManagerInternals { + return manager as unknown as BrowserManagerInternals; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("BrowserManager auto-connect", () => { + it("parses DevToolsActivePort and returns the browser websocket endpoint", async () => { + const homeDir = "/Users/tester"; + const devToolsPath = path.join( + homeDir, + "Library", + "Application Support", + "Google", + "Chrome", + "DevToolsActivePort", + ); + const websocketUrl = "ws://127.0.0.1:9333/devtools/browser/from-port-file"; + const readFile = vi.fn(async (filePath: string) => { + if (filePath === devToolsPath) { + return "9333\n/devtools/browser/from-port-file\n"; + } + + throw createEnoentError(filePath); + }); + const { manager } = createManager({ + homedir: () => homeDir, + readFile, + }); + + await expect(getInternals(manager).readDevToolsActivePort()).resolves.toBe(websocketUrl); + }); + + it("returns null when DevToolsActivePort is missing", async () => { + const { manager } = createManager(); + + await expect(getInternals(manager).readDevToolsActivePort()).resolves.toBeNull(); + }); + + it("ignores malformed DevToolsActivePort files", async () => { + const homeDir = "/Users/tester"; + const malformedPath = path.join( + homeDir, + "Library", + "Application Support", + "Google", + "Chrome", + "DevToolsActivePort", + ); + const readFile = vi.fn(async (filePath: string) => { + if (filePath === malformedPath) { + return "not-a-port\n/not-a-browser-path\n"; + } + + throw createEnoentError(filePath); + }); + const { manager } = createManager({ + homedir: () => homeDir, + readFile, + }); + + await expect(getInternals(manager).readDevToolsActivePort()).resolves.toBeNull(); + }); + + it("discovers Chrome by trying DevToolsActivePort before common ports", async () => { + const homeDir = "/Users/tester"; + const devToolsPath = path.join( + homeDir, + "Library", + "Application Support", + "Google", + "Chrome", + "DevToolsActivePort", + ); + const requests: string[] = []; + const websocketUrl = "ws://127.0.0.1:9555/devtools/browser/from-discovery"; + const readFile = vi.fn(async (filePath: string) => { + if (filePath === devToolsPath) { + return "9555\n/devtools/browser/from-discovery\n"; + } + + throw createEnoentError(filePath); + }); + const fetch = vi.fn() as typeof globalThis.fetch; + const { manager } = createManager({ fetch, homedir: () => homeDir, readFile }); + + await expect(getInternals(manager).discoverChrome()).resolves.toBe(websocketUrl); + expect(requests).toEqual([]); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("probes a running CDP port via /json/version", async () => { + const websocketUrl = "ws://127.0.0.1/devtools/browser/probed"; + const server = createServer((request, response) => { + if (request.url !== "/json/version") { + response.statusCode = 404; + response.end(); + return; + } + + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ webSocketDebuggerUrl: websocketUrl })); + }); + + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("failed to resolve server port"); + } + + const { manager } = createManager({ + fetch: globalThis.fetch, + }); + + await expect(getInternals(manager).probePort(address.port)).resolves.toBe(websocketUrl); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + } + }); + + it("connectBrowser resolves HTTP endpoints, registers a connected browser, and creates fresh named pages", async () => { + const existingPage = new MockPage({ + targetId: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + title: "Existing tab", + url: "https://example.com/existing", + }); + const context = new MockContext([existingPage]); + const browser = new MockBrowser([context]); + const connectOverCDP = vi.fn(async () => browser); + const fetch = vi.fn(async (input: RequestInfo | URL) => { + expect(String(input)).toBe("http://127.0.0.1:9222/json/version"); + return new Response( + JSON.stringify({ + webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/browser/connected-browser", + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + }, + ); + }) as typeof globalThis.fetch; + const { manager } = createManager({ + connectOverCDP, + fetch, + }); + + await manager.connectBrowser("attached", "http://127.0.0.1:9222"); + const namedPage = await manager.getPage("attached", "dashboard"); + + expect(connectOverCDP).toHaveBeenCalledWith( + "ws://127.0.0.1:9222/devtools/browser/connected-browser", + ); + expect(namedPage).not.toBe(existingPage); + expect(context.newPageCalls).toBe(1); + await expect(manager.listPages("attached")).resolves.toEqual([ + { + id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + name: null, + title: "Existing tab", + url: "https://example.com/existing", + }, + { + id: "00000000000000000000000000000001", + name: "dashboard", + title: "", + url: "about:blank", + }, + ]); + expect(manager.listBrowsers()).toEqual([ + { + name: "attached", + pages: ["dashboard"], + status: "connected", + type: "connected", + }, + ]); + }); + + it("getBrowser returns connected entries without relaunching them", async () => { + const browser = new MockBrowser([new MockContext()]); + const connectOverCDP = vi.fn(async () => browser); + const { manager } = createManager({ + connectOverCDP, + }); + + await manager.connectBrowser( + "attached", + "ws://127.0.0.1:9222/devtools/browser/external-session", + ); + + const entry = manager.getBrowser("attached"); + + expect(entry).toMatchObject({ + name: "attached", + type: "connected", + browser, + }); + expect(connectOverCDP).toHaveBeenCalledTimes(1); + + browser.disconnect(); + + expect(manager.getBrowser("attached")).toBeUndefined(); + }); + + it("connectBrowser falls back to DevToolsActivePort when /json/version returns 404", async () => { + const homeDir = "/Users/tester"; + const devToolsPath = path.join( + homeDir, + "Library", + "Application Support", + "Google", + "Chrome", + "DevToolsActivePort", + ); + const browser = new MockBrowser([new MockContext()]); + const connectOverCDP = vi.fn(async () => browser); + const fetch = vi.fn(async (input: RequestInfo | URL) => { + expect(String(input)).toBe("http://127.0.0.1:9222/json/version"); + return new Response("not found", { status: 404 }); + }) as typeof globalThis.fetch; + const readFile = vi.fn(async (filePath: string) => { + if (filePath === devToolsPath) { + return "9222\n/devtools/browser/from-active-port\n"; + } + + throw createEnoentError(filePath); + }); + const { manager } = createManager({ + connectOverCDP, + fetch, + homedir: () => homeDir, + readFile, + }); + + await manager.connectBrowser("attached", "http://127.0.0.1:9222"); + + expect(connectOverCDP).toHaveBeenCalledWith( + "ws://127.0.0.1:9222/devtools/browser/from-active-port", + ); + }); + + it("reports a helpful error when /json/version returns 404 and no matching DevToolsActivePort exists", async () => { + const homeDir = "/Users/tester"; + const readFile = vi.fn(async (filePath: string) => { + if (filePath.endsWith("DevToolsActivePort")) { + return "9333\n/devtools/browser/different-port\n"; + } + + throw createEnoentError(filePath); + }); + const fetch = vi.fn(async () => new Response("not found", { status: 404 })) as typeof globalThis.fetch; + const { manager } = createManager({ + fetch, + homedir: () => homeDir, + readFile, + }); + + let error: Error | undefined; + try { + await manager.connectBrowser("missing", "http://127.0.0.1:9222"); + } catch (reason) { + error = reason as Error; + } + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toMatch(/DevToolsActivePort/); + expect(error?.message).toMatch(/remote-debugging-port=9222/); + }); + + it("keeps external browsers alive on stopAll by only closing the CDP connection", async () => { + const context = new MockContext(); + const browser = new MockBrowser([context]); + const connectOverCDP = vi.fn(async () => browser); + const { manager } = createManager({ + connectOverCDP, + }); + + await manager.connectBrowser( + "attached", + "ws://127.0.0.1:9222/devtools/browser/external-session", + ); + await manager.stopAll(); + + expect(browser.closeCalls).toBe(1); + expect(context.closeCalls).toBe(0); + expect(manager.browserCount()).toBe(0); + }); + + it("autoConnect falls through discovery methods until a browser is found", async () => { + const browser = new MockBrowser([new MockContext()]); + const requests: string[] = []; + const connectOverCDP = vi.fn(async () => browser); + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + requests.push(url); + + if (url === "http://127.0.0.1:9223/json/version") { + return new Response( + JSON.stringify({ + webSocketDebuggerUrl: "ws://127.0.0.1:9223/devtools/browser/discovered", + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + }, + ); + } + + throw new Error("connection refused"); + }) as typeof globalThis.fetch; + const readFile = vi.fn(async (filePath: string) => { + throw createEnoentError(filePath); + }); + const { manager } = createManager({ + connectOverCDP, + fetch, + readFile, + }); + + await manager.autoConnect("auto-browser"); + + expect(requests.slice(0, 2)).toEqual([ + "http://127.0.0.1:9222/json/version", + "http://127.0.0.1:9223/json/version", + ]); + expect(connectOverCDP).toHaveBeenCalledWith("ws://127.0.0.1:9223/devtools/browser/discovered"); + expect(manager.listBrowsers()).toEqual([ + { + name: "auto-browser", + pages: [], + status: "connected", + type: "connected", + }, + ]); + }); + + it("autoConnect falls back from DevToolsActivePort to port probing when the direct websocket is stale", async () => { + const homeDir = "/Users/tester"; + const devToolsPath = path.join( + homeDir, + "Library", + "Application Support", + "Google", + "Chrome", + "DevToolsActivePort", + ); + const staleEndpoint = "ws://127.0.0.1:9222/devtools/browser/from-active-port"; + const discoveredEndpoint = "ws://127.0.0.1:9223/devtools/browser/discovered"; + const browser = new MockBrowser([new MockContext()]); + const requests: string[] = []; + const connectOverCDP = vi.fn(async (endpoint: string) => { + if (endpoint === staleEndpoint) { + throw new Error("stale DevToolsActivePort"); + } + + return browser; + }); + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + requests.push(url); + + if (url === "http://127.0.0.1:9222/json/version") { + return new Response("not found", { status: 404 }); + } + + if (url === "http://127.0.0.1:9223/json/version") { + return new Response( + JSON.stringify({ + webSocketDebuggerUrl: discoveredEndpoint, + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + }, + ); + } + + throw new Error("connection refused"); + }) as typeof globalThis.fetch; + const readFile = vi.fn(async (filePath: string) => { + if (filePath === devToolsPath) { + return "9222\n/devtools/browser/from-active-port\n"; + } + + throw createEnoentError(filePath); + }); + const { manager } = createManager({ + connectOverCDP, + fetch, + homedir: () => homeDir, + readFile, + }); + + await manager.autoConnect("auto-browser"); + + expect(connectOverCDP).toHaveBeenCalledTimes(2); + expect(connectOverCDP).toHaveBeenNthCalledWith(1, staleEndpoint); + expect(connectOverCDP).toHaveBeenNthCalledWith(2, discoveredEndpoint); + expect(requests).toEqual([ + "http://127.0.0.1:9222/json/version", + "http://127.0.0.1:9223/json/version", + ]); + }); + + it("reports a clear error when auto-discovery finds no running Chrome", async () => { + const readFile = vi.fn(async (filePath: string) => { + throw createEnoentError(filePath); + }); + const fetch = vi.fn(async () => { + throw new Error("connection refused"); + }) as typeof globalThis.fetch; + const { manager } = createManager({ + fetch, + readFile, + }); + + await expect(manager.autoConnect("missing")).rejects.toThrowError( + /remote-debugging-port=9222/, + ); + }); +}); + +describe("protocol execute request", () => { + it("accepts connect=\"auto\" and explicit CDP URLs", () => { + const autoResult = parseRequest( + JSON.stringify({ + id: "req-auto", + type: "execute", + browser: "default", + script: 'console.log("hi")', + connect: "auto", + }), + ); + const manualResult = parseRequest( + JSON.stringify({ + id: "req-manual", + type: "execute", + browser: "default", + script: 'console.log("hi")', + connect: "http://127.0.0.1:9222", + }), + ); + + expect(autoResult).toEqual({ + success: true, + request: { + id: "req-auto", + type: "execute", + browser: "default", + script: 'console.log("hi")', + connect: "auto", + }, + }); + expect(manualResult).toEqual({ + success: true, + request: { + id: "req-manual", + type: "execute", + browser: "default", + script: 'console.log("hi")', + connect: "http://127.0.0.1:9222", + }, + }); + }); +}); diff --git a/daemon/src/sandbox/__tests__/forked-client-bundle.test.ts b/daemon/src/sandbox/__tests__/forked-client-bundle.test.ts new file mode 100644 index 00000000..9dd9a4be --- /dev/null +++ b/daemon/src/sandbox/__tests__/forked-client-bundle.test.ts @@ -0,0 +1,59 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { promisify } from "node:util"; + +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { QuickJSHost } from "../quickjs-host.js"; + +const execFileAsync = promisify(execFile); +const daemonDir = new URL("../../../", import.meta.url); +const bundleUrl = new URL("../../../dist/sandbox-client.js", import.meta.url); +const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + +const hosts = new Set(); + +let bundleCode = ""; + +async function createHost(): Promise { + const host = await QuickJSHost.create(); + hosts.add(host); + return host; +} + +afterEach(() => { + for (const host of hosts) { + host.dispose(); + } + hosts.clear(); +}); + +beforeAll(async () => { + await execFileAsync(pnpmCommand, ["run", "bundle:sandbox-client"], { + cwd: daemonDir, + }); + bundleCode = await readFile(bundleUrl, "utf8"); +}, 120_000); + +describe("forked Playwright bundle", () => { + it("loads into QuickJS and exposes the client entry points", async () => { + const host = await createHost(); + + expect(() => + host.executeScriptSync(bundleCode, { + filename: "sandbox-client.js", + }), + ).not.toThrow(); + + expect(host.executeScriptSync("typeof __PlaywrightClient.Connection")).toBe("function"); + expect(host.executeScriptSync("typeof __PlaywrightClient.quickjsPlatform")).toBe("object"); + + expect(() => + host.executeScriptSync(` + globalThis.__sandboxConnection = new __PlaywrightClient.Connection(); + `), + ).not.toThrow(); + + expect(host.executeScriptSync("typeof __sandboxConnection.dispatch")).toBe("function"); + }, 120_000); +}); diff --git a/daemon/src/sandbox/__tests__/named-pages.test.ts b/daemon/src/sandbox/__tests__/named-pages.test.ts new file mode 100644 index 00000000..0e9b6282 --- /dev/null +++ b/daemon/src/sandbox/__tests__/named-pages.test.ts @@ -0,0 +1,222 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { BrowserManager } from "../../browser-manager.js"; +import { QuickJSSandbox } from "../quickjs-sandbox.js"; + +const browserName = "named-pages"; + +interface CapturedOutput { + stdout: string[]; + stderr: string[]; +} + +function pageNames(pages: Array<{ name: string | null }>): string[] { + return pages + .map((page) => page.name) + .filter((name): name is string => typeof name === "string") + .sort((left, right) => left.localeCompare(right)); +} + +function createOutput(): CapturedOutput & { + sink: { + onStdout: (data: string) => void; + onStderr: (data: string) => void; + }; +} { + const stdout: string[] = []; + const stderr: string[] = []; + + return { + stdout, + stderr, + sink: { + onStdout: (data) => { + stdout.push(data); + }, + onStderr: (data) => { + stderr.push(data); + }, + }, + }; +} + +function outputLines(output: CapturedOutput): string[] { + return output.stdout.map((line) => line.trim()).filter((line) => line.length > 0); +} + +describe.sequential("QuickJS named page management", () => { + let browserRootDir = ""; + let manager: BrowserManager; + + beforeAll(async () => { + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-quickjs-named-pages-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + }, 180_000); + + afterEach(async () => { + await manager.stopBrowser(browserName); + }, 180_000); + + afterAll(async () => { + await manager.stopAll(); + await rm(browserRootDir, { + recursive: true, + force: true, + }); + }, 180_000); + + async function createSandbox(output: ReturnType): Promise { + await manager.ensureBrowser(browserName, { + headless: true, + }); + + const sandbox = new QuickJSSandbox({ + manager, + browserName, + onStdout: output.sink.onStdout, + onStderr: output.sink.onStderr, + timeoutMs: 60_000, + }); + await sandbox.initialize(); + return sandbox; + } + + async function runUserScript(sandbox: QuickJSSandbox, script: string): Promise { + await sandbox.executeScript(`(async () => {\n${script}\n})()`); + } + + it("persists named pages across script executions", async () => { + const output = createOutput(); + const sandbox = await createSandbox(output); + + try { + await runUserScript(sandbox, ` + const page = await browser.getPage("persist"); + await page.goto("https://example.com"); + await page.evaluate(() => { + window.name = "persisted-state"; + }); + console.log(await page.title()); + `); + + await runUserScript(sandbox, ` + const page = await browser.getPage("persist"); + console.log(page.url()); + console.log(await page.title()); + console.log(await page.evaluate(() => window.name)); + `); + } finally { + await sandbox.dispose(); + } + + expect(output.stderr).toEqual([]); + expect(outputLines(output)).toContain("https://example.com/"); + expect(outputLines(output)).toContain("Example Domain"); + expect(outputLines(output)).toContain("persisted-state"); + expect(pageNames(await manager.listPages(browserName))).toEqual(["persist"]); + }, 120_000); + + it("cleans up anonymous pages after each script execution", async () => { + const output = createOutput(); + await manager.ensureBrowser(browserName, { + headless: true, + }); + const entry = await manager.ensureBrowser(browserName); + const baselinePageCount = entry.context.pages().filter((page) => !page.isClosed()).length; + const sandbox = await createSandbox(output); + + try { + await runUserScript(sandbox, ` + const page = await browser.newPage(); + await page.goto("https://example.com"); + console.log(await page.title()); + `); + } finally { + await sandbox.dispose(); + } + + const livePages = entry.context.pages().filter((page) => !page.isClosed()); + + expect(output.stderr).toEqual([]); + expect(outputLines(output)).toContain("Example Domain"); + expect(livePages).toHaveLength(baselinePageCount); + expect(livePages.map((page) => page.url())).not.toContain("https://example.com/"); + expect(pageNames(await manager.listPages(browserName))).toEqual([]); + }, 120_000); + + it("lists the current named pages", async () => { + const output = createOutput(); + const sandbox = await createSandbox(output); + + try { + await runUserScript(sandbox, ` + await browser.getPage("beta"); + await browser.getPage("alpha"); + console.log(JSON.stringify(await browser.listPages())); + `); + } finally { + await sandbox.dispose(); + } + + expect(output.stderr).toEqual([]); + const listedPages = JSON.parse(outputLines(output).at(-1) ?? "[]") as Array<{ + id: string; + title: string; + url: string; + name: string | null; + }>; + + expect(listedPages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + name: "alpha", + title: expect.any(String), + url: expect.any(String), + }), + expect.objectContaining({ + id: expect.any(String), + name: "beta", + title: expect.any(String), + url: expect.any(String), + }), + ]), + ); + expect(pageNames(listedPages)).toEqual(["alpha", "beta"]); + expect(pageNames(await manager.listPages(browserName))).toEqual(["alpha", "beta"]); + }, 120_000); + + it("closes named pages by name", async () => { + const output = createOutput(); + const sandbox = await createSandbox(output); + + try { + await runUserScript(sandbox, ` + await browser.getPage("close-me"); + `); + await runUserScript(sandbox, ` + console.log(JSON.stringify(await browser.listPages())); + await browser.closePage("close-me"); + console.log(JSON.stringify(await browser.listPages())); + `); + } finally { + await sandbox.dispose(); + } + + expect(output.stderr).toEqual([]); + const listedBeforeClose = JSON.parse(outputLines(output).at(0) ?? "[]") as Array<{ + name: string | null; + }>; + const listedAfterClose = JSON.parse(outputLines(output).at(1) ?? "[]") as Array<{ + name: string | null; + }>; + + expect(pageNames(listedBeforeClose)).toEqual(["close-me"]); + expect(pageNames(listedAfterClose)).toEqual([]); + expect(pageNames(await manager.listPages(browserName))).toEqual([]); + }, 120_000); +}); diff --git a/daemon/src/sandbox/__tests__/playwright-api.test.ts b/daemon/src/sandbox/__tests__/playwright-api.test.ts new file mode 100644 index 00000000..91f42360 --- /dev/null +++ b/daemon/src/sandbox/__tests__/playwright-api.test.ts @@ -0,0 +1,809 @@ +import { execFile } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { BrowserManager } from "../../browser-manager.js"; +import { QuickJSSandbox } from "../quickjs-sandbox.js"; + +const execFileAsync = promisify(execFile); +const daemonDir = new URL("../../../", import.meta.url); +const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +const SANDBOX_TIMEOUT_MS = 60_000; + +const TEST_PAGE_HTML = String.raw` + + + Test Page + + + +

Hello World

+

Some text

+
Inner HTML
+ + + + + + + + +
+
    +
  • Item 1
  • +
  • Item 2
  • +
  • Item 3
  • +
+
+ Alpha + Child A +
+
+ Beta + Child B +
+
+ Nested child +
+ +
Transient element
+ + Example Link +
Mouse Target
+
+ + + +`; + +interface CapturedOutput { + stdout: string[]; + stderr: string[]; +} + +interface JsonSandboxHarness { + dispose: () => Promise; + runJson: (script: string) => Promise; +} + +interface NavigationServer { + baseUrl: string; + close: () => Promise; +} + +function createOutput(): CapturedOutput & { + sink: { + onStdout: (data: string) => void; + onStderr: (data: string) => void; + }; +} { + const stdout: string[] = []; + const stderr: string[] = []; + + return { + stdout, + stderr, + sink: { + onStdout: (data) => { + stdout.push(data); + }, + onStderr: (data) => { + stderr.push(data); + }, + }, + }; +} + +function clearOutput(output: CapturedOutput): void { + output.stdout.length = 0; + output.stderr.length = 0; +} + +function outputLines(output: CapturedOutput): string[] { + return output.stdout.map((line) => line.trim()).filter((line) => line.length > 0); +} + +function parseLastJsonLine(output: CapturedOutput): T { + const lines = outputLines(output); + expect(lines.length).toBeGreaterThan(0); + return JSON.parse(lines.at(-1)!) as T; +} + +function withTestPage(pageName: string, body: string): string { + return ` + const page = await browser.getPage(${JSON.stringify(pageName)}); + await page.setContent(${JSON.stringify(TEST_PAGE_HTML)}, { waitUntil: "load" }); + ${body} + `; +} + +async function createSandboxHarness( + manager: BrowserManager, + browserName: string, +): Promise { + await manager.ensureBrowser(browserName, { + headless: true, + }); + + const output = createOutput(); + const sandbox = new QuickJSSandbox({ + manager, + browserName, + onStdout: output.sink.onStdout, + onStderr: output.sink.onStderr, + timeoutMs: SANDBOX_TIMEOUT_MS, + }); + + await sandbox.initialize(); + + return { + dispose: async () => { + await sandbox.dispose(); + }, + runJson: async (script: string): Promise => { + clearOutput(output); + await sandbox.executeScript(`(async () => {\n${script}\n})()`); + expect(output.stderr).toEqual([]); + return parseLastJsonLine(output); + }, + }; +} + +function navigationPageHtml(title: string, route: string, nextPath?: string): string { + const nextLink = nextPath + ? `Next` + : 'No next link'; + + return ` + + + ${title} + + +

${title}

+
${route}
+ ${nextLink} + +`; +} + +function handleNavigationRequest(request: IncomingMessage, response: ServerResponse): void { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + let html = ""; + + switch (url.pathname) { + case "/nav/first": + html = navigationPageHtml("First Page", "/nav/first", "/nav/second"); + break; + case "/nav/second": + html = navigationPageHtml("Second Page", "/nav/second", "/nav/third"); + break; + case "/nav/third": + html = navigationPageHtml("Third Page", "/nav/third"); + break; + default: + response.writeHead(404, { + "content-type": "text/plain; charset=utf-8", + }); + response.end("not found"); + return; + } + + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }); + response.end(html); +} + +async function createNavigationServer(): Promise { + const server = createServer(handleNavigationRequest); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Navigation test server did not expose a TCP address"); + } + + const { port } = address as AddressInfo; + + return { + baseUrl: `http://127.0.0.1:${port}`, + close: async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }, + }; +} + +describe.sequential("QuickJS Playwright Page API coverage", () => { + let browserRootDir = ""; + let manager: BrowserManager; + + beforeAll(async () => { + await execFileAsync(pnpmCommand, ["run", "bundle:sandbox-client"], { + cwd: daemonDir, + }); + + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-playwright-api-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + }, 180_000); + + afterAll(async () => { + await manager.stopAll(); + await rm(browserRootDir, { + recursive: true, + force: true, + }); + }, 180_000); + + describe.sequential("navigation", () => { + const browserName = "playwright-navigation"; + let harness: JsonSandboxHarness; + let navigationServer: NavigationServer; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + navigationServer = await createNavigationServer(); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await navigationServer.close(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("supports goto with waitUntil, url(), and waitForURL()", async () => { + const firstUrl = `${navigationServer.baseUrl}/nav/first`; + const result = await harness.runJson<{ + firstUrl: string; + secondUrl: string; + firstTitle: string; + secondTitle: string; + }>(` + const page = await browser.getPage("navigation-goto"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "domcontentloaded" }); + const firstTitle = await page.title(); + const firstUrl = page.url(); + const secondNavigation = page.waitForURL("**/nav/second"); + await page.click("#next-link"); + await secondNavigation; + console.log(JSON.stringify({ + firstUrl, + secondUrl: page.url(), + firstTitle, + secondTitle: await page.title(), + })); + `); + + expect(result.firstUrl).toBe(firstUrl); + expect(result.secondUrl).toBe(`${navigationServer.baseUrl}/nav/second`); + expect(result.firstTitle).toBe("First Page"); + expect(result.secondTitle).toBe("Second Page"); + }); + + it("supports goBack(), goForward(), and reload()", async () => { + const firstUrl = `${navigationServer.baseUrl}/nav/first`; + const secondUrl = `${navigationServer.baseUrl}/nav/second`; + + const result = await harness.runJson<{ + backUrl: string; + backTitle: string; + forwardUrl: string; + reloadTitle: string; + }>(` + const page = await browser.getPage("navigation-history"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + await page.goto(${JSON.stringify(secondUrl)}, { waitUntil: "load" }); + await page.goBack({ waitUntil: "load" }); + const backUrl = page.url(); + const backTitle = await page.title(); + await page.goForward({ waitUntil: "load" }); + const forwardUrl = page.url(); + await page.evaluate(() => { + document.title = "Mutated Title"; + }); + await page.reload({ waitUntil: "load" }); + console.log(JSON.stringify({ + backUrl, + backTitle, + forwardUrl, + reloadTitle: await page.title(), + })); + `); + + expect(result.backUrl).toBe(firstUrl); + expect(result.backTitle).toBe("First Page"); + expect(result.forwardUrl).toBe(secondUrl); + expect(result.reloadTitle).toBe("Second Page"); + }); + }); + + describe.sequential("content and evaluation", () => { + const browserName = "playwright-content-evaluation"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("reads page content through page methods", async () => { + const result = await harness.runJson<{ + title: string; + contentHasTitle: boolean; + contentHasFooter: boolean; + text: string | null; + html: string; + innerText: string; + kind: string | null; + href: string | null; + }>(withTestPage( + "content-reading", + ` + const content = await page.content(); + console.log(JSON.stringify({ + title: await page.title(), + contentHasTitle: content.includes("Test Page"), + contentHasFooter: content.includes("Footer content"), + text: await page.textContent("#text"), + html: await page.innerHTML("#html-block"), + innerText: await page.innerText("#text"), + kind: await page.getAttribute("#text", "data-kind"), + href: await page.getAttribute("#link", "href"), + })); + `, + )); + + expect(result.title).toBe("Test Page"); + expect(result.contentHasTitle).toBe(true); + expect(result.contentHasFooter).toBe(true); + expect(result.text).toBe("Some text"); + expect(result.html).toContain("HTML"); + expect(result.innerText).toBe("Some text"); + expect(result.kind).toBe("primary"); + expect(result.href).toBe("https://example.com"); + }); + + it("supports evaluate(), evaluate(arg), $eval(), and $$eval()", async () => { + const result = await harness.runJson<{ + pageTitle: string; + sum: number; + upperText: string; + listItems: string[]; + }>(withTestPage( + "evaluation-methods", + ` + const pageTitle = await page.evaluate(() => document.title); + const sum = await page.evaluate((values) => values.left + values.right, { + left: 2, + right: 3, + }); + const upperText = await page.$eval("#text", (element) => { + return (element.textContent ?? "").toUpperCase(); + }); + const listItems = await page.$$eval("#list li", (elements) => { + return elements.map((element) => element.textContent ?? ""); + }); + console.log(JSON.stringify({ + pageTitle, + sum, + upperText, + listItems, + })); + `, + )); + + expect(result.pageTitle).toBe("Test Page"); + expect(result.sum).toBe(5); + expect(result.upperText).toBe("SOME TEXT"); + expect(result.listItems).toEqual(["Item 1", "Item 2", "Item 3"]); + }); + }); + + describe.sequential("form interaction and waiting", () => { + const browserName = "playwright-form-waiting"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("supports fill(), click(), type(), press(), check(), uncheck(), and selectOption()", async () => { + const result = await harness.runJson<{ + bio: string; + name: string; + email: string; + inputCount: number; + enterResult: string | null; + clickResult: string | null; + checkedAfterCheck: boolean; + checkedAfterUncheck: boolean; + selectedValues: string[]; + selectedValue: string; + }>(withTestPage( + "form-interaction", + ` + await page.fill("#bio", "QuickJS bio"); + await page.type("#name", "Ada"); + const name = await page.inputValue("#name"); + const inputCount = await page.evaluate(() => window.events.inputCount); + await page.press("#name", "Enter"); + const enterResult = await page.textContent("#result"); + await page.type("#email", "ada@example.com"); + await page.check("#agree"); + const checkedAfterCheck = await page.isChecked("#agree"); + const selectedValues = await page.selectOption("#color", "blue"); + const selectedValue = await page.inputValue("#color"); + await page.click("#submit"); + const clickResult = await page.textContent("#result"); + await page.uncheck("#agree"); + const checkedAfterUncheck = await page.isChecked("#agree"); + console.log(JSON.stringify({ + bio: await page.inputValue("#bio"), + name, + email: await page.inputValue("#email"), + inputCount, + enterResult, + clickResult, + checkedAfterCheck, + checkedAfterUncheck, + selectedValues, + selectedValue, + })); + `, + )); + + expect(result.bio).toBe("QuickJS bio"); + expect(result.name).toBe("Ada"); + expect(result.email).toBe("ada@example.com"); + expect(result.inputCount).toBe(3); + expect(result.enterResult).toBe("enter:Ada"); + expect(result.clickResult).toBe("clicked:Ada:blue"); + expect(result.checkedAfterCheck).toBe(true); + expect(result.checkedAfterUncheck).toBe(false); + expect(result.selectedValues).toEqual(["blue"]); + expect(result.selectedValue).toBe("blue"); + }); + + it("supports waitForSelector(), waitForTimeout(), and waitForFunction()", async () => { + const result = await harness.runJson<{ + timeoutElapsed: number; + waitTargetText: string | null; + transientHidden: boolean; + readyValue: string; + }>(withTestPage( + "wait-methods", + ` + const start = Date.now(); + await page.waitForTimeout(40); + const timeoutElapsed = Date.now() - start; + await page.waitForSelector("#wait-target"); + await page.waitForSelector("#transient", { state: "hidden" }); + const readyHandle = await page.waitForFunction(() => window.extraReady); + const readyValue = await readyHandle.jsonValue(); + await readyHandle.dispose(); + console.log(JSON.stringify({ + timeoutElapsed, + waitTargetText: await page.textContent("#wait-target"), + transientHidden: await page.isHidden("#transient"), + readyValue, + })); + `, + )); + + expect(result.timeoutElapsed).toBeGreaterThanOrEqual(20); + expect(result.waitTargetText).toBe("Loaded later"); + expect(result.transientHidden).toBe(true); + expect(result.readyValue).toBe("ok"); + }); + }); + + describe.sequential("locators and multiple elements", () => { + const browserName = "playwright-locators"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("supports locator actions, text helpers, visibility, enabled state, and nth accessors", async () => { + const result = await harness.runJson<{ + textContent: string | null; + innerText: string; + kind: string | null; + visible: boolean; + hiddenVisible: boolean; + submitEnabled: boolean; + disabledEnabled: boolean; + count: number; + first: string | null; + last: string | null; + second: string | null; + clickResult: string | null; + }>(withTestPage( + "locator-basics", + ` + await page.locator("#name").fill("Grace"); + await page.locator("#submit").click(); + console.log(JSON.stringify({ + textContent: await page.locator("#text").textContent(), + innerText: await page.locator("#text").innerText(), + kind: await page.locator("#text").getAttribute("data-kind"), + visible: await page.locator("#text").isVisible(), + hiddenVisible: await page.locator("#hidden").isVisible(), + submitEnabled: await page.locator("#submit").isEnabled(), + disabledEnabled: await page.locator("#disabled").isEnabled(), + count: await page.locator("#list li").count(), + first: await page.locator("#list li").first().textContent(), + last: await page.locator("#list li").last().textContent(), + second: await page.locator("#list li").nth(1).textContent(), + clickResult: await page.locator("#result").textContent(), + })); + `, + )); + + expect(result.textContent).toBe("Some text"); + expect(result.innerText).toBe("Some text"); + expect(result.kind).toBe("primary"); + expect(result.visible).toBe(true); + expect(result.hiddenVisible).toBe(false); + expect(result.submitEnabled).toBe(true); + expect(result.disabledEnabled).toBe(false); + expect(result.count).toBe(3); + expect(result.first).toBe("Item 1"); + expect(result.last).toBe("Item 3"); + expect(result.second).toBe("Item 2"); + expect(result.clickResult).toBe("clicked:Grace:red"); + }); + + it("supports filter(), chained locators, locator.all(), and iterating multiple elements", async () => { + const result = await harness.runJson<{ + betaLabel: string | null; + betaChild: string | null; + nestedChild: string | null; + items: string[]; + }>(withTestPage( + "locator-advanced", + ` + const betaCard = page.locator(".card").filter({ hasText: "Beta" }); + const items = []; + for (const item of await page.locator("#list li").all()) { + items.push(await item.innerText()); + } + console.log(JSON.stringify({ + betaLabel: await betaCard.locator(".label").textContent(), + betaChild: await betaCard.locator(".child").textContent(), + nestedChild: await page.locator(".parent").locator(".child").textContent(), + items, + })); + `, + )); + + expect(result.betaLabel).toBe("Beta"); + expect(result.betaChild).toBe("Child B"); + expect(result.nestedChild).toBe("Nested child"); + expect(result.items).toEqual(["Item 1", "Item 2", "Item 3"]); + }); + }); + + describe.sequential("screenshots and input devices", () => { + const browserName = "playwright-media-input"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("supports screenshot() and screenshot({ fullPage: true })", async () => { + const result = await harness.runJson<{ + screenshotLength: number; + fullPageLength: number; + }>(withTestPage( + "screenshots", + ` + const screenshot = await page.screenshot(); + const fullPage = await page.screenshot({ fullPage: true }); + console.log(JSON.stringify({ + screenshotLength: screenshot.length, + fullPageLength: fullPage.length, + })); + `, + )); + + expect(result.screenshotLength).toBeGreaterThan(0); + expect(result.fullPageLength).toBeGreaterThan(0); + }); + + it("supports page.keyboard and page.mouse", async () => { + const result = await harness.runJson<{ + typedValue: string; + enterResult: string | null; + mouseResult: string | null; + }>(withTestPage( + "input-devices", + ` + await page.click("#name"); + await page.keyboard.type("hello"); + await page.keyboard.press("Enter"); + const enterResult = await page.textContent("#result"); + await page.mouse.click(80, 80); + console.log(JSON.stringify({ + typedValue: await page.inputValue("#name"), + enterResult, + mouseResult: await page.getAttribute("#result", "data-mouse"), + })); + `, + )); + + expect(result.typedValue).toBe("hello"); + expect(result.enterResult).toBe("enter:hello"); + expect(result.mouseResult).toBe("clicked"); + }); + }); + + describe.sequential("events", () => { + const browserName = "playwright-events"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("supports page.on('console')", async () => { + const result = await harness.runJson<{ + messages: Array<{ type: string; text: string }>; + }>(withTestPage( + "console-events", + ` + const messages = []; + page.on("console", (message) => { + messages.push({ + type: message.type(), + text: message.text(), + }); + }); + await page.evaluate(() => { + console.log("from-page", 123); + }); + await page.waitForTimeout(50); + console.log(JSON.stringify({ messages })); + `, + )); + + expect(result.messages).toEqual([ + { + type: "log", + text: "from-page 123", + }, + ]); + }); + }); +}); diff --git a/daemon/src/sandbox/__tests__/protocol-bridge.test.ts b/daemon/src/sandbox/__tests__/protocol-bridge.test.ts new file mode 100644 index 00000000..61da58c5 --- /dev/null +++ b/daemon/src/sandbox/__tests__/protocol-bridge.test.ts @@ -0,0 +1,59 @@ +import type { Browser, Page } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createProtocolBridge, type ProtocolBridge } from "../protocol-bridge.js"; +import type { PlaywrightClientLike } from "../playwright-internals.js"; + +describe.sequential("protocol bridge", () => { + let bridge: ProtocolBridge; + let playwright: PlaywrightClientLike; + let browser: Browser | undefined; + let page: Page | undefined; + + beforeAll(async () => { + bridge = createProtocolBridge(); + playwright = await bridge.initializePlaywright(); + }, 60_000); + + afterAll(async () => { + if (page && !page.isClosed()) { + await page.close(); + } + + if (browser?.isConnected()) { + await browser.close(); + } + + await bridge.dispose("test teardown"); + }, 60_000); + + it("initializes Playwright through the bridge", () => { + expect(playwright.chromium).toBeDefined(); + }); + + it("launches Chromium and executes core page operations", async () => { + browser = await playwright.chromium.launch({ headless: true }); + page = await browser.newPage(); + + await page.goto("https://example.com"); + + await expect(page.title()).resolves.toBe("Example Domain"); + await expect(page.locator("h1").textContent()).resolves.toBe("Example Domain"); + await expect(page.evaluate(() => document.title)).resolves.toBe("Example Domain"); + }, 120_000); + + it("cleans up page, browser, and bridge connection", async () => { + const activePage = page; + const activeBrowser = browser; + + expect(activePage).toBeDefined(); + expect(activeBrowser).toBeDefined(); + + await activePage?.close(); + expect(activePage?.isClosed()).toBe(true); + + await activeBrowser?.close(); + expect(activeBrowser?.isConnected()).toBe(false); + + await bridge.dispose("test cleanup"); + }, 60_000); +}); diff --git a/daemon/src/sandbox/__tests__/quickjs-host.test.ts b/daemon/src/sandbox/__tests__/quickjs-host.test.ts new file mode 100644 index 00000000..4c746a28 --- /dev/null +++ b/daemon/src/sandbox/__tests__/quickjs-host.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { QuickJSHost } from "../quickjs-host.js"; + +const hosts = new Set(); + +async function createHost( + options: Parameters[0] = {}, +): Promise { + const host = await QuickJSHost.create(options); + hosts.add(host); + return host; +} + +afterEach(() => { + for (const host of hosts) { + host.dispose(); + } + hosts.clear(); +}); + +describe("QuickJSHost", () => { + it("executes simple expressions", async () => { + const host = await createHost({ + globals: { + appName: "dev-browser", + }, + }); + + expect(host.executeScriptSync("1 + 2 + 3")).toBe(6); + expect(host.executeScriptSync("appName")).toBe("dev-browser"); + }); + + it("exposes host functions and transport callbacks", async () => { + const sentMessages: string[] = []; + const host = await createHost({ + hostFunctions: { + add: (left: unknown, right: unknown) => Number(left) + Number(right), + }, + onTransportSend: (message) => { + sentMessages.push(message); + }, + }); + + expect(host.executeScriptSync('__hostCall("add", JSON.stringify([4, 5]))')).toBe(9); + expect( + host.executeScriptSync('__transport_send(JSON.stringify({ type: "ping", id: 1 }))'), + ).toBeUndefined(); + expect(sentMessages).toEqual(['{"type":"ping","id":1}']); + }); + + it("handles async host functions", async () => { + const host = await createHost({ + hostFunctions: { + addAsync: async (left: unknown, right: unknown) => { + await new Promise((resolve) => setTimeout(resolve, 5)); + return Number(left) + Number(right); + }, + }, + }); + + await expect( + host.executeScript(` + (async () => { + return await __hostCall("addAsync", JSON.stringify([7, 8])); + })() + `), + ).resolves.toBe(15); + }); + + it("lets the host call functions inside the sandbox", async () => { + const host = await createHost(); + + host.executeScriptSync(` + globalThis.__transport_receive = (message) => { + globalThis.lastMessage = message; + return message.toUpperCase(); + }; + `); + + await expect(host.callFunction("__transport_receive", "hello from host")).resolves.toBe( + "HELLO FROM HOST", + ); + expect(host.executeScriptSync("lastMessage")).toBe("hello from host"); + }); + + it("supports setTimeout and clearTimeout", async () => { + const host = await createHost(); + + await expect( + host.executeScript(` + (async () => { + let fired = false; + const cancelled = setTimeout(() => { + fired = true; + }, 1); + clearTimeout(cancelled); + + return await new Promise((resolve) => { + setTimeout(() => resolve(fired ? "wrong" : "done"), 5); + }); + })() + `), + ).resolves.toBe("done"); + }); + + it("enforces memory limits", async () => { + const host = await createHost({ + memoryLimitBytes: 1024 * 1024, + }); + + await expect( + host.executeScript(` + const chunks = []; + while (true) { + chunks.push("x".repeat(1024)); + } + `), + ).rejects.toThrow(/out of memory/i); + }); + + it("enforces CPU time limits", async () => { + const host = await createHost({ + cpuTimeoutMs: 10, + }); + + await expect(host.executeScript("while (true) {}")).rejects.toThrow(/interrupted/i); + }); + + it("routes console output to the host callback", async () => { + const entries: Array<{ level: string; args: unknown[] }> = []; + const host = await createHost({ + onConsole: (level, args) => { + entries.push({ level, args }); + }, + }); + + expect(host.executeScriptSync('console.log("hello", 42, { ok: true })')).toBeUndefined(); + expect(entries).toEqual([ + { + level: "log", + args: ["hello", 42, { ok: true }], + }, + ]); + }); + + it("disposes resources cleanly", async () => { + const host = await createHost(); + + host.executeScriptSync("setTimeout(() => {}, 1)"); + + expect(() => host.dispose()).not.toThrow(); + expect(host.disposed).toBe(true); + expect(() => host.dispose()).not.toThrow(); + await expect(host.executeScript("1 + 1")).rejects.toThrow(/disposed/i); + + hosts.delete(host); + }); +}); diff --git a/daemon/src/sandbox/__tests__/sandbox-file-io.test.ts b/daemon/src/sandbox/__tests__/sandbox-file-io.test.ts new file mode 100644 index 00000000..10ff7d48 --- /dev/null +++ b/daemon/src/sandbox/__tests__/sandbox-file-io.test.ts @@ -0,0 +1,238 @@ +import { execFile } from "node:child_process"; +import { + mkdtemp, + readFile as readFileFs, + rm, + stat, + symlink, + writeFile as writeFileFs, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { BrowserManager } from "../../browser-manager.js"; +import { DEV_BROWSER_TMP_DIR, ensureDevBrowserTempDir, resolveDevBrowserTempPath } from "../../temp-files.js"; +import { runScript } from "../script-runner-quickjs.js"; + +const execFileAsync = promisify(execFile); +const daemonDir = new URL("../../../", import.meta.url); +const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +const browserName = "sandbox-file-io"; +const INVALID_PATH_ERROR = + /absolute paths are not allowed|null bytes|must not contain|escapes the controlled temp directory|symlink/i; + +interface CapturedOutput { + stdout: string[]; + stderr: string[]; +} + +function createOutput(): CapturedOutput & { + sink: { + onStdout: (data: string) => void; + onStderr: (data: string) => void; + }; +} { + const stdout: string[] = []; + const stderr: string[] = []; + + return { + stdout, + stderr, + sink: { + onStdout: (data) => { + stdout.push(data); + }, + onStderr: (data) => { + stderr.push(data); + }, + }, + }; +} + +function parseLastJsonLine(output: CapturedOutput): T { + const lines = output.stdout.map((line) => line.trim()).filter((line) => line.length > 0); + const lastLine = lines.at(-1); + if (!lastLine) { + throw new Error("Expected sandbox output"); + } + + return JSON.parse(lastLine) as T; +} + +describe.sequential("QuickJS sandbox file I/O", () => { + let browserRootDir = ""; + let manager: BrowserManager; + const cleanupPaths = new Set(); + + beforeAll(async () => { + await execFileAsync(pnpmCommand, ["run", "bundle:sandbox-client"], { + cwd: daemonDir, + }); + + await ensureDevBrowserTempDir(); + + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-quickjs-file-io-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + await manager.ensureBrowser(browserName, { + headless: true, + }); + }, 180_000); + + afterEach(async () => { + for (const filePath of cleanupPaths) { + await rm(filePath, { + recursive: true, + force: true, + }); + } + cleanupPaths.clear(); + }); + + afterAll(async () => { + await manager.stopAll(); + await rm(browserRootDir, { + recursive: true, + force: true, + }); + }, 180_000); + + async function runSandboxScript(script: string): Promise { + const output = createOutput(); + await runScript(script, manager, browserName, output.sink, { + timeout: 60_000, + }); + return output; + } + + async function expectSandboxScriptToThrow(script: string, matcher = INVALID_PATH_ERROR): Promise { + const output = createOutput(); + await expect(runScript(script, manager, browserName, output.sink, { timeout: 60_000 })).rejects.toThrow( + matcher, + ); + } + + it("saves screenshot buffers into the controlled temp directory", async () => { + const requestedName = "sandbox file io screenshot?.png"; + const expectedPath = await resolveDevBrowserTempPath(requestedName); + cleanupPaths.add(expectedPath); + + const output = await runSandboxScript(` + const page = await browser.getPage("file-io-save-screenshot"); + await page.setContent("

Screenshot

"); + const screenshot = await page.screenshot(); + const savedPath = await saveScreenshot(screenshot, ${JSON.stringify(requestedName)}); + console.log(JSON.stringify({ savedPath, size: screenshot.length })); + `); + + const result = parseLastJsonLine<{ savedPath: string; size: number }>(output); + expect(result.savedPath).toBe(expectedPath); + expect(result.savedPath.startsWith(`${path.resolve(DEV_BROWSER_TMP_DIR)}${path.sep}`)).toBe(true); + expect(result.size).toBeGreaterThan(0); + expect((await stat(result.savedPath)).size).toBeGreaterThan(0); + }, 120_000); + + it("writes page.screenshot({ path }) into the controlled temp directory", async () => { + const requestedName = "page screenshot path?.png"; + const expectedPath = await resolveDevBrowserTempPath(requestedName); + cleanupPaths.add(expectedPath); + + const output = await runSandboxScript(` + const page = await browser.getPage("file-io-page-screenshot"); + await page.setContent("

Path Screenshot

"); + const options = { path: ${JSON.stringify(requestedName)} }; + const screenshot = await page.screenshot(options); + console.log(JSON.stringify({ savedPath: options.path, size: screenshot.length })); + `); + + const result = parseLastJsonLine<{ savedPath: string; size: number }>(output); + expect(result.savedPath).toBe(expectedPath); + expect(result.size).toBeGreaterThan(0); + expect((await stat(result.savedPath)).size).toBeGreaterThan(0); + }, 120_000); + + it("writes and reads back controlled temp files", async () => { + const requestedName = "sandbox file io data?.json"; + const expectedPath = await resolveDevBrowserTempPath(requestedName); + cleanupPaths.add(expectedPath); + + const output = await runSandboxScript(` + const savedPath = await writeFile( + ${JSON.stringify(requestedName)}, + JSON.stringify({ ok: true, value: 42 }), + ); + const content = await readFile(${JSON.stringify(requestedName)}); + console.log(JSON.stringify({ savedPath, content })); + `); + + const result = parseLastJsonLine<{ savedPath: string; content: string }>(output); + expect(result.savedPath).toBe(expectedPath); + expect(result.content).toBe('{"ok":true,"value":42}'); + expect(await readFileFs(result.savedPath, "utf8")).toBe(result.content); + }); + + it("sanitizes harmless filename characters before writing", async () => { + const requestedName = "report 2026:03:18?.json"; + const expectedPath = await resolveDevBrowserTempPath(requestedName); + cleanupPaths.add(expectedPath); + + const output = await runSandboxScript(` + const savedPath = await writeFile(${JSON.stringify(requestedName)}, "sanitized"); + console.log(JSON.stringify({ savedPath })); + `); + + const result = parseLastJsonLine<{ savedPath: string }>(output); + expect(result.savedPath).toBe(expectedPath); + expect(path.basename(result.savedPath)).toBe("report_2026_03_18_.json"); + expect(await readFileFs(result.savedPath, "utf8")).toBe("sanitized"); + }); + + it("rejects traversal, absolute paths, and null bytes", async () => { + const invalidNames = [ + "../escape", + "..\\escape", + "../../etc/passwd", + "safe/../../escape", + "/absolute/path", + "C:\\evil.txt", + "bad\0name.txt", + ]; + + for (const invalidName of invalidNames) { + await expectSandboxScriptToThrow( + `await writeFile(${JSON.stringify(invalidName)}, "blocked");`, + ); + await expectSandboxScriptToThrow(`await readFile(${JSON.stringify(invalidName)});`); + await expectSandboxScriptToThrow( + `await saveScreenshot(new Uint8Array([1, 2, 3]), ${JSON.stringify(invalidName)});`, + ); + } + }); + + it("rejects unsafe screenshot path options", async () => { + await expectSandboxScriptToThrow(` + const page = await browser.getPage("file-io-invalid-screenshot"); + await page.setContent("

Invalid

"); + await page.screenshot({ path: "../escape.png" }); + `); + }, 120_000); + + it("rejects symlinked temp-file targets", async () => { + const symlinkName = "sandbox-file-io-symlink.txt"; + const symlinkPath = path.join(DEV_BROWSER_TMP_DIR, symlinkName); + const targetPath = path.join(os.tmpdir(), "sandbox-file-io-symlink-target.txt"); + + cleanupPaths.add(symlinkPath); + cleanupPaths.add(targetPath); + + await writeFileFs(targetPath, "outside", "utf8"); + await symlink(targetPath, symlinkPath); + + await expectSandboxScriptToThrow( + `await writeFile(${JSON.stringify(symlinkName)}, "should fail");`, + ); + await expectSandboxScriptToThrow(`await readFile(${JSON.stringify(symlinkName)});`); + }); +}); diff --git a/daemon/src/sandbox/__tests__/sandbox-integration.test.ts b/daemon/src/sandbox/__tests__/sandbox-integration.test.ts new file mode 100644 index 00000000..4f151fb2 --- /dev/null +++ b/daemon/src/sandbox/__tests__/sandbox-integration.test.ts @@ -0,0 +1,177 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { BrowserManager } from "../../browser-manager.js"; +import { runScript } from "../script-runner-quickjs.js"; + +const execFileAsync = promisify(execFile); +const daemonDir = new URL("../../../", import.meta.url); +const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + +interface CapturedOutput { + stdout: string[]; + stderr: string[]; +} + +function createOutput(): CapturedOutput & { + sink: { + onStdout: (data: string) => void; + onStderr: (data: string) => void; + }; +} { + const stdout: string[] = []; + const stderr: string[] = []; + + return { + stdout, + stderr, + sink: { + onStdout: (data) => { + stdout.push(data); + }, + onStderr: (data) => { + stderr.push(data); + }, + }, + }; +} + +describe.sequential("QuickJS sandbox integration", () => { + let browserRootDir = ""; + let manager: BrowserManager; + + beforeAll(async () => { + await execFileAsync(pnpmCommand, ["run", "bundle:sandbox-client"], { + cwd: daemonDir, + }); + + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-quickjs-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + await manager.ensureBrowser("default", { + headless: true, + }); + }, 180_000); + + afterAll(async () => { + await manager.stopAll(); + await rm(browserRootDir, { + recursive: true, + force: true, + }); + }, 180_000); + + it("navigates through QuickJS and logs the page title", async () => { + const output = createOutput(); + + await runScript( + ` + const page = await browser.getPage("nav"); + await page.goto("https://example.com"); + console.log(await page.title()); + `, + manager, + "default", + output.sink, + { + timeout: 60_000, + }, + ); + + expect(output.stdout.join("")).toContain("Example Domain"); + expect(output.stderr.join("")).toBe(""); + }, 120_000); + + it("supports locator operations", async () => { + const output = createOutput(); + + await runScript( + ` + const page = await browser.getPage("locator"); + await page.goto("https://example.com"); + console.log(await page.locator("h1").textContent()); + `, + manager, + "default", + output.sink, + { + timeout: 60_000, + }, + ); + + expect(output.stdout.join("")).toContain("Example Domain"); + }, 120_000); + + it("supports page.evaluate", async () => { + const output = createOutput(); + + await runScript( + ` + const page = await browser.getPage("evaluate"); + await page.goto("https://example.com"); + console.log(await page.evaluate(() => document.title)); + `, + manager, + "default", + output.sink, + { + timeout: 60_000, + }, + ); + + expect(output.stdout.join("")).toContain("Example Domain"); + }, 120_000); + + it("reports thrown script errors", async () => { + const output = createOutput(); + + await expect( + runScript( + ` + throw new Error("boom"); + `, + manager, + "default", + output.sink, + ), + ).rejects.toThrow("boom"); + }); + + it("enforces CPU timeouts", async () => { + const output = createOutput(); + + await expect( + runScript( + ` + while (true) {} + `, + manager, + "default", + output.sink, + { + timeout: 25, + }, + ), + ).rejects.toThrow(/timed out|interrupted/i); + }, 120_000); + + it("routes console output to stdout", async () => { + const output = createOutput(); + + await runScript( + ` + console.log("sandbox", 42, { ok: true }); + `, + manager, + "default", + output.sink, + ); + + expect(output.stdout.join("")).toContain("sandbox 42 { ok: true }"); + expect(output.stderr.join("")).toBe(""); + }); +}); diff --git a/daemon/src/sandbox/__tests__/sandbox-security.test.ts b/daemon/src/sandbox/__tests__/sandbox-security.test.ts new file mode 100644 index 00000000..3eee23b2 --- /dev/null +++ b/daemon/src/sandbox/__tests__/sandbox-security.test.ts @@ -0,0 +1,193 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { BrowserManager } from "../../browser-manager.js"; +import { runScript } from "../script-runner-quickjs.js"; + +const browserName = "sandbox-security"; + +interface CapturedOutput { + stdout: string[]; + stderr: string[]; +} + +function createOutput(): CapturedOutput & { + sink: { + onStdout: (data: string) => void; + onStderr: (data: string) => void; + }; +} { + const stdout: string[] = []; + const stderr: string[] = []; + + return { + stdout, + stderr, + sink: { + onStdout: (data) => { + stdout.push(data); + }, + onStderr: (data) => { + stderr.push(data); + }, + }, + }; +} + +describe.sequential("QuickJS sandbox security", () => { + let browserRootDir = ""; + let manager: BrowserManager; + + beforeAll(async () => { + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-quickjs-security-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + await manager.ensureBrowser(browserName, { + headless: true, + }); + }, 180_000); + + afterAll(async () => { + await manager.stopAll(); + await rm(browserRootDir, { + recursive: true, + force: true, + }); + }, 180_000); + + async function runSandboxScript( + script: string, + options: { timeout?: number; memoryLimitBytes?: number } = {}, + ): Promise { + const output = createOutput(); + await runScript(script, manager, browserName, output.sink, options); + return output; + } + + function expectSandboxScriptToThrow( + script: string, + matcher: RegExp, + options: { timeout?: number; memoryLimitBytes?: number } = {}, + ): Promise { + const output = createOutput(); + return expect(runScript(script, manager, browserName, output.sink, options)).rejects.toThrow( + matcher, + ); + } + + it("does not expose require", async () => { + await expectSandboxScriptToThrow(`require("fs");`, /require|not defined/i); + }); + + it("does not expose process", async () => { + await expectSandboxScriptToThrow(`process.exit();`, /process|not defined/i); + }); + + it("does not expose fetch", async () => { + await expectSandboxScriptToThrow(`fetch("https://evil.com");`, /fetch|not defined/i); + }); + + it("does not expose WebSocket", async () => { + await expectSandboxScriptToThrow( + `new WebSocket("wss://evil.example");`, + /WebSocket|not defined/i, + ); + }); + + it("does not escape through the constructor chain", async () => { + await expectSandboxScriptToThrow( + `this.constructor.constructor("return process")();`, + /process|not defined/i, + ); + }); + + it("does not allow dynamic imports", async () => { + await expectSandboxScriptToThrow(`await import("node:fs");`, /import|module|load/i); + }); + + it("enforces memory limits", async () => { + await expectSandboxScriptToThrow( + ` + const chunks = []; + while (true) { + chunks.push("x".repeat(1024)); + } + `, + /out of memory/i, + { + memoryLimitBytes: 1024 * 1024, + }, + ); + }, 120_000); + + it("enforces CPU time limits", async () => { + await expectSandboxScriptToThrow(`while (true) {}`, /timed out|interrupted/i, { + timeout: 25, + }); + }, 120_000); + + it("only exposes the expected globals and browser API", async () => { + const output = await runSandboxScript(` + console.log( + JSON.stringify({ + globals: Object.getOwnPropertyNames(globalThis).sort(), + browserKeys: Object.getOwnPropertyNames(browser).sort(), + browserHasNullPrototype: Object.getPrototypeOf(browser) === null, + }), + ); + `); + + expect(output.stderr).toEqual([]); + expect(output.stdout).toHaveLength(1); + + const reportLine = output.stdout[0]; + if (reportLine === undefined) { + throw new Error("Sandbox globals report was not captured"); + } + + const payload = JSON.parse(reportLine) as { + globals: string[]; + browserKeys: string[]; + browserHasNullPrototype: boolean; + }; + + expect(payload.globals).not.toContain("require"); + expect(payload.globals).not.toContain("process"); + expect(payload.globals).not.toContain("fetch"); + expect(payload.globals).not.toContain("__dirname"); + expect(payload.globals).not.toContain("__filename"); + expect(payload.globals).not.toContain("__hostCall"); + expect(payload.globals).not.toContain("__transport_send"); + expect(payload.globals).not.toContain("__connection"); + expect(payload.globals).not.toContain("__playwright"); + expect(payload.globals).not.toContain("__browser"); + expect(payload.globals).not.toContain("__PlaywrightClient"); + expect(payload.globals).not.toContain("__createPlaywrightClient"); + expect(payload.globals).toContain("readFile"); + expect(payload.globals).toContain("saveScreenshot"); + expect(payload.globals).toContain("writeFile"); + expect(payload.browserKeys).toEqual(["closePage", "getPage", "listPages", "newPage"]); + expect(payload.browserHasNullPrototype).toBe(true); + }, 120_000); + + it("captures console output without leaking to host stdout", async () => { + const output = createOutput(); + const stdoutSpy = vi.spyOn(process.stdout, "write"); + let leakedToHostStdout = false; + + try { + await runScript(`console.log("secret");`, manager, browserName, output.sink); + leakedToHostStdout = stdoutSpy.mock.calls.some((args) => + args.some((value) => String(value).includes("secret")), + ); + } finally { + stdoutSpy.mockRestore(); + } + + expect(output.stdout.join("")).toContain("secret"); + expect(output.stderr).toEqual([]); + expect(leakedToHostStdout).toBe(false); + }); +}); diff --git a/daemon/src/sandbox/forked-client/README.md b/daemon/src/sandbox/forked-client/README.md new file mode 100644 index 00000000..a27ea1d6 --- /dev/null +++ b/daemon/src/sandbox/forked-client/README.md @@ -0,0 +1,375 @@ +# Forked Playwright Client + +## What this is + +This directory is a forked subset of Playwright's client-side code, adapted to run inside the QuickJS WASM sandbox used by dev-browser. + +The goal is simple: + +- sandboxed user scripts should get normal Playwright client objects like `Page`, `Frame`, `Locator`, and `ElementHandle` +- the sandbox should not get direct access to the host filesystem, processes, sockets, or browser ownership + +The fork sits on the client side only. The real browser, dispatcher graph, and browser lifecycle stay on the host side. + +```text +User script + -> QuickJS sandbox + -> forked Playwright client + -> transport bridge + -> host Playwright dispatcher + -> real Playwright + -> browser +``` + +Relevant local paths: + +```text +bundle-entry.ts +quickjs-platform.ts +src/client/* +src/protocol/* +src/utils/isomorphic/* +types/* +../quickjs-sandbox.ts +../host-bridge.ts +../protocol-bridge.ts +../sandbox-transport.ts +``` + +## Why we forked it + +Upstream Playwright client code is not a drop-in fit for QuickJS: + +- it assumes Node.js APIs such as `fs`, `path`, and stream types +- it expects upstream transports like WebSocket or Playwright's local pipe helpers +- it includes surfaces that do not make sense in the sandbox, such as Android, Electron, downloads/artifacts-to-disk, and local utility helpers + +The fork lets dev-browser keep the Playwright object model and protocol semantics while replacing the runtime assumptions around it. + +## Source version and provenance + +The provenance markers for this fork do not fully agree, so treat this directory as the source of truth. + +- `../../../package.json` declares: + +```json +"playwright": "^1.52.0", +"playwright-core": "^1.52.0" +``` + +- The research notes at `/Users/sawyerhood/.middleman/notes/dev-browser/research/playwright-fork-sandbox.md` record upstream commit `3912da7`. +- In practice, the checked-in fork aligns most closely with that `3912da7` snapshot, with local edits layered on top. + +When updating, diff against the exact upstream tag or commit you choose. Do not trust the semver range in `../../../package.json` by itself. + +Upstream path mapping for this fork: + +```text +src/client/* <- packages/playwright-core/src/client/* +src/protocol/channels.d.ts <- packages/protocol/src/channels.d.ts +src/protocol/* <- packages/playwright-core/src/protocol/* +src/utils/isomorphic/* <- packages/playwright-core/src/utils/isomorphic/* +types/* <- packages/playwright-core/types/* +``` + +## Change tiers + +### Tier 1: verbatim + +Strict byte-for-byte copies are rare in this fork because most `.ts` files add `// @ts-nocheck` and/or replace monorepo alias imports with local relative imports. + +Strict verbatim file list: + +```text +types/structs.d.ts +``` + +Everything else should be treated as fork-maintained, even when the runtime logic is effectively upstream. + +### Tier 2: modified + +These files have intentional behavior changes or important local wiring: + +- `quickjs-platform.ts` + - new file + - provides a minimal `Platform` implementation for QuickJS + - supplies colors, GUID generation, a lightweight pseudo-SHA1, noop zones/logging, and throws for Node-only APIs like `fs`, `path`, and streams + +- `bundle-entry.ts` + - new file + - esbuild entry point for the sandbox bundle + - exports `Connection`, `quickjsPlatform`, and the public client-side types used by the sandbox loader + +- `src/client/platform.ts` + - re-exports `quickjsPlatform` + - replaces upstream monorepo alias imports with local relative imports so this directory can bundle standalone + +- `src/client/connection.ts` + - defaults `new Connection()` to `quickjsPlatform` + - keeps the client object graph local to this fork by importing `../protocol/channels` instead of upstream monorepo aliases + - still creates Android/Electron objects when the protocol says they exist, but those classes are stubbed locally + +- `src/client/browserType.ts` + - hard-disables `browserType.connect()` and `browserType.launchPersistentContext()` + - keeps browser ownership on the host side instead of letting sandboxed code launch or attach directly + - leaves `launch()` and `connectOverCDP()` in place for the protocol surface, subject to host policy + +- `src/client/fileUtils.ts` + - replaces upstream filesystem helpers with sandbox-safe behavior + - `mkdirIfNeeded()` becomes a noop + - `writeTempFile()` delegates to `globalThis.writeFile`, which is injected by `../quickjs-sandbox.ts` + +- `src/client/page.ts` + - intercepts `page.screenshot({ path })` + - strips `path` before sending the protocol request + - saves the returned buffer through `writeTempFile()` so sandboxed code never writes host paths directly + +- `src/client/elementHandle.ts` + - same screenshot-path interception as `src/client/page.ts` + +- `src/client/browserContext.ts` + - removes `Debugger` wiring from the client-side initializer + - replaces upstream `@recorder/actions` imports with local `types/recorder-actions.d.ts` + +- `src/protocol/channels.d.ts` + - vendored locally from Playwright's protocol package + - kept here so the fork bundles without depending on the upstream monorepo layout + +- `src/protocol/validator.ts` + - local validator snapshot matching the vendored protocol/types in this directory + +- `types/protocol.d.ts` +- `types/types.d.ts` + - vendored type declarations that preserve the normal Playwright API surface inside the sandbox + - note that the types intentionally still describe some APIs that later throw at runtime because the runtime is stubbed + +- `src/client/connect.ts` + - copied into the fork, but it is not the main QuickJS transport path + - the sandbox path constructs `Connection` directly in `../quickjs-sandbox.ts` and pumps protocol JSON through `../host-bridge.ts` + +- Most other `.ts` files in `src/client/`, `src/protocol/`, and `src/utils/isomorphic/` + - mechanical edits only + - mainly `// @ts-nocheck` plus replacing upstream aliases like `@protocol/*`, `@isomorphic/*`, and `@recorder/*` with local relative imports + +### Tier 3: stubbed + +These files keep the object graph and type surface intact, but replace the implementation with empty or throwing behavior: + +```text +src/client/android.ts +src/client/artifact.ts +src/client/electron.ts +src/client/fetch.ts +src/client/harRouter.ts +src/client/localUtils.ts +src/client/screencast.ts +src/client/stream.ts +src/client/tracing.ts +src/client/video.ts +src/client/writableStream.ts +types/recorder-actions.d.ts +``` + +Why they are stubbed: + +- Android and Electron are not part of the sandbox execution model +- artifacts, streams, tracing, and HAR helpers assume filesystem or stream access the sandbox does not have +- `APIRequest` and `LocalUtils` would otherwise pull in more host capabilities than this sandbox should expose +- `types/recorder-actions.d.ts` exists only to satisfy local type imports from `src/client/browserContext.ts` + +Related note: + +- `src/client/download.ts` is not itself stubbed, but it delegates to `Artifact`, so download file access is effectively unsupported in the sandbox + +### Tier 4: skipped + +These upstream files are intentionally not copied into this fork: + +```text +src/client/DEPS.list +src/protocol/DEPS.list +src/protocol/callMetadata.d.ts +src/protocol/progress.d.ts +src/protocol/protocol.yml +src/utils/isomorphic/DEPS.list +src/utils/isomorphic/trace/DEPS.list +src/utils/isomorphic/trace/entries.ts +src/utils/isomorphic/trace/snapshotRenderer.ts +src/utils/isomorphic/trace/snapshotServer.ts +src/utils/isomorphic/trace/snapshotStorage.ts +src/utils/isomorphic/trace/traceLoader.ts +src/utils/isomorphic/trace/traceModel.ts +src/utils/isomorphic/trace/traceModernizer.ts +src/utils/isomorphic/trace/traceUtils.ts +src/utils/isomorphic/trace/versions/traceV3.ts +src/utils/isomorphic/trace/versions/traceV4.ts +src/utils/isomorphic/trace/versions/traceV5.ts +src/utils/isomorphic/trace/versions/traceV6.ts +src/utils/isomorphic/trace/versions/traceV7.ts +src/utils/isomorphic/trace/versions/traceV8.ts +``` + +Why they are skipped: + +- `DEPS.list` and protocol generator inputs are monorepo/build metadata, not runtime requirements for the sandbox bundle +- the trace viewer and trace model helpers are not needed to expose the Playwright page automation surface inside QuickJS + +## `quickjs-platform.ts` + +`quickjs-platform.ts` exists because upstream Playwright expects a `Platform` object and QuickJS is neither Node nor a browser runtime in the way Playwright assumes. + +It does three important things: + +- provides the small cross-cutting utilities the client always needs +- explicitly throws for host features the sandbox must not get +- gives `Connection` a safe default runtime via `src/client/connection.ts` + +The file is intentionally small. If you are adding a new path-based or stream-based API to the sandbox, this is one of the first places to inspect. + +## `bundle-entry.ts` + +`bundle-entry.ts` is the esbuild entry point for the fork. + +It does not try to recreate the published `playwright` package. It exports only the client-side pieces the sandbox loader needs: + +```text +Connection +quickjsPlatform +Browser +BrowserContext +Frame +Locator +Page +Playwright +``` + +`../quickjs-sandbox.ts` loads the built bundle, evaluates it inside QuickJS, and captures the resulting `__PlaywrightClient` global. + +## `stubs/` + +There is currently no `stubs/` directory in this fork. + +Stubbed behavior lives inline in the files listed under Tier 3 instead. + +## How the bundle works + +Build inputs and outputs: + +```text +entry point: bundle-entry.ts +build script: ../../../scripts/bundle-sandbox-client.ts +output: ../../../dist/sandbox-client.js +format: IIFE +global: __PlaywrightClient +platform: neutral +target: es2022 +``` + +Rebuild command: + +```bash +cd daemon && pnpm run bundle:sandbox-client +``` + +The build uses `platform: "neutral"` on purpose. This code is not a normal Node bundle and not a normal browser bundle. It is a self-contained client bundle that QuickJS can evaluate safely. + +## Architecture context + +The fork is only one piece of the stack: + +```text +User script + -> QuickJS sandbox + -> forked Playwright client + -> transport bridge + -> host Playwright dispatcher + -> real Playwright + -> browser +``` + +Concrete local pieces: + +- `../quickjs-sandbox.ts` loads `../../../dist/sandbox-client.js`, installs host callbacks like `writeFile`, and creates the sandbox-side `Connection` +- `../host-bridge.ts` owns the host-side `DispatcherConnection`, `RootDispatcher`, and `PlaywrightDispatcher` +- `../protocol-bridge.ts` wires both sides together +- `../sandbox-transport.ts` shows the same shape in the pure Node test bridge + +One subtle but important detail: + +- the QuickJS transport path does not go through `src/client/browserType.connect()` +- instead, the sandbox creates `Connection` directly and exchanges raw Playwright protocol messages with the host bridge + +That is why `browserType.connect()` is stubbed even though the sandbox still has a transport bridge. + +## How to update the fork + +1. Clone Playwright at the exact target tag or commit you want to adopt. + + Do not rely only on the `^1.52.0` range in `../../../package.json`. Pick one concrete upstream revision first. + +2. Diff the upstream files against this directory. + + Compare at least these upstream paths: + + ```text + packages/playwright-core/src/client/* + packages/playwright-core/src/protocol/* + packages/playwright-core/src/utils/isomorphic/* + packages/playwright-core/types/* + packages/protocol/src/channels.d.ts + ``` + +3. Reapply the local fork changes while keeping upstream behavior where possible. + + The most conflict-prone files are: + + ```text + quickjs-platform.ts + bundle-entry.ts + src/client/connection.ts + src/client/platform.ts + src/client/browserType.ts + src/client/fileUtils.ts + src/client/page.ts + src/client/elementHandle.ts + src/client/browserContext.ts + src/client/artifact.ts + src/client/fetch.ts + src/client/localUtils.ts + src/client/harRouter.ts + src/client/tracing.ts + src/client/android.ts + src/client/electron.ts + src/client/stream.ts + src/client/writableStream.ts + src/protocol/channels.d.ts + src/protocol/validator.ts + types/types.d.ts + types/protocol.d.ts + types/recorder-actions.d.ts + ``` + + These are the places where upstream changes are most likely to collide with sandbox-specific behavior. + +4. Rebuild the sandbox client bundle. + + ```bash + cd daemon && pnpm run bundle:sandbox-client + ``` + +5. Run the daemon test suite. + + ```bash + cd daemon && pnpm vitest run + ``` + +6. Update the version/provenance section in this README. + + Record the new upstream tag or commit and keep any package-version mismatch explicit. + +## Practical rules + +- Prefer copying upstream code first, then reapplying the sandbox edits. +- Keep new unsupported features stubbed explicitly instead of silently half-working. +- If an upstream change touches screenshot path handling, `Platform`, or protocol type generation, expect manual merge work. +- If you add a new stubbed surface, keep the runtime error message explicit so sandbox users fail fast. diff --git a/daemon/src/sandbox/forked-client/bundle-entry.ts b/daemon/src/sandbox/forked-client/bundle-entry.ts new file mode 100644 index 00000000..555636a1 --- /dev/null +++ b/daemon/src/sandbox/forked-client/bundle-entry.ts @@ -0,0 +1,9 @@ +export { Connection } from "./src/client/connection"; +export { quickjsPlatform } from "./quickjs-platform"; + +export type { Browser } from "./src/client/browser"; +export type { BrowserContext } from "./src/client/browserContext"; +export type { Frame } from "./src/client/frame"; +export type { Locator } from "./src/client/locator"; +export type { Page } from "./src/client/page"; +export type { Playwright } from "./src/client/playwright"; diff --git a/daemon/src/sandbox/forked-client/package.json b/daemon/src/sandbox/forked-client/package.json new file mode 100644 index 00000000..5bbefffb --- /dev/null +++ b/daemon/src/sandbox/forked-client/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/daemon/src/sandbox/forked-client/quickjs-platform.ts b/daemon/src/sandbox/forked-client/quickjs-platform.ts new file mode 100644 index 00000000..445d7e8a --- /dev/null +++ b/daemon/src/sandbox/forked-client/quickjs-platform.ts @@ -0,0 +1,56 @@ +import { webColors } from "./src/utils/isomorphic/colors"; + +import type { Platform, Zone } from "./src/client/platform"; + +const noopZone: Zone = { + push: () => noopZone, + pop: () => noopZone, + run: (callback: () => T) => callback(), + data: () => undefined as T | undefined, +}; + +function unsupported(apiName: string): never { + throw new Error(`${apiName} is not available in the QuickJS sandbox`); +} + +function pseudoSha1(text: string): string { + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +export const quickjsPlatform: Platform = { + name: "empty", + boxedStackPrefixes: () => [], + calculateSha1: async (text: string) => pseudoSha1(text), + colors: webColors, + createGuid: () => + "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (token) => { + const value = Math.floor(Math.random() * 16); + const nibble = token === "x" ? value : (value & 0x3) | 0x8; + return nibble.toString(16); + }), + defaultMaxListeners: () => 10, + env: {}, + fs: () => unsupported("fs"), + inspectCustom: undefined, + isDebugMode: () => false, + isJSDebuggerAttached: () => false, + isLogEnabled: () => false, + isUnderTest: () => false, + log: () => {}, + path: () => unsupported("path"), + pathSeparator: "/", + showInternalStackFrames: () => false, + streamFile: () => unsupported("streamFile"), + streamReadable: () => unsupported("streamReadable"), + streamWritable: () => unsupported("streamWritable"), + zodToJsonSchema: () => unsupported("zodToJsonSchema"), + zones: { + empty: noopZone, + current: () => noopZone, + }, +}; diff --git a/daemon/src/sandbox/forked-client/src/client/android.ts b/daemon/src/sandbox/forked-client/src/client/android.ts new file mode 100644 index 00000000..d6ff3d43 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/android.ts @@ -0,0 +1,28 @@ +// @ts-nocheck +import { ChannelOwner } from './channelOwner'; + +export class Android extends ChannelOwner { + static from(channel) { + return channel?._object; + } +} + +export class AndroidDevice extends ChannelOwner { + static from(channel) { + return channel?._object; + } +} + +export class AndroidSocket extends ChannelOwner { + static from(channel) { + return channel?._object; + } +} + +export class AndroidInput {} + +export class AndroidWebView extends ChannelOwner { + static from(channel) { + return channel?._object; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/api.ts b/daemon/src/sandbox/forked-client/src/client/api.ts new file mode 100644 index 00000000..711f5371 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/api.ts @@ -0,0 +1,49 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Android, AndroidDevice, AndroidInput, AndroidSocket, AndroidWebView } from './android'; +export { Browser } from './browser'; +export { BrowserContext } from './browserContext'; +export type { BrowserServer } from './browserType'; +export { BrowserType } from './browserType'; +export { Clock } from './clock'; +export { ConsoleMessage } from './consoleMessage'; +export { Coverage } from './coverage'; +export { Debugger } from './debugger'; +export { Dialog } from './dialog'; +export type { Disposable } from './disposable'; +export { Download } from './download'; +export { Electron, ElectronApplication } from './electron'; +export { FrameLocator, Locator } from './locator'; +export { ElementHandle } from './elementHandle'; +export { FileChooser } from './fileChooser'; +export type { Screencast } from './screencast'; +export type { Logger } from './types'; +export { TimeoutError } from './errors'; +export { Frame } from './frame'; +export { Keyboard, Mouse, Touchscreen } from './input'; +export { JSHandle } from './jsHandle'; +export { Request, Response, Route, WebSocket, WebSocketRoute } from './network'; +export { APIRequest, APIRequestContext, APIResponse } from './fetch'; +export { Page } from './page'; +export { Selectors } from './selectors'; +export { Tracing } from './tracing'; +export { Video } from './video'; +export { Worker } from './worker'; +export { CDPSession } from './cdpSession'; +export { Playwright } from './playwright'; +export { WebError } from './webError'; diff --git a/daemon/src/sandbox/forked-client/src/client/artifact.ts b/daemon/src/sandbox/forked-client/src/client/artifact.ts new file mode 100644 index 00000000..403d3930 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/artifact.ts @@ -0,0 +1,36 @@ +// @ts-nocheck +import { ChannelOwner } from './channelOwner'; + +export class Artifact extends ChannelOwner { + static from(channel) { + return channel?._object; + } + + async pathAfterFinished() { + throw new Error('Artifacts are not available in the QuickJS sandbox'); + } + + async saveAs() { + throw new Error('Artifacts are not available in the QuickJS sandbox'); + } + + async failure() { + return null; + } + + async createReadStream() { + throw new Error('Artifacts are not available in the QuickJS sandbox'); + } + + async readIntoBuffer() { + throw new Error('Artifacts are not available in the QuickJS sandbox'); + } + + async cancel() { + await this._channel.cancel?.(); + } + + async delete() { + await this._channel.delete?.(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/browser.ts b/daemon/src/sandbox/forked-client/src/client/browser.ts new file mode 100644 index 00000000..25907d28 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/browser.ts @@ -0,0 +1,207 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Artifact } from './artifact'; +import { BrowserContext, prepareBrowserContextParams } from './browserContext'; +import { CDPSession } from './cdpSession'; +import { ChannelOwner } from './channelOwner'; +import { isTargetClosedError } from './errors'; +import { Events } from './events'; +import { mkdirIfNeeded } from './fileUtils'; + +import type { BrowserType } from './browserType'; +import type { Page } from './page'; +import type { BrowserContextOptions, LaunchOptions, Logger } from './types'; +import type * as api from '../../types/types'; +import type * as channels from '../protocol/channels'; + +export class Browser extends ChannelOwner implements api.Browser { + readonly _contexts = new Set(); + private _isConnected = true; + private _closedPromise: Promise; + _shouldCloseConnectionOnClose = false; + _browserType!: BrowserType; + private _options: LaunchOptions = {}; + private _userDataDir: string | undefined; + readonly _name: string; + private _path: string | undefined; + _closeReason: string | undefined; + + static from(browser: channels.BrowserChannel): Browser { + return (browser as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.BrowserInitializer) { + super(parent, type, guid, initializer); + this._name = initializer.name; + this._channel.on('context', ({ context }) => this._didCreateContext(BrowserContext.from(context))); + this._channel.on('close', () => this._didClose()); + this._closedPromise = new Promise(f => this.once(Events.Browser.Disconnected, f)); + } + + browserType(): BrowserType { + return this._browserType; + } + + async newContext(options: BrowserContextOptions = {}): Promise { + return await this._innerNewContext(options, false); + } + + async _newContextForReuse(options: BrowserContextOptions = {}): Promise { + return await this._innerNewContext(options, true); + } + + async _disconnectFromReusedContext(reason: string) { + const context = [...this._contexts].find(context => context._forReuse); + if (!context) + return; + await this._instrumentation.runBeforeCloseBrowserContext(context); + for (const page of context.pages()) + page._onClose(); + context._onClose(); + await this._channel.disconnectFromReusedContext({ reason }); + } + + async _innerNewContext(userOptions: BrowserContextOptions = {}, forReuse: boolean): Promise { + const options = this._browserType._playwright.selectors._withSelectorOptions(userOptions); + await this._instrumentation.runBeforeCreateBrowserContext(options); + const contextOptions = await prepareBrowserContextParams(this._platform, options); + const response = forReuse ? await this._channel.newContextForReuse(contextOptions) : await this._channel.newContext(contextOptions); + const context = BrowserContext.from(response.context); + if (forReuse) + context._forReuse = true; + if (options.logger) + context._logger = options.logger; + await context._initializeHarFromOptions(options.recordHar); + await this._instrumentation.runAfterCreateBrowserContext(context); + return context; + } + + _connectToBrowserType(browserType: BrowserType, browserOptions: LaunchOptions, logger: Logger | undefined, userDataDir?: string) { + // Note: when using connect(), `browserType` is different from `this._parent`. + // This is why browser type is not wired up in the constructor, + // and instead this separate method is called later on. + this._browserType = browserType; + this._options = browserOptions; + this._userDataDir = userDataDir; + this._logger = logger; + for (const context of this._contexts) + this._setupBrowserContext(context); + } + + private _didCreateContext(context: BrowserContext) { + context._browser = this; + this._contexts.add(context); + // Note: when connecting to a browser, initial contexts arrive before `browserType` is set, + // and will be configured later in `_connectToBrowserType`. + if (this._browserType) + this._setupBrowserContext(context); + } + + private _setupBrowserContext(context: BrowserContext) { + context._logger = this._logger; + context.tracing._tracesDir = this._options.tracesDir; + this._browserType._contexts.add(context); + this._browserType._playwright.selectors._contextsForSelectors.add(context); + context.setDefaultTimeout(this._browserType._playwright._defaultContextTimeout); + context.setDefaultNavigationTimeout(this._browserType._playwright._defaultContextNavigationTimeout); + } + + contexts(): BrowserContext[] { + return [...this._contexts]; + } + + version(): string { + return this._initializer.version; + } + + async _register(title: string, options: { workspaceDir?: string, metadata?: Record, wsPath?: string } = {}): Promise<{ pipeName: string }> { + const { pipeName } = await this._channel.startServer({ title, ...options }); + return { pipeName }; + } + + async _unregister(): Promise { + await this._channel.stopServer(); + } + + async newPage(options: BrowserContextOptions = {}): Promise { + return await this._wrapApiCall(async () => { + const context = await this.newContext(options); + const page = await context.newPage(); + page._ownedContext = context; + context._ownerPage = page; + return page; + }, { title: 'Create page' }); + } + + isConnected(): boolean { + return this._isConnected; + } + + launchOptions(): LaunchOptions { + return this._options; + } + + userDataDir(): string | null { + return this._userDataDir ?? null; + } + + async newBrowserCDPSession(): Promise { + return CDPSession.from((await this._channel.newBrowserCDPSession()).session); + } + + async startTracing(page?: Page, options: { path?: string; screenshots?: boolean; categories?: string[]; } = {}) { + this._path = options.path; + await this._channel.startTracing({ ...options, page: page ? page._channel : undefined }); + } + + async stopTracing(): Promise { + const artifact = Artifact.from((await this._channel.stopTracing()).artifact); + const buffer = await artifact.readIntoBuffer(); + await artifact.delete(); + if (this._path) { + await mkdirIfNeeded(this._platform, this._path); + await this._platform.fs().promises.writeFile(this._path, buffer); + this._path = undefined; + } + return buffer; + } + + async [Symbol.asyncDispose]() { + await this.close(); + } + + async close(options: { reason?: string } = {}): Promise { + this._closeReason = options.reason; + try { + if (this._shouldCloseConnectionOnClose) + this._connection.close(); + else + await this._channel.close(options); + await this._closedPromise; + } catch (e) { + if (isTargetClosedError(e)) + return; + throw e; + } + } + + _didClose() { + this._isConnected = false; + this.emit(Events.Browser.Disconnected, this); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/browserContext.ts b/daemon/src/sandbox/forked-client/src/client/browserContext.ts new file mode 100644 index 00000000..2fcce012 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/browserContext.ts @@ -0,0 +1,652 @@ +// @ts-nocheck +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Artifact } from './artifact'; +import { Browser } from './browser'; +import { CDPSession } from './cdpSession'; +import { ChannelOwner } from './channelOwner'; +import { evaluationScript } from './clientHelper'; +import { Clock } from './clock'; +import { ConsoleMessage } from './consoleMessage'; +import { Dialog } from './dialog'; +import { DisposableObject, DisposableStub } from './disposable'; +import { TargetClosedError, parseError } from './errors'; +import { Events } from './events'; +import { APIRequestContext } from './fetch'; +import { Frame } from './frame'; +import { HarRouter } from './harRouter'; +import * as network from './network'; +import { BindingCall, Page } from './page'; +import { Tracing } from './tracing'; +import { Waiter } from './waiter'; +import { WebError } from './webError'; +import { Worker } from './worker'; +import { TimeoutSettings } from './timeoutSettings'; +import { mkdirIfNeeded } from './fileUtils'; +import { headersArrayToObject, headersObjectToArray } from '../utils/isomorphic/headers'; +import { urlMatchesEqual } from '../utils/isomorphic/urlMatch'; +import { isRegExp, isString } from '../utils/isomorphic/rtti'; +import { rewriteErrorMessage } from '../utils/isomorphic/stackTrace'; + +import type { BrowserContextOptions, Headers, SetStorageState, StorageState, WaitForEventOptions } from './types'; +import type * as structs from '../../types/structs'; +import type * as api from '../../types/types'; +import type { URLMatch } from '../utils/isomorphic/urlMatch'; +import type { Platform } from './platform'; +import type * as channels from '../protocol/channels'; +import type * as actions from '../../types/recorder-actions'; + +interface RecorderEventSink { + actionAdded?(page: Page, actionInContext: actions.ActionInContext, code: string): void; + actionUpdated?(page: Page, actionInContext: actions.ActionInContext, code: string): void; + signalAdded?(page: Page, signal: actions.SignalInContext): void; +} + +export class BrowserContext extends ChannelOwner implements api.BrowserContext { + _pages = new Set(); + _routes: network.RouteHandler[] = []; + _webSocketRoutes: network.WebSocketRouteHandler[] = []; + // Browser is null for browser contexts created outside of normal browser, e.g. android or electron. + _browser: Browser | null = null; + readonly _bindings = new Map any>(); + _timeoutSettings: TimeoutSettings; + _ownerPage: Page | undefined; + _forReuse = false; + private _closedPromise: Promise; + readonly _options: channels.BrowserNewContextParams; + + readonly request: APIRequestContext; + readonly tracing: Tracing; + readonly clock: Clock; + + readonly _serviceWorkers = new Set(); + private _harRecorders = new Map(); + private _closingStatus: 'none' | 'closing' | 'closed' = 'none'; + private _closeReason: string | undefined; + private _harRouters: HarRouter[] = []; + private _onRecorderEventSink: RecorderEventSink | undefined; + + + static from(context: channels.BrowserContextChannel): BrowserContext { + return (context as any)._object; + } + + static fromNullable(context: channels.BrowserContextChannel | null): BrowserContext | null { + return context ? BrowserContext.from(context) : null; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.BrowserContextInitializer) { + super(parent, type, guid, initializer); + this._options = initializer.options; + this._timeoutSettings = new TimeoutSettings(this._platform); + this.tracing = Tracing.from(initializer.tracing); + this.request = APIRequestContext.from(initializer.requestContext); + this.request._timeoutSettings = this._timeoutSettings; + this.clock = new Clock(this); + + this._channel.on('bindingCall', ({ binding }) => this._onBinding(BindingCall.from(binding))); + this._channel.on('close', () => this._onClose()); + this._channel.on('page', ({ page }) => this._onPage(Page.from(page))); + this._channel.on('route', ({ route }) => this._onRoute(network.Route.from(route))); + this._channel.on('webSocketRoute', ({ webSocketRoute }) => this._onWebSocketRoute(network.WebSocketRoute.from(webSocketRoute))); + this._channel.on('serviceWorker', ({ worker }) => { + const serviceWorker = Worker.from(worker); + serviceWorker._context = this; + this._serviceWorkers.add(serviceWorker); + this.emit(Events.BrowserContext.ServiceWorker, serviceWorker); + }); + this._channel.on('console', event => { + const worker = Worker.fromNullable(event.worker); + const page = Page.fromNullable(event.page); + const consoleMessage = new ConsoleMessage(this._platform, event, page, worker); + worker?.emit(Events.Worker.Console, consoleMessage); + page?.emit(Events.Page.Console, consoleMessage); + if (worker && this._serviceWorkers.has(worker)) { + const scope = this._serviceWorkerScope(worker); + for (const page of this._pages) { + if (scope && page.url().startsWith(scope)) + page.emit(Events.Page.Console, consoleMessage); + } + } + this.emit(Events.BrowserContext.Console, consoleMessage); + }); + this._channel.on('pageError', ({ error, page }) => { + const pageObject = Page.from(page); + const parsedError = parseError(error); + this.emit(Events.BrowserContext.WebError, new WebError(pageObject, parsedError)); + if (pageObject) + pageObject.emit(Events.Page.PageError, parsedError); + }); + this._channel.on('dialog', ({ dialog }) => { + const dialogObject = Dialog.from(dialog); + let hasListeners = this.emit(Events.BrowserContext.Dialog, dialogObject); + const page = dialogObject.page(); + if (page) + hasListeners = page.emit(Events.Page.Dialog, dialogObject) || hasListeners; + if (!hasListeners) { + // Although we do similar handling on the server side, we still need this logic + // on the client side due to a possible race condition between two async calls: + // a) removing "dialog" listener subscription (client->server) + // b) actual "dialog" event (server->client) + if (dialogObject.type() === 'beforeunload') + dialog.accept({}).catch(() => {}); + else + dialog.dismiss().catch(() => {}); + } + }); + this._channel.on('request', ({ request, page }) => this._onRequest(network.Request.from(request), Page.fromNullable(page))); + this._channel.on('requestFailed', ({ request, failureText, responseEndTiming, page }) => this._onRequestFailed(network.Request.from(request), responseEndTiming, failureText, Page.fromNullable(page))); + this._channel.on('requestFinished', params => this._onRequestFinished(params)); + this._channel.on('response', ({ response, page }) => this._onResponse(network.Response.from(response), Page.fromNullable(page))); + this._channel.on('recorderEvent', ({ event, data, page, code }) => { + if (event === 'actionAdded') + this._onRecorderEventSink?.actionAdded?.(Page.from(page), data as actions.ActionInContext, code); + else if (event === 'actionUpdated') + this._onRecorderEventSink?.actionUpdated?.(Page.from(page), data as actions.ActionInContext, code); + else if (event === 'signalAdded') + this._onRecorderEventSink?.signalAdded?.(Page.from(page), data as actions.SignalInContext); + }); + this._closedPromise = new Promise(f => this.once(Events.BrowserContext.Close, f)); + + this._setEventToSubscriptionMapping(new Map([ + [Events.BrowserContext.Console, 'console'], + [Events.BrowserContext.Dialog, 'dialog'], + [Events.BrowserContext.Request, 'request'], + [Events.BrowserContext.Response, 'response'], + [Events.BrowserContext.RequestFinished, 'requestFinished'], + [Events.BrowserContext.RequestFailed, 'requestFailed'], + ])); + } + + async _initializeHarFromOptions(recordHar: BrowserContextOptions['recordHar']) { + if (!recordHar) + return; + const defaultContent = recordHar.path.endsWith('.zip') ? 'attach' : 'embed'; + await this._recordIntoHAR(recordHar.path, null, { + url: recordHar.urlFilter, + updateContent: recordHar.content ?? (recordHar.omitContent ? 'omit' : defaultContent), + updateMode: recordHar.mode ?? 'full', + }); + } + + private _onPage(page: Page): void { + this._pages.add(page); + this.emit(Events.BrowserContext.Page, page); + if (page._opener && !page._opener.isClosed()) + page._opener.emit(Events.Page.Popup, page); + } + + private _onRequest(request: network.Request, page: Page | null) { + this.emit(Events.BrowserContext.Request, request); + if (page) + page.emit(Events.Page.Request, request); + } + + private _onResponse(response: network.Response, page: Page | null) { + this.emit(Events.BrowserContext.Response, response); + if (page) + page.emit(Events.Page.Response, response); + } + + private _onRequestFailed(request: network.Request, responseEndTiming: number, failureText: string | undefined, page: Page | null) { + request._failureText = failureText || null; + request._setResponseEndTiming(responseEndTiming); + this.emit(Events.BrowserContext.RequestFailed, request); + if (page) + page.emit(Events.Page.RequestFailed, request); + } + + private _onRequestFinished(params: channels.BrowserContextRequestFinishedEvent) { + const { responseEndTiming } = params; + const request = network.Request.from(params.request); + const response = network.Response.fromNullable(params.response); + const page = Page.fromNullable(params.page); + request._setResponseEndTiming(responseEndTiming); + this.emit(Events.BrowserContext.RequestFinished, request); + if (page) + page.emit(Events.Page.RequestFinished, request); + if (response) + response._finishedPromise.resolve(null); + } + + async _onRoute(route: network.Route) { + route._context = this; + const page = route.request()._safePage(); + const routeHandlers = this._routes.slice(); + for (const routeHandler of routeHandlers) { + // If the page or the context was closed we stall all requests right away. + if (page?._closeWasCalled || this.isClosedOrClosing()) + return; + if (!routeHandler.matches(route.request().url())) + continue; + const index = this._routes.indexOf(routeHandler); + if (index === -1) + continue; + if (routeHandler.willExpire()) + this._routes.splice(index, 1); + const handled = await routeHandler.handle(route); + if (!this._routes.length) + this._updateInterceptionPatterns({ internal: true }).catch(() => {}); + if (handled) + return; + } + // If the page is closed or unrouteAll() was called without waiting and interception disabled, + // the method will throw an error - silence it. + await route._innerContinue(true /* isFallback */).catch(() => {}); + } + + async _onWebSocketRoute(webSocketRoute: network.WebSocketRoute) { + const routeHandler = this._webSocketRoutes.find(route => route.matches(webSocketRoute.url())); + if (routeHandler) + await routeHandler.handle(webSocketRoute); + else + webSocketRoute.connectToServer(); + } + + async _onBinding(bindingCall: BindingCall) { + const func = this._bindings.get(bindingCall._initializer.name); + if (!func) + return; + await bindingCall.call(func); + } + + private _serviceWorkerScope(serviceWorker: Worker) { + try { + let url = new URL('.', serviceWorker.url()).href; + if (!url.endsWith('/')) + url += '/'; + return url; + } catch { + return null; + } + } + + setDefaultNavigationTimeout(timeout: number | undefined) { + this._timeoutSettings.setDefaultNavigationTimeout(timeout); + } + + setDefaultTimeout(timeout: number | undefined) { + this._timeoutSettings.setDefaultTimeout(timeout); + } + + browser(): Browser | null { + return this._browser; + } + + contextOptions() { + return contextParamsToPublicOptions(this._options); + } + + pages(): Page[] { + return [...this._pages]; + } + + isClosedOrClosing(): boolean { + return this._closingStatus !== 'none'; + } + + async newPage(): Promise { + if (this._ownerPage) + throw new Error('Please use browser.newContext()'); + return Page.from((await this._channel.newPage()).page); + } + + async cookies(urls?: string | string[]): Promise { + if (!urls) + urls = []; + if (urls && typeof urls === 'string') + urls = [urls]; + return (await this._channel.cookies({ urls: urls as string[] })).cookies; + } + + async addCookies(cookies: network.SetNetworkCookieParam[]): Promise { + await this._channel.addCookies({ cookies }); + } + + async clearCookies(options: network.ClearNetworkCookieOptions = {}): Promise { + await this._channel.clearCookies({ + name: isString(options.name) ? options.name : undefined, + nameRegexSource: isRegExp(options.name) ? options.name.source : undefined, + nameRegexFlags: isRegExp(options.name) ? options.name.flags : undefined, + domain: isString(options.domain) ? options.domain : undefined, + domainRegexSource: isRegExp(options.domain) ? options.domain.source : undefined, + domainRegexFlags: isRegExp(options.domain) ? options.domain.flags : undefined, + path: isString(options.path) ? options.path : undefined, + pathRegexSource: isRegExp(options.path) ? options.path.source : undefined, + pathRegexFlags: isRegExp(options.path) ? options.path.flags : undefined, + }); + } + + async grantPermissions(permissions: string[], options?: { origin?: string }): Promise { + await this._channel.grantPermissions({ permissions, ...options }); + } + + async clearPermissions(): Promise { + await this._channel.clearPermissions(); + } + + async setGeolocation(geolocation: { longitude: number, latitude: number, accuracy?: number } | null): Promise { + await this._channel.setGeolocation({ geolocation: geolocation || undefined }); + } + + async setExtraHTTPHeaders(headers: Headers): Promise { + network.validateHeaders(headers); + await this._channel.setExtraHTTPHeaders({ headers: headersObjectToArray(headers) }); + } + + async setOffline(offline: boolean): Promise { + await this._channel.setOffline({ offline }); + } + + async setHTTPCredentials(httpCredentials: { username: string, password: string } | null): Promise { + await this._channel.setHTTPCredentials({ httpCredentials: httpCredentials || undefined }); + } + + async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any) { + const source = await evaluationScript(this._platform, script, arg); + return DisposableObject.from((await this._channel.addInitScript({ source })).disposable); + } + + async exposeBinding(name: string, callback: (source: structs.BindingSource, ...args: any[]) => any, options: { handle?: boolean } = {}): Promise { + const result = await this._channel.exposeBinding({ name, needsHandle: options.handle }); + this._bindings.set(name, callback); + return DisposableObject.from(result.disposable); + } + + async exposeFunction(name: string, callback: Function): Promise { + const result = await this._channel.exposeBinding({ name }); + const binding = (source: structs.BindingSource, ...args: any[]) => callback(...args); + this._bindings.set(name, binding); + return DisposableObject.from(result.disposable); + } + + async route(url: URLMatch, handler: network.RouteHandlerCallback, options: { times?: number } = {}): Promise { + this._routes.unshift(new network.RouteHandler(this._platform, this._options.baseURL, url, handler, options.times)); + await this._updateInterceptionPatterns({ title: 'Route requests' }); + return new DisposableStub(() => this.unroute(url, handler)); + } + + async routeWebSocket(url: URLMatch, handler: network.WebSocketRouteHandlerCallback): Promise { + this._webSocketRoutes.unshift(new network.WebSocketRouteHandler(this._options.baseURL, url, handler)); + await this._updateWebSocketInterceptionPatterns({ title: 'Route WebSockets' }); + } + + async _recordIntoHAR(har: string, page: Page | null, options: { url?: string | RegExp, updateContent?: 'attach' | 'embed' | 'omit', updateMode?: 'minimal' | 'full'} = {}): Promise { + const { harId } = await this._channel.harStart({ + page: page?._channel, + options: { + zip: har.endsWith('.zip'), + content: options.updateContent ?? 'attach', + urlGlob: isString(options.url) ? options.url : undefined, + urlRegexSource: isRegExp(options.url) ? options.url.source : undefined, + urlRegexFlags: isRegExp(options.url) ? options.url.flags : undefined, + mode: options.updateMode ?? 'minimal', + }, + }); + this._harRecorders.set(harId, { path: har, content: options.updateContent ?? 'attach' }); + } + + async routeFromHAR(har: string, options: { url?: string | RegExp, notFound?: 'abort' | 'fallback', update?: boolean, updateContent?: 'attach' | 'embed', updateMode?: 'minimal' | 'full' } = {}): Promise { + const localUtils = this._connection.localUtils(); + if (!localUtils) + throw new Error('Route from har is not supported in thin clients'); + if (options.update) { + await this._recordIntoHAR(har, null, options); + return; + } + const harRouter = await HarRouter.create(localUtils, har, options.notFound || 'abort', { urlMatch: options.url }); + this._harRouters.push(harRouter); + await harRouter.addContextRoute(this); + } + + private _disposeHarRouters() { + this._harRouters.forEach(router => router.dispose()); + this._harRouters = []; + } + + async unrouteAll(options?: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise { + await this._unrouteInternal(this._routes, [], options?.behavior); + this._disposeHarRouters(); + } + + async unroute(url: URLMatch, handler?: network.RouteHandlerCallback): Promise { + const removed = []; + const remaining = []; + for (const route of this._routes) { + if (urlMatchesEqual(route.url, url) && (!handler || route.handler === handler)) + removed.push(route); + else + remaining.push(route); + } + await this._unrouteInternal(removed, remaining, 'default'); + } + + private async _unrouteInternal(removed: network.RouteHandler[], remaining: network.RouteHandler[], behavior?: 'wait'|'ignoreErrors'|'default'): Promise { + this._routes = remaining; + if (behavior && behavior !== 'default') { + const promises = removed.map(routeHandler => routeHandler.stop(behavior)); + await Promise.all(promises); + } + await this._updateInterceptionPatterns({ title: 'Unroute requests' }); + } + + private async _updateInterceptionPatterns(options: { internal: true } | { title: string }) { + const patterns = network.RouteHandler.prepareInterceptionPatterns(this._routes); + await this._wrapApiCall(() => this._channel.setNetworkInterceptionPatterns({ patterns }), options); + } + + private async _updateWebSocketInterceptionPatterns(options: { internal: true } | { title: string }) { + const patterns = network.WebSocketRouteHandler.prepareInterceptionPatterns(this._webSocketRoutes); + await this._wrapApiCall(() => this._channel.setWebSocketInterceptionPatterns({ patterns }), options); + } + + _effectiveCloseReason(): string | undefined { + return this._closeReason || this._browser?._closeReason; + } + + async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise { + return await this._wrapApiCall(async () => { + const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); + const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; + const waiter = Waiter.createForEvent(this, event); + waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); + if (event !== Events.BrowserContext.Close) + waiter.rejectOnEvent(this, Events.BrowserContext.Close, () => new TargetClosedError(this._effectiveCloseReason())); + const result = await waiter.waitForEvent(this, event, predicate as any); + waiter.dispose(); + return result; + }); + } + + async storageState(options: { path?: string, indexedDB?: boolean } = {}): Promise { + const state = await this._channel.storageState({ indexedDB: options.indexedDB }); + if (options.path) { + await mkdirIfNeeded(this._platform, options.path); + await this._platform.fs().promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8'); + } + return state; + } + + async setStorageState(storageState: string | SetStorageState): Promise { + const state = await prepareStorageState(this._platform, storageState); + await this._channel.setStorageState({ storageState: state }); + } + + backgroundPages(): Page[] { + return []; + } + + serviceWorkers(): Worker[] { + return [...this._serviceWorkers]; + } + + async newCDPSession(page: Page | Frame): Promise { + // channelOwner.ts's validation messages don't handle the pseudo-union type, so we're explicit here + if (!(page instanceof Page) && !(page instanceof Frame)) + throw new Error('page: expected Page or Frame'); + const result = await this._channel.newCDPSession(page instanceof Page ? { page: page._channel } : { frame: page._channel }); + return CDPSession.from(result.session); + } + + _onClose() { + this._closingStatus = 'closed'; + this._browser?._contexts.delete(this); + this._browser?._browserType._contexts.delete(this); + this._browser?._browserType._playwright.selectors._contextsForSelectors.delete(this); + this._disposeHarRouters(); + this.tracing._resetStackCounter(); + this.emit(Events.BrowserContext.Close, this); + } + + async [Symbol.asyncDispose]() { + await this.close(); + } + + async close(options: { reason?: string } = {}): Promise { + if (this.isClosedOrClosing()) + return; + this._closeReason = options.reason; + this._closingStatus = 'closing'; + await this.request.dispose(options); + await this._instrumentation.runBeforeCloseBrowserContext(this); + await this._wrapApiCall(async () => { + for (const [harId, harParams] of this._harRecorders) { + const har = await this._channel.harExport({ harId }); + const artifact = Artifact.from(har.artifact); + // Server side will compress artifact if content is attach or if file is .zip. + const isCompressed = harParams.content === 'attach' || harParams.path.endsWith('.zip'); + const needCompressed = harParams.path.endsWith('.zip'); + if (isCompressed && !needCompressed) { + const localUtils = this._connection.localUtils(); + if (!localUtils) + throw new Error('Uncompressed har is not supported in thin clients'); + await artifact.saveAs(harParams.path + '.tmp'); + await localUtils.harUnzip({ zipFile: harParams.path + '.tmp', harFile: harParams.path }); + } else { + await artifact.saveAs(harParams.path); + } + await artifact.delete(); + } + }, { internal: true }); + await this._channel.close(options); + await this._closedPromise; + } + + async _enableRecorder(params: channels.BrowserContextEnableRecorderParams, eventSink?: RecorderEventSink) { + if (eventSink) + this._onRecorderEventSink = eventSink; + await this._channel.enableRecorder(params); + } + + async _disableRecorder() { + this._onRecorderEventSink = undefined; + await this._channel.disableRecorder(); + } + + async _exposeConsoleApi() { + await this._channel.exposeConsoleApi(); + } + +} + +async function prepareStorageState(platform: Platform, storageState: string | SetStorageState): Promise> { + if (typeof storageState !== 'string') + return storageState as any; + try { + return JSON.parse(await platform.fs().promises.readFile(storageState, 'utf8')); + } catch (e) { + rewriteErrorMessage(e, `Error reading storage state from ${storageState}:\n` + e.message); + throw e; + } +} + +export async function prepareBrowserContextParams(platform: Platform, options: BrowserContextOptions): Promise { + if (options.videoSize && !options.videosPath) + throw new Error(`"videoSize" option requires "videosPath" to be specified`); + if (options.extraHTTPHeaders) + network.validateHeaders(options.extraHTTPHeaders); + const contextParams: channels.BrowserNewContextParams = { + ...options, + viewport: options.viewport === null ? undefined : options.viewport, + noDefaultViewport: options.viewport === null, + extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined, + storageState: options.storageState ? await prepareStorageState(platform, options.storageState) : undefined, + serviceWorkers: options.serviceWorkers, + colorScheme: options.colorScheme === null ? 'no-override' : options.colorScheme, + reducedMotion: options.reducedMotion === null ? 'no-override' : options.reducedMotion, + forcedColors: options.forcedColors === null ? 'no-override' : options.forcedColors, + contrast: options.contrast === null ? 'no-override' : options.contrast, + acceptDownloads: toAcceptDownloadsProtocol(options.acceptDownloads), + clientCertificates: await toClientCertificatesProtocol(platform, options.clientCertificates), + }; + if (!contextParams.recordVideo && options.videosPath) { + contextParams.recordVideo = { + dir: options.videosPath, + size: options.videoSize + }; + } + if (contextParams.recordVideo && contextParams.recordVideo.dir) + contextParams.recordVideo.dir = platform.path().resolve(contextParams.recordVideo.dir); + return contextParams; +} + +function contextParamsToPublicOptions(params: channels.BrowserNewContextParams): api.BrowserContextOptions { + const result = { + ...params, + viewport: params.noDefaultViewport ? null : params.viewport, + extraHTTPHeaders: params.extraHTTPHeaders ? headersArrayToObject(params.extraHTTPHeaders, false) : undefined, + colorScheme: params.colorScheme === 'no-override' ? null : params.colorScheme, + reducedMotion: params.reducedMotion === 'no-override' ? null : params.reducedMotion, + forcedColors: params.forcedColors === 'no-override' ? null : params.forcedColors, + contrast: params.contrast === 'no-override' ? null : params.contrast, + acceptDownloads: params.acceptDownloads === 'accept' ? true : params.acceptDownloads === 'deny' ? false : undefined, + storageState: undefined, + }; + delete result.clientCertificates; + delete result.noDefaultViewport; + delete result.selectorEngines; + return result; +} + +function toAcceptDownloadsProtocol(acceptDownloads?: boolean) { + if (acceptDownloads === undefined) + return undefined; + if (acceptDownloads) + return 'accept'; + return 'deny'; +} + +export async function toClientCertificatesProtocol(platform: Platform, certs?: BrowserContextOptions['clientCertificates']): Promise { + if (!certs) + return undefined; + + const bufferizeContent = async (value?: Buffer, path?: string): Promise => { + if (value) + return value; + if (path) + return await platform.fs().promises.readFile(path); + }; + + return await Promise.all(certs.map(async cert => ({ + origin: cert.origin, + cert: await bufferizeContent(cert.cert, cert.certPath), + key: await bufferizeContent(cert.key, cert.keyPath), + pfx: await bufferizeContent(cert.pfx, cert.pfxPath), + passphrase: cert.passphrase, + }))); +} diff --git a/daemon/src/sandbox/forked-client/src/client/browserType.ts b/daemon/src/sandbox/forked-client/src/client/browserType.ts new file mode 100644 index 00000000..7ac95cad --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/browserType.ts @@ -0,0 +1,148 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Browser } from './browser'; +import { BrowserContext, prepareBrowserContextParams } from './browserContext'; +import { ChannelOwner } from './channelOwner'; +import { envObjectToArray } from './clientHelper'; +import { assert } from '../utils/isomorphic/assert'; +import { headersObjectToArray } from '../utils/isomorphic/headers'; +import { TimeoutSettings } from './timeoutSettings'; + +import type { Playwright } from './playwright'; +import type { ConnectOptions, LaunchOptions, LaunchPersistentContextOptions, LaunchServerOptions } from './types'; +import type * as api from '../../types/types'; +import type * as channels from '../protocol/channels'; +import type { ChildProcess } from 'child_process'; + +export interface BrowserServerLauncher { + launchServer(options?: LaunchServerOptions): Promise; +} + +// This is here just for api generation and checking. +export interface BrowserServer extends api.BrowserServer { + process(): ChildProcess; + wsEndpoint(): string; + close(): Promise; + kill(): Promise; +} + +export class BrowserType extends ChannelOwner implements api.BrowserType { + _serverLauncher?: BrowserServerLauncher; + _contexts = new Set(); + _playwright!: Playwright; + + static from(browserType: channels.BrowserTypeChannel): BrowserType { + return (browserType as any)._object; + } + + executablePath(): string { + if (!this._initializer.executablePath) + throw new Error('Browser is not supported on current platform'); + return this._initializer.executablePath; + } + + name(): string { + return this._initializer.name; + } + + async launch(options: LaunchOptions = {}): Promise { + assert(!(options as any).userDataDir, 'userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistentContext` instead'); + assert(!(options as any).port, 'Cannot specify a port without launching as a server.'); + + const logger = options.logger || this._playwright._defaultLaunchOptions?.logger; + options = { ...this._playwright._defaultLaunchOptions, ...options }; + const launchOptions: channels.BrowserTypeLaunchParams = { + ...options, + ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined, + ignoreAllDefaultArgs: !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs), + env: options.env ? envObjectToArray(options.env) : undefined, + timeout: new TimeoutSettings(this._platform).launchTimeout(options), + }; + return await this._wrapApiCall(async () => { + const browser = Browser.from((await this._channel.launch(launchOptions)).browser); + browser._connectToBrowserType(this, options, logger); + return browser; + }); + } + + async launchServer(options: LaunchServerOptions = {}): Promise { + if (!this._serverLauncher) + throw new Error('Launching server is not supported'); + options = { ...this._playwright._defaultLaunchOptions, ...options }; + return await this._serverLauncher.launchServer(options); + } + + async launchPersistentContext(userDataDir: string, options: LaunchPersistentContextOptions = {}): Promise { + void userDataDir; + void options; + throw new Error('browserType.launchPersistentContext() is not available in the QuickJS sandbox'); + } + + connect(options: api.ConnectOptions & { wsEndpoint: string }): Promise; + connect(endpoint: string, options?: api.ConnectOptions): Promise; + async connect(optionsOrEndpoint: string | (api.ConnectOptions & { wsEndpoint?: string }), options?: api.ConnectOptions): Promise{ + if (typeof optionsOrEndpoint === 'string') + return await this._connect({ ...options, endpoint: optionsOrEndpoint }); + assert(optionsOrEndpoint.wsEndpoint, 'options.wsEndpoint is required'); + return await this._connect({ ...options, endpoint: optionsOrEndpoint.wsEndpoint }); + } + + async _connect(params: ConnectOptions): Promise { + void params; + throw new Error('browserType.connect() is not available in the QuickJS sandbox'); + } + + async connectOverCDP(options: api.ConnectOverCDPOptions & { wsEndpoint?: string }): Promise; + async connectOverCDP(endpointURL: string, options?: api.ConnectOverCDPOptions): Promise; + async connectOverCDP(endpointURLOrOptions: (api.ConnectOverCDPOptions & { wsEndpoint?: string })|string, options?: api.ConnectOverCDPOptions) { + if (typeof endpointURLOrOptions === 'string') + return await this._connectOverCDP(endpointURLOrOptions, options); + const endpointURL = 'endpointURL' in endpointURLOrOptions ? endpointURLOrOptions.endpointURL : endpointURLOrOptions.wsEndpoint; + assert(endpointURL, 'Cannot connect over CDP without wsEndpoint.'); + return await this.connectOverCDP(endpointURL, endpointURLOrOptions); + } + + async _connectOverCDP(endpointURL: string, params: api.ConnectOverCDPOptions = {}): Promise { + if (this.name() !== 'chromium') + throw new Error('Connecting over CDP is only supported in Chromium.'); + const headers = params.headers ? headersObjectToArray(params.headers) : undefined; + const result = await this._channel.connectOverCDP({ + endpointURL, + headers, + slowMo: params.slowMo, + timeout: new TimeoutSettings(this._platform).timeout(params), + isLocal: params.isLocal, + }); + const browser = Browser.from(result.browser); + browser._connectToBrowserType(this, {}, params.logger); + if (result.defaultContext) + await this._instrumentation.runAfterCreateBrowserContext(BrowserContext.from(result.defaultContext)); + return browser; + } + + async _connectOverCDPTransport(transport: /* ConnectionTransport */ any) { + if (this.name() !== 'chromium') + throw new Error('Connecting over CDP is only supported in Chromium.'); + const result = await this._channel.connectOverCDPTransport({ transport }); + const browser = Browser.from(result.browser); + browser._connectToBrowserType(this, {}, undefined); + if (result.defaultContext) + await this._instrumentation.runAfterCreateBrowserContext(BrowserContext.from(result.defaultContext)); + return browser; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/cdpSession.ts b/daemon/src/sandbox/forked-client/src/client/cdpSession.ts new file mode 100644 index 00000000..cb66f830 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/cdpSession.ts @@ -0,0 +1,59 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChannelOwner } from './channelOwner'; + +import type * as api from '../../types/types'; +import type { Protocol } from '../server/chromium/protocol'; +import type * as channels from '../protocol/channels'; + +export class CDPSession extends ChannelOwner implements api.CDPSession { + static from(cdpSession: channels.CDPSessionChannel): CDPSession { + return (cdpSession as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.CDPSessionInitializer) { + super(parent, type, guid, initializer); + + this._channel.on('event', event => { + this.emit(event.method, event.params); + this.emit('event', event); + }); + + this._channel.on('close', () => { + this.emit('close'); + }); + + this.on = super.on; + this.addListener = super.addListener; + this.off = super.removeListener; + this.removeListener = super.removeListener; + this.once = super.once; + } + + async send( + method: T, + params?: Protocol.CommandParameters[T] + ): Promise { + const result = await this._channel.send({ method, params }); + return result.result as Protocol.CommandReturnValues[T]; + } + + async detach() { + return await this._channel.detach(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/channelOwner.ts b/daemon/src/sandbox/forked-client/src/client/channelOwner.ts new file mode 100644 index 00000000..1ec94339 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/channelOwner.ts @@ -0,0 +1,244 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the 'License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EventEmitter } from './eventEmitter'; +import { ValidationError, maybeFindValidator } from '../protocol/validator'; +import { methodMetainfo } from '../utils/isomorphic/protocolMetainfo'; +import { captureLibraryStackTrace } from './clientStackTrace'; +import { stringifyStackFrames } from '../utils/isomorphic/stackTrace'; + +import type { ClientInstrumentation } from './clientInstrumentation'; +import type { Connection } from './connection'; +import type { Logger } from './types'; +import type { ValidatorContext } from '../protocol/validator'; +import type { Platform } from './platform'; +import type * as channels from '../protocol/channels'; + +type Listener = (...args: any[]) => void; + +export abstract class ChannelOwner extends EventEmitter { + readonly _connection: Connection; + private _parent: ChannelOwner | undefined; + private _objects = new Map(); + + readonly _type: string; + readonly _guid: string; + readonly _channel: T; + readonly _initializer: channels.InitializerTraits; + _logger: Logger | undefined; + readonly _instrumentation: ClientInstrumentation; + private _eventToSubscriptionMapping: Map = new Map(); + _wasCollected: boolean = false; + + constructor(parent: ChannelOwner | Connection, type: string, guid: string, initializer: channels.InitializerTraits) { + const connection = parent instanceof ChannelOwner ? parent._connection : parent; + super(connection._platform); + this.setMaxListeners(0); + this._connection = connection; + this._type = type; + this._guid = guid; + this._parent = parent instanceof ChannelOwner ? parent : undefined; + this._instrumentation = this._connection._instrumentation; + + this._connection._objects.set(guid, this); + if (this._parent) { + this._parent._objects.set(guid, this); + this._logger = this._parent._logger; + } + + this._channel = this._createChannel(new EventEmitter(connection._platform)); + this._initializer = initializer; + } + + _setEventToSubscriptionMapping(mapping: Map) { + this._eventToSubscriptionMapping = mapping; + } + + private _updateSubscription(event: string | symbol, enabled: boolean) { + const protocolEvent = this._eventToSubscriptionMapping.get(String(event)); + if (protocolEvent) + (this._channel as any).updateSubscription({ event: protocolEvent, enabled }).catch(() => {}); + } + + override on(event: string | symbol, listener: Listener): this { + if (!this.listenerCount(event)) + this._updateSubscription(event, true); + super.on(event, listener); + return this; + } + + override addListener(event: string | symbol, listener: Listener): this { + if (!this.listenerCount(event)) + this._updateSubscription(event, true); + super.addListener(event, listener); + return this; + } + + override prependListener(event: string | symbol, listener: Listener): this { + if (!this.listenerCount(event)) + this._updateSubscription(event, true); + super.prependListener(event, listener); + return this; + } + + override off(event: string | symbol, listener: Listener): this { + super.off(event, listener); + if (!this.listenerCount(event)) + this._updateSubscription(event, false); + return this; + } + + override removeListener(event: string | symbol, listener: Listener): this { + super.removeListener(event, listener); + if (!this.listenerCount(event)) + this._updateSubscription(event, false); + return this; + } + + _adopt(child: ChannelOwner) { + child._parent!._objects.delete(child._guid); + this._objects.set(child._guid, child); + child._parent = this; + } + + _dispose(reason: 'gc' | undefined) { + // Clean up from parent and connection. + if (this._parent) + this._parent._objects.delete(this._guid); + this._connection._objects.delete(this._guid); + this._wasCollected = reason === 'gc'; + + // Dispose all children. + for (const object of [...this._objects.values()]) + object._dispose(reason); + this._objects.clear(); + } + + _debugScopeState(): any { + return { + _guid: this._guid, + objects: Array.from(this._objects.values()).map(o => o._debugScopeState()), + }; + } + + private _validatorToWireContext(): ValidatorContext { + return { + tChannelImpl: tChannelImplToWire, + binary: this._connection.rawBuffers() ? 'buffer' : 'toBase64', + isUnderTest: () => this._platform.isUnderTest(), + }; + } + + private _createChannel(base: Object): T { + const channel = new Proxy(base, { + get: (obj: any, prop: string | symbol) => { + if (typeof prop === 'string') { + const validator = maybeFindValidator(this._type, prop, 'Params'); + const { internal } = methodMetainfo.get(this._type + '.' + prop) || {}; + if (validator) { + return async (params: any) => { + return await this._wrapApiCall(async apiZone => { + const validatedParams = validator(params, '', this._validatorToWireContext()); + if (!apiZone.internal && !apiZone.reported) { + // Reporting/tracing/logging this api call for the first time. + apiZone.reported = true; + this._instrumentation.onApiCallBegin(apiZone, { type: this._type, method: prop, params }); + logApiCall(this._platform, this._logger, `=> ${apiZone.apiName} started`); + return await this._connection.sendMessageToServer(this, prop, validatedParams, apiZone); + } + // Since this api call is either internal, or has already been reported/traced once, + // passing as internal. + return await this._connection.sendMessageToServer(this, prop, validatedParams, { internal: true }); + }, { internal }); + }; + } + } + return obj[prop]; + }, + }); + (channel as any)._object = this; + return channel; + } + + async _wrapApiCall(func: (apiZone: ApiZone) => Promise, options?: { internal?: boolean, title?: string }): Promise { + const logger = this._logger; + const existingApiZone = this._platform.zones.current().data(); + if (existingApiZone) + return await func(existingApiZone); + + const stackTrace = captureLibraryStackTrace(this._platform); + const apiZone: ApiZone = { title: options?.title, apiName: stackTrace.apiName, frames: stackTrace.frames, internal: options?.internal ?? false, reported: false, userData: undefined, stepId: undefined }; + + try { + const result = await this._platform.zones.current().push(apiZone).run(async () => await func(apiZone)); + if (!options?.internal) { + logApiCall(this._platform, logger, `<= ${apiZone.apiName} succeeded`); + this._instrumentation.onApiCallEnd(apiZone); + } + return result; + } catch (e) { + const innerError = ((this._platform.showInternalStackFrames() || this._platform.isUnderTest()) && e.stack) ? '\n\n' + e.stack : ''; + if (apiZone.apiName && !apiZone.apiName.includes('')) + e.message = apiZone.apiName + ': ' + e.message; + const stackFrames = '\n' + stringifyStackFrames(stackTrace.frames).join('\n') + innerError; + if (stackFrames.trim()) + e.stack = e.message + stackFrames; + else + e.stack = ''; + if (!options?.internal) { + apiZone.error = e; + logApiCall(this._platform, logger, `<= ${apiZone.apiName} failed`); + this._instrumentation.onApiCallEnd(apiZone); + } + throw e; + } + } + + private toJSON() { + // Jest's expect library tries to print objects sometimes. + // RPC objects can contain links to lots of other objects, + // which can cause jest to crash. Let's help it out + // by just returning the important values. + return { + _type: this._type, + _guid: this._guid, + }; + } +} + +function logApiCall(platform: Platform, logger: Logger | undefined, message: string) { + if (logger && logger.isEnabled('api', 'info')) + logger.log('api', 'info', message, [], { color: 'cyan' }); + platform.log('api', message); +} + +function tChannelImplToWire(names: '*' | string[], arg: any, path: string, context: ValidatorContext) { + if (arg._object instanceof ChannelOwner && (names === '*' || names.includes(arg._object._type))) + return { guid: arg._object._guid }; + throw new ValidationError(`${path}: expected channel ${names.toString()}`); +} + +type ApiZone = { + apiName: string; + frames: channels.StackFrame[]; + title?: string; + internal?: boolean; + reported: boolean; + userData: any; + stepId?: string; + error?: Error; +}; diff --git a/daemon/src/sandbox/forked-client/src/client/clientHelper.ts b/daemon/src/sandbox/forked-client/src/client/clientHelper.ts new file mode 100644 index 00000000..51b5a04f --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/clientHelper.ts @@ -0,0 +1,55 @@ +// @ts-nocheck +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isString } from '../utils/isomorphic/rtti'; + +import type { Platform } from './platform'; + +export function envObjectToArray(env: NodeJS.ProcessEnv): { name: string, value: string }[] { + const result: { name: string, value: string }[] = []; + for (const name in env) { + if (!Object.is(env[name], undefined)) + result.push({ name, value: String(env[name]) }); + } + return result; +} + +export async function evaluationScript(platform: Platform, fun: Function | string | { path?: string, content?: string }, arg?: any, addSourceUrl: boolean = true): Promise { + if (typeof fun === 'function') { + const source = fun.toString(); + const argString = Object.is(arg, undefined) ? 'undefined' : JSON.stringify(arg); + return `(${source})(${argString})`; + } + if (arg !== undefined) + throw new Error('Cannot evaluate a string with arguments'); + if (isString(fun)) + return fun; + if (fun.content !== undefined) + return fun.content; + if (fun.path !== undefined) { + let source = await platform.fs().promises.readFile(fun.path, 'utf8'); + if (addSourceUrl) + source = addSourceUrlToScript(source, fun.path); + return source; + } + throw new Error('Either path or content property must be present'); +} + +export function addSourceUrlToScript(source: string, path: string): string { + return `${source}\n//# sourceURL=${path.replace(/\n/g, '')}`; +} diff --git a/daemon/src/sandbox/forked-client/src/client/clientInstrumentation.ts b/daemon/src/sandbox/forked-client/src/client/clientInstrumentation.ts new file mode 100644 index 00000000..7d1bb3d7 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/clientInstrumentation.ts @@ -0,0 +1,91 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { BrowserContext } from './browserContext'; +import type { APIRequestContext, NewContextOptions } from './fetch'; +import type { StackFrame } from '../protocol/channels'; +import type { Page } from './page'; +import type { BrowserContextOptions } from './types'; + +// Instrumentation can mutate the data, for example change apiName or stepId. +export interface ApiCallData { + apiName: string; + title?: string; + frames: StackFrame[]; + userData: any; + stepId?: string; + error?: Error; +} + +export interface ClientInstrumentation { + addListener(listener: ClientInstrumentationListener): void; + removeListener(listener: ClientInstrumentationListener): void; + removeAllListeners(): void; + onApiCallBegin(apiCall: ApiCallData, channel: { type: string, method: string, params?: Record }): void; + onApiCallEnd(apiCall: ApiCallData): void; + onWillPause(options: { keepTestTimeout: boolean }): void; + onPage(page: Page): void; + + runBeforeCreateBrowserContext(options: BrowserContextOptions): Promise; + runBeforeCreateRequestContext(options: NewContextOptions): Promise; + runAfterCreateBrowserContext(context: BrowserContext): Promise; + runAfterCreateRequestContext(context: APIRequestContext): Promise; + runBeforeCloseBrowserContext(context: BrowserContext): Promise; + runBeforeCloseRequestContext(context: APIRequestContext): Promise; +} + +export interface ClientInstrumentationListener { + onApiCallBegin?(apiCall: ApiCallData, channel: { type: string, method: string, params?: Record }): void; + onApiCallEnd?(apiCall: ApiCallData): void; + onWillPause?(options: { keepTestTimeout: boolean }): void; + onPage?(page: Page): void; + runBeforeCreateBrowserContext?(options: BrowserContextOptions): Promise; + runBeforeCreateRequestContext?(options: NewContextOptions): Promise; + runAfterCreateBrowserContext?(context: BrowserContext): Promise; + runAfterCreateRequestContext?(context: APIRequestContext): Promise; + runBeforeCloseBrowserContext?(context: BrowserContext): Promise; + runBeforeCloseRequestContext?(context: APIRequestContext): Promise; +} + +export function createInstrumentation(): ClientInstrumentation { + const listeners: ClientInstrumentationListener[] = []; + return new Proxy({}, { + get: (obj: any, prop: string | symbol) => { + if (typeof prop !== 'string') + return obj[prop]; + if (prop === 'addListener') + return (listener: ClientInstrumentationListener) => listeners.push(listener); + if (prop === 'removeListener') + return (listener: ClientInstrumentationListener) => listeners.splice(listeners.indexOf(listener), 1); + if (prop === 'removeAllListeners') + return () => listeners.splice(0, listeners.length); + if (prop.startsWith('run')) { + return async (...params: any[]) => { + for (const listener of listeners) + await (listener as any)[prop]?.(...params); + }; + } + if (prop.startsWith('on')) { + return (...params: any[]) => { + for (const listener of listeners) + (listener as any)[prop]?.(...params); + }; + } + return obj[prop]; + }, + }); +} diff --git a/daemon/src/sandbox/forked-client/src/client/clientStackTrace.ts b/daemon/src/sandbox/forked-client/src/client/clientStackTrace.ts new file mode 100644 index 00000000..864300ea --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/clientStackTrace.ts @@ -0,0 +1,77 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { captureRawStack, parseStackFrame } from '../utils/isomorphic/stackTrace'; + +import type { Platform } from './platform'; +import type { StackFrame } from '@isomorphic/stackTrace'; + +export function captureLibraryStackTrace(platform: Platform): { frames: StackFrame[], apiName: string } { + const stack = captureRawStack(); + + type ParsedFrame = { + frame: StackFrame; + frameText: string; + isPlaywrightLibrary: boolean; + }; + let parsedFrames = stack.map(line => { + const frame = parseStackFrame(line, platform.pathSeparator, platform.showInternalStackFrames()); + if (!frame || !frame.file) + return null; + const isPlaywrightLibrary = !!platform.coreDir && frame.file.startsWith(platform.coreDir); + const parsed: ParsedFrame = { + frame, + frameText: line, + isPlaywrightLibrary + }; + return parsed; + }).filter(Boolean) as ParsedFrame[]; + + let apiName = ''; + + // Deepest transition between non-client code calling into client + // code is the api entry. + for (let i = 0; i < parsedFrames.length - 1; i++) { + const parsedFrame = parsedFrames[i]; + if (parsedFrame.isPlaywrightLibrary && !parsedFrames[i + 1].isPlaywrightLibrary) { + apiName = apiName || normalizeAPIName(parsedFrame.frame.function); + break; + } + } + + function normalizeAPIName(name?: string): string { + if (!name) + return ''; + const match = name.match(/(API|JS|CDP|[A-Z])(.*)/); + if (!match) + return name; + return match[1].toLowerCase() + match[2]; + } + + // This is for the inspector so that it did not include the test runner stack frames. + const filterPrefixes = platform.boxedStackPrefixes(); + parsedFrames = parsedFrames.filter(f => { + if (filterPrefixes.some(prefix => f.frame.file.startsWith(prefix))) + return false; + return true; + }); + + return { + frames: parsedFrames.map(p => p.frame), + apiName + }; +} diff --git a/daemon/src/sandbox/forked-client/src/client/clock.ts b/daemon/src/sandbox/forked-client/src/client/clock.ts new file mode 100644 index 00000000..a0bcf9be --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/clock.ts @@ -0,0 +1,72 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { BrowserContext } from './browserContext'; +import type * as api from '../../types/types'; + +export class Clock implements api.Clock { + private _browserContext: BrowserContext; + + constructor(browserContext: BrowserContext) { + this._browserContext = browserContext; + } + + async install(options: { time?: number | string | Date } = { }) { + await this._browserContext._channel.clockInstall(options.time !== undefined ? parseTime(options.time) : {}); + } + + async fastForward(ticks: number | string) { + await this._browserContext._channel.clockFastForward(parseTicks(ticks)); + } + + async pauseAt(time: number | string | Date) { + await this._browserContext._channel.clockPauseAt(parseTime(time)); + } + + async resume() { + await this._browserContext._channel.clockResume({}); + } + + async runFor(ticks: number | string) { + await this._browserContext._channel.clockRunFor(parseTicks(ticks)); + } + + async setFixedTime(time: string | number | Date) { + await this._browserContext._channel.clockSetFixedTime(parseTime(time)); + } + + async setSystemTime(time: string | number | Date) { + await this._browserContext._channel.clockSetSystemTime(parseTime(time)); + } +} + +function parseTime(time: string | number | Date): { timeNumber?: number, timeString?: string } { + if (typeof time === 'number') + return { timeNumber: time }; + if (typeof time === 'string') + return { timeString: time }; + if (!isFinite(time.getTime())) + throw new Error(`Invalid date: ${time}`); + return { timeNumber: time.getTime() }; +} + +function parseTicks(ticks: string | number): { ticksNumber?: number, ticksString?: string } { + return { + ticksNumber: typeof ticks === 'number' ? ticks : undefined, + ticksString: typeof ticks === 'string' ? ticks : undefined + }; +} diff --git a/daemon/src/sandbox/forked-client/src/client/connect.ts b/daemon/src/sandbox/forked-client/src/client/connect.ts new file mode 100644 index 00000000..7c21e828 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/connect.ts @@ -0,0 +1,165 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { monotonicTime } from '../utils/isomorphic/time'; +import { raceAgainstDeadline } from '../utils/isomorphic/timeoutRunner'; +import { Browser } from './browser'; +import { ChannelOwner } from './channelOwner'; +import { Connection } from './connection'; +import { Events } from './events'; + +import type { Playwright } from './playwright'; +import type { ConnectOptions, HeadersArray } from './types'; +import type * as channels from '../protocol/channels'; + +export async function connectToBrowser(playwright: Playwright, params: ConnectOptions): Promise { + const deadline = params.timeout ? monotonicTime() + params.timeout : 0; + const nameParam = params.browserName ? { 'x-playwright-browser': params.browserName } : {}; + const headers = { ...nameParam, ...params.headers }; + const connectParams: channels.LocalUtilsConnectParams = { + endpoint: params.endpoint!, + headers, + exposeNetwork: params.exposeNetwork, + slowMo: params.slowMo, + timeout: params.timeout || 0, + }; + if ((params as any).__testHookRedirectPortForwarding) + connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding; + const connection = await connectToEndpoint(playwright._connection, connectParams); + let browser: Browser; + connection.on('close', () => { + // Emulate all pages, contexts and the browser closing upon disconnect. + for (const context of browser?.contexts() || []) { + for (const page of context.pages()) + page._onClose(); + context._onClose(); + } + setTimeout(() => browser?._didClose(), 0); + }); + + const result = await raceAgainstDeadline(async () => { + // For tests. + if ((params as any).__testHookBeforeCreateBrowser) + await (params as any).__testHookBeforeCreateBrowser(); + + const playwright = await connection!.initializePlaywright(); + if (!playwright._initializer.preLaunchedBrowser) { + connection.close(); + throw new Error('Malformed endpoint. Did you use BrowserType.launchServer method?'); + } + playwright.selectors = playwright.selectors; + browser = Browser.from(playwright._initializer.preLaunchedBrowser!); + browser._shouldCloseConnectionOnClose = true; + browser.on(Events.Browser.Disconnected, () => connection.close()); + return browser; + }, deadline); + if (!result.timedOut) { + return result.result; + } else { + connection.close(); + throw new Error(`Timeout ${params.timeout}ms exceeded`); + } +} + +export async function connectToEndpoint(parentConnection: Connection, params: channels.LocalUtilsConnectParams): Promise { + const localUtils = parentConnection.localUtils(); + const transport = localUtils ? new JsonPipeTransport(localUtils) : new WebSocketTransport(); + const connectHeaders = await transport.connect(params); + const connection = new Connection(parentConnection._platform, localUtils, parentConnection._instrumentation, connectHeaders); + connection.markAsRemote(); + connection.on('close', () => transport.close()); + + let closeError: string | undefined; + const onTransportClosed = (reason?: string) => { + connection.close(reason || closeError); + }; + transport.onClose(reason => onTransportClosed(reason)); + connection.onmessage = message => transport.send(message).catch(() => onTransportClosed()); + transport.onMessage(message => { + try { + connection!.dispatch(message); + } catch (e) { + closeError = String(e); + transport.close().catch(() => {}); + } + }); + return connection; +} + +interface Transport { + connect(params: channels.LocalUtilsConnectParams): Promise; + send(message: any): Promise; + onMessage(callback: (message: object) => void): void; + onClose(callback: (reason?: string) => void): void; + close(): Promise; +} + +class JsonPipeTransport implements Transport { + private _pipe: channels.JsonPipeChannel | undefined; + private _owner: ChannelOwner; + + constructor(owner: ChannelOwner) { + this._owner = owner; + } + + async connect(params: channels.LocalUtilsConnectParams) { + const { pipe, headers: connectHeaders } = await this._owner._channel.connect(params); + this._pipe = pipe; + return connectHeaders; + } + + async send(message: object) { + await this._pipe!.send({ message }); + } + + onMessage(callback: (message: object) => void) { + this._pipe!.on('message', ({ message }) => callback(message)); + } + + onClose(callback: (reason?: string) => void) { + this._pipe!.on('closed', ({ reason }) => callback(reason)); + } + + async close() { + await this._pipe!.close().catch(() => {}); + } +} + +class WebSocketTransport implements Transport { + private _ws: WebSocket | undefined; + + async connect(params: channels.LocalUtilsConnectParams) { + this._ws = new window.WebSocket(params.endpoint); + return []; + } + + async send(message: object) { + this._ws!.send(JSON.stringify(message)); + } + + onMessage(callback: (message: object) => void) { + this._ws!.addEventListener('message', event => callback(JSON.parse(event.data))); + } + + onClose(callback: (reason?: string) => void) { + this._ws!.addEventListener('close', () => callback()); + } + + async close() { + this._ws!.close(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/connection.ts b/daemon/src/sandbox/forked-client/src/client/connection.ts new file mode 100644 index 00000000..8823a596 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/connection.ts @@ -0,0 +1,354 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the 'License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EventEmitter } from './eventEmitter'; +import { Android, AndroidDevice, AndroidSocket } from './android'; +import { Artifact } from './artifact'; +import { Browser } from './browser'; +import { BrowserContext } from './browserContext'; +import { BrowserType } from './browserType'; +import { CDPSession } from './cdpSession'; +import { ChannelOwner } from './channelOwner'; +import { createInstrumentation } from './clientInstrumentation'; +import { Debugger } from './debugger'; +import { Dialog } from './dialog'; +import { DisposableObject } from './disposable'; +import { Electron, ElectronApplication } from './electron'; +import { ElementHandle } from './elementHandle'; +import { TargetClosedError, parseError } from './errors'; +import { APIRequestContext } from './fetch'; +import { Frame } from './frame'; +import { JSHandle } from './jsHandle'; +import { JsonPipe } from './jsonPipe'; +import { LocalUtils } from './localUtils'; +import { Request, Response, Route, WebSocket, WebSocketRoute } from './network'; +import { BindingCall, Page } from './page'; +import { Playwright } from './playwright'; +import { Stream } from './stream'; +import { Tracing } from './tracing'; +import { Worker } from './worker'; +import { WritableStream } from './writableStream'; +import { ValidationError, findValidator } from '../protocol/validator'; +import { rewriteErrorMessage } from '../utils/isomorphic/stackTrace'; +import { quickjsPlatform } from '../../quickjs-platform'; +import type { ClientInstrumentation } from './clientInstrumentation'; +import type { HeadersArray } from './types'; +import type { ValidatorContext } from '../protocol/validator'; +import type { Platform } from './platform'; +import type * as channels from '../protocol/channels'; + +class Root extends ChannelOwner { + constructor(connection: Connection) { + super(connection, 'Root', '', {}); + } + + async initialize(): Promise { + return Playwright.from((await this._channel.initialize({ + sdkLanguage: 'javascript', + })).playwright); + } +} + +class DummyChannelOwner extends ChannelOwner { +} + +export class Connection extends EventEmitter { + readonly _objects = new Map(); + onmessage = (message: object): void => {}; + private _lastId = 0; + private _callbacks = new Map void, reject: (a: Error) => void, title: string | undefined, type: string, method: string }>(); + private _rootObject: Root; + private _closedError: Error | undefined; + private _isRemote = false; + private _localUtils?: LocalUtils; + private _rawBuffers = false; + // Some connections allow resolving in-process dispatchers. + toImpl: ((client: ChannelOwner | Connection) => any) | undefined; + private _tracingCount = 0; + readonly _instrumentation: ClientInstrumentation; + // Used from @playwright/test fixtures -> TODO remove? + readonly headers: HeadersArray; + + constructor(platform: Platform = quickjsPlatform, localUtils?: LocalUtils, instrumentation?: ClientInstrumentation, headers: HeadersArray = []) { + super(platform); + this._instrumentation = instrumentation || createInstrumentation(); + this._localUtils = localUtils; + this._rootObject = new Root(this); + this.headers = headers; + } + + markAsRemote() { + this._isRemote = true; + } + + isRemote() { + return this._isRemote; + } + + useRawBuffers() { + this._rawBuffers = true; + } + + rawBuffers() { + return this._rawBuffers; + } + + localUtils(): LocalUtils | undefined { + return this._localUtils; + } + + async initializePlaywright(): Promise { + return await this._rootObject.initialize(); + } + + getObjectWithKnownName(guid: string): any { + return this._objects.get(guid)!; + } + + setIsTracing(isTracing: boolean) { + if (isTracing) + this._tracingCount++; + else + this._tracingCount--; + } + + async sendMessageToServer(object: ChannelOwner, method: string, params: any, options: { apiName?: string, title?: string, internal?: boolean, frames?: channels.StackFrame[], stepId?: string }): Promise { + if (this._closedError) + throw this._closedError; + if (object._wasCollected) + throw new Error('The object has been collected to prevent unbounded heap growth.'); + + const guid = object._guid; + const type = object._type; + const id = ++this._lastId; + const message = { id, guid, method, params }; + if (this._platform.isLogEnabled('channel')) { + // Do not include metadata in debug logs to avoid noise. + this._platform.log('channel', 'SEND> ' + JSON.stringify(message)); + } + const location = options.frames?.[0] ? { file: options.frames[0].file, line: options.frames[0].line, column: options.frames[0].column } : undefined; + const metadata: channels.Metadata = { title: options.title, location, internal: options.internal, stepId: options.stepId }; + if (this._tracingCount && options.frames && type !== 'LocalUtils') + this._localUtils?.addStackToTracingNoReply({ callData: { stack: options.frames ?? [], id } }).catch(() => {}); + // We need to exit zones before calling into the server, otherwise + // when we receive events from the server, we would be in an API zone. + this._platform.zones.empty.run(() => this.onmessage({ ...message, metadata })); + return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject, title: options.title, type, method })); + } + + private _validatorFromWireContext(): ValidatorContext { + return { + tChannelImpl: this._tChannelImplFromWire.bind(this), + binary: this._rawBuffers ? 'buffer' : 'fromBase64', + isUnderTest: () => this._platform.isUnderTest(), + }; + } + + dispatch(message: object) { + if (this._closedError) + return; + + const { id, guid, method, params, result, error, log } = message as any; + if (id) { + if (this._platform.isLogEnabled('channel')) + this._platform.log('channel', '; + const validator = findValidator(type, '', 'Initializer'); + initializer = validator(initializer, '', this._validatorFromWireContext()); + switch (type) { + case 'Android': + result = new Android(parent, type, guid, initializer); + break; + case 'AndroidSocket': + result = new AndroidSocket(parent, type, guid, initializer); + break; + case 'AndroidDevice': + result = new AndroidDevice(parent, type, guid, initializer); + break; + case 'APIRequestContext': + result = new APIRequestContext(parent, type, guid, initializer); + break; + case 'Artifact': + result = new Artifact(parent, type, guid, initializer); + break; + case 'BindingCall': + result = new BindingCall(parent, type, guid, initializer); + break; + case 'Browser': + result = new Browser(parent, type, guid, initializer); + break; + case 'BrowserContext': + result = new BrowserContext(parent, type, guid, initializer); + break; + case 'BrowserType': + result = new BrowserType(parent, type, guid, initializer); + break; + case 'CDPSession': + result = new CDPSession(parent, type, guid, initializer); + break; + case 'Debugger': + result = new Debugger(parent, type, guid, initializer); + break; + case 'Dialog': + result = new Dialog(parent, type, guid, initializer); + break; + case 'Disposable': + result = new DisposableObject(parent, type, guid, initializer); + break; + case 'Electron': + result = new Electron(parent, type, guid, initializer); + break; + case 'ElectronApplication': + result = new ElectronApplication(parent, type, guid, initializer); + break; + case 'ElementHandle': + result = new ElementHandle(parent, type, guid, initializer); + break; + case 'Frame': + result = new Frame(parent, type, guid, initializer); + break; + case 'JSHandle': + result = new JSHandle(parent, type, guid, initializer); + break; + case 'JsonPipe': + result = new JsonPipe(parent, type, guid, initializer); + break; + case 'LocalUtils': + result = new LocalUtils(parent, type, guid, initializer); + if (!this._localUtils) + this._localUtils = result as LocalUtils; + break; + case 'Page': + result = new Page(parent, type, guid, initializer); + break; + case 'Playwright': + result = new Playwright(parent, type, guid, initializer); + break; + case 'Request': + result = new Request(parent, type, guid, initializer); + break; + case 'Response': + result = new Response(parent, type, guid, initializer); + break; + case 'Route': + result = new Route(parent, type, guid, initializer); + break; + case 'Stream': + result = new Stream(parent, type, guid, initializer); + break; + case 'SocksSupport': + result = new DummyChannelOwner(parent, type, guid, initializer); + break; + case 'Tracing': + result = new Tracing(parent, type, guid, initializer); + break; + case 'WebSocket': + result = new WebSocket(parent, type, guid, initializer); + break; + case 'WebSocketRoute': + result = new WebSocketRoute(parent, type, guid, initializer); + break; + case 'Worker': + result = new Worker(parent, type, guid, initializer); + break; + case 'WritableStream': + result = new WritableStream(parent, type, guid, initializer); + break; + default: + throw new Error('Missing type ' + type); + } + return result; + } +} + +function formatCallLog(platform: Platform, log: string[] | undefined): string { + if (!log || !log.some(l => !!l)) + return ''; + return ` +Call log: +${platform.colors.dim(log.join('\n'))} +`; +} diff --git a/daemon/src/sandbox/forked-client/src/client/consoleMessage.ts b/daemon/src/sandbox/forked-client/src/client/consoleMessage.ts new file mode 100644 index 00000000..21bcd5c9 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/consoleMessage.ts @@ -0,0 +1,73 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JSHandle } from './jsHandle'; + +import type * as api from '../../types/types'; +import type { Platform } from './platform'; +import type * as channels from '../protocol/channels'; +import type { Page } from './page'; +import type { Worker } from './worker'; + +type ConsoleMessageLocation = channels.BrowserContextConsoleEvent['location']; + +export class ConsoleMessage implements api.ConsoleMessage { + + private _page: Page | null; + private _worker: Worker | null; + private _event: channels.BrowserContextConsoleEvent | channels.ElectronApplicationConsoleEvent; + + constructor(platform: Platform, event: channels.BrowserContextConsoleEvent | channels.ElectronApplicationConsoleEvent, page: Page | null, worker: Worker | null) { + this._page = page; + this._worker = worker; + this._event = event; + if (platform.inspectCustom) + (this as any)[platform.inspectCustom] = () => this._inspect(); + } + + worker() { + return this._worker; + } + + page() { + return this._page; + } + + type(): ReturnType { + return this._event.type as ReturnType; + } + + text(): string { + return this._event.text; + } + + args(): JSHandle[] { + return this._event.args.map(JSHandle.from); + } + + location(): ConsoleMessageLocation { + return this._event.location; + } + + timestamp(): number { + return this._event.timestamp; + } + + private _inspect() { + return this.text(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/coverage.ts b/daemon/src/sandbox/forked-client/src/client/coverage.ts new file mode 100644 index 00000000..6a170f2d --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/coverage.ts @@ -0,0 +1,43 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type * as api from '../../types/types'; +import type * as channels from '../protocol/channels'; + +export class Coverage implements api.Coverage { + private _channel: channels.PageChannel; + + constructor(channel: channels.PageChannel) { + this._channel = channel; + } + + async startJSCoverage(options: channels.PageStartJSCoverageOptions = {}) { + await this._channel.startJSCoverage(options); + } + + async stopJSCoverage(): Promise { + return (await this._channel.stopJSCoverage()).entries; + } + + async startCSSCoverage(options: channels.PageStartCSSCoverageOptions = {}) { + await this._channel.startCSSCoverage(options); + } + + async stopCSSCoverage(): Promise { + return (await this._channel.stopCSSCoverage()).entries; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/debugger.ts b/daemon/src/sandbox/forked-client/src/client/debugger.ts new file mode 100644 index 00000000..ad0c2338 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/debugger.ts @@ -0,0 +1,60 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChannelOwner } from './channelOwner'; +import { Events } from './events'; + +import type * as api from '../../types/types'; +import type * as channels from '../protocol/channels'; + +type PausedDetail = { location: { file: string, line?: number, column?: number }, title: string }; + +export class Debugger extends ChannelOwner implements api.Debugger { + private _pausedDetails: PausedDetail[] = []; + + static from(channel: channels.DebuggerChannel): Debugger { + return (channel as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.DebuggerInitializer) { + super(parent, type, guid, initializer); + this._channel.on('pausedStateChanged', ({ pausedDetails }) => { + this._pausedDetails = pausedDetails; + this.emit(Events.Debugger.PausedStateChanged); + }); + } + + async pause(): Promise { + await this._channel.pause(); + } + + async resume(): Promise { + await this._channel.resume(); + } + + async next(): Promise { + await this._channel.next(); + } + + async runTo(location: { file: string, line?: number, column?: number }): Promise { + await this._channel.runTo({ location }); + } + + pausedDetails(): PausedDetail[] { + return this._pausedDetails; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/dialog.ts b/daemon/src/sandbox/forked-client/src/client/dialog.ts new file mode 100644 index 00000000..1e50e739 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/dialog.ts @@ -0,0 +1,62 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChannelOwner } from './channelOwner'; +import { Page } from './page'; + +import type * as api from '../../types/types'; +import type * as channels from '../protocol/channels'; + + +export class Dialog extends ChannelOwner implements api.Dialog { + static from(dialog: channels.DialogChannel): Dialog { + return (dialog as any)._object; + } + + private _page: Page | null; + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.DialogInitializer) { + super(parent, type, guid, initializer); + // Note: dialogs that open early during page initialization block it. + // Therefore, we must report the dialog without a page to be able to handle it. + this._page = Page.fromNullable(initializer.page); + } + + page() { + return this._page; + } + + type(): string { + return this._initializer.type; + } + + message(): string { + return this._initializer.message; + } + + defaultValue(): string { + return this._initializer.defaultValue; + } + + async accept(promptText: string | undefined) { + await this._channel.accept({ promptText }); + } + + async dismiss() { + await this._channel.dismiss(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/disposable.ts b/daemon/src/sandbox/forked-client/src/client/disposable.ts new file mode 100644 index 00000000..a5dd5d71 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/disposable.ts @@ -0,0 +1,77 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChannelOwner } from './channelOwner'; +import { isTargetClosedError } from './errors'; + +import type * as channels from '../protocol/channels'; + +export interface Disposable { + dispose: () => Promise; +} + +export class DisposableObject extends ChannelOwner implements Disposable { + static from(channel: channels.DisposableChannel): DisposableObject { + return (channel as any)._object; + } + + async [Symbol.asyncDispose]() { + await this.dispose(); + } + + async dispose() { + try { + await this._channel.dispose(); + } catch (e) { + if (isTargetClosedError(e)) + return; + throw e; + } + } +} + +export class DisposableStub implements Disposable { + private _dispose: (() => Promise) | undefined; + + constructor(dispose: () => Promise) { + this._dispose = dispose; + } + + async [Symbol.asyncDispose]() { + await this.dispose(); + } + + async dispose() { + if (!this._dispose) + return; + try { + const dispose = this._dispose; + this._dispose = undefined; + await dispose(); + } catch (e) { + if (isTargetClosedError(e)) + return; + throw e; + } + } +} + +export async function disposeAll(disposables: Disposable[]) { + const copy = [...disposables]; + disposables.length = 0; + await Promise.all(copy.map(d => d.dispose())); +} diff --git a/daemon/src/sandbox/forked-client/src/client/download.ts b/daemon/src/sandbox/forked-client/src/client/download.ts new file mode 100644 index 00000000..2a9aefcc --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/download.ts @@ -0,0 +1,71 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Artifact } from './artifact'; +import type { Page } from './page'; +import type * as api from '../../types/types'; +import type { Readable } from 'stream'; + +export class Download implements api.Download { + private _page: Page; + private _url: string; + private _suggestedFilename: string; + private _artifact: Artifact; + + constructor(page: Page, url: string, suggestedFilename: string, artifact: Artifact) { + this._page = page; + this._url = url; + this._suggestedFilename = suggestedFilename; + this._artifact = artifact; + } + + page(): Page { + return this._page; + } + + url(): string { + return this._url; + } + + suggestedFilename(): string { + return this._suggestedFilename; + } + + async path(): Promise { + return await this._artifact.pathAfterFinished(); + } + + async saveAs(path: string): Promise { + return await this._artifact.saveAs(path); + } + + async failure(): Promise { + return await this._artifact.failure(); + } + + async createReadStream(): Promise { + return await this._artifact.createReadStream(); + } + + async cancel(): Promise { + return await this._artifact.cancel(); + } + + async delete(): Promise { + return await this._artifact.delete(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/electron.ts b/daemon/src/sandbox/forked-client/src/client/electron.ts new file mode 100644 index 00000000..9a5279a3 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/electron.ts @@ -0,0 +1,14 @@ +// @ts-nocheck +import { ChannelOwner } from './channelOwner'; + +export class Electron extends ChannelOwner { + static from(channel) { + return channel?._object; + } +} + +export class ElectronApplication extends ChannelOwner { + static from(channel) { + return channel?._object; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/elementHandle.ts b/daemon/src/sandbox/forked-client/src/client/elementHandle.ts new file mode 100644 index 00000000..4d5ef61b --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/elementHandle.ts @@ -0,0 +1,335 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Frame } from './frame'; +import { JSHandle, parseResult, serializeArgument } from './jsHandle'; +import { assert } from '../utils/isomorphic/assert'; +import { fileUploadSizeLimit, writeTempFile } from './fileUtils'; +import { isString } from '../utils/isomorphic/rtti'; +import { WritableStream } from './writableStream'; +import { getMimeTypeForPath } from '../utils/isomorphic/mimeType'; + +import type { BrowserContext } from './browserContext'; +import type { ChannelOwner } from './channelOwner'; +import type { Locator } from './locator'; +import type { FilePayload, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types'; +import type * as structs from '../../types/structs'; +import type * as api from '../../types/types'; +import type { Platform } from './platform'; +import type * as channels from '../protocol/channels'; + +export class ElementHandle extends JSHandle implements api.ElementHandle { + private _frame: Frame; + readonly _elementChannel: channels.ElementHandleChannel; + + static override from(handle: channels.ElementHandleChannel): ElementHandle { + return (handle as any)._object; + } + + static fromNullable(handle: channels.ElementHandleChannel | undefined): ElementHandle | null { + return handle ? ElementHandle.from(handle) : null; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.JSHandleInitializer) { + super(parent, type, guid, initializer); + this._frame = parent as Frame; + this._elementChannel = this._channel as channels.ElementHandleChannel; + } + + override asElement(): T extends Node ? ElementHandle : null { + return this as any; + } + + async ownerFrame(): Promise { + return Frame.fromNullable((await this._elementChannel.ownerFrame()).frame); + } + + async contentFrame(): Promise { + return Frame.fromNullable((await this._elementChannel.contentFrame()).frame); + } + + async getAttribute(name: string): Promise { + const value = (await this._elementChannel.getAttribute({ name })).value; + return value === undefined ? null : value; + } + + async inputValue(): Promise { + return (await this._elementChannel.inputValue()).value; + } + + async textContent(): Promise { + const value = (await this._elementChannel.textContent()).value; + return value === undefined ? null : value; + } + + async innerText(): Promise { + return (await this._elementChannel.innerText()).value; + } + + async innerHTML(): Promise { + return (await this._elementChannel.innerHTML()).value; + } + + async isChecked(): Promise { + return (await this._elementChannel.isChecked()).value; + } + + async isDisabled(): Promise { + return (await this._elementChannel.isDisabled()).value; + } + + async isEditable(): Promise { + return (await this._elementChannel.isEditable()).value; + } + + async isEnabled(): Promise { + return (await this._elementChannel.isEnabled()).value; + } + + async isHidden(): Promise { + return (await this._elementChannel.isHidden()).value; + } + + async isVisible(): Promise { + return (await this._elementChannel.isVisible()).value; + } + + async dispatchEvent(type: string, eventInit: Object = {}) { + await this._elementChannel.dispatchEvent({ type, eventInit: serializeArgument(eventInit) }); + } + + async scrollIntoViewIfNeeded(options: channels.ElementHandleScrollIntoViewIfNeededOptions & TimeoutOptions = {}) { + await this._elementChannel.scrollIntoViewIfNeeded({ ...options, timeout: this._frame._timeout(options) }); + } + + async hover(options: channels.ElementHandleHoverOptions & TimeoutOptions = {}): Promise { + await this._elementChannel.hover({ ...options, timeout: this._frame._timeout(options) }); + } + + async click(options: channels.ElementHandleClickOptions & TimeoutOptions = {}): Promise { + return await this._elementChannel.click({ ...options, timeout: this._frame._timeout(options) }); + } + + async dblclick(options: channels.ElementHandleDblclickOptions & TimeoutOptions = {}): Promise { + return await this._elementChannel.dblclick({ ...options, timeout: this._frame._timeout(options) }); + } + + async tap(options: channels.ElementHandleTapOptions & TimeoutOptions = {}): Promise { + return await this._elementChannel.tap({ ...options, timeout: this._frame._timeout(options) }); + } + + async selectOption(values: string | api.ElementHandle | SelectOption | string[] | api.ElementHandle[] | SelectOption[] | null, options: SelectOptionOptions = {}): Promise { + const result = await this._elementChannel.selectOption({ ...convertSelectOptionValues(values), ...options, timeout: this._frame._timeout(options) }); + return result.values; + } + + async fill(value: string, options: channels.ElementHandleFillOptions & TimeoutOptions = {}): Promise { + return await this._elementChannel.fill({ value, ...options, timeout: this._frame._timeout(options) }); + } + + async selectText(options: channels.ElementHandleSelectTextOptions & TimeoutOptions = {}): Promise { + await this._elementChannel.selectText({ ...options, timeout: this._frame._timeout(options) }); + } + + async setInputFiles(files: string | FilePayload | string[] | FilePayload[], options: channels.ElementHandleSetInputFilesOptions & TimeoutOptions = {}) { + const frame = await this.ownerFrame(); + if (!frame) + throw new Error('Cannot set input files to detached element'); + const converted = await convertInputFiles(this._platform, files, frame.page().context()); + await this._elementChannel.setInputFiles({ ...converted, ...options, timeout: this._frame._timeout(options) }); + } + + async focus(): Promise { + await this._elementChannel.focus(); + } + + async type(text: string, options: channels.ElementHandleTypeOptions & TimeoutOptions = {}): Promise { + await this._elementChannel.type({ text, ...options, timeout: this._frame._timeout(options) }); + } + + async press(key: string, options: channels.ElementHandlePressOptions & TimeoutOptions = {}): Promise { + await this._elementChannel.press({ key, ...options, timeout: this._frame._timeout(options) }); + } + + async check(options: channels.ElementHandleCheckOptions & TimeoutOptions = {}) { + return await this._elementChannel.check({ ...options, timeout: this._frame._timeout(options) }); + } + + async uncheck(options: channels.ElementHandleUncheckOptions & TimeoutOptions = {}) { + return await this._elementChannel.uncheck({ ...options, timeout: this._frame._timeout(options) }); + } + + async setChecked(checked: boolean, options?: channels.ElementHandleCheckOptions) { + if (checked) + await this.check(options); + else + await this.uncheck(options); + } + + async boundingBox(): Promise { + const value = (await this._elementChannel.boundingBox()).value; + return value === undefined ? null : value; + } + + async screenshot(options: Omit & TimeoutOptions & { path?: string, mask?: api.Locator[] } = {}): Promise { + const mask = options.mask as Locator[] | undefined; + const copy: channels.ElementHandleScreenshotParams = { + ...options, + path: undefined, + mask: undefined, + timeout: this._frame._timeout(options), + }; + if (!copy.type) + copy.type = determineScreenshotType(options); + if (mask) { + copy.mask = mask.map(locator => ({ + frame: locator._frame._channel, + selector: locator._selector, + })); + } + const result = await this._elementChannel.screenshot(copy); + if (options.path) { + options.path = await writeTempFile(options.path, result.binary); + } + return result.binary; + } + + async $(selector: string): Promise | null> { + return ElementHandle.fromNullable((await this._elementChannel.querySelector({ selector })).element) as ElementHandle | null; + } + + async $$(selector: string): Promise[]> { + const result = await this._elementChannel.querySelectorAll({ selector }); + return result.elements.map(h => ElementHandle.from(h) as ElementHandle); + } + + async $eval(selector: string, pageFunction: structs.PageFunctionOn, arg?: Arg): Promise { + const result = await this._elementChannel.evalOnSelector({ selector, expression: String(pageFunction), isFunction: typeof pageFunction === 'function', arg: serializeArgument(arg) }); + return parseResult(result.value); + } + + async $$eval(selector: string, pageFunction: structs.PageFunctionOn, arg?: Arg): Promise { + const result = await this._elementChannel.evalOnSelectorAll({ selector, expression: String(pageFunction), isFunction: typeof pageFunction === 'function', arg: serializeArgument(arg) }); + return parseResult(result.value); + } + + async waitForElementState(state: 'visible' | 'hidden' | 'stable' | 'enabled' | 'disabled', options: TimeoutOptions = {}): Promise { + return await this._elementChannel.waitForElementState({ state, ...options, timeout: this._frame._timeout(options) }); + } + + waitForSelector(selector: string, options: channels.ElementHandleWaitForSelectorOptions & TimeoutOptions & { state: 'attached' | 'visible' }): Promise>; + waitForSelector(selector: string, options?: channels.ElementHandleWaitForSelectorOptions & TimeoutOptions): Promise | null>; + async waitForSelector(selector: string, options: channels.ElementHandleWaitForSelectorOptions & TimeoutOptions = {}): Promise | null> { + const result = await this._elementChannel.waitForSelector({ selector, ...options, timeout: this._frame._timeout(options) }); + return ElementHandle.fromNullable(result.element) as ElementHandle | null; + } +} + +export function convertSelectOptionValues(values: string | api.ElementHandle | SelectOption | string[] | api.ElementHandle[] | SelectOption[] | null): { elements?: channels.ElementHandleChannel[], options?: SelectOption[] } { + if (values === null) + return {}; + if (!Array.isArray(values)) + values = [values as any]; + if (!values.length) + return {}; + for (let i = 0; i < values.length; i++) + assert(values[i] !== null, `options[${i}]: expected object, got null`); + if (values[0] instanceof ElementHandle) + return { elements: (values as ElementHandle[]).map((v: ElementHandle) => v._elementChannel) }; + if (isString(values[0])) + return { options: (values as string[]).map(valueOrLabel => ({ valueOrLabel })) }; + return { options: values as SelectOption[] }; +} + +type SetInputFilesFiles = Pick; + +function filePayloadExceedsSizeLimit(payloads: FilePayload[]) { + return payloads.reduce((size, item) => size + (item.buffer ? item.buffer.byteLength : 0), 0) >= fileUploadSizeLimit; +} + +async function resolvePathsAndDirectoryForInputFiles(platform: Platform, items: string[]): Promise<[string[] | undefined, string | undefined]> { + let localPaths: string[] | undefined; + let localDirectory: string | undefined; + for (const item of items) { + const stat = await platform.fs().promises.stat(item as string); + if (stat.isDirectory()) { + if (localDirectory) + throw new Error('Multiple directories are not supported'); + localDirectory = platform.path().resolve(item as string); + } else { + localPaths ??= []; + localPaths.push(platform.path().resolve(item as string)); + } + } + if (localPaths?.length && localDirectory) + throw new Error('File paths must be all files or a single directory'); + return [localPaths, localDirectory]; +} + +export async function convertInputFiles(platform: Platform, files: string | FilePayload | string[] | FilePayload[], context: BrowserContext): Promise { + const items: (string | FilePayload)[] = Array.isArray(files) ? files.slice() : [files]; + + if (items.some(item => typeof item === 'string')) { + if (!items.every(item => typeof item === 'string')) + throw new Error('File paths cannot be mixed with buffers'); + + const [localPaths, localDirectory] = await resolvePathsAndDirectoryForInputFiles(platform, items); + + if (context._connection.isRemote()) { + const files = localDirectory ? (await platform.fs().promises.readdir(localDirectory, { withFileTypes: true, recursive: true })).filter(f => f.isFile()).map(f => platform.path().join(f.parentPath, f.name)) : localPaths!; + const { writableStreams, rootDir } = await context._wrapApiCall(async () => context._channel.createTempFiles({ + rootDirName: localDirectory ? platform.path().basename(localDirectory) : undefined, + items: await Promise.all(files.map(async file => { + const lastModifiedMs = (await platform.fs().promises.stat(file)).mtimeMs; + return { + name: localDirectory ? platform.path().relative(localDirectory, file) : platform.path().basename(file), + lastModifiedMs + }; + })), + }), { internal: true }); + for (let i = 0; i < files.length; i++) { + const writable = WritableStream.from(writableStreams[i]); + await platform.streamFile(files[i], writable.stream()); + } + return { + directoryStream: rootDir, + streams: localDirectory ? undefined : writableStreams, + }; + } + return { + localPaths, + localDirectory, + }; + } + + const payloads = items as FilePayload[]; + if (filePayloadExceedsSizeLimit(payloads)) + throw new Error('Cannot set buffer larger than 50Mb, please write it to a file and pass its path instead.'); + return { payloads }; +} + +export function determineScreenshotType(options: { path?: string, type?: 'png' | 'jpeg' }): 'png' | 'jpeg' | undefined { + if (options.path) { + const mimeType = getMimeTypeForPath(options.path); + if (mimeType === 'image/png') + return 'png'; + else if (mimeType === 'image/jpeg') + return 'jpeg'; + throw new Error(`path: unsupported mime type "${mimeType}"`); + } + return options.type; +} diff --git a/daemon/src/sandbox/forked-client/src/client/errors.ts b/daemon/src/sandbox/forked-client/src/client/errors.ts new file mode 100644 index 00000000..f93a66a7 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/errors.ts @@ -0,0 +1,66 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { parseSerializedValue, serializeValue } from '../protocol/serializers'; +import { isError } from '../utils/isomorphic/rtti'; + +import type { SerializedError } from '../protocol/channels'; + +export class TimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'TimeoutError'; + } +} + +export class TargetClosedError extends Error { + constructor(cause?: string) { + super(cause || 'Target page, context or browser has been closed'); + } +} + +export function isTargetClosedError(error: Error) { + return error instanceof TargetClosedError; +} + +export function serializeError(e: any): SerializedError { + if (isError(e)) + return { error: { message: e.message, stack: e.stack, name: e.name } }; + return { value: serializeValue(e, value => ({ fallThrough: value })) }; +} + +export function parseError(error: SerializedError): Error { + if (!error.error) { + if (error.value === undefined) + throw new Error('Serialized error must have either an error or a value'); + return parseSerializedValue(error.value, undefined); + } + if (error.error.name === 'TimeoutError') { + const e = new TimeoutError(error.error.message); + e.stack = error.error.stack || ''; + return e; + } + if (error.error.name === 'TargetClosedError') { + const e = new TargetClosedError(error.error.message); + e.stack = error.error.stack || ''; + return e; + } + const e = new Error(error.error.message); + e.stack = error.error.stack || ''; + e.name = error.error.name; + return e; +} diff --git a/daemon/src/sandbox/forked-client/src/client/eventEmitter.ts b/daemon/src/sandbox/forked-client/src/client/eventEmitter.ts new file mode 100644 index 00000000..33db668e --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/eventEmitter.ts @@ -0,0 +1,399 @@ +// @ts-nocheck +/** + * Copyright Joyent, Inc. and other Node contributors. + * Modifications copyright (c) Microsoft Corporation. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit + * persons to whom the Software is furnished to do so, subject to the + * following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN + * NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE + * USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import type { EventEmitter as EventEmitterType } from 'events'; +import type { Platform } from './platform'; + +type EventType = string | symbol; +type Listener = (...args: any[]) => any; +type EventMap = Record; + +export class EventEmitter implements EventEmitterType { + + private _events: EventMap | undefined = undefined; + private _eventsCount = 0; + private _maxListeners: number | undefined = undefined; + readonly _pendingHandlers = new Map>>(); + private _rejectionHandler: ((error: Error) => void) | undefined; + readonly _platform: Platform; + + constructor(platform: Platform) { + this._platform = platform; + if (this._events === undefined || this._events === Object.getPrototypeOf(this)._events) { + this._events = Object.create(null); + this._eventsCount = 0; + } + this._maxListeners = this._maxListeners || undefined; + this.on = this.addListener; + this.off = this.removeListener; + } + + setMaxListeners(n: number): this { + if (typeof n !== 'number' || n < 0 || Number.isNaN(n)) + throw new RangeError('The value of "n" is out of range. It must be a non-negative number. Received ' + n + '.'); + this._maxListeners = n; + return this; + } + + getMaxListeners(): number { + return this._maxListeners === undefined ? this._platform.defaultMaxListeners() : this._maxListeners; + } + + emit(type: EventType, ...args: any[]): boolean { + const events = this._events; + if (events === undefined) + return false; + + const handler = events?.[type]; + if (handler === undefined) + return false; + + if (typeof handler === 'function') { + this._callHandler(type, handler, args); + } else { + const len = handler.length; + const listeners = handler.slice(); + for (let i = 0; i < len; ++i) + this._callHandler(type, listeners[i], args); + } + return true; + } + + private _callHandler(type: EventType, handler: Listener, args: any[]): void { + const promise = Reflect.apply(handler, this, args); + if (!(promise instanceof Promise)) + return; + let set = this._pendingHandlers.get(type); + if (!set) { + set = new Set(); + this._pendingHandlers.set(type, set); + } + set.add(promise); + promise.catch(e => { + if (this._rejectionHandler) + this._rejectionHandler(e); + else + throw e; + }).finally(() => set.delete(promise)); + } + + addListener(type: EventType, listener: Listener): this { + return this._addListener(type, listener, false); + } + + on(type: EventType, listener: Listener): this { + return this._addListener(type, listener, false); + } + + private _addListener(type: EventType, listener: Listener, prepend: boolean): this { + checkListener(listener); + let events = this._events; + let existing; + if (events === undefined) { + events = this._events = Object.create(null); + this._eventsCount = 0; + } else { + // To avoid recursion in the case that type === "newListener"! Before + // adding it to the listeners, first emit "newListener". + if (events.newListener !== undefined) { + this.emit('newListener', type, unwrapListener(listener)); + + // Re-assign `events` because a newListener handler could have caused the + // this._events to be assigned to a new object + events = this._events!; + } + existing = events[type]; + } + + if (existing === undefined) { + // Optimize the case of one listener. Don't need the extra array object. + existing = events![type] = listener; + ++this._eventsCount; + } else { + if (typeof existing === 'function') { + // Adding the second element, need to change to array. + existing = events![type] = + prepend ? [listener, existing] : [existing, listener]; + // If we've already got an array, just append. + } else if (prepend) { + existing.unshift(listener); + } else { + existing.push(listener); + } + + // Check for listener leak + const m = this.getMaxListeners(); + if (m > 0 && existing.length > m && !(existing as any).warned) { + (existing as any).warned = true; + // No error code for this since it is a Warning + const w = new Error('Possible EventEmitter memory leak detected. ' + + existing.length + ' ' + String(type) + ' listeners ' + + 'added. Use emitter.setMaxListeners() to ' + + 'increase limit') as any; + w.name = 'MaxListenersExceededWarning'; + w.emitter = this; + w.type = type; + w.count = existing.length; + if (!this._platform.isUnderTest()) { + // eslint-disable-next-line no-console + console.warn(w); + } + } + } + + return this; + } + + prependListener(type: EventType, listener: Listener): this { + return this._addListener(type, listener, true); + } + + once(type: EventType, listener: Listener): this { + checkListener(listener); + this.on(type, new OnceWrapper(this, type, listener).wrapperFunction); + return this; + } + + prependOnceListener(type: EventType, listener: Listener): this { + checkListener(listener); + this.prependListener(type, new OnceWrapper(this, type, listener).wrapperFunction); + return this; + } + + removeListener(type: EventType, listener: Listener): this { + checkListener(listener); + + const events = this._events; + if (events === undefined) + return this; + + const list = events[type]; + if (list === undefined) + return this; + + if (list === listener || (list as any).listener === listener) { + if (--this._eventsCount === 0) { + this._events = Object.create(null); + } else { + delete events[type]; + if (events.removeListener) + this.emit('removeListener', type, (list as any).listener ?? listener); + } + } else if (typeof list !== 'function') { + let position = -1; + let originalListener; + + for (let i = list.length - 1; i >= 0; i--) { + if (list[i] === listener || wrappedListener(list[i]) === listener) { + originalListener = wrappedListener(list[i]); + position = i; + break; + } + } + + if (position < 0) + return this; + + if (position === 0) + list.shift(); + else + list.splice(position, 1); + + if (list.length === 1) + events[type] = list[0]; + + if (events.removeListener !== undefined) + this.emit('removeListener', type, originalListener || listener); + } + + return this; + + } + + off(type: EventType, listener: Listener): this { + return this.removeListener(type, listener); + } + + removeAllListeners(type?: EventType): this; + removeAllListeners(type: EventType | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; + removeAllListeners(type?: string, options?: { behavior?: 'wait'|'ignoreErrors'|'default' }): this | Promise { + this._removeAllListeners(type); + if (!options) + return this; + + if (options.behavior === 'wait') { + const errors: Error[] = []; + this._rejectionHandler = error => errors.push(error); + return this._waitFor(type).then(() => { + if (errors.length) + throw errors[0]; + }); + } + + if (options.behavior === 'ignoreErrors') + this._rejectionHandler = () => {}; + + return Promise.resolve(); + } + + private _removeAllListeners(type?: string) { + const events = this._events; + if (!events) + return; + + // not listening for removeListener, no need to emit + if (!events.removeListener) { + if (type === undefined) { + this._events = Object.create(null); + this._eventsCount = 0; + } else if (events[type] !== undefined) { + if (--this._eventsCount === 0) + this._events = Object.create(null); + else + delete events[type]; + } + return; + } + + // emit removeListener for all listeners on all events + if (type === undefined) { + const keys = Object.keys(events); + let key; + for (let i = 0; i < keys.length; ++i) { + key = keys[i]; + if (key === 'removeListener') + continue; + this._removeAllListeners(key); + } + this._removeAllListeners('removeListener'); + this._events = Object.create(null); + this._eventsCount = 0; + return; + } + + const listeners = events[type]; + + if (typeof listeners === 'function') { + this.removeListener(type, listeners); + } else if (listeners !== undefined) { + // LIFO order + for (let i = listeners.length - 1; i >= 0; i--) + this.removeListener(type, listeners[i]); + } + } + + listeners(type: EventType): Listener[] { + return this._listeners(this, type, true); + } + + rawListeners(type: EventType): Listener[] { + return this._listeners(this, type, false); + } + + listenerCount(type: EventType): number { + const events = this._events; + if (events !== undefined) { + const listener = events[type]; + if (typeof listener === 'function') + return 1; + if (listener !== undefined) + return listener.length; + } + return 0; + } + + eventNames(): Array { + return this._eventsCount > 0 && this._events ? Reflect.ownKeys(this._events) : []; + } + + private async _waitFor(type?: EventType) { + let promises: Promise[] = []; + if (type) { + promises = [...(this._pendingHandlers.get(type) || [])]; + } else { + promises = []; + for (const [, pending] of this._pendingHandlers) + promises.push(...pending); + } + await Promise.all(promises); + } + + private _listeners(target: EventEmitter, type: EventType, unwrap: boolean): Listener[] { + const events = target._events; + + if (events === undefined) + return []; + + const listener = events[type]; + if (listener === undefined) + return []; + + if (typeof listener === 'function') + return unwrap ? [unwrapListener(listener)] : [listener]; + + return unwrap ? unwrapListeners(listener) : listener.slice(); + } +} + +function checkListener(listener: any) { + if (typeof listener !== 'function') + throw new TypeError('The "listener" argument must be of type Function. Received type ' + typeof listener); +} + +class OnceWrapper { + private _fired = false; + readonly wrapperFunction: (...args: any[]) => Promise | void; + readonly _listener: Listener; + private _eventEmitter: EventEmitter; + private _eventType: EventType; + + constructor(eventEmitter: EventEmitter, eventType: EventType, listener: Listener) { + this._eventEmitter = eventEmitter; + this._eventType = eventType; + this._listener = listener; + this.wrapperFunction = this._handle.bind(this); + (this.wrapperFunction as any).listener = listener; + } + + private _handle(...args: any[]) { + if (this._fired) + return; + this._fired = true; + this._eventEmitter.removeListener(this._eventType, this.wrapperFunction); + return this._listener.apply(this._eventEmitter, args); + } +} + +function unwrapListener(l: Listener): Listener { + return wrappedListener(l) ?? l; +} + +function unwrapListeners(arr: Listener[]): Listener[] { + return arr.map(l => wrappedListener(l) ?? l); +} + +function wrappedListener(l: Listener): Listener { + return (l as any).listener; +} diff --git a/daemon/src/sandbox/forked-client/src/client/events.ts b/daemon/src/sandbox/forked-client/src/client/events.ts new file mode 100644 index 00000000..1806c4e1 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/events.ts @@ -0,0 +1,103 @@ +// @ts-nocheck +/** + * Copyright 2019 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const Events = { + AndroidDevice: { + WebView: 'webview', + Close: 'close' + }, + + AndroidSocket: { + Data: 'data', + Close: 'close' + }, + + AndroidWebView: { + Close: 'close' + }, + + Browser: { + Disconnected: 'disconnected' + }, + + Debugger: { + PausedStateChanged: 'pausedstatechanged' + }, + + BrowserContext: { + Console: 'console', + Close: 'close', + Dialog: 'dialog', + Page: 'page', + // Can't use just 'error' due to node.js special treatment of error events. + // @see https://nodejs.org/api/events.html#events_error_events + WebError: 'weberror', + BackgroundPage: 'backgroundpage', // Deprecated in v1.56, never emitted anymore. + ServiceWorker: 'serviceworker', + Request: 'request', + Response: 'response', + RequestFailed: 'requestfailed', + RequestFinished: 'requestfinished', + }, + + BrowserServer: { + Close: 'close', + }, + + Page: { + Close: 'close', + Crash: 'crash', + Console: 'console', + Dialog: 'dialog', + Download: 'download', + FileChooser: 'filechooser', + DOMContentLoaded: 'domcontentloaded', + // Can't use just 'error' due to node.js special treatment of error events. + // @see https://nodejs.org/api/events.html#events_error_events + PageError: 'pageerror', + Request: 'request', + Response: 'response', + RequestFailed: 'requestfailed', + RequestFinished: 'requestfinished', + FrameAttached: 'frameattached', + FrameDetached: 'framedetached', + FrameNavigated: 'framenavigated', + Load: 'load', + Popup: 'popup', + WebSocket: 'websocket', + Worker: 'worker', + }, + + WebSocket: { + Close: 'close', + Error: 'socketerror', + FrameReceived: 'framereceived', + FrameSent: 'framesent', + }, + + Worker: { + Close: 'close', + Console: 'console', + }, + + ElectronApplication: { + Close: 'close', + Console: 'console', + Window: 'window', + }, +}; diff --git a/daemon/src/sandbox/forked-client/src/client/fetch.ts b/daemon/src/sandbox/forked-client/src/client/fetch.ts new file mode 100644 index 00000000..7f79bb94 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/fetch.ts @@ -0,0 +1,36 @@ +// @ts-nocheck +import { ChannelOwner } from './channelOwner'; + +function unsupported() { + throw new Error('APIRequest is not available in the QuickJS sandbox'); +} + +export class APIRequestContext extends ChannelOwner { + static from(channel) { + return channel?._object; + } + + async storageState() { + return { cookies: [], origins: [] }; + } + + async dispose() { + await this._channel.dispose?.({}); + } +} + +export class APIRequest { + constructor(playwright) { + this._playwright = playwright; + } + + async newContext() { + unsupported(); + } +} + +export class APIResponse { + async body() { + unsupported(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/fileChooser.ts b/daemon/src/sandbox/forked-client/src/client/fileChooser.ts new file mode 100644 index 00000000..2e828031 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/fileChooser.ts @@ -0,0 +1,50 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ElementHandle } from './elementHandle'; +import type { Page } from './page'; +import type { FilePayload, TimeoutOptions } from './types'; +import type * as api from '../../types/types'; +import type * as channels from '../protocol/channels'; + +export class FileChooser implements api.FileChooser { + private _page: Page; + private _elementHandle: ElementHandle; + private _isMultiple: boolean; + + constructor(page: Page, elementHandle: ElementHandle, isMultiple: boolean) { + this._page = page; + this._elementHandle = elementHandle; + this._isMultiple = isMultiple; + } + + element(): ElementHandle { + return this._elementHandle; + } + + isMultiple(): boolean { + return this._isMultiple; + } + + page(): Page { + return this._page; + } + + async setFiles(files: string | FilePayload | string[] | FilePayload[], options?: channels.ElementHandleSetInputFilesOptions & TimeoutOptions) { + return await this._elementHandle.setInputFiles(files, options); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/fileUtils.ts b/daemon/src/sandbox/forked-client/src/client/fileUtils.ts new file mode 100644 index 00000000..c8e56d67 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/fileUtils.ts @@ -0,0 +1,11 @@ +// @ts-nocheck +export const fileUploadSizeLimit = 50 * 1024 * 1024; + +export async function mkdirIfNeeded() {} + +export async function writeTempFile(path, data) { + const writer = globalThis.writeFile; + if (typeof writer !== 'function') + throw new Error('writeFile() is not available in the QuickJS sandbox'); + return await writer(path, data); +} diff --git a/daemon/src/sandbox/forked-client/src/client/frame.ts b/daemon/src/sandbox/forked-client/src/client/frame.ts new file mode 100644 index 00000000..ca43bfe4 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/frame.ts @@ -0,0 +1,481 @@ +// @ts-nocheck +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EventEmitter } from './eventEmitter'; +import { ChannelOwner } from './channelOwner'; +import { addSourceUrlToScript } from './clientHelper'; +import { ElementHandle, convertInputFiles, convertSelectOptionValues } from './elementHandle'; +import { Events } from './events'; +import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from './jsHandle'; +import { FrameLocator, Locator, testIdAttributeName } from './locator'; +import * as network from './network'; +import { kLifecycleEvents } from './types'; +import { Waiter } from './waiter'; +import { assert } from '../utils/isomorphic/assert'; +import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from '../utils/isomorphic/locatorUtils'; +import { urlMatches } from '../utils/isomorphic/urlMatch'; +import { TimeoutSettings } from './timeoutSettings'; + +import type { LocatorOptions } from './locator'; +import type { Page } from './page'; +import type { FilePayload, LifecycleEvent, SelectOption, SelectOptionOptions, StrictOptions, TimeoutOptions, WaitForFunctionOptions } from './types'; +import type * as structs from '../../types/structs'; +import type * as api from '../../types/types'; +import type { ByRoleOptions } from '../utils/isomorphic/locatorUtils'; +import type { URLMatch } from '../utils/isomorphic/urlMatch'; +import type * as channels from '../protocol/channels'; + +export type WaitForNavigationOptions = { + timeout?: number, + waitUntil?: LifecycleEvent, + url?: URLMatch, +}; + +export class Frame extends ChannelOwner implements api.Frame { + _eventEmitter: EventEmitter; + _loadStates: Set; + _parentFrame: Frame | null = null; + _url = ''; + _name = ''; + _detached = false; + _childFrames = new Set(); + _page: Page | undefined; + + static from(frame: channels.FrameChannel): Frame { + return (frame as any)._object; + } + + static fromNullable(frame: channels.FrameChannel | undefined): Frame | null { + return frame ? Frame.from(frame) : null; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.FrameInitializer) { + super(parent, type, guid, initializer); + this._eventEmitter = new EventEmitter(parent._platform); + this._eventEmitter.setMaxListeners(0); + this._parentFrame = Frame.fromNullable(initializer.parentFrame); + if (this._parentFrame) + this._parentFrame._childFrames.add(this); + this._name = initializer.name; + this._url = initializer.url; + this._loadStates = new Set(initializer.loadStates); + this._channel.on('loadstate', event => { + if (event.add) { + this._loadStates.add(event.add); + this._eventEmitter.emit('loadstate', event.add); + } + if (event.remove) + this._loadStates.delete(event.remove); + if (!this._parentFrame && event.add === 'load' && this._page) + this._page.emit(Events.Page.Load, this._page); + if (!this._parentFrame && event.add === 'domcontentloaded' && this._page) + this._page.emit(Events.Page.DOMContentLoaded, this._page); + }); + this._channel.on('navigated', event => { + this._url = event.url; + this._name = event.name; + this._eventEmitter.emit('navigated', event); + if (!event.error && this._page) + this._page.emit(Events.Page.FrameNavigated, this); + }); + } + + page(): Page { + return this._page!; + } + + _timeout(options?: TimeoutOptions): number { + const timeoutSettings = this._page?._timeoutSettings || new TimeoutSettings(this._platform); + return timeoutSettings.timeout(options || {}); + } + + _navigationTimeout(options?: TimeoutOptions): number { + const timeoutSettings = this._page?._timeoutSettings || new TimeoutSettings(this._platform); + return timeoutSettings.navigationTimeout(options || {}); + } + + async goto(url: string, options: channels.FrameGotoOptions & TimeoutOptions = {}): Promise { + const waitUntil = verifyLoadState('waitUntil', options.waitUntil === undefined ? 'load' : options.waitUntil); + return network.Response.fromNullable((await this._channel.goto({ url, ...options, waitUntil, timeout: this._navigationTimeout(options) })).response); + } + + private _setupNavigationWaiter(options: { timeout?: number }): Waiter { + const waiter = new Waiter(this._page!, ''); + if (this._page!.isClosed()) + waiter.rejectImmediately(this._page!._closeErrorWithReason()); + waiter.rejectOnEvent(this._page!, Events.Page.Close, () => this._page!._closeErrorWithReason()); + waiter.rejectOnEvent(this._page!, Events.Page.Crash, new Error('Navigation failed because page crashed!')); + waiter.rejectOnEvent(this._page!, Events.Page.FrameDetached, new Error('Navigating frame was detached!'), frame => frame === this); + const timeout = this._page!._timeoutSettings.navigationTimeout(options); + waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded.`); + return waiter; + } + + async waitForNavigation(options: WaitForNavigationOptions = {}): Promise { + return await this._page!._wrapApiCall(async () => { + const waitUntil = verifyLoadState('waitUntil', options.waitUntil === undefined ? 'load' : options.waitUntil); + const waiter = this._setupNavigationWaiter(options); + + const toUrl = typeof options.url === 'string' ? ` to "${options.url}"` : ''; + waiter.log(`waiting for navigation${toUrl} until "${waitUntil}"`); + + const navigatedEvent = await waiter.waitForEvent(this._eventEmitter, 'navigated', event => { + // Any failed navigation results in a rejection. + if (event.error) + return true; + waiter.log(` navigated to "${event.url}"`); + return urlMatches(this._page?.context()._options.baseURL, event.url, options.url); + }); + if (navigatedEvent.error) { + const e = new Error(navigatedEvent.error); + e.stack = ''; + await waiter.waitForPromise(Promise.reject(e)); + } + + if (!this._loadStates.has(waitUntil)) { + await waiter.waitForEvent(this._eventEmitter, 'loadstate', s => { + waiter.log(` "${s}" event fired`); + return s === waitUntil; + }); + } + + const request = navigatedEvent.newDocument ? network.Request.fromNullable(navigatedEvent.newDocument.request) : null; + const response = request ? await waiter.waitForPromise(request._finalRequest()._internalResponse()) : null; + waiter.dispose(); + return response; + }, { title: 'Wait for navigation' }); + } + + async waitForLoadState(state: LifecycleEvent = 'load', options: { timeout?: number } = {}): Promise { + state = verifyLoadState('state', state); + return await this._page!._wrapApiCall(async () => { + const waiter = this._setupNavigationWaiter(options); + if (this._loadStates.has(state)) { + waiter.log(` not waiting, "${state}" event already fired`); + } else { + await waiter.waitForEvent(this._eventEmitter, 'loadstate', s => { + waiter.log(` "${s}" event fired`); + return s === state; + }); + } + waiter.dispose(); + }, { title: `Wait for load state "${state}"` }); + } + + async waitForURL(url: URLMatch, options: { waitUntil?: LifecycleEvent, timeout?: number } = {}): Promise { + if (urlMatches(this._page?.context()._options.baseURL, this.url(), url)) + return await this.waitForLoadState(options.waitUntil, options); + + await this.waitForNavigation({ url, ...options }); + } + + async frameElement(): Promise { + return ElementHandle.from((await this._channel.frameElement()).element); + } + + async evaluateHandle(pageFunction: structs.PageFunction, arg?: Arg): Promise> { + assertMaxArguments(arguments.length, 2); + const result = await this._channel.evaluateExpressionHandle({ expression: String(pageFunction), isFunction: typeof pageFunction === 'function', arg: serializeArgument(arg) }); + return JSHandle.from(result.handle) as any as structs.SmartHandle; + } + + async evaluate(pageFunction: structs.PageFunction, arg?: Arg): Promise { + assertMaxArguments(arguments.length, 2); + const result = await this._channel.evaluateExpression({ expression: String(pageFunction), isFunction: typeof pageFunction === 'function', arg: serializeArgument(arg) }); + return parseResult(result.value); + } + + async _evaluateExposeUtilityScript(pageFunction: structs.PageFunction, arg?: Arg): Promise { + assertMaxArguments(arguments.length, 2); + const result = await this._channel.evaluateExpression({ expression: String(pageFunction), isFunction: typeof pageFunction === 'function', arg: serializeArgument(arg) }); + return parseResult(result.value); + } + + async $(selector: string, options?: { strict?: boolean }): Promise | null> { + const result = await this._channel.querySelector({ selector, ...options }); + return ElementHandle.fromNullable(result.element) as ElementHandle | null; + } + + waitForSelector(selector: string, options: channels.FrameWaitForSelectorOptions & TimeoutOptions & { state: 'attached' | 'visible' }): Promise>; + waitForSelector(selector: string, options?: channels.FrameWaitForSelectorOptions & TimeoutOptions): Promise | null>; + async waitForSelector(selector: string, options: channels.FrameWaitForSelectorOptions & TimeoutOptions = {}): Promise | null> { + if ((options as any).visibility) + throw new Error('options.visibility is not supported, did you mean options.state?'); + if ((options as any).waitFor && (options as any).waitFor !== 'visible') + throw new Error('options.waitFor is not supported, did you mean options.state?'); + const result = await this._channel.waitForSelector({ selector, ...options, timeout: this._timeout(options) }); + return ElementHandle.fromNullable(result.element) as ElementHandle | null; + } + + async dispatchEvent(selector: string, type: string, eventInit?: any, options: channels.FrameDispatchEventOptions & TimeoutOptions = {}): Promise { + await this._channel.dispatchEvent({ selector, type, eventInit: serializeArgument(eventInit), ...options, timeout: this._timeout(options) }); + } + + async $eval(selector: string, pageFunction: structs.PageFunctionOn, arg?: Arg): Promise { + assertMaxArguments(arguments.length, 3); + const result = await this._channel.evalOnSelector({ selector, expression: String(pageFunction), isFunction: typeof pageFunction === 'function', arg: serializeArgument(arg) }); + return parseResult(result.value); + } + + async $$eval(selector: string, pageFunction: structs.PageFunctionOn, arg?: Arg): Promise { + assertMaxArguments(arguments.length, 3); + const result = await this._channel.evalOnSelectorAll({ selector, expression: String(pageFunction), isFunction: typeof pageFunction === 'function', arg: serializeArgument(arg) }); + return parseResult(result.value); + } + + async $$(selector: string): Promise[]> { + const result = await this._channel.querySelectorAll({ selector }); + return result.elements.map(e => ElementHandle.from(e) as ElementHandle); + } + + async _queryCount(selector: string, options?: {}): Promise { + return (await this._channel.queryCount({ selector, ...options })).value; + } + + async content(): Promise { + return (await this._channel.content()).value; + } + + async setContent(html: string, options: channels.FrameSetContentOptions & TimeoutOptions = {}): Promise { + const waitUntil = verifyLoadState('waitUntil', options.waitUntil === undefined ? 'load' : options.waitUntil); + await this._channel.setContent({ html, ...options, waitUntil, timeout: this._navigationTimeout(options) }); + } + + name(): string { + return this._name || ''; + } + + url(): string { + return this._url; + } + + parentFrame(): Frame | null { + return this._parentFrame; + } + + childFrames(): Frame[] { + return Array.from(this._childFrames); + } + + isDetached(): boolean { + return this._detached; + } + + async addScriptTag(options: { url?: string, path?: string, content?: string, type?: string } = {}): Promise { + const copy = { ...options }; + if (copy.path) { + copy.content = (await this._platform.fs().promises.readFile(copy.path)).toString(); + copy.content = addSourceUrlToScript(copy.content, copy.path); + } + return ElementHandle.from((await this._channel.addScriptTag({ ...copy })).element); + } + + async addStyleTag(options: { url?: string; path?: string; content?: string; } = {}): Promise { + const copy = { ...options }; + if (copy.path) { + copy.content = (await this._platform.fs().promises.readFile(copy.path)).toString(); + copy.content += '/*# sourceURL=' + copy.path.replace(/\n/g, '') + '*/'; + } + return ElementHandle.from((await this._channel.addStyleTag({ ...copy })).element); + } + + async click(selector: string, options: channels.FrameClickOptions & TimeoutOptions = {}) { + return await this._channel.click({ selector, ...options, timeout: this._timeout(options) }); + } + + async dblclick(selector: string, options: channels.FrameDblclickOptions & TimeoutOptions = {}) { + return await this._channel.dblclick({ selector, ...options, timeout: this._timeout(options) }); + } + + async dragAndDrop(source: string, target: string, options: channels.FrameDragAndDropOptions & TimeoutOptions = {}) { + return await this._channel.dragAndDrop({ source, target, ...options, timeout: this._timeout(options) }); + } + + async tap(selector: string, options: channels.FrameTapOptions & TimeoutOptions = {}) { + return await this._channel.tap({ selector, ...options, timeout: this._timeout(options) }); + } + + async fill(selector: string, value: string, options: channels.FrameFillOptions & TimeoutOptions = {}) { + return await this._channel.fill({ selector, value, ...options, timeout: this._timeout(options) }); + } + + async _highlight(selector: string) { + return await this._channel.highlight({ selector }); + } + + locator(selector: string, options?: LocatorOptions): Locator { + return new Locator(this, selector, options); + } + + getByTestId(testId: string | RegExp): Locator { + return this.locator(getByTestIdSelector(testIdAttributeName(), testId)); + } + + getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByAltTextSelector(text, options)); + } + + getByLabel(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByLabelSelector(text, options)); + } + + getByPlaceholder(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByPlaceholderSelector(text, options)); + } + + getByText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByTextSelector(text, options)); + } + + getByTitle(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByTitleSelector(text, options)); + } + + getByRole(role: string, options: ByRoleOptions = {}): Locator { + return this.locator(getByRoleSelector(role, options)); + } + + frameLocator(selector: string): FrameLocator { + return new FrameLocator(this, selector); + } + + async focus(selector: string, options: channels.FrameFocusOptions & TimeoutOptions = {}) { + await this._channel.focus({ selector, ...options, timeout: this._timeout(options) }); + } + + async textContent(selector: string, options: channels.FrameTextContentOptions & TimeoutOptions = {}): Promise { + const value = (await this._channel.textContent({ selector, ...options, timeout: this._timeout(options) })).value; + return value === undefined ? null : value; + } + + async innerText(selector: string, options: channels.FrameInnerTextOptions & TimeoutOptions = {}): Promise { + return (await this._channel.innerText({ selector, ...options, timeout: this._timeout(options) })).value; + } + + async innerHTML(selector: string, options: channels.FrameInnerHTMLOptions & TimeoutOptions = {}): Promise { + return (await this._channel.innerHTML({ selector, ...options, timeout: this._timeout(options) })).value; + } + + async getAttribute(selector: string, name: string, options: channels.FrameGetAttributeOptions & TimeoutOptions = {}): Promise { + const value = (await this._channel.getAttribute({ selector, name, ...options, timeout: this._timeout(options) })).value; + return value === undefined ? null : value; + } + + async inputValue(selector: string, options: channels.FrameInputValueOptions & TimeoutOptions = {}): Promise { + return (await this._channel.inputValue({ selector, ...options, timeout: this._timeout(options) })).value; + } + + async isChecked(selector: string, options: channels.FrameIsCheckedOptions & TimeoutOptions = {}): Promise { + return (await this._channel.isChecked({ selector, ...options, timeout: this._timeout(options) })).value; + } + + async isDisabled(selector: string, options: channels.FrameIsDisabledOptions & TimeoutOptions = {}): Promise { + return (await this._channel.isDisabled({ selector, ...options, timeout: this._timeout(options) })).value; + } + + async isEditable(selector: string, options: channels.FrameIsEditableOptions & TimeoutOptions = {}): Promise { + return (await this._channel.isEditable({ selector, ...options, timeout: this._timeout(options) })).value; + } + + async isEnabled(selector: string, options: channels.FrameIsEnabledOptions & TimeoutOptions = {}): Promise { + return (await this._channel.isEnabled({ selector, ...options, timeout: this._timeout(options) })).value; + } + + async isHidden(selector: string, options: channels.FrameIsHiddenOptions & TimeoutOptions = {}): Promise { + return (await this._channel.isHidden({ selector, ...options })).value; + } + + async isVisible(selector: string, options: channels.FrameIsVisibleOptions & TimeoutOptions = {}): Promise { + return (await this._channel.isVisible({ selector, ...options })).value; + } + + async hover(selector: string, options: channels.FrameHoverOptions & TimeoutOptions = {}) { + await this._channel.hover({ selector, ...options, timeout: this._timeout(options) }); + } + + async selectOption(selector: string, values: string | api.ElementHandle | SelectOption | string[] | api.ElementHandle[] | SelectOption[] | null, options: SelectOptionOptions & StrictOptions = {}): Promise { + return (await this._channel.selectOption({ selector, ...convertSelectOptionValues(values), ...options, timeout: this._timeout(options) })).values; + } + + async setInputFiles(selector: string, files: string | FilePayload | string[] | FilePayload[], options: channels.FrameSetInputFilesOptions & TimeoutOptions = {}): Promise { + const converted = await convertInputFiles(this._platform, files, this.page().context()); + await this._channel.setInputFiles({ selector, ...converted, ...options, timeout: this._timeout(options) }); + } + + async type(selector: string, text: string, options: channels.FrameTypeOptions & TimeoutOptions = {}) { + await this._channel.type({ selector, text, ...options, timeout: this._timeout(options) }); + } + + async press(selector: string, key: string, options: channels.FramePressOptions & TimeoutOptions = {}) { + await this._channel.press({ selector, key, ...options, timeout: this._timeout(options) }); + } + + async check(selector: string, options: channels.FrameCheckOptions & TimeoutOptions = {}) { + await this._channel.check({ selector, ...options, timeout: this._timeout(options) }); + } + + async uncheck(selector: string, options: channels.FrameUncheckOptions & TimeoutOptions = {}) { + await this._channel.uncheck({ selector, ...options, timeout: this._timeout(options) }); + } + + async setChecked(selector: string, checked: boolean, options?: channels.FrameCheckOptions) { + if (checked) + await this.check(selector, options); + else + await this.uncheck(selector, options); + } + + async waitForTimeout(timeout: number) { + await this._channel.waitForTimeout({ waitTimeout: timeout }); + } + + async waitForFunction(pageFunction: structs.PageFunction, arg?: Arg, options: WaitForFunctionOptions = {}): Promise> { + if (typeof options.polling === 'string') + assert(options.polling === 'raf', 'Unknown polling option: ' + options.polling); + const result = await this._channel.waitForFunction({ + ...options, + pollingInterval: options.polling === 'raf' ? undefined : options.polling, + expression: String(pageFunction), + isFunction: typeof pageFunction === 'function', + arg: serializeArgument(arg), + timeout: this._timeout(options), + }); + return JSHandle.from(result.handle) as any as structs.SmartHandle; + } + + async title(): Promise { + return (await this._channel.title()).value; + } + + async _expect(expression: string, options: Omit): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean, errorMessage?: string }> { + const params: channels.FrameExpectParams = { expression, ...options, isNot: !!options.isNot }; + params.expectedValue = serializeArgument(options.expectedValue); + const result = (await this._channel.expect(params)); + if (result.received !== undefined) + result.received = parseResult(result.received); + return result; + } +} + +export function verifyLoadState(name: string, waitUntil: LifecycleEvent): LifecycleEvent { + if (waitUntil as unknown === 'networkidle0') + waitUntil = 'networkidle'; + if (!kLifecycleEvents.has(waitUntil)) + throw new Error(`${name}: expected one of (load|domcontentloaded|networkidle|commit)`); + return waitUntil; +} diff --git a/daemon/src/sandbox/forked-client/src/client/harRouter.ts b/daemon/src/sandbox/forked-client/src/client/harRouter.ts new file mode 100644 index 00000000..b20a0a53 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/harRouter.ts @@ -0,0 +1,20 @@ +// @ts-nocheck +function unsupported() { + throw new Error('HAR routing is not available in the QuickJS sandbox'); +} + +export class HarRouter { + static async create() { + return new HarRouter(); + } + + async addContextRoute() { + unsupported(); + } + + async addPageRoute() { + unsupported(); + } + + async dispose() {} +} diff --git a/daemon/src/sandbox/forked-client/src/client/input.ts b/daemon/src/sandbox/forked-client/src/client/input.ts new file mode 100644 index 00000000..dd674475 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/input.ts @@ -0,0 +1,95 @@ +// @ts-nocheck +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Page } from './page'; +import type * as api from '../../types/types'; +import type * as channels from '../protocol/channels'; + +export class Keyboard implements api.Keyboard { + private _page: Page; + + constructor(page: Page) { + this._page = page; + } + + async down(key: string) { + await this._page._channel.keyboardDown({ key }); + } + + async up(key: string) { + await this._page._channel.keyboardUp({ key }); + } + + async insertText(text: string) { + await this._page._channel.keyboardInsertText({ text }); + } + + async type(text: string, options: channels.PageKeyboardTypeOptions = {}) { + await this._page._channel.keyboardType({ text, ...options }); + } + + async press(key: string, options: channels.PageKeyboardPressOptions = {}) { + await this._page._channel.keyboardPress({ key, ...options }); + } +} + +export class Mouse implements api.Mouse { + private _page: Page; + + constructor(page: Page) { + this._page = page; + } + + async move(x: number, y: number, options: { steps?: number } = {}) { + await this._page._channel.mouseMove({ x, y, ...options }); + } + + async down(options: channels.PageMouseDownOptions = {}) { + await this._page._channel.mouseDown({ ...options }); + } + + async up(options: channels.PageMouseUpOptions = {}) { + await this._page._channel.mouseUp(options); + } + + async click(x: number, y: number, options: channels.PageMouseClickOptions = {}) { + await this._page._channel.mouseClick({ x, y, ...options }); + } + + async dblclick(x: number, y: number, options: Omit = {}) { + await this._page._wrapApiCall(async () => { + await this.click(x, y, { ...options, clickCount: 2 }); + }, { title: 'Double click' }); + } + + async wheel(deltaX: number, deltaY: number) { + await this._page._channel.mouseWheel({ deltaX, deltaY }); + } +} + +export class Touchscreen implements api.Touchscreen { + private _page: Page; + + constructor(page: Page) { + this._page = page; + } + + async tap(x: number, y: number) { + await this._page._channel.touchscreenTap({ x, y }); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/jsHandle.ts b/daemon/src/sandbox/forked-client/src/client/jsHandle.ts new file mode 100644 index 00000000..80a88eaa --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/jsHandle.ts @@ -0,0 +1,112 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChannelOwner } from './channelOwner'; +import { isTargetClosedError } from './errors'; +import { parseSerializedValue, serializeValue } from '../protocol/serializers'; + +import type * as structs from '../../types/structs'; +import type * as api from '../../types/types'; +import type * as channels from '../protocol/channels'; + + +export class JSHandle extends ChannelOwner implements api.JSHandle { + private _preview: string; + + static from(handle: channels.JSHandleChannel): JSHandle { + return (handle as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.JSHandleInitializer) { + super(parent, type, guid, initializer); + this._preview = this._initializer.preview; + this._channel.on('previewUpdated', ({ preview }) => this._preview = preview); + } + + async evaluate(pageFunction: structs.PageFunctionOn, arg?: Arg): Promise { + const result = await this._channel.evaluateExpression({ expression: String(pageFunction), isFunction: typeof pageFunction === 'function', arg: serializeArgument(arg) }); + return parseResult(result.value); + } + + async evaluateHandle(pageFunction: structs.PageFunctionOn, arg?: Arg): Promise> { + const result = await this._channel.evaluateExpressionHandle({ expression: String(pageFunction), isFunction: typeof pageFunction === 'function', arg: serializeArgument(arg) }); + return JSHandle.from(result.handle) as any as structs.SmartHandle; + } + + async getProperty(propertyName: string): Promise { + const result = await this._channel.getProperty({ name: propertyName }); + return JSHandle.from(result.handle); + } + + async getProperties(): Promise> { + const map = new Map(); + for (const { name, value } of (await this._channel.getPropertyList()).properties) + map.set(name, JSHandle.from(value)); + return map; + } + + async jsonValue(): Promise { + return parseResult((await this._channel.jsonValue()).value); + } + + asElement(): T extends Node ? api.ElementHandle : null { + return null as any; + } + + async [Symbol.asyncDispose]() { + await this.dispose(); + } + + async dispose() { + try { + await this._channel.dispose(); + } catch (e) { + if (isTargetClosedError(e)) + return; + throw e; + } + } + + override toString(): string { + return this._preview; + } +} + +// This function takes care of converting all JSHandles to their channels, +// so that generic channel serializer converts them to guids. +export function serializeArgument(arg: any): channels.SerializedArgument { + const handles: channels.Channel[] = []; + const pushHandle = (channel: channels.Channel): number => { + handles.push(channel); + return handles.length - 1; + }; + const value = serializeValue(arg, value => { + if (value instanceof JSHandle) + return { h: pushHandle(value._channel) }; + return { fallThrough: value }; + }); + return { value, handles }; +} + +export function parseResult(value: channels.SerializedValue): any { + return parseSerializedValue(value, undefined); +} + +export function assertMaxArguments(count: number, max: number): asserts count { + if (count > max) + throw new Error('Too many arguments. If you need to pass more than 1 argument to the function wrap them in an object.'); +} diff --git a/daemon/src/sandbox/forked-client/src/client/jsonPipe.ts b/daemon/src/sandbox/forked-client/src/client/jsonPipe.ts new file mode 100644 index 00000000..79b2262a --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/jsonPipe.ts @@ -0,0 +1,8 @@ +// @ts-nocheck +import { ChannelOwner } from './channelOwner'; + +export class JsonPipe extends ChannelOwner { + static from(channel) { + return channel?._object; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/localUtils.ts b/daemon/src/sandbox/forked-client/src/client/localUtils.ts new file mode 100644 index 00000000..0ff7999a --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/localUtils.ts @@ -0,0 +1,40 @@ +// @ts-nocheck +import { ChannelOwner } from './channelOwner'; + +function unsupported() { + throw new Error('LocalUtils is not available in the QuickJS sandbox'); +} + +export class LocalUtils extends ChannelOwner { + devices = {}; + + static from(channel) { + return channel?._object; + } + + async connect() { + unsupported(); + } + + async addStackToTracingNoReply() {} + + async zip() { + unsupported(); + } + + async harOpen() { + unsupported(); + } + + async harLookup() { + unsupported(); + } + + async harClose() { + unsupported(); + } + + async harUnzip() { + unsupported(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/locator.ts b/daemon/src/sandbox/forked-client/src/client/locator.ts new file mode 100644 index 00000000..aeb3dfc8 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/locator.ts @@ -0,0 +1,476 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ElementHandle } from './elementHandle'; +import { asLocatorDescription, locatorCustomDescription } from '../utils/isomorphic/locatorGenerators'; +import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from '../utils/isomorphic/locatorUtils'; +import { escapeForTextSelector } from '../utils/isomorphic/stringUtils'; +import { isString } from '../utils/isomorphic/rtti'; +import { monotonicTime } from '../utils/isomorphic/time'; + +import type { Frame } from './frame'; +import type { FilePayload, FrameExpectParams, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types'; +import type * as structs from '../../types/structs'; +import type * as api from '../../types/types'; +import type { ByRoleOptions } from '../utils/isomorphic/locatorUtils'; +import type * as channels from '../protocol/channels'; + + +export type LocatorOptions = { + hasText?: string | RegExp; + hasNotText?: string | RegExp; + has?: Locator; + hasNot?: Locator; + visible?: boolean; +}; + +export class Locator implements api.Locator { + _frame: Frame; + _selector: string; + + constructor(frame: Frame, selector: string, options?: LocatorOptions) { + this._frame = frame; + this._selector = selector; + + if (options?.hasText) + this._selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`; + + if (options?.hasNotText) + this._selector += ` >> internal:has-not-text=${escapeForTextSelector(options.hasNotText, false)}`; + + if (options?.has) { + const locator = options.has; + if (locator._frame !== frame) + throw new Error(`Inner "has" locator must belong to the same frame.`); + this._selector += ` >> internal:has=` + JSON.stringify(locator._selector); + } + + if (options?.hasNot) { + const locator = options.hasNot; + if (locator._frame !== frame) + throw new Error(`Inner "hasNot" locator must belong to the same frame.`); + this._selector += ` >> internal:has-not=` + JSON.stringify(locator._selector); + } + + if (options?.visible !== undefined) + this._selector += ` >> visible=${options.visible ? 'true' : 'false'}`; + + if (this._frame._platform.inspectCustom) + (this as any)[this._frame._platform.inspectCustom] = () => this._inspect(); + } + + private async _withElement(task: (handle: ElementHandle, timeout?: number) => Promise, options: { title: string, internal?: boolean, timeout?: number }): Promise { + const timeout = this._frame._timeout({ timeout: options.timeout }); + const deadline = timeout ? monotonicTime() + timeout : 0; + + return await this._frame._wrapApiCall(async () => { + const result = await this._frame._channel.waitForSelector({ selector: this._selector, strict: true, state: 'attached', timeout }); + const handle = ElementHandle.fromNullable(result.element) as ElementHandle | null; + if (!handle) + throw new Error(`Could not resolve ${this._selector} to DOM Element`); + try { + return await task(handle, deadline ? deadline - monotonicTime() : 0); + } finally { + await handle.dispose(); + } + }, { title: options.title, internal: options.internal }); + } + + _equals(locator: Locator) { + return this._frame === locator._frame && this._selector === locator._selector; + } + + page() { + return this._frame.page(); + } + + async boundingBox(options?: TimeoutOptions): Promise { + return await this._withElement(h => h.boundingBox(), { title: 'Bounding box', timeout: options?.timeout }); + } + + async check(options: channels.ElementHandleCheckOptions & TimeoutOptions = {}) { + return await this._frame.check(this._selector, { strict: true, ...options }); + } + + async click(options: channels.ElementHandleClickOptions & TimeoutOptions = {}): Promise { + return await this._frame.click(this._selector, { strict: true, ...options }); + } + + async dblclick(options: channels.ElementHandleDblclickOptions & TimeoutOptions = {}): Promise { + await this._frame.dblclick(this._selector, { strict: true, ...options }); + } + + async dispatchEvent(type: string, eventInit: Object = {}, options?: TimeoutOptions) { + return await this._frame.dispatchEvent(this._selector, type, eventInit, { strict: true, ...options }); + } + + async dragTo(target: Locator, options: channels.FrameDragAndDropOptions & TimeoutOptions = {}) { + return await this._frame.dragAndDrop(this._selector, target._selector, { + strict: true, + ...options, + }); + } + + async evaluate(pageFunction: structs.PageFunctionOn, arg?: Arg, options?: TimeoutOptions): Promise { + return await this._withElement(h => h.evaluate(pageFunction, arg), { title: 'Evaluate', timeout: options?.timeout }); + } + + async evaluateAll(pageFunction: structs.PageFunctionOn, arg?: Arg): Promise { + return await this._frame.$$eval(this._selector, pageFunction, arg); + } + + async evaluateHandle(pageFunction: structs.PageFunctionOn, arg?: Arg, options?: TimeoutOptions): Promise> { + return await this._withElement(h => h.evaluateHandle(pageFunction, arg), { title: 'Evaluate', timeout: options?.timeout }); + } + + async fill(value: string, options: channels.ElementHandleFillOptions & TimeoutOptions = {}): Promise { + return await this._frame.fill(this._selector, value, { strict: true, ...options }); + } + + async clear(options: channels.ElementHandleFillOptions = {}): Promise { + await this._frame._wrapApiCall(() => this.fill('', options), { title: 'Clear' }); + } + + async _highlight() { + // VS Code extension uses this one, keep it for now. + return await this._frame._highlight(this._selector); + } + + async highlight() { + return await this._frame._highlight(this._selector); + } + + locator(selectorOrLocator: string | Locator, options?: Omit): Locator { + if (isString(selectorOrLocator)) + return new Locator(this._frame, this._selector + ' >> ' + selectorOrLocator, options); + if (selectorOrLocator._frame !== this._frame) + throw new Error(`Locators must belong to the same frame.`); + return new Locator(this._frame, this._selector + ' >> internal:chain=' + JSON.stringify(selectorOrLocator._selector), options); + } + + getByTestId(testId: string | RegExp): Locator { + return this.locator(getByTestIdSelector(testIdAttributeName(), testId)); + } + + getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByAltTextSelector(text, options)); + } + + getByLabel(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByLabelSelector(text, options)); + } + + getByPlaceholder(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByPlaceholderSelector(text, options)); + } + + getByText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByTextSelector(text, options)); + } + + getByTitle(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByTitleSelector(text, options)); + } + + getByRole(role: string, options: ByRoleOptions = {}): Locator { + return this.locator(getByRoleSelector(role, options)); + } + + frameLocator(selector: string): FrameLocator { + return new FrameLocator(this._frame, this._selector + ' >> ' + selector); + } + + filter(options?: LocatorOptions): Locator { + return new Locator(this._frame, this._selector, options); + } + + async elementHandle(options?: TimeoutOptions): Promise> { + return await this._frame.waitForSelector(this._selector, { strict: true, state: 'attached', ...options })!; + } + + async elementHandles(): Promise[]> { + return await this._frame.$$(this._selector); + } + + contentFrame() { + return new FrameLocator(this._frame, this._selector); + } + + describe(description: string) { + return new Locator(this._frame, this._selector + ' >> internal:describe=' + JSON.stringify(description)); + } + + description(): string | null { + return locatorCustomDescription(this._selector) || null; + } + + first(): Locator { + return new Locator(this._frame, this._selector + ' >> nth=0'); + } + + last(): Locator { + return new Locator(this._frame, this._selector + ` >> nth=-1`); + } + + nth(index: number): Locator { + return new Locator(this._frame, this._selector + ` >> nth=${index}`); + } + + and(locator: Locator): Locator { + if (locator._frame !== this._frame) + throw new Error(`Locators must belong to the same frame.`); + return new Locator(this._frame, this._selector + ` >> internal:and=` + JSON.stringify(locator._selector)); + } + + or(locator: Locator): Locator { + if (locator._frame !== this._frame) + throw new Error(`Locators must belong to the same frame.`); + return new Locator(this._frame, this._selector + ` >> internal:or=` + JSON.stringify(locator._selector)); + } + + async focus(options?: TimeoutOptions): Promise { + return await this._frame.focus(this._selector, { strict: true, ...options }); + } + + async blur(options?: TimeoutOptions): Promise { + await this._frame._channel.blur({ selector: this._selector, strict: true, ...options, timeout: this._frame._timeout(options) }); + } + + // options are only here for testing + async count(_options?: {}): Promise { + return await this._frame._queryCount(this._selector, _options); + } + + async toCode(): Promise { + const { resolvedSelector } = await this._frame._channel.resolveSelector({ selector: this._selector }); + return new Locator(this._frame, resolvedSelector).toString(); + } + + async getAttribute(name: string, options?: TimeoutOptions): Promise { + return await this._frame.getAttribute(this._selector, name, { strict: true, ...options }); + } + + async hover(options: channels.ElementHandleHoverOptions & TimeoutOptions = {}): Promise { + return await this._frame.hover(this._selector, { strict: true, ...options }); + } + + async innerHTML(options?: TimeoutOptions): Promise { + return await this._frame.innerHTML(this._selector, { strict: true, ...options }); + } + + async innerText(options?: TimeoutOptions): Promise { + return await this._frame.innerText(this._selector, { strict: true, ...options }); + } + + async inputValue(options?: TimeoutOptions): Promise { + return await this._frame.inputValue(this._selector, { strict: true, ...options }); + } + + async isChecked(options?: TimeoutOptions): Promise { + return await this._frame.isChecked(this._selector, { strict: true, ...options }); + } + + async isDisabled(options?: TimeoutOptions): Promise { + return await this._frame.isDisabled(this._selector, { strict: true, ...options }); + } + + async isEditable(options?: TimeoutOptions): Promise { + return await this._frame.isEditable(this._selector, { strict: true, ...options }); + } + + async isEnabled(options?: TimeoutOptions): Promise { + return await this._frame.isEnabled(this._selector, { strict: true, ...options }); + } + + async isHidden(options?: TimeoutOptions): Promise { + return await this._frame.isHidden(this._selector, { strict: true, ...options }); + } + + async isVisible(options?: TimeoutOptions): Promise { + return await this._frame.isVisible(this._selector, { strict: true, ...options }); + } + + async press(key: string, options: channels.ElementHandlePressOptions & TimeoutOptions = {}): Promise { + return await this._frame.press(this._selector, key, { strict: true, ...options }); + } + + async screenshot(options: Omit & TimeoutOptions & { path?: string, mask?: api.Locator[] } = {}): Promise { + const mask = options.mask as Locator[] | undefined; + return await this._withElement((h, timeout) => h.screenshot({ ...options, mask, timeout }), { title: 'Screenshot', timeout: options.timeout }); + } + + async ariaSnapshot(options?: TimeoutOptions): Promise { + const result = await this._frame._channel.ariaSnapshot({ ...options, selector: this._selector, timeout: this._frame._timeout(options) }); + return result.snapshot; + } + + async scrollIntoViewIfNeeded(options: channels.ElementHandleScrollIntoViewIfNeededOptions & TimeoutOptions = {}) { + return await this._withElement((h, timeout) => h.scrollIntoViewIfNeeded({ ...options, timeout }), { title: 'Scroll into view', timeout: options.timeout }); + } + + async selectOption(values: string | api.ElementHandle | SelectOption | string[] | api.ElementHandle[] | SelectOption[] | null, options: SelectOptionOptions = {}): Promise { + return await this._frame.selectOption(this._selector, values, { strict: true, ...options }); + } + + async selectText(options: channels.ElementHandleSelectTextOptions & TimeoutOptions = {}): Promise { + return await this._withElement((h, timeout) => h.selectText({ ...options, timeout }), { title: 'Select text', timeout: options.timeout }); + } + + async setChecked(checked: boolean, options?: channels.ElementHandleCheckOptions & TimeoutOptions) { + if (checked) + await this.check(options); + else + await this.uncheck(options); + } + + async setInputFiles(files: string | FilePayload | string[] | FilePayload[], options: channels.ElementHandleSetInputFilesOptions & TimeoutOptions = {}) { + return await this._frame.setInputFiles(this._selector, files, { strict: true, ...options }); + } + + async tap(options: channels.ElementHandleTapOptions & TimeoutOptions = {}): Promise { + return await this._frame.tap(this._selector, { strict: true, ...options }); + } + + async textContent(options?: TimeoutOptions): Promise { + return await this._frame.textContent(this._selector, { strict: true, ...options }); + } + + async type(text: string, options: channels.ElementHandleTypeOptions & TimeoutOptions = {}): Promise { + return await this._frame.type(this._selector, text, { strict: true, ...options }); + } + + async pressSequentially(text: string, options: channels.ElementHandleTypeOptions & TimeoutOptions = {}): Promise { + return await this.type(text, options); + } + + async uncheck(options: channels.ElementHandleUncheckOptions & TimeoutOptions = {}) { + return await this._frame.uncheck(this._selector, { strict: true, ...options }); + } + + async all(): Promise { + return new Array(await this.count()).fill(0).map((e, i) => this.nth(i)); + } + + async allInnerTexts(): Promise { + return await this._frame.$$eval(this._selector, ee => ee.map(e => (e as HTMLElement).innerText)); + } + + async allTextContents(): Promise { + return await this._frame.$$eval(this._selector, ee => ee.map(e => e.textContent || '')); + } + + waitFor(options: channels.FrameWaitForSelectorOptions & TimeoutOptions & { state: 'attached' | 'visible' }): Promise; + waitFor(options?: channels.FrameWaitForSelectorOptions & TimeoutOptions): Promise; + async waitFor(options?: channels.FrameWaitForSelectorOptions & TimeoutOptions): Promise { + await this._frame._channel.waitForSelector({ selector: this._selector, strict: true, omitReturnValue: true, ...options, timeout: this._frame._timeout(options) }); + } + + async snapshotForAI(options: TimeoutOptions & { depth?: number } = {}): Promise<{ full: string }> { + return await this._frame._page!._channel.snapshotForAI({ timeout: this._frame._timeout(options), selector: this._selector, depth: options.depth }); + } + + async _expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean, errorMessage?: string }> { + return this._frame._expect(expression, { + ...options, + selector: this._selector, + }); + } + + private _inspect() { + return this.toString(); + } + + toString() { + return asLocatorDescription('javascript', this._selector); + } +} + +export class FrameLocator implements api.FrameLocator { + private _frame: Frame; + private _frameSelector: string; + + constructor(frame: Frame, selector: string) { + this._frame = frame; + this._frameSelector = selector; + } + + locator(selectorOrLocator: string | Locator, options?: LocatorOptions): Locator { + if (isString(selectorOrLocator)) + return new Locator(this._frame, this._frameSelector + ' >> internal:control=enter-frame >> ' + selectorOrLocator, options); + if (selectorOrLocator._frame !== this._frame) + throw new Error(`Locators must belong to the same frame.`); + return new Locator(this._frame, this._frameSelector + ' >> internal:control=enter-frame >> ' + selectorOrLocator._selector, options); + } + + getByTestId(testId: string | RegExp): Locator { + return this.locator(getByTestIdSelector(testIdAttributeName(), testId)); + } + + getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByAltTextSelector(text, options)); + } + + getByLabel(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByLabelSelector(text, options)); + } + + getByPlaceholder(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByPlaceholderSelector(text, options)); + } + + getByText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByTextSelector(text, options)); + } + + getByTitle(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.locator(getByTitleSelector(text, options)); + } + + getByRole(role: string, options: ByRoleOptions = {}): Locator { + return this.locator(getByRoleSelector(role, options)); + } + + owner() { + return new Locator(this._frame, this._frameSelector); + } + + frameLocator(selector: string): FrameLocator { + return new FrameLocator(this._frame, this._frameSelector + ' >> internal:control=enter-frame >> ' + selector); + } + + first(): FrameLocator { + return new FrameLocator(this._frame, this._frameSelector + ' >> nth=0'); + } + + last(): FrameLocator { + return new FrameLocator(this._frame, this._frameSelector + ` >> nth=-1`); + } + + nth(index: number): FrameLocator { + return new FrameLocator(this._frame, this._frameSelector + ` >> nth=${index}`); + } +} + +let _testIdAttributeName: string = 'data-testid'; + +export function testIdAttributeName(): string { + return _testIdAttributeName; +} + +export function setTestIdAttribute(attributeName: string) { + _testIdAttributeName = attributeName; +} diff --git a/daemon/src/sandbox/forked-client/src/client/network.ts b/daemon/src/sandbox/forked-client/src/client/network.ts new file mode 100644 index 00000000..484e0c11 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/network.ts @@ -0,0 +1,953 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChannelOwner } from './channelOwner'; +import { isTargetClosedError } from './errors'; +import { Events } from './events'; +import { APIResponse } from './fetch'; +import { Frame } from './frame'; +import { Waiter } from './waiter'; +import { Worker } from './worker'; +import { assert } from '../utils/isomorphic/assert'; +import { headersObjectToArray } from '../utils/isomorphic/headers'; +import { serializeURLMatch, urlMatches } from '../utils/isomorphic/urlMatch'; +import { LongStandingScope, ManualPromise } from '../utils/isomorphic/manualPromise'; +import { MultiMap } from '../utils/isomorphic/multimap'; +import { isString } from '../utils/isomorphic/rtti'; +import { rewriteErrorMessage } from '../utils/isomorphic/stackTrace'; +import { getMimeTypeForPath } from '../utils/isomorphic/mimeType'; + +import type { BrowserContext } from './browserContext'; +import type { Page } from './page'; +import type { Headers, RemoteAddr, SecurityDetails, WaitForEventOptions } from './types'; +import type { Serializable } from '../../types/structs'; +import type * as api from '../../types/types'; +import type { HeadersArray } from '../utils/isomorphic/types'; +import type { URLMatch } from '../utils/isomorphic/urlMatch'; +import type * as channels from '../protocol/channels'; +import type { Platform, Zone } from './platform'; + +export type NetworkCookie = { + name: string, + value: string, + domain: string, + path: string, + expires: number, + httpOnly: boolean, + secure: boolean, + sameSite: 'Strict' | 'Lax' | 'None' +}; + +export type SetNetworkCookieParam = { + name: string, + value: string, + url?: string, + domain?: string, + path?: string, + expires?: number, + httpOnly?: boolean, + secure?: boolean, + sameSite?: 'Strict' | 'Lax' | 'None' +}; + +export type ClearNetworkCookieOptions = { + name?: string | RegExp, + domain?: string | RegExp, + path?: string | RegExp, +}; + +type SerializedFallbackOverrides = { + url?: string; + method?: string; + headers?: Headers; + postDataBuffer?: Buffer; +}; + +type FallbackOverrides = { + url?: string; + method?: string; + headers?: Headers; + postData?: string | Buffer | Serializable; +}; + +export class Request extends ChannelOwner implements api.Request { + private _redirectedFrom: Request | null = null; + private _redirectedTo: Request | null = null; + _failureText: string | null = null; + _response: Response | null = null; + private _hasResponse = false; + private _provisionalHeaders: RawHeaders; + private _actualHeadersPromise: Promise | undefined; + _timing: ResourceTiming; + private _fallbackOverrides: SerializedFallbackOverrides = {}; + + static from(request: channels.RequestChannel): Request { + return (request as any)._object; + } + + static fromNullable(request: channels.RequestChannel | undefined): Request | null { + return request ? Request.from(request) : null; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.RequestInitializer) { + super(parent, type, guid, initializer); + this._redirectedFrom = Request.fromNullable(initializer.redirectedFrom); + if (this._redirectedFrom) + this._redirectedFrom._redirectedTo = this; + this._provisionalHeaders = new RawHeaders(initializer.headers); + this._timing = { + startTime: 0, + domainLookupStart: -1, + domainLookupEnd: -1, + connectStart: -1, + secureConnectionStart: -1, + connectEnd: -1, + requestStart: -1, + responseStart: -1, + responseEnd: -1, + }; + this._hasResponse = this._initializer.hasResponse; + this._channel.on('response', () => this._hasResponse = true); + } + + url(): string { + return this._fallbackOverrides.url || this._initializer.url; + } + + resourceType(): string { + return this._initializer.resourceType; + } + + method(): string { + return this._fallbackOverrides.method || this._initializer.method; + } + + postData(): string | null { + return (this._fallbackOverrides.postDataBuffer || this._initializer.postData)?.toString('utf-8') || null; + } + + postDataBuffer(): Buffer | null { + return this._fallbackOverrides.postDataBuffer || this._initializer.postData || null; + } + + postDataJSON(): Object | null { + const postData = this.postData(); + if (!postData) + return null; + + const contentType = this.headers()['content-type']; + if (contentType?.includes('application/x-www-form-urlencoded')) { + const entries: Record = {}; + const parsed = new URLSearchParams(postData); + for (const [k, v] of parsed.entries()) + entries[k] = v; + return entries; + } + + try { + return JSON.parse(postData); + } catch (e) { + throw new Error('POST data is not a valid JSON object: ' + postData); + } + } + + /** + * @deprecated + */ + headers(): Headers { + if (this._fallbackOverrides.headers) + return RawHeaders._fromHeadersObjectLossy(this._fallbackOverrides.headers).headers(); + return this._provisionalHeaders.headers(); + } + + async _actualHeaders(): Promise { + if (this._fallbackOverrides.headers) + return RawHeaders._fromHeadersObjectLossy(this._fallbackOverrides.headers); + + if (!this._actualHeadersPromise) { + this._actualHeadersPromise = this._wrapApiCall(async () => { + return new RawHeaders((await this._channel.rawRequestHeaders()).headers); + }, { internal: true }); + } + return await this._actualHeadersPromise; + } + + async allHeaders(): Promise { + return (await this._actualHeaders()).headers(); + } + + async headersArray(): Promise { + return (await this._actualHeaders()).headersArray(); + } + + async headerValue(name: string): Promise { + return (await this._actualHeaders()).get(name); + } + + async response(): Promise { + return Response.fromNullable((await this._channel.response()).response); + } + + async _internalResponse(): Promise { + return Response.fromNullable((await this._channel.response()).response); + } + + existingResponse(): Response | null { + return this._response; + } + + frame(): Frame { + if (!this._initializer.frame) { + assert(this.serviceWorker()); + throw new Error('Service Worker requests do not have an associated frame.'); + } + const frame = Frame.from(this._initializer.frame); + if (!frame._page) { + throw new Error([ + 'Frame for this navigation request is not available, because the request', + 'was issued before the frame is created. You can check whether the request', + 'is a navigation request by calling isNavigationRequest() method.', + ].join('\n')); + } + return frame; + } + + _safePage(): Page | null { + return Frame.fromNullable(this._initializer.frame)?._page || null; + } + + serviceWorker(): Worker | null { + return this._initializer.serviceWorker ? Worker.from(this._initializer.serviceWorker) : null; + } + + isNavigationRequest(): boolean { + return this._initializer.isNavigationRequest; + } + + redirectedFrom(): Request | null { + return this._redirectedFrom; + } + + redirectedTo(): Request | null { + return this._redirectedTo; + } + + failure(): { errorText: string; } | null { + if (this._failureText === null) + return null; + return { + errorText: this._failureText + }; + } + + timing(): ResourceTiming { + return this._timing; + } + + async sizes(): Promise { + const response = await this.response(); + if (!response) + throw new Error('Unable to fetch sizes for failed request'); + return (await response._channel.sizes()).sizes; + } + + _setResponseEndTiming(responseEndTiming: number) { + this._timing.responseEnd = responseEndTiming; + if (this._timing.responseStart === -1) + this._timing.responseStart = responseEndTiming; + } + + _finalRequest(): Request { + return this._redirectedTo ? this._redirectedTo._finalRequest() : this; + } + + _applyFallbackOverrides(overrides: FallbackOverrides) { + if (overrides.url) + this._fallbackOverrides.url = overrides.url; + if (overrides.method) + this._fallbackOverrides.method = overrides.method; + if (overrides.headers) + this._fallbackOverrides.headers = overrides.headers; + + if (isString(overrides.postData)) + this._fallbackOverrides.postDataBuffer = Buffer.from(overrides.postData, 'utf-8'); + else if (overrides.postData instanceof Buffer) + this._fallbackOverrides.postDataBuffer = overrides.postData; + else if (overrides.postData) + this._fallbackOverrides.postDataBuffer = Buffer.from(JSON.stringify(overrides.postData), 'utf-8'); + } + + _fallbackOverridesForContinue() { + return this._fallbackOverrides; + } + + _targetClosedScope(): LongStandingScope { + return this.serviceWorker()?._closedScope || this._safePage()?._closedOrCrashedScope || new LongStandingScope(); + } +} + +export class Route extends ChannelOwner implements api.Route { + private _handlingPromise: ManualPromise | null = null; + _context!: BrowserContext; + _didThrow: boolean = false; + + static from(route: channels.RouteChannel): Route { + return (route as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.RouteInitializer) { + super(parent, type, guid, initializer); + } + + request(): Request { + return Request.from(this._initializer.request); + } + + private async _raceWithTargetClose(promise: Promise): Promise { + // When page closes or crashes, we catch any potential rejects from this Route. + // Note that page could be missing when routing popup's initial request that + // does not have a Page initialized just yet. + return await this.request()._targetClosedScope().safeRace(promise); + } + + async _startHandling(): Promise { + this._handlingPromise = new ManualPromise(); + return await this._handlingPromise; + } + + async fallback(options: FallbackOverrides = {}) { + this._checkNotHandled(); + this.request()._applyFallbackOverrides(options); + this._reportHandled(false); + } + + async abort(errorCode?: string) { + await this._handleRoute(async () => { + await this._raceWithTargetClose(this._channel.abort({ errorCode })); + }); + } + + async _redirectNavigationRequest(url: string) { + await this._handleRoute(async () => { + await this._raceWithTargetClose(this._channel.redirectNavigationRequest({ url })); + }); + } + + async fetch(options: FallbackOverrides & { maxRedirects?: number, maxRetries?: number, timeout?: number } = {}): Promise { + return await this._wrapApiCall(async () => { + return await this._context.request._innerFetch({ request: this.request(), data: options.postData, ...options }); + }); + } + + async fulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, json?: any, path?: string } = {}) { + await this._handleRoute(async () => { + await this._innerFulfill(options); + }); + } + + private async _handleRoute(callback: () => Promise) { + this._checkNotHandled(); + try { + await callback(); + this._reportHandled(true); + } catch (e) { + this._didThrow = true; + throw e; + } + } + + private async _innerFulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, json?: any, path?: string } = {}): Promise { + let fetchResponseUid; + let { status: statusOption, headers: headersOption, body } = options; + + if (options.json !== undefined) { + assert(options.body === undefined, 'Can specify either body or json parameters'); + body = JSON.stringify(options.json); + } + + if (options.response instanceof APIResponse) { + statusOption ??= options.response.status(); + headersOption ??= options.response.headers(); + if (body === undefined && options.path === undefined) { + if (options.response._request._connection === this._connection) + fetchResponseUid = (options.response as APIResponse)._fetchUid(); + else + body = await options.response.body(); + } + } + + let isBase64 = false; + let length = 0; + if (options.path) { + const buffer = await this._platform.fs().promises.readFile(options.path); + body = buffer.toString('base64'); + isBase64 = true; + length = buffer.length; + } else if (isString(body)) { + isBase64 = false; + length = Buffer.byteLength(body); + } else if (body) { + length = body.length; + body = body.toString('base64'); + isBase64 = true; + } + + const headers: Headers = {}; + for (const header of Object.keys(headersOption || {})) + headers[header.toLowerCase()] = String(headersOption![header]); + if (options.contentType) + headers['content-type'] = String(options.contentType); + else if (options.json) + headers['content-type'] = 'application/json'; + else if (options.path) + headers['content-type'] = getMimeTypeForPath(options.path) || 'application/octet-stream'; + if (length && !('content-length' in headers)) + headers['content-length'] = String(length); + + await this._raceWithTargetClose(this._channel.fulfill({ + status: statusOption || 200, + headers: headersObjectToArray(headers), + body, + isBase64, + fetchResponseUid + })); + } + + async continue(options: FallbackOverrides = {}) { + await this._handleRoute(async () => { + this.request()._applyFallbackOverrides(options); + await this._innerContinue(false /* isFallback */); + }); + } + + _checkNotHandled() { + if (!this._handlingPromise) + throw new Error('Route is already handled!'); + } + + _reportHandled(done: boolean) { + const chain = this._handlingPromise!; + this._handlingPromise = null; + chain.resolve(done); + } + + async _innerContinue(isFallback: boolean) { + const options = this.request()._fallbackOverridesForContinue(); + return await this._raceWithTargetClose(this._channel.continue({ + url: options.url, + method: options.method, + headers: options.headers ? headersObjectToArray(options.headers) : undefined, + postData: options.postDataBuffer, + isFallback, + })); + } +} + +export class WebSocketRoute extends ChannelOwner implements api.WebSocketRoute { + static from(route: channels.WebSocketRouteChannel): WebSocketRoute { + return (route as any)._object; + } + + private _onPageMessage?: (message: string | Buffer) => any; + private _onPageClose?: (code: number | undefined, reason: string | undefined) => any; + private _onServerMessage?: (message: string | Buffer) => any; + private _onServerClose?: (code: number | undefined, reason: string | undefined) => any; + private _server: api.WebSocketRoute; + private _connected = false; + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.WebSocketRouteInitializer) { + super(parent, type, guid, initializer); + + this._server = { + onMessage: (handler: (message: string | Buffer) => any) => { + this._onServerMessage = handler; + }, + + onClose: (handler: (code: number | undefined, reason: string | undefined) => any) => { + this._onServerClose = handler; + }, + + connectToServer: () => { + throw new Error(`connectToServer must be called on the page-side WebSocketRoute`); + }, + + url: () => { + return this._initializer.url; + }, + + close: async (options: { code?: number, reason?: string } = {}) => { + await this._channel.closeServer({ ...options, wasClean: true }).catch(() => {}); + }, + + send: (message: string | Buffer) => { + if (isString(message)) + this._channel.sendToServer({ message, isBase64: false }).catch(() => {}); + else + this._channel.sendToServer({ message: message.toString('base64'), isBase64: true }).catch(() => {}); + }, + + async [Symbol.asyncDispose]() { + await this.close(); + }, + }; + + this._channel.on('messageFromPage', ({ message, isBase64 }) => { + if (this._onPageMessage) + this._onPageMessage(isBase64 ? Buffer.from(message, 'base64') : message); + else if (this._connected) + this._channel.sendToServer({ message, isBase64 }).catch(() => {}); + }); + + this._channel.on('messageFromServer', ({ message, isBase64 }) => { + if (this._onServerMessage) + this._onServerMessage(isBase64 ? Buffer.from(message, 'base64') : message); + else + this._channel.sendToPage({ message, isBase64 }).catch(() => {}); + }); + + this._channel.on('closePage', ({ code, reason, wasClean }) => { + if (this._onPageClose) + this._onPageClose(code, reason); + else + this._channel.closeServer({ code, reason, wasClean }).catch(() => {}); + }); + + this._channel.on('closeServer', ({ code, reason, wasClean }) => { + if (this._onServerClose) + this._onServerClose(code, reason); + else + this._channel.closePage({ code, reason, wasClean }).catch(() => {}); + }); + } + + url() { + return this._initializer.url; + } + + async close(options: { code?: number, reason?: string } = {}) { + await this._channel.closePage({ ...options, wasClean: true }).catch(() => {}); + } + + connectToServer() { + if (this._connected) + throw new Error('Already connected to the server'); + this._connected = true; + this._channel.connect().catch(() => {}); + return this._server; + } + + send(message: string | Buffer) { + if (isString(message)) + this._channel.sendToPage({ message, isBase64: false }).catch(() => {}); + else + this._channel.sendToPage({ message: message.toString('base64'), isBase64: true }).catch(() => {}); + } + + onMessage(handler: (message: string | Buffer) => any) { + this._onPageMessage = handler; + } + + onClose(handler: (code: number | undefined, reason: string | undefined) => any) { + this._onPageClose = handler; + } + + async [Symbol.asyncDispose]() { + await this.close(); + } + + async _afterHandle() { + if (this._connected) + return; + // Ensure that websocket is "open" and can send messages without an actual server connection. + // If this happens after the page has been closed, ignore the error. + await this._channel.ensureOpened().catch(() => {}); + } +} + +export class WebSocketRouteHandler { + private readonly _baseURL: string | undefined; + readonly url: URLMatch; + readonly handler: WebSocketRouteHandlerCallback; + + constructor(baseURL: string | undefined, url: URLMatch, handler: WebSocketRouteHandlerCallback) { + this._baseURL = baseURL; + this.url = url; + this.handler = handler; + } + + static prepareInterceptionPatterns(handlers: WebSocketRouteHandler[]) { + const patterns: channels.BrowserContextSetWebSocketInterceptionPatternsParams['patterns'] = []; + let all = false; + for (const handler of handlers) { + const serialized = serializeURLMatch(handler.url); + if (serialized) + patterns.push(serialized); + else + all = true; + } + if (all) + return [{ glob: '**/*' }]; + return patterns; + } + + public matches(wsURL: string): boolean { + return urlMatches(this._baseURL, wsURL, this.url, true); + } + + public async handle(webSocketRoute: WebSocketRoute) { + const handler = this.handler; + await handler(webSocketRoute); + await webSocketRoute._afterHandle(); + } +} + +export type RouteHandlerCallback = (route: Route, request: Request) => Promise | void; +export type WebSocketRouteHandlerCallback = (ws: WebSocketRoute) => Promise | void; + +export type ResourceTiming = { + startTime: number; + domainLookupStart: number; + domainLookupEnd: number; + connectStart: number; + secureConnectionStart: number; + connectEnd: number; + requestStart: number; + responseStart: number; + responseEnd: number; +}; + +export type RequestSizes = { + requestBodySize: number; + requestHeadersSize: number; + responseBodySize: number; + responseHeadersSize: number; +}; + +export class Response extends ChannelOwner implements api.Response { + private _provisionalHeaders: RawHeaders; + private _actualHeadersPromise: Promise | undefined; + private _request: Request; + readonly _finishedPromise = new ManualPromise(); + + static from(response: channels.ResponseChannel): Response { + return (response as any)._object; + } + + static fromNullable(response: channels.ResponseChannel | undefined): Response | null { + return response ? Response.from(response) : null; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.ResponseInitializer) { + super(parent, type, guid, initializer); + this._provisionalHeaders = new RawHeaders(initializer.headers); + this._request = Request.from(this._initializer.request); + this._request._response = this; + Object.assign(this._request._timing, this._initializer.timing); + } + + url(): string { + return this._initializer.url; + } + + ok(): boolean { + // Status 0 is for file:// URLs + return this._initializer.status === 0 || (this._initializer.status >= 200 && this._initializer.status <= 299); + } + + status(): number { + return this._initializer.status; + } + + statusText(): string { + return this._initializer.statusText; + } + + fromServiceWorker(): boolean { + return this._initializer.fromServiceWorker; + } + + /** + * @deprecated + */ + headers(): Headers { + return this._provisionalHeaders.headers(); + } + + async _actualHeaders(): Promise { + if (!this._actualHeadersPromise) { + this._actualHeadersPromise = (async () => { + return new RawHeaders((await this._channel.rawResponseHeaders()).headers); + })(); + } + return await this._actualHeadersPromise; + } + + async allHeaders(): Promise { + return (await this._actualHeaders()).headers(); + } + + async headersArray(): Promise { + return (await this._actualHeaders()).headersArray().slice(); + } + + async headerValue(name: string): Promise { + return (await this._actualHeaders()).get(name); + } + + async headerValues(name: string): Promise { + return (await this._actualHeaders()).getAll(name); + } + + async finished(): Promise { + return await this.request()._targetClosedScope().race(this._finishedPromise); + } + + async body(): Promise { + return (await this._channel.body()).binary; + } + + async text(): Promise { + const content = await this.body(); + return content.toString('utf8'); + } + + async json(): Promise { + const content = await this.text(); + return JSON.parse(content); + } + + request(): Request { + return this._request; + } + + frame(): Frame { + return this._request.frame(); + } + + async serverAddr(): Promise { + return (await this._channel.serverAddr()).value || null; + } + + async securityDetails(): Promise { + return (await this._channel.securityDetails()).value || null; + } + + async httpVersion(): Promise { + return (await this._channel.httpVersion()).value; + } +} + +export class WebSocket extends ChannelOwner implements api.WebSocket { + private _page: Page; + private _isClosed: boolean; + + static from(webSocket: channels.WebSocketChannel): WebSocket { + return (webSocket as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.WebSocketInitializer) { + super(parent, type, guid, initializer); + this._isClosed = false; + this._page = parent as Page; + this._channel.on('frameSent', event => { + if (event.opcode === 1) + this.emit(Events.WebSocket.FrameSent, { payload: event.data }); + else if (event.opcode === 2) + this.emit(Events.WebSocket.FrameSent, { payload: Buffer.from(event.data, 'base64') }); + }); + this._channel.on('frameReceived', event => { + if (event.opcode === 1) + this.emit(Events.WebSocket.FrameReceived, { payload: event.data }); + else if (event.opcode === 2) + this.emit(Events.WebSocket.FrameReceived, { payload: Buffer.from(event.data, 'base64') }); + }); + this._channel.on('socketError', ({ error }) => this.emit(Events.WebSocket.Error, error)); + this._channel.on('close', () => { + this._isClosed = true; + this.emit(Events.WebSocket.Close, this); + }); + } + + url(): string { + return this._initializer.url; + } + + isClosed(): boolean { + return this._isClosed; + } + + async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise { + return await this._wrapApiCall(async () => { + const timeout = this._page._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); + const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; + const waiter = Waiter.createForEvent(this, event); + waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); + if (event !== Events.WebSocket.Error) + waiter.rejectOnEvent(this, Events.WebSocket.Error, new Error('Socket error')); + if (event !== Events.WebSocket.Close) + waiter.rejectOnEvent(this, Events.WebSocket.Close, new Error('Socket closed')); + waiter.rejectOnEvent(this._page, Events.Page.Close, () => this._page._closeErrorWithReason()); + const result = await waiter.waitForEvent(this, event, predicate as any); + waiter.dispose(); + return result; + }); + } +} + +export function validateHeaders(headers: Headers) { + for (const key of Object.keys(headers)) { + const value = headers[key]; + if (!Object.is(value, undefined) && !isString(value)) + throw new Error(`Expected value of header "${key}" to be String, but "${typeof value}" is found.`); + } +} + +export class RouteHandler { + private handledCount = 0; + private readonly _baseURL: string | undefined; + private readonly _times: number; + readonly url: URLMatch; + readonly handler: RouteHandlerCallback; + private _ignoreException: boolean = false; + private _activeInvocations: Set<{ complete: Promise, route: Route }> = new Set(); + private _savedZone: Zone; + + constructor(platform: Platform, baseURL: string | undefined, url: URLMatch, handler: RouteHandlerCallback, times: number = Number.MAX_SAFE_INTEGER) { + this._baseURL = baseURL; + this._times = times; + this.url = url; + this.handler = handler; + this._savedZone = platform.zones.current().pop(); + } + + static prepareInterceptionPatterns(handlers: RouteHandler[]) { + const patterns: channels.BrowserContextSetNetworkInterceptionPatternsParams['patterns'] = []; + let all = false; + for (const handler of handlers) { + const serialized = serializeURLMatch(handler.url); + if (serialized) + patterns.push(serialized); + else + all = true; + } + if (all) + return [{ glob: '**/*' }]; + return patterns; + } + + public matches(requestURL: string): boolean { + return urlMatches(this._baseURL, requestURL, this.url); + } + + public async handle(route: Route): Promise { + return await this._savedZone.run(async () => this._handleImpl(route)); + } + + private async _handleImpl(route: Route): Promise { + const handlerInvocation = { complete: new ManualPromise(), route } ; + this._activeInvocations.add(handlerInvocation); + try { + return await this._handleInternal(route); + } catch (e) { + // If the handler was stopped (without waiting for completion), we ignore all exceptions. + if (this._ignoreException) + return false; + if (isTargetClosedError(e)) { + // We are failing in the handler because the target close closed. + // Give user a hint! + rewriteErrorMessage(e, `"${e.message}" while running route callback.\nConsider awaiting \`await page.unrouteAll({ behavior: 'ignoreErrors' })\`\nbefore the end of the test to ignore remaining routes in flight.`); + } + throw e; + } finally { + handlerInvocation.complete.resolve(); + this._activeInvocations.delete(handlerInvocation); + } + } + + async stop(behavior: 'wait' | 'ignoreErrors') { + // When a handler is manually unrouted or its page/context is closed we either + // - wait for the current handler invocations to finish + // - or do not wait, if the user opted out of it, but swallow all exceptions + // that happen after the unroute/close. + if (behavior === 'ignoreErrors') { + this._ignoreException = true; + } else { + const promises = []; + for (const activation of this._activeInvocations) { + if (!activation.route._didThrow) + promises.push(activation.complete); + } + await Promise.all(promises); + } + } + + private async _handleInternal(route: Route): Promise { + ++this.handledCount; + const handledPromise = route._startHandling(); + // Extract handler into a variable to avoid [RouteHandler.handler] in the stack. + const handler = this.handler; + const [handled] = await Promise.all([ + handledPromise, + handler(route, route.request()), + ]); + return handled; + } + + public willExpire(): boolean { + return this.handledCount + 1 >= this._times; + } +} + +export class RawHeaders { + private _headersArray: HeadersArray; + private _headersMap = new MultiMap(); + + static _fromHeadersObjectLossy(headers: Headers): RawHeaders { + const headersArray: HeadersArray = Object.entries(headers).map(([name, value]) => ({ + name, value + })).filter(header => header.value !== undefined); + return new RawHeaders(headersArray); + } + + constructor(headers: HeadersArray) { + this._headersArray = headers; + for (const header of headers) + this._headersMap.set(header.name.toLowerCase(), header.value); + } + + get(name: string): string | null { + const values = this.getAll(name); + if (!values || !values.length) + return null; + return values.join(name.toLowerCase() === 'set-cookie' ? '\n' : ', '); + } + + getAll(name: string): string[] { + return [...this._headersMap.get(name.toLowerCase())]; + } + + headers(): Headers { + const result: Headers = {}; + for (const name of this._headersMap.keys()) + result[name] = this.get(name)!; + return result; + } + + headersArray(): HeadersArray { + return this._headersArray; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/page.ts b/daemon/src/sandbox/forked-client/src/client/page.ts new file mode 100644 index 00000000..943fd4b4 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/page.ts @@ -0,0 +1,902 @@ +// @ts-nocheck +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Artifact } from './artifact'; +import { ChannelOwner } from './channelOwner'; +import { evaluationScript } from './clientHelper'; +import { Coverage } from './coverage'; +import { DisposableObject, DisposableStub } from './disposable'; +import { Download } from './download'; +import { ElementHandle, determineScreenshotType } from './elementHandle'; +import { TargetClosedError, isTargetClosedError, parseError, serializeError } from './errors'; +import { Events } from './events'; +import { FileChooser } from './fileChooser'; +import { Frame, verifyLoadState } from './frame'; +import { HarRouter } from './harRouter'; +import { Keyboard, Mouse, Touchscreen } from './input'; +import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from './jsHandle'; +import { Request, Response, Route, RouteHandler, WebSocket, WebSocketRoute, WebSocketRouteHandler, validateHeaders } from './network'; +import { Video } from './video'; +import { Screencast } from './screencast'; +import { Waiter } from './waiter'; +import { Worker } from './worker'; +import { TimeoutSettings } from './timeoutSettings'; +import { assert } from '../utils/isomorphic/assert'; +import { writeTempFile } from './fileUtils'; +import { headersObjectToArray } from '../utils/isomorphic/headers'; +import { trimStringWithEllipsis } from '../utils/isomorphic/stringUtils'; +import { urlMatches, urlMatchesEqual } from '../utils/isomorphic/urlMatch'; +import { LongStandingScope } from '../utils/isomorphic/manualPromise'; +import { isObject, isRegExp, isString } from '../utils/isomorphic/rtti'; +import { ConsoleMessage } from './consoleMessage'; +import type { BrowserContext } from './browserContext'; +import type { Clock } from './clock'; +import type { APIRequestContext } from './fetch'; +import type { WaitForNavigationOptions } from './frame'; +import type { FrameLocator, Locator, LocatorOptions } from './locator'; +import type { RouteHandlerCallback, WebSocketRouteHandlerCallback } from './network'; +import type { FilePayload, Headers, LifecycleEvent, SelectOption, SelectOptionOptions, Size, TimeoutOptions, WaitForEventOptions, WaitForFunctionOptions } from './types'; +import type * as structs from '../../types/structs'; +import type * as api from '../../types/types'; +import type { ByRoleOptions } from '../utils/isomorphic/locatorUtils'; +import type { URLMatch } from '../utils/isomorphic/urlMatch'; +import type * as channels from '../protocol/channels'; + +type PDFOptions = Omit & { + width?: string | number, + height?: string | number, + margin?: { + top?: string | number, + bottom?: string | number, + left?: string | number, + right?: string | number + }, + path?: string, +}; + +export type ExpectScreenshotOptions = Omit & { + expected?: Buffer, + locator?: api.Locator, + timeout: number, + isNot: boolean, + mask?: api.Locator[], +}; + +export class Page extends ChannelOwner implements api.Page { + private _browserContext: BrowserContext; + _ownedContext: BrowserContext | undefined; + + private _mainFrame: Frame; + private _frames = new Set(); + _workers = new Set(); + private _closed = false; + readonly _closedOrCrashedScope = new LongStandingScope(); + private _viewportSize: Size | undefined; + _routes: RouteHandler[] = []; + _webSocketRoutes: WebSocketRouteHandler[] = []; + + readonly coverage: Coverage; + readonly keyboard: Keyboard; + readonly mouse: Mouse; + readonly request: APIRequestContext; + readonly touchscreen: Touchscreen; + readonly clock: Clock; + readonly screencast: Screencast; + + + readonly _bindings = new Map any>(); + readonly _timeoutSettings: TimeoutSettings; + private _video: Video; + readonly _opener: Page | null; + private _closeReason: string | undefined; + _closeWasCalled: boolean = false; + private _harRouters: HarRouter[] = []; + + private _locatorHandlers = new Map any, times: number | undefined }>(); + + static from(page: channels.PageChannel): Page { + return (page as any)._object; + } + + static fromNullable(page: channels.PageChannel | undefined): Page | null { + return page ? Page.from(page) : null; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.PageInitializer) { + super(parent, type, guid, initializer); + this._instrumentation.onPage(this); + this._browserContext = parent as unknown as BrowserContext; + this._timeoutSettings = new TimeoutSettings(this._platform, this._browserContext._timeoutSettings); + + this.keyboard = new Keyboard(this); + this.mouse = new Mouse(this); + this.request = this._browserContext.request; + this.touchscreen = new Touchscreen(this); + this.clock = this._browserContext.clock; + + this._mainFrame = Frame.from(initializer.mainFrame); + this._mainFrame._page = this; + this._frames.add(this._mainFrame); + this._viewportSize = initializer.viewportSize; + this._closed = initializer.isClosed; + this._opener = Page.fromNullable(initializer.opener); + this._video = new Video(this, this._connection, initializer.video ? Artifact.from(initializer.video) : undefined); + this.screencast = new Screencast(this); + + this._channel.on('bindingCall', ({ binding }) => this._onBinding(BindingCall.from(binding))); + this._channel.on('close', () => this._onClose()); + this._channel.on('crash', () => this._onCrash()); + this._channel.on('download', ({ url, suggestedFilename, artifact }) => { + const artifactObject = Artifact.from(artifact); + this.emit(Events.Page.Download, new Download(this, url, suggestedFilename, artifactObject)); + }); + this._channel.on('fileChooser', ({ element, isMultiple }) => this.emit(Events.Page.FileChooser, new FileChooser(this, ElementHandle.from(element), isMultiple))); + this._channel.on('frameAttached', ({ frame }) => this._onFrameAttached(Frame.from(frame))); + this._channel.on('frameDetached', ({ frame }) => this._onFrameDetached(Frame.from(frame))); + this._channel.on('locatorHandlerTriggered', ({ uid }) => this._onLocatorHandlerTriggered(uid)); + this._channel.on('route', ({ route }) => this._onRoute(Route.from(route))); + this._channel.on('webSocketRoute', ({ webSocketRoute }) => this._onWebSocketRoute(WebSocketRoute.from(webSocketRoute))); + this._channel.on('viewportSizeChanged', ({ viewportSize }) => this._viewportSize = viewportSize); + this._channel.on('webSocket', ({ webSocket }) => this.emit(Events.Page.WebSocket, WebSocket.from(webSocket))); + this._channel.on('worker', ({ worker }) => this._onWorker(Worker.from(worker))); + + this.coverage = new Coverage(this._channel); + + this.once(Events.Page.Close, () => this._closedOrCrashedScope.close(this._closeErrorWithReason())); + this.once(Events.Page.Crash, () => this._closedOrCrashedScope.close(new TargetClosedError())); + + this._setEventToSubscriptionMapping(new Map([ + [Events.Page.Console, 'console'], + [Events.Page.Dialog, 'dialog'], + [Events.Page.Request, 'request'], + [Events.Page.Response, 'response'], + [Events.Page.RequestFinished, 'requestFinished'], + [Events.Page.RequestFailed, 'requestFailed'], + [Events.Page.FileChooser, 'fileChooser'], + ])); + } + + private _onFrameAttached(frame: Frame) { + frame._page = this; + this._frames.add(frame); + if (frame._parentFrame) + frame._parentFrame._childFrames.add(frame); + this.emit(Events.Page.FrameAttached, frame); + } + + private _onFrameDetached(frame: Frame) { + this._frames.delete(frame); + frame._detached = true; + if (frame._parentFrame) + frame._parentFrame._childFrames.delete(frame); + this.emit(Events.Page.FrameDetached, frame); + } + + private async _onRoute(route: Route) { + route._context = this.context(); + const routeHandlers = this._routes.slice(); + for (const routeHandler of routeHandlers) { + // If the page was closed we stall all requests right away. + if (this._closeWasCalled || this._browserContext.isClosedOrClosing()) + return; + if (!routeHandler.matches(route.request().url())) + continue; + const index = this._routes.indexOf(routeHandler); + if (index === -1) + continue; + if (routeHandler.willExpire()) + this._routes.splice(index, 1); + const handled = await routeHandler.handle(route); + if (!this._routes.length) + this._updateInterceptionPatterns({ internal: true }).catch(() => {}); + if (handled) + return; + } + + await this._browserContext._onRoute(route); + } + + private async _onWebSocketRoute(webSocketRoute: WebSocketRoute) { + const routeHandler = this._webSocketRoutes.find(route => route.matches(webSocketRoute.url())); + if (routeHandler) + await routeHandler.handle(webSocketRoute); + else + await this._browserContext._onWebSocketRoute(webSocketRoute); + } + + async _onBinding(bindingCall: BindingCall) { + const func = this._bindings.get(bindingCall._initializer.name); + if (func) { + await bindingCall.call(func); + return; + } + await this._browserContext._onBinding(bindingCall); + } + + _onWorker(worker: Worker): void { + this._workers.add(worker); + worker._page = this; + this.emit(Events.Page.Worker, worker); + } + + _onClose() { + this._closed = true; + this._browserContext._pages.delete(this); + this._disposeHarRouters(); + this.emit(Events.Page.Close, this); + } + + private _onCrash() { + this.emit(Events.Page.Crash, this); + } + + context(): BrowserContext { + return this._browserContext; + } + + async opener(): Promise { + if (!this._opener || this._opener.isClosed()) + return null; + return this._opener; + } + + mainFrame(): Frame { + return this._mainFrame; + } + + frame(frameSelector: string | { name?: string, url?: URLMatch }): Frame | null { + const name = isString(frameSelector) ? frameSelector : frameSelector.name; + const url = isObject(frameSelector) ? frameSelector.url : undefined; + assert(name || url, 'Either name or url matcher should be specified'); + return this.frames().find(f => { + if (name) + return f.name() === name; + return urlMatches(this._browserContext._options.baseURL, f.url(), url); + }) || null; + } + + frames(): Frame[] { + return [...this._frames]; + } + + setDefaultNavigationTimeout(timeout: number) { + this._timeoutSettings.setDefaultNavigationTimeout(timeout); + } + + setDefaultTimeout(timeout: number) { + this._timeoutSettings.setDefaultTimeout(timeout); + } + + video(): Video { + return this._video; + } + + async pickLocator(): Promise { + const { selector } = await this._channel.pickLocator({}); + return this.locator(selector); + } + + async cancelPickLocator(): Promise { + await this._channel.cancelPickLocator({}); + } + + async $(selector: string, options?: { strict?: boolean }): Promise | null> { + return await this._mainFrame.$(selector, options); + } + + waitForSelector(selector: string, options: channels.FrameWaitForSelectorOptions & TimeoutOptions & { state: 'attached' | 'visible' }): Promise>; + waitForSelector(selector: string, options?: channels.FrameWaitForSelectorOptions & TimeoutOptions): Promise | null>; + async waitForSelector(selector: string, options?: channels.FrameWaitForSelectorOptions & TimeoutOptions): Promise | null> { + return await this._mainFrame.waitForSelector(selector, options); + } + + async dispatchEvent(selector: string, type: string, eventInit?: any, options?: channels.FrameDispatchEventOptions): Promise { + return await this._mainFrame.dispatchEvent(selector, type, eventInit, options); + } + + async evaluateHandle(pageFunction: structs.PageFunction, arg?: Arg): Promise> { + assertMaxArguments(arguments.length, 2); + return await this._mainFrame.evaluateHandle(pageFunction, arg); + } + + async $eval(selector: string, pageFunction: structs.PageFunctionOn, arg?: Arg): Promise { + assertMaxArguments(arguments.length, 3); + return await this._mainFrame.$eval(selector, pageFunction, arg); + } + + async $$eval(selector: string, pageFunction: structs.PageFunctionOn, arg?: Arg): Promise { + assertMaxArguments(arguments.length, 3); + return await this._mainFrame.$$eval(selector, pageFunction, arg); + } + + async $$(selector: string): Promise[]> { + return await this._mainFrame.$$(selector); + } + + async addScriptTag(options: { url?: string; path?: string; content?: string; type?: string; } = {}): Promise { + return await this._mainFrame.addScriptTag(options); + } + + async addStyleTag(options: { url?: string; path?: string; content?: string; } = {}): Promise { + return await this._mainFrame.addStyleTag(options); + } + + async exposeFunction(name: string, callback: Function) { + const result = await this._channel.exposeBinding({ name }); + const binding = (source: structs.BindingSource, ...args: any[]) => callback(...args); + this._bindings.set(name, binding); + return DisposableObject.from(result.disposable); + } + + async exposeBinding(name: string, callback: (source: structs.BindingSource, ...args: any[]) => any, options: { handle?: boolean } = {}) { + const result = await this._channel.exposeBinding({ name, needsHandle: options.handle }); + this._bindings.set(name, callback); + return DisposableObject.from(result.disposable); + } + + async setExtraHTTPHeaders(headers: Headers) { + validateHeaders(headers); + await this._channel.setExtraHTTPHeaders({ headers: headersObjectToArray(headers) }); + } + + url(): string { + return this._mainFrame.url(); + } + + async content(): Promise { + return await this._mainFrame.content(); + } + + async setContent(html: string, options?: channels.FrameSetContentOptions & TimeoutOptions): Promise { + return await this._mainFrame.setContent(html, options); + } + + async goto(url: string, options?: channels.FrameGotoOptions & TimeoutOptions): Promise { + return await this._mainFrame.goto(url, options); + } + + async reload(options: channels.PageReloadOptions & TimeoutOptions = {}): Promise { + const waitUntil = verifyLoadState('waitUntil', options.waitUntil === undefined ? 'load' : options.waitUntil); + return Response.fromNullable((await this._channel.reload({ ...options, waitUntil, timeout: this._timeoutSettings.navigationTimeout(options) })).response); + } + + async addLocatorHandler(locator: Locator, handler: (locator: Locator) => any, options: { times?: number, noWaitAfter?: boolean } = {}): Promise { + if (locator._frame !== this._mainFrame) + throw new Error(`Locator must belong to the main frame of this page`); + if (options.times === 0) + return; + const { uid } = await this._channel.registerLocatorHandler({ selector: locator._selector, noWaitAfter: options.noWaitAfter }); + this._locatorHandlers.set(uid, { locator, handler, times: options.times }); + } + + private async _onLocatorHandlerTriggered(uid: number) { + let remove = false; + try { + const handler = this._locatorHandlers.get(uid); + if (handler && handler.times !== 0) { + if (handler.times !== undefined) + handler.times--; + await handler.handler(handler.locator); + } + remove = handler?.times === 0; + } finally { + if (remove) + this._locatorHandlers.delete(uid); + this._channel.resolveLocatorHandlerNoReply({ uid, remove }).catch(() => {}); + } + } + + async removeLocatorHandler(locator: Locator): Promise { + for (const [uid, data] of this._locatorHandlers) { + if (data.locator._equals(locator)) { + this._locatorHandlers.delete(uid); + await this._channel.unregisterLocatorHandler({ uid }).catch(() => {}); + } + } + } + + async waitForLoadState(state?: LifecycleEvent, options?: TimeoutOptions): Promise { + return await this._mainFrame.waitForLoadState(state, options); + } + + async waitForNavigation(options?: WaitForNavigationOptions): Promise { + return await this._mainFrame.waitForNavigation(options); + } + + async waitForURL(url: URLMatch, options?: TimeoutOptions & { waitUntil?: LifecycleEvent }): Promise { + return await this._mainFrame.waitForURL(url, options); + } + + async waitForRequest(urlOrPredicate: string | RegExp | ((r: Request) => boolean | Promise), options: TimeoutOptions = {}): Promise { + const predicate = async (request: Request) => { + if (isString(urlOrPredicate) || isRegExp(urlOrPredicate)) + return urlMatches(this._browserContext._options.baseURL, request.url(), urlOrPredicate); + return await urlOrPredicate(request); + }; + const trimmedUrl = trimUrl(urlOrPredicate); + const logLine = trimmedUrl ? `waiting for request ${trimmedUrl}` : undefined; + return await this._waitForEvent(Events.Page.Request, { predicate, timeout: options.timeout }, logLine); + } + + async waitForResponse(urlOrPredicate: string | RegExp | ((r: Response) => boolean | Promise), options: TimeoutOptions = {}): Promise { + const predicate = async (response: Response) => { + if (isString(urlOrPredicate) || isRegExp(urlOrPredicate)) + return urlMatches(this._browserContext._options.baseURL, response.url(), urlOrPredicate); + return await urlOrPredicate(response); + }; + const trimmedUrl = trimUrl(urlOrPredicate); + const logLine = trimmedUrl ? `waiting for response ${trimmedUrl}` : undefined; + return await this._waitForEvent(Events.Page.Response, { predicate, timeout: options.timeout }, logLine); + } + + async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise { + return await this._waitForEvent(event, optionsOrPredicate, `waiting for event "${event}"`); + } + + _closeErrorWithReason(): TargetClosedError { + return new TargetClosedError(this._closeReason || this._browserContext._effectiveCloseReason()); + } + + private async _waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions, logLine?: string): Promise { + return await this._wrapApiCall(async () => { + const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); + const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; + const waiter = Waiter.createForEvent(this, event); + if (logLine) + waiter.log(logLine); + waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); + if (event !== Events.Page.Crash) + waiter.rejectOnEvent(this, Events.Page.Crash, new Error('Page crashed')); + if (event !== Events.Page.Close) + waiter.rejectOnEvent(this, Events.Page.Close, () => this._closeErrorWithReason()); + const result = await waiter.waitForEvent(this, event, predicate as any); + waiter.dispose(); + return result; + }); + } + + async goBack(options: channels.PageGoBackOptions & TimeoutOptions = {}): Promise { + const waitUntil = verifyLoadState('waitUntil', options.waitUntil === undefined ? 'load' : options.waitUntil); + return Response.fromNullable((await this._channel.goBack({ ...options, waitUntil, timeout: this._timeoutSettings.navigationTimeout(options) })).response); + } + + async goForward(options: channels.PageGoForwardOptions & TimeoutOptions = {}): Promise { + const waitUntil = verifyLoadState('waitUntil', options.waitUntil === undefined ? 'load' : options.waitUntil); + return Response.fromNullable((await this._channel.goForward({ ...options, waitUntil, timeout: this._timeoutSettings.navigationTimeout(options) })).response); + } + + async requestGC() { + await this._channel.requestGC(); + } + + async emulateMedia(options: { media?: 'screen' | 'print' | null, colorScheme?: 'dark' | 'light' | 'no-preference' | null, reducedMotion?: 'reduce' | 'no-preference' | null, forcedColors?: 'active' | 'none' | null, contrast?: 'no-preference' | 'more' | null } = {}) { + await this._channel.emulateMedia({ + media: options.media === null ? 'no-override' : options.media, + colorScheme: options.colorScheme === null ? 'no-override' : options.colorScheme, + reducedMotion: options.reducedMotion === null ? 'no-override' : options.reducedMotion, + forcedColors: options.forcedColors === null ? 'no-override' : options.forcedColors, + contrast: options.contrast === null ? 'no-override' : options.contrast, + }); + } + + async setViewportSize(viewportSize: Size) { + this._viewportSize = viewportSize; + await this._channel.setViewportSize({ viewportSize }); + } + + viewportSize(): Size | null { + return this._viewportSize || null; + } + + async evaluate(pageFunction: structs.PageFunction, arg?: Arg): Promise { + assertMaxArguments(arguments.length, 2); + return await this._mainFrame.evaluate(pageFunction, arg); + } + + async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any) { + const source = await evaluationScript(this._platform, script, arg); + return DisposableObject.from((await this._channel.addInitScript({ source })).disposable); + } + + async route(url: URLMatch, handler: RouteHandlerCallback, options: { times?: number } = {}): Promise { + this._routes.unshift(new RouteHandler(this._platform, this._browserContext._options.baseURL, url, handler, options.times)); + await this._updateInterceptionPatterns({ title: 'Route requests' }); + return new DisposableStub(() => this.unroute(url, handler)); + } + + async routeFromHAR(har: string, options: { url?: string | RegExp, notFound?: 'abort' | 'fallback', update?: boolean, updateContent?: 'attach' | 'embed', updateMode?: 'minimal' | 'full'} = {}): Promise { + const localUtils = this._connection.localUtils(); + if (!localUtils) + throw new Error('Route from har is not supported in thin clients'); + if (options.update) { + await this._browserContext._recordIntoHAR(har, this, options); + return; + } + const harRouter = await HarRouter.create(localUtils, har, options.notFound || 'abort', { urlMatch: options.url }); + this._harRouters.push(harRouter); + await harRouter.addPageRoute(this); + } + + async routeWebSocket(url: URLMatch, handler: WebSocketRouteHandlerCallback): Promise { + this._webSocketRoutes.unshift(new WebSocketRouteHandler(this._browserContext._options.baseURL, url, handler)); + await this._updateWebSocketInterceptionPatterns({ title: 'Route WebSockets' }); + } + + private _disposeHarRouters() { + this._harRouters.forEach(router => router.dispose()); + this._harRouters = []; + } + + async unrouteAll(options?: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise { + await this._unrouteInternal(this._routes, [], options?.behavior); + this._disposeHarRouters(); + } + + async unroute(url: URLMatch, handler?: RouteHandlerCallback): Promise { + const removed = []; + const remaining = []; + for (const route of this._routes) { + if (urlMatchesEqual(route.url, url) && (!handler || route.handler === handler)) + removed.push(route); + else + remaining.push(route); + } + await this._unrouteInternal(removed, remaining, 'default'); + } + + private async _unrouteInternal(removed: RouteHandler[], remaining: RouteHandler[], behavior?: 'wait'|'ignoreErrors'|'default'): Promise { + this._routes = remaining; + if (behavior && behavior !== 'default') { + const promises = removed.map(routeHandler => routeHandler.stop(behavior)); + await Promise.all(promises); + } + await this._updateInterceptionPatterns({ title: 'Unroute requests' }); + } + + private async _updateInterceptionPatterns(options: { internal: true } | { title: string }) { + const patterns = RouteHandler.prepareInterceptionPatterns(this._routes); + await this._wrapApiCall(() => this._channel.setNetworkInterceptionPatterns({ patterns }), options); + } + + private async _updateWebSocketInterceptionPatterns(options: { internal: true } | { title: string }) { + const patterns = WebSocketRouteHandler.prepareInterceptionPatterns(this._webSocketRoutes); + await this._wrapApiCall(() => this._channel.setWebSocketInterceptionPatterns({ patterns }), options); + } + + async screenshot(options: Omit & TimeoutOptions & { path?: string, mask?: api.Locator[] } = {}): Promise { + const mask = options.mask as Locator[] | undefined; + const copy: channels.PageScreenshotParams = { + ...options, + path: undefined, + mask: undefined, + timeout: this._timeoutSettings.timeout(options), + }; + if (!copy.type) + copy.type = determineScreenshotType(options); + if (mask) { + copy.mask = mask.map(locator => ({ + frame: locator._frame._channel, + selector: locator._selector, + })); + } + const result = await this._channel.screenshot(copy); + if (options.path) { + options.path = await writeTempFile(options.path, result.binary); + } + return result.binary; + } + + async _expectScreenshot(options: ExpectScreenshotOptions): Promise<{ actual?: Buffer, previous?: Buffer, diff?: Buffer, errorMessage?: string, log?: string[], timedOut?: boolean}> { + const mask = options?.mask ? options?.mask.map(locator => ({ + frame: (locator as Locator)._frame._channel, + selector: (locator as Locator)._selector, + })) : undefined; + const locator = options.locator ? { + frame: (options.locator as Locator)._frame._channel, + selector: (options.locator as Locator)._selector, + } : undefined; + return await this._channel.expectScreenshot({ + ...options, + isNot: !!options.isNot, + locator, + mask, + }); + } + + async title(): Promise { + return await this._mainFrame.title(); + } + + async bringToFront(): Promise { + await this._channel.bringToFront(); + } + + async [Symbol.asyncDispose]() { + await this.close(); + } + + async close(options: { runBeforeUnload?: boolean, reason?: string } = {}) { + this._closeReason = options.reason; + if (!options.runBeforeUnload) + this._closeWasCalled = true; + try { + if (this._ownedContext) + await this._ownedContext.close(); + else + await this._channel.close(options); + } catch (e) { + if (isTargetClosedError(e) && !options.runBeforeUnload) + return; + throw e; + } + } + + isClosed(): boolean { + return this._closed; + } + + async click(selector: string, options?: channels.FrameClickOptions & TimeoutOptions) { + return await this._mainFrame.click(selector, options); + } + + async dragAndDrop(source: string, target: string, options?: channels.FrameDragAndDropOptions & TimeoutOptions) { + return await this._mainFrame.dragAndDrop(source, target, options); + } + + async dblclick(selector: string, options?: channels.FrameDblclickOptions & TimeoutOptions) { + await this._mainFrame.dblclick(selector, options); + } + + async tap(selector: string, options?: channels.FrameTapOptions & TimeoutOptions) { + return await this._mainFrame.tap(selector, options); + } + + async fill(selector: string, value: string, options?: channels.FrameFillOptions & TimeoutOptions) { + return await this._mainFrame.fill(selector, value, options); + } + + async clearConsoleMessages(): Promise { + await this._channel.clearConsoleMessages(); + } + + async consoleMessages(options?: { filter?: 'all' | 'sinceNavigation' }): Promise { + const { messages } = await this._channel.consoleMessages({ filter: options?.filter }); + return messages.map(message => new ConsoleMessage(this._platform, message, this, null)); + } + + async clearPageErrors(): Promise { + await this._channel.clearPageErrors(); + } + + async pageErrors(options?: { filter?: 'all' | 'sinceNavigation' }): Promise { + const { errors } = await this._channel.pageErrors({ filter: options?.filter }); + return errors.map(error => parseError(error)); + } + + locator(selector: string, options?: LocatorOptions): Locator { + return this.mainFrame().locator(selector, options); + } + + getByTestId(testId: string | RegExp): Locator { + return this.mainFrame().getByTestId(testId); + } + + getByAltText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.mainFrame().getByAltText(text, options); + } + + getByLabel(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.mainFrame().getByLabel(text, options); + } + + getByPlaceholder(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.mainFrame().getByPlaceholder(text, options); + } + + getByText(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.mainFrame().getByText(text, options); + } + + getByTitle(text: string | RegExp, options?: { exact?: boolean }): Locator { + return this.mainFrame().getByTitle(text, options); + } + + getByRole(role: string, options: ByRoleOptions = {}): Locator { + return this.mainFrame().getByRole(role, options); + } + + frameLocator(selector: string): FrameLocator { + return this.mainFrame().frameLocator(selector); + } + + async focus(selector: string, options?: channels.FrameFocusOptions & TimeoutOptions) { + return await this._mainFrame.focus(selector, options); + } + + async textContent(selector: string, options?: channels.FrameTextContentOptions & TimeoutOptions): Promise { + return await this._mainFrame.textContent(selector, options); + } + + async innerText(selector: string, options?: channels.FrameInnerTextOptions & TimeoutOptions): Promise { + return await this._mainFrame.innerText(selector, options); + } + + async innerHTML(selector: string, options?: channels.FrameInnerHTMLOptions & TimeoutOptions): Promise { + return await this._mainFrame.innerHTML(selector, options); + } + + async getAttribute(selector: string, name: string, options?: channels.FrameGetAttributeOptions & TimeoutOptions): Promise { + return await this._mainFrame.getAttribute(selector, name, options); + } + + async inputValue(selector: string, options?: channels.FrameInputValueOptions & TimeoutOptions): Promise { + return await this._mainFrame.inputValue(selector, options); + } + + async isChecked(selector: string, options?: channels.FrameIsCheckedOptions & TimeoutOptions): Promise { + return await this._mainFrame.isChecked(selector, options); + } + + async isDisabled(selector: string, options?: channels.FrameIsDisabledOptions & TimeoutOptions): Promise { + return await this._mainFrame.isDisabled(selector, options); + } + + async isEditable(selector: string, options?: channels.FrameIsEditableOptions & TimeoutOptions): Promise { + return await this._mainFrame.isEditable(selector, options); + } + + async isEnabled(selector: string, options?: channels.FrameIsEnabledOptions & TimeoutOptions): Promise { + return await this._mainFrame.isEnabled(selector, options); + } + + async isHidden(selector: string, options?: channels.FrameIsHiddenOptions & TimeoutOptions): Promise { + return await this._mainFrame.isHidden(selector, options); + } + + async isVisible(selector: string, options?: channels.FrameIsVisibleOptions & TimeoutOptions): Promise { + return await this._mainFrame.isVisible(selector, options); + } + + async hover(selector: string, options?: channels.FrameHoverOptions & TimeoutOptions) { + return await this._mainFrame.hover(selector, options); + } + + async selectOption(selector: string, values: string | api.ElementHandle | SelectOption | string[] | api.ElementHandle[] | SelectOption[] | null, options?: SelectOptionOptions): Promise { + return await this._mainFrame.selectOption(selector, values, options); + } + + async setInputFiles(selector: string, files: string | FilePayload | string[] | FilePayload[], options?: channels.FrameSetInputFilesOptions & TimeoutOptions): Promise { + return await this._mainFrame.setInputFiles(selector, files, options); + } + + async type(selector: string, text: string, options?: channels.FrameTypeOptions & TimeoutOptions) { + return await this._mainFrame.type(selector, text, options); + } + + async press(selector: string, key: string, options?: channels.FramePressOptions & TimeoutOptions) { + return await this._mainFrame.press(selector, key, options); + } + + async check(selector: string, options?: channels.FrameCheckOptions & TimeoutOptions) { + return await this._mainFrame.check(selector, options); + } + + async uncheck(selector: string, options?: channels.FrameUncheckOptions & TimeoutOptions) { + return await this._mainFrame.uncheck(selector, options); + } + + async setChecked(selector: string, checked: boolean, options?: channels.FrameCheckOptions & TimeoutOptions) { + return await this._mainFrame.setChecked(selector, checked, options); + } + + async waitForTimeout(timeout: number) { + return await this._mainFrame.waitForTimeout(timeout); + } + + async waitForFunction(pageFunction: structs.PageFunction, arg?: Arg, options?: WaitForFunctionOptions): Promise> { + return await this._mainFrame.waitForFunction(pageFunction, arg, options); + } + + async requests() { + const { requests } = await this._channel.requests(); + return requests.map(request => Request.from(request)); + } + + workers(): Worker[] { + return [...this._workers]; + } + + async pause(_options?: { __testHookKeepTestTimeout: boolean }) { + if (this._platform.isJSDebuggerAttached()) + return; + const defaultNavigationTimeout = this._browserContext._timeoutSettings.defaultNavigationTimeout(); + const defaultTimeout = this._browserContext._timeoutSettings.defaultTimeout(); + this._browserContext.setDefaultNavigationTimeout(0); + this._browserContext.setDefaultTimeout(0); + this._instrumentation?.onWillPause({ keepTestTimeout: !!_options?.__testHookKeepTestTimeout }); + await this._closedOrCrashedScope.safeRace(this.context()._channel.pause()); + this._browserContext.setDefaultNavigationTimeout(defaultNavigationTimeout); + this._browserContext.setDefaultTimeout(defaultTimeout); + } + + async pdf(options: PDFOptions = {}): Promise { + const transportOptions: channels.PagePdfParams = { ...options } as channels.PagePdfParams; + if (transportOptions.margin) + transportOptions.margin = { ...transportOptions.margin }; + if (typeof options.width === 'number') + transportOptions.width = options.width + 'px'; + if (typeof options.height === 'number') + transportOptions.height = options.height + 'px'; + for (const margin of ['top', 'right', 'bottom', 'left']) { + const index = margin as 'top' | 'right' | 'bottom' | 'left'; + if (options.margin && typeof options.margin[index] === 'number') + transportOptions.margin![index] = transportOptions.margin![index] + 'px'; + } + const result = await this._channel.pdf(transportOptions); + if (options.path) { + const platform = this._platform; + await platform.fs().promises.mkdir(platform.path().dirname(options.path), { recursive: true }); + await platform.fs().promises.writeFile(options.path, result.pdf); + } + return result.pdf; + } + + async snapshotForAI(options: TimeoutOptions & { track?: string, depth?: number } = {}): Promise<{ full: string, incremental?: string }> { + return await this._channel.snapshotForAI({ timeout: this._timeoutSettings.timeout(options), track: options.track, depth: options.depth }); + } + + async _setDockTile(image: Buffer) { + await this._channel.setDockTile({ image }); + } +} + +export class BindingCall extends ChannelOwner { + static from(channel: channels.BindingCallChannel): BindingCall { + return (channel as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.BindingCallInitializer) { + super(parent, type, guid, initializer); + } + + async call(func: (source: structs.BindingSource, ...args: any[]) => any) { + try { + const frame = Frame.from(this._initializer.frame); + const source = { + context: frame._page!.context(), + page: frame._page!, + frame + }; + let result: any; + if (this._initializer.handle) + result = await func(source, JSHandle.from(this._initializer.handle)); + else + result = await func(source, ...this._initializer.args!.map(parseResult)); + this._channel.resolve({ result: serializeArgument(result) }).catch(() => {}); + } catch (e) { + this._channel.reject({ error: serializeError(e) }).catch(() => {}); + } + } +} + +function trimUrl(param: any): string | undefined { + if (isRegExp(param)) + return `/${trimStringWithEllipsis(param.source, 50)}/${param.flags}`; + if (isString(param)) + return `"${trimStringWithEllipsis(param, 50)}"`; +} diff --git a/daemon/src/sandbox/forked-client/src/client/platform.ts b/daemon/src/sandbox/forked-client/src/client/platform.ts new file mode 100644 index 00000000..77c343d3 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/platform.ts @@ -0,0 +1,131 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { webColors } from '../utils/isomorphic/colors'; + +import type * as fs from 'fs'; +import type * as path from 'path'; +import type { Readable, Writable } from 'stream'; +import type { Colors } from '../utils/isomorphic/colors'; +import type * as channels from '../protocol/channels'; + +export type Zone = { + push(data: unknown): Zone; + pop(): Zone; + run(func: () => R): R; + data(): T | undefined; +}; + +const noopZone: Zone = { + push: () => noopZone, + pop: () => noopZone, + run: func => func(), + data: () => undefined, +}; + +export type Platform = { + name: 'node' | 'web' | 'empty'; + + boxedStackPrefixes: () => string[]; + calculateSha1: (text: string) => Promise; + colors: Colors; + coreDir?: string; + createGuid: () => string; + defaultMaxListeners: () => number; + env: Record; + fs: () => typeof fs; + inspectCustom: symbol | undefined; + isDebugMode: () => boolean; + isJSDebuggerAttached: () => boolean; + isLogEnabled: (name: 'api' | 'channel') => boolean; + isUnderTest: () => boolean, + log: (name: 'api' | 'channel', message: string | Error | object) => void; + path: () => typeof path; + pathSeparator: string; + showInternalStackFrames: () => boolean, + streamFile: (path: string, writable: Writable) => Promise, + streamReadable: (channel: channels.StreamChannel) => Readable, + streamWritable: (channel: channels.WritableStreamChannel) => Writable, + zodToJsonSchema: (schema: any) => any, + zones: { empty: Zone, current: () => Zone; }; +}; + +export const emptyPlatform: Platform = { + name: 'empty', + + boxedStackPrefixes: () => [], + + calculateSha1: async () => { + throw new Error('Not implemented'); + }, + + colors: webColors, + + createGuid: () => { + throw new Error('Not implemented'); + }, + + defaultMaxListeners: () => 10, + + env: {}, + + fs: () => { + throw new Error('Not implemented'); + }, + + inspectCustom: undefined, + + isDebugMode: () => false, + + isJSDebuggerAttached: () => false, + + isLogEnabled(name: 'api' | 'channel') { + return false; + }, + + isUnderTest: () => false, + + log(name: 'api' | 'channel', message: string | Error | object) { }, + + path: () => { + throw new Error('Function not implemented.'); + }, + + pathSeparator: '/', + + showInternalStackFrames: () => false, + + streamFile(path: string, writable: Writable): Promise { + throw new Error('Streams are not available'); + }, + + streamReadable: (channel: channels.StreamChannel) => { + throw new Error('Streams are not available'); + }, + + streamWritable: (channel: channels.WritableStreamChannel) => { + throw new Error('Streams are not available'); + }, + + zodToJsonSchema: (schema: any) => { + throw new Error('Zod is not available'); + }, + + zones: { empty: noopZone, current: () => noopZone }, +}; + +export { quickjsPlatform } from '../../quickjs-platform'; diff --git a/daemon/src/sandbox/forked-client/src/client/playwright.ts b/daemon/src/sandbox/forked-client/src/client/playwright.ts new file mode 100644 index 00000000..f50c5400 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/playwright.ts @@ -0,0 +1,85 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Android } from './android'; +import { Browser } from './browser'; +import { BrowserType } from './browserType'; +import { ChannelOwner } from './channelOwner'; +import { Electron } from './electron'; +import { TimeoutError } from './errors'; +import { APIRequest } from './fetch'; +import { Selectors } from './selectors'; + +import type * as channels from '../protocol/channels'; +import type { LaunchOptions } from '../../types/types'; + +export class Playwright extends ChannelOwner { + readonly _android: Android; + readonly _electron: Electron; + readonly chromium: BrowserType; + readonly firefox: BrowserType; + readonly webkit: BrowserType; + readonly devices: any; + selectors: Selectors; + readonly request: APIRequest; + readonly errors: { TimeoutError: typeof TimeoutError }; + + // Instrumentation. + _defaultLaunchOptions?: LaunchOptions; + _defaultContextTimeout?: number; + _defaultContextNavigationTimeout?: number; + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.PlaywrightInitializer) { + super(parent, type, guid, initializer); + this.request = new APIRequest(this); + this.chromium = BrowserType.from(initializer.chromium); + this.chromium._playwright = this; + this.firefox = BrowserType.from(initializer.firefox); + this.firefox._playwright = this; + this.webkit = BrowserType.from(initializer.webkit); + this.webkit._playwright = this; + this._android = Android.from(initializer.android); + this._android._playwright = this; + this._electron = Electron.from(initializer.electron); + this._electron._playwright = this; + this.devices = this._connection.localUtils()?.devices ?? {}; + this.selectors = new Selectors(this._connection._platform); + this.errors = { TimeoutError }; + } + + static from(channel: channels.PlaywrightChannel): Playwright { + return (channel as any)._object; + } + + private _browserTypes(): BrowserType[] { + return [this.chromium, this.firefox, this.webkit]; + } + + _preLaunchedBrowser(): Browser { + const browser = Browser.from(this._initializer.preLaunchedBrowser!); + browser._connectToBrowserType(this[browser._name as 'chromium' | 'firefox' | 'webkit'], {}, undefined); + return browser; + } + + _allContexts() { + return this._browserTypes().flatMap(type => [...type._contexts]); + } + + _allPages() { + return this._allContexts().flatMap(context => context.pages()); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/screencast.ts b/daemon/src/sandbox/forked-client/src/client/screencast.ts new file mode 100644 index 00000000..04be59c1 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/screencast.ts @@ -0,0 +1,12 @@ +// @ts-nocheck +export class Screencast { + constructor(page) { + this._page = page; + } + + async start() { + throw new Error('Screencast is not available in the QuickJS sandbox'); + } + + async stop() {} +} diff --git a/daemon/src/sandbox/forked-client/src/client/selectors.ts b/daemon/src/sandbox/forked-client/src/client/selectors.ts new file mode 100644 index 00000000..ac34bf5a --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/selectors.ts @@ -0,0 +1,60 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { evaluationScript } from './clientHelper'; +import { setTestIdAttribute } from './locator'; + +import type { SelectorEngine } from './types'; +import type * as api from '../../types/types'; +import type * as channels from '../protocol/channels'; +import type { BrowserContext } from './browserContext'; +import type { Platform } from './platform'; + +export class Selectors implements api.Selectors { + private _platform: Platform; + private _selectorEngines: channels.SelectorEngine[] = []; + private _testIdAttributeName: string | undefined; + readonly _contextsForSelectors = new Set(); + + constructor(platform: Platform) { + this._platform = platform; + } + + async register(name: string, script: string | (() => SelectorEngine) | { path?: string, content?: string }, options: { contentScript?: boolean } = {}): Promise { + if (this._selectorEngines.some(engine => engine.name === name)) + throw new Error(`selectors.register: "${name}" selector engine has been already registered`); + + const source = await evaluationScript(this._platform, script, undefined, false); + const selectorEngine: channels.SelectorEngine = { ...options, name, source }; + for (const context of this._contextsForSelectors) + await context._channel.registerSelectorEngine({ selectorEngine }); + this._selectorEngines.push(selectorEngine); + } + + setTestIdAttribute(attributeName: string) { + this._testIdAttributeName = attributeName; + setTestIdAttribute(attributeName); + for (const context of this._contextsForSelectors) { + context._options.testIdAttributeName = attributeName; + context._channel.setTestIdAttributeName({ testIdAttributeName: attributeName }).catch(() => {}); + } + } + + _withSelectorOptions(options: T) { + return { ...options, selectorEngines: this._selectorEngines, testIdAttributeName: this._testIdAttributeName }; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/stream.ts b/daemon/src/sandbox/forked-client/src/client/stream.ts new file mode 100644 index 00000000..a13a618a --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/stream.ts @@ -0,0 +1,12 @@ +// @ts-nocheck +import { ChannelOwner } from './channelOwner'; + +export class Stream extends ChannelOwner { + static from(channel) { + return channel?._object; + } + + stream() { + throw new Error('Readable streams are not available in the QuickJS sandbox'); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/timeoutSettings.ts b/daemon/src/sandbox/forked-client/src/client/timeoutSettings.ts new file mode 100644 index 00000000..e15f3201 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/timeoutSettings.ts @@ -0,0 +1,85 @@ +// @ts-nocheck +/** + * Copyright 2019 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT, DEFAULT_PLAYWRIGHT_TIMEOUT } from '../utils/isomorphic/time'; + +import type { Platform } from './platform'; + +export class TimeoutSettings { + private _parent: TimeoutSettings | undefined; + private _defaultTimeout: number | undefined; + private _defaultNavigationTimeout: number | undefined; + private _platform: Platform; + + constructor(platform: Platform, parent?: TimeoutSettings) { + this._parent = parent; + this._platform = platform; + } + + setDefaultTimeout(timeout: number | undefined) { + this._defaultTimeout = timeout; + } + + setDefaultNavigationTimeout(timeout: number | undefined) { + this._defaultNavigationTimeout = timeout; + } + + defaultNavigationTimeout() { + return this._defaultNavigationTimeout; + } + + defaultTimeout() { + return this._defaultTimeout; + } + + navigationTimeout(options: { timeout?: number }): number { + if (typeof options.timeout === 'number') + return options.timeout; + if (this._defaultNavigationTimeout !== undefined) + return this._defaultNavigationTimeout; + if (this._platform.isDebugMode()) + return 0; + if (this._defaultTimeout !== undefined) + return this._defaultTimeout; + if (this._parent) + return this._parent.navigationTimeout(options); + return DEFAULT_PLAYWRIGHT_TIMEOUT; + } + + timeout(options: { timeout?: number }): number { + if (typeof options.timeout === 'number') + return options.timeout; + if (this._platform.isDebugMode()) + return 0; + if (this._defaultTimeout !== undefined) + return this._defaultTimeout; + if (this._parent) + return this._parent.timeout(options); + return DEFAULT_PLAYWRIGHT_TIMEOUT; + } + + launchTimeout(options: { timeout?: number }): number { + if (typeof options.timeout === 'number') + return options.timeout; + if (this._platform.isDebugMode()) + return 0; + if (this._parent) + return this._parent.launchTimeout(options); + return DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/tracing.ts b/daemon/src/sandbox/forked-client/src/client/tracing.ts new file mode 100644 index 00000000..43c4ba3d --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/tracing.ts @@ -0,0 +1,22 @@ +// @ts-nocheck +import { ChannelOwner } from './channelOwner'; + +export class Tracing extends ChannelOwner { + _tracesDir; + + static from(channel) { + return channel?._object; + } + + async start() {} + + async startChunk() {} + + async stop() {} + + async stopChunk() {} + + async group() {} + + async groupEnd() {} +} diff --git a/daemon/src/sandbox/forked-client/src/client/types.ts b/daemon/src/sandbox/forked-client/src/client/types.ts new file mode 100644 index 00000000..a3282c1e --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/types.ts @@ -0,0 +1,146 @@ +// @ts-nocheck +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Size } from '../utils/isomorphic/types'; +import type * as channels from '../protocol/channels'; +export type { HeadersArray, Point, Quad, Rect, Size } from '../utils/isomorphic/types'; + +type LoggerSeverity = 'verbose' | 'info' | 'warning' | 'error'; +export interface Logger { + isEnabled(name: string, severity: LoggerSeverity): boolean; + log(name: string, severity: LoggerSeverity, message: string | Error, args: any[], hints: { color?: string }): void; +} + +export type TimeoutOptions = { timeout?: number }; +export type StrictOptions = { strict?: boolean }; +export type Headers = { [key: string]: string }; + +export type WaitForEventOptions = Function | TimeoutOptions & { predicate?: Function }; +export type WaitForFunctionOptions = TimeoutOptions & { polling?: 'raf' | number }; + +export type SelectOption = { value?: string, label?: string, index?: number, valueOrLabel?: string }; +export type SelectOptionOptions = TimeoutOptions & { force?: boolean }; +export type FilePayload = { name: string, mimeType: string, buffer: Buffer }; +export type StorageState = { + cookies: channels.NetworkCookie[], + origins: (Omit)[], +}; +export type SetStorageState = { + cookies?: channels.SetNetworkCookie[], + origins?: (Omit & { indexedDB?: unknown[] })[] +}; + +export type LifecycleEvent = channels.LifecycleEvent; +export const kLifecycleEvents: Set = new Set(['load', 'domcontentloaded', 'networkidle', 'commit']); + +export type ClientCertificate = { + origin: string; + cert?: Buffer; + certPath?: string; + key?: Buffer; + keyPath?: string; + pfx?: Buffer; + pfxPath?: string; + passphrase?: string; +}; + +export type BrowserContextOptions = Omit & { + viewport?: Size | null; + extraHTTPHeaders?: Headers; + logger?: Logger; + videosPath?: string; + videoSize?: Size; + storageState?: string | SetStorageState; + har?: { + path: string; + fallback?: 'abort'|'continue'; + urlFilter?: string|RegExp; + }; + recordHar?: { + path: string, + omitContent?: boolean, + content?: 'omit' | 'embed' | 'attach', + mode?: 'full' | 'minimal', + urlFilter?: string | RegExp, + }; + colorScheme?: 'dark' | 'light' | 'no-preference' | null; + reducedMotion?: 'reduce' | 'no-preference' | null; + forcedColors?: 'active' | 'none' | null; + contrast?: 'more' | 'no-preference' | null; + acceptDownloads?: boolean; + clientCertificates?: ClientCertificate[]; +}; + +type LaunchOverrides = { + ignoreDefaultArgs?: boolean | string[]; + env?: NodeJS.ProcessEnv; + logger?: Logger; + firefoxUserPrefs?: { [key: string]: string | number | boolean }; +} & TimeoutOptions; + +export type LaunchOptions = Omit & LaunchOverrides; +export type LaunchPersistentContextOptions = Omit; + +export type ConnectOptions = { + endpoint: string; + browserName?: string; + headers?: { [key: string]: string; }; + exposeNetwork?: string; + slowMo?: number; + timeout?: number; + logger?: Logger; +}; +export type LaunchServerOptions = LaunchOptions & { + host?: string, + port?: number, + wsPath?: string, +}; + +export type LaunchAndroidServerOptions = { + deviceSerialNumber?: string, + adbHost?: string, + adbPort?: number, + omitDriverInstall?: boolean, + host?: string, + port?: number, + wsPath?: string, +}; + +export type StartServerOptions = { + host?: string, + port?: number, + wsPath?: string, + workspaceDir?: string, + metadata?: Record, +}; + +export type SelectorEngine = { + /** + * Returns the first element matching given selector in the root's subtree. + */ + query(root: HTMLElement, selector: string): HTMLElement | null; + /** + * Returns all elements matching given selector in the root's subtree. + */ + queryAll(root: HTMLElement, selector: string): HTMLElement[]; +}; + +export type RemoteAddr = channels.RemoteAddr; +export type SecurityDetails = channels.SecurityDetails; + +export type FrameExpectParams = Omit & { expectedValue?: any }; diff --git a/daemon/src/sandbox/forked-client/src/client/video.ts b/daemon/src/sandbox/forked-client/src/client/video.ts new file mode 100644 index 00000000..15d4a133 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/video.ts @@ -0,0 +1,29 @@ +// @ts-nocheck +import { EventEmitter } from './eventEmitter'; + +export class Video extends EventEmitter { + constructor(page, connection, artifact) { + super(page._platform); + this._page = page; + this._connection = connection; + this._artifact = artifact; + } + + async start() { + throw new Error('Video capture is not available in the QuickJS sandbox'); + } + + async stop() {} + + async path() { + throw new Error('Video capture is not available in the QuickJS sandbox'); + } + + async saveAs() { + throw new Error('Video capture is not available in the QuickJS sandbox'); + } + + async delete() { + await this._artifact?.delete?.(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/waiter.ts b/daemon/src/sandbox/forked-client/src/client/waiter.ts new file mode 100644 index 00000000..6ebe8f5e --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/waiter.ts @@ -0,0 +1,148 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TimeoutError } from './errors'; +import { rewriteErrorMessage } from '../utils/isomorphic/stackTrace'; + +import type { ChannelOwner } from './channelOwner'; +import type * as channels from '../protocol/channels'; +import type { EventEmitter } from 'events'; +import type { Zone } from './platform'; + +export class Waiter { + private _dispose: (() => void)[]; + private _failures: Promise[] = []; + private _immediateError?: Error; + private _logs: string[] = []; + private _channelOwner: ChannelOwner; + private _waitId: string; + private _error: string | undefined; + private _savedZone: Zone; + + constructor(channelOwner: ChannelOwner, event: string) { + this._waitId = channelOwner._platform.createGuid(); + this._channelOwner = channelOwner; + this._savedZone = channelOwner._platform.zones.current().pop(); + + this._channelOwner._channel.waitForEventInfo({ info: { waitId: this._waitId, phase: 'before', event } }).catch(() => {}); + this._dispose = [ + () => this._channelOwner._wrapApiCall(async () => { + await this._channelOwner._channel.waitForEventInfo({ info: { waitId: this._waitId, phase: 'after', error: this._error } }); + }, { internal: true }).catch(() => {}), + ]; + } + + static createForEvent(channelOwner: ChannelOwner, event: string) { + return new Waiter(channelOwner, event); + } + + async waitForEvent(emitter: EventEmitter, event: string, predicate?: (arg: T) => boolean | Promise): Promise { + const { promise, dispose } = waitForEvent(emitter, event, this._savedZone, predicate); + return await this.waitForPromise(promise, dispose); + } + + rejectOnEvent(emitter: EventEmitter, event: string, error: Error | (() => Error), predicate?: (arg: T) => boolean | Promise) { + const { promise, dispose } = waitForEvent(emitter, event, this._savedZone, predicate); + this._rejectOn(promise.then(() => { throw (typeof error === 'function' ? error() : error); }), dispose); + } + + rejectOnTimeout(timeout: number, message: string) { + if (!timeout) + return; + const { promise, dispose } = waitForTimeout(timeout); + this._rejectOn(promise.then(() => { throw new TimeoutError(message); }), dispose); + } + + rejectImmediately(error: Error) { + this._immediateError = error; + } + + dispose() { + for (const dispose of this._dispose) + dispose(); + } + + async waitForPromise(promise: Promise, dispose?: () => void): Promise { + try { + if (this._immediateError) + throw this._immediateError; + const result = await Promise.race([promise, ...this._failures]); + if (dispose) + dispose(); + return result; + } catch (e) { + if (dispose) + dispose(); + this._error = e.message; + this.dispose(); + rewriteErrorMessage(e, e.message + formatLogRecording(this._logs)); + throw e; + } + } + + log(s: string) { + this._logs.push(s); + this._channelOwner._wrapApiCall(async () => { + await this._channelOwner._channel.waitForEventInfo({ info: { waitId: this._waitId, phase: 'log', message: s } }); + }, { internal: true }).catch(() => {}); + } + + private _rejectOn(promise: Promise, dispose?: () => void) { + this._failures.push(promise); + if (dispose) + this._dispose.push(dispose); + } +} + +function waitForEvent(emitter: EventEmitter, event: string, savedZone: Zone, predicate?: (arg: T) => boolean | Promise): { promise: Promise, dispose: () => void } { + let listener: (eventArg: any) => void; + const promise = new Promise((resolve, reject) => { + listener = async (eventArg: any) => { + await savedZone.run(async () => { + try { + if (predicate && !(await predicate(eventArg))) + return; + emitter.removeListener(event, listener); + resolve(eventArg); + } catch (e) { + emitter.removeListener(event, listener); + reject(e); + } + }); + }; + emitter.addListener(event, listener); + }); + const dispose = () => emitter.removeListener(event, listener); + return { promise, dispose }; +} + +function waitForTimeout(timeout: number): { promise: Promise, dispose: () => void } { + let timeoutId: any; + const promise = new Promise(resolve => timeoutId = setTimeout(resolve, timeout)); + const dispose = () => clearTimeout(timeoutId); + return { promise, dispose }; +} + +function formatLogRecording(log: string[]): string { + if (!log.length) + return ''; + const header = ` logs `; + const headerLength = 60; + const leftLength = (headerLength - header.length) / 2; + const rightLength = headerLength - header.length - leftLength; + return `\n${'='.repeat(leftLength)}${header}${'='.repeat(rightLength)}\n${log.join('\n')}\n${'='.repeat(headerLength)}`; +} diff --git a/daemon/src/sandbox/forked-client/src/client/webError.ts b/daemon/src/sandbox/forked-client/src/client/webError.ts new file mode 100644 index 00000000..fb4fe7d8 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/webError.ts @@ -0,0 +1,37 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Page } from './page'; +import type * as api from '../../types/types'; + +export class WebError implements api.WebError { + private _page: Page | null; + private _error: Error; + + constructor(page: Page | null, error: Error) { + this._page = page; + this._error = error; + } + + page() { + return this._page; + } + + error() { + return this._error; + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/worker.ts b/daemon/src/sandbox/forked-client/src/client/worker.ts new file mode 100644 index 00000000..a11322fc --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/worker.ts @@ -0,0 +1,92 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChannelOwner } from './channelOwner'; +import { TargetClosedError } from './errors'; +import { Events } from './events'; +import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from './jsHandle'; +import { LongStandingScope } from '../utils/isomorphic/manualPromise'; +import { TimeoutSettings } from './timeoutSettings'; +import { Waiter } from './waiter'; + +import type { BrowserContext } from './browserContext'; +import type { Page } from './page'; +import type * as structs from '../../types/structs'; +import type * as api from '../../types/types'; +import type * as channels from '../protocol/channels'; +import type { WaitForEventOptions } from './types'; + + +export class Worker extends ChannelOwner implements api.Worker { + _page: Page | undefined; // Set for web workers. + _context: BrowserContext | undefined; // Set for service workers. + readonly _closedScope = new LongStandingScope(); + + static fromNullable(worker: channels.WorkerChannel | undefined): Worker | null { + return worker ? Worker.from(worker) : null; + } + + static from(worker: channels.WorkerChannel): Worker { + return (worker as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.WorkerInitializer) { + super(parent, type, guid, initializer); + this._setEventToSubscriptionMapping(new Map([ + [Events.Worker.Console, 'console'], + ])); + this._channel.on('close', () => { + if (this._page) + this._page._workers.delete(this); + if (this._context) + this._context._serviceWorkers.delete(this); + this.emit(Events.Worker.Close, this); + }); + this.once(Events.Worker.Close, () => this._closedScope.close(this._page?._closeErrorWithReason() || new TargetClosedError())); + } + + url(): string { + return this._initializer.url; + } + + async evaluate(pageFunction: structs.PageFunction, arg?: Arg): Promise { + assertMaxArguments(arguments.length, 2); + const result = await this._channel.evaluateExpression({ expression: String(pageFunction), isFunction: typeof pageFunction === 'function', arg: serializeArgument(arg) }); + return parseResult(result.value); + } + + async evaluateHandle(pageFunction: structs.PageFunction, arg?: Arg): Promise> { + assertMaxArguments(arguments.length, 2); + const result = await this._channel.evaluateExpressionHandle({ expression: String(pageFunction), isFunction: typeof pageFunction === 'function', arg: serializeArgument(arg) }); + return JSHandle.from(result.handle) as any as structs.SmartHandle; + } + + async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise { + return await this._wrapApiCall(async () => { + const timeoutSettings = this._page?._timeoutSettings ?? this._context?._timeoutSettings ?? new TimeoutSettings(this._platform); + const timeout = timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); + const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; + const waiter = Waiter.createForEvent(this, event); + waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); + if (event !== Events.Worker.Close) + waiter.rejectOnEvent(this, Events.Worker.Close, () => new TargetClosedError()); + const result = await waiter.waitForEvent(this, event, predicate as any); + waiter.dispose(); + return result; + }); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/writableStream.ts b/daemon/src/sandbox/forked-client/src/client/writableStream.ts new file mode 100644 index 00000000..7ed33f18 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/writableStream.ts @@ -0,0 +1,12 @@ +// @ts-nocheck +import { ChannelOwner } from './channelOwner'; + +export class WritableStream extends ChannelOwner { + static from(channel) { + return channel?._object; + } + + stream() { + throw new Error('Writable streams are not available in the QuickJS sandbox'); + } +} diff --git a/daemon/src/sandbox/forked-client/src/protocol/channels.d.ts b/daemon/src/sandbox/forked-client/src/protocol/channels.d.ts new file mode 100644 index 00000000..edb26f81 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/protocol/channels.d.ts @@ -0,0 +1,175 @@ +// @ts-nocheck +// Generated for the QuickJS Playwright fork. +// The upstream generated channels.ts type file is not present in the scratch source tree. + +export type APIRequestContextChannel = any; +export type APIRequestContextInitializer = any; +export type APIResponse = any; +export type AndroidChannel = any; +export type AndroidDeviceChannel = any; +export type AndroidDeviceInitializer = any; +export type AndroidInitializer = any; +export type AndroidSelector = any; +export type AndroidSocketChannel = any; +export type AndroidSocketInitializer = any; +export type AndroidWebView = any; +export type ArtifactChannel = any; +export type BindingCallChannel = any; +export type BindingCallInitializer = any; +export type BrowserChannel = any; +export type BrowserContextChannel = any; +export type BrowserContextConsoleEvent = any; +export type BrowserContextEnableRecorderParams = any; +export type BrowserContextInitializer = any; +export type BrowserContextRequestFinishedEvent = any; +export type BrowserContextSetNetworkInterceptionPatternsParams = any; +export type BrowserContextSetWebSocketInterceptionPatternsParams = any; +export type BrowserContextUpdateSubscriptionParams = any; +export type BrowserInitializer = any; +export type BrowserNewContextOptions = any; +export type BrowserNewContextParams = any; +export type BrowserTypeChannel = any; +export type BrowserTypeLaunchOptions = any; +export type BrowserTypeLaunchParams = any; +export type CDPSessionChannel = any; +export type CDPSessionInitializer = any; +export type Channel = any; +export type DebuggerChannel = any; +export type DebuggerInitializer = any; +export type DialogChannel = any; +export type DialogInitializer = any; +export type DisposableChannel = any; +export type ElectronApplicationChannel = any; +export type ElectronApplicationConsoleEvent = any; +export type ElectronApplicationInitializer = any; +export type ElectronApplicationUpdateSubscriptionParams = any; +export type ElectronChannel = any; +export type ElectronInitializer = any; +export type ElectronLaunchOptions = any; +export type ElectronLaunchParams = any; +export type ElementHandleChannel = any; +export type ElementHandleCheckOptions = any; +export type ElementHandleClickOptions = any; +export type ElementHandleDblclickOptions = any; +export type ElementHandleFillOptions = any; +export type ElementHandleHoverOptions = any; +export type ElementHandlePressOptions = any; +export type ElementHandleScreenshotOptions = any; +export type ElementHandleScreenshotParams = any; +export type ElementHandleScrollIntoViewIfNeededOptions = any; +export type ElementHandleSelectTextOptions = any; +export type ElementHandleSetInputFilesOptions = any; +export type ElementHandleSetInputFilesParams = any; +export type ElementHandleTapOptions = any; +export type ElementHandleTypeOptions = any; +export type ElementHandleUncheckOptions = any; +export type ElementHandleWaitForSelectorOptions = any; +export type EventTargetChannel = any; +export type FormField = any; +export type FrameChannel = any; +export type FrameCheckOptions = any; +export type FrameClickOptions = any; +export type FrameDblclickOptions = any; +export type FrameDispatchEventOptions = any; +export type FrameDragAndDropOptions = any; +export type FrameExpectParams = any; +export type FrameFillOptions = any; +export type FrameFocusOptions = any; +export type FrameGetAttributeOptions = any; +export type FrameGotoOptions = any; +export type FrameHoverOptions = any; +export type FrameInitializer = any; +export type FrameInnerHTMLOptions = any; +export type FrameInnerTextOptions = any; +export type FrameInputValueOptions = any; +export type FrameIsCheckedOptions = any; +export type FrameIsDisabledOptions = any; +export type FrameIsEditableOptions = any; +export type FrameIsEnabledOptions = any; +export type FrameIsHiddenOptions = any; +export type FrameIsVisibleOptions = any; +export type FrameNavigatedEvent = any; +export type FramePressOptions = any; +export type FrameSetContentOptions = any; +export type FrameSetInputFilesOptions = any; +export type FrameTapOptions = any; +export type FrameTextContentOptions = any; +export type FrameTypeOptions = any; +export type FrameUncheckOptions = any; +export type FrameWaitForSelectorOptions = any; +export type InitializerTraits = any; +export type JSHandleChannel = any; +export type JSHandleInitializer = any; +export type JsonPipeChannel = any; +export type JsonPipeInitializer = any; +export type LifecycleEvent = any; +export type LocalUtilsAddStackToTracingNoReplyParams = any; +export type LocalUtilsChannel = any; +export type LocalUtilsConnectParams = any; +export type LocalUtilsHarCloseParams = any; +export type LocalUtilsHarLookupParams = any; +export type LocalUtilsHarLookupResult = any; +export type LocalUtilsHarOpenParams = any; +export type LocalUtilsHarOpenResult = any; +export type LocalUtilsHarUnzipParams = any; +export type LocalUtilsInitializer = any; +export type LocalUtilsTraceDiscardedParams = any; +export type LocalUtilsTracingStartedParams = any; +export type LocalUtilsTracingStartedResult = any; +export type LocalUtilsZipParams = any; +export type Metadata = any; +export type NameValue = any; +export type NetworkCookie = any; +export type OriginStorage = any; +export type PageChannel = any; +export type PageExpectScreenshotOptions = any; +export type PageGoBackOptions = any; +export type PageGoForwardOptions = any; +export type PageInitializer = any; +export type PageKeyboardPressOptions = any; +export type PageKeyboardTypeOptions = any; +export type PageMouseClickOptions = any; +export type PageMouseDownOptions = any; +export type PageMouseUpOptions = any; +export type PagePdfParams = any; +export type PageReloadOptions = any; +export type PageScreenshotOptions = any; +export type PageScreenshotParams = any; +export type PageStartCSSCoverageOptions = any; +export type PageStartJSCoverageOptions = any; +export type PageStopCSSCoverageResult = any; +export type PageStopJSCoverageResult = any; +export type PageUpdateSubscriptionParams = any; +export type PlaywrightChannel = any; +export type PlaywrightInitializer = any; +export type PlaywrightNewRequestOptions = any; +export type PlaywrightNewRequestParams = any; +export type RemoteAddr = any; +export type RequestChannel = any; +export type RequestInitializer = any; +export type ResponseChannel = any; +export type ResponseInitializer = any; +export type RootChannel = any; +export type RouteChannel = any; +export type RouteInitializer = any; +export type SecurityDetails = any; +export type SelectorEngine = any; +export type SerializedArgument = any; +export type SerializedValue = any; +export type SetNetworkCookie = any; +export type SetOriginStorage = any; +export type StackFrame = any; +export type StreamChannel = any; +export type StreamInitializer = any; +export type TracingChannel = any; +export type TracingInitializer = any; +export type WebSocketChannel = any; +export type WebSocketInitializer = any; +export type WebSocketRouteChannel = any; +export type WebSocketRouteInitializer = any; +export type WorkerChannel = any; +export type WorkerInitializer = any; +export type WorkerUpdateSubscriptionParams = any; +export type WritableStreamChannel = any; +export type WritableStreamInitializer = any; +export type js = any; diff --git a/daemon/src/sandbox/forked-client/src/protocol/serializers.ts b/daemon/src/sandbox/forked-client/src/protocol/serializers.ts new file mode 100644 index 00000000..d5e97e9b --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/protocol/serializers.ts @@ -0,0 +1,210 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { SerializedValue } from './channels'; + +export function parseSerializedValue(value: SerializedValue, handles: any[] | undefined): any { + return innerParseSerializedValue(value, handles, new Map(), []); +} + +function innerParseSerializedValue(value: SerializedValue, handles: any[] | undefined, refs: Map, accessChain: Array): any { + if (value.ref !== undefined) + return refs.get(value.ref); + if (value.n !== undefined) + return value.n; + if (value.s !== undefined) + return value.s; + if (value.b !== undefined) + return value.b; + if (value.v !== undefined) { + if (value.v === 'undefined') + return undefined; + if (value.v === 'null') + return null; + if (value.v === 'NaN') + return NaN; + if (value.v === 'Infinity') + return Infinity; + if (value.v === '-Infinity') + return -Infinity; + if (value.v === '-0') + return -0; + } + if (value.d !== undefined) + return new Date(value.d); + if (value.u !== undefined) + return new URL(value.u); + if (value.bi !== undefined) + return BigInt(value.bi); + if (value.e !== undefined) { + const error = new Error(value.e.m); + error.name = value.e.n; + error.stack = value.e.s; + return error; + } + if (value.r !== undefined) + return new RegExp(value.r.p, value.r.f); + if (value.ta !== undefined) { + const ctor = typedArrayKindToConstructor[value.ta.k] as any; + return new ctor(value.ta.b.buffer, value.ta.b.byteOffset, value.ta.b.length / ctor.BYTES_PER_ELEMENT); + } + + if (value.a !== undefined) { + const result: any[] = []; + refs.set(value.id!, result); + for (let i = 0; i < value.a.length; i++) + result.push(innerParseSerializedValue(value.a[i], handles, refs, [...accessChain, i])); + return result; + } + if (value.o !== undefined) { + const result: any = {}; + refs.set(value.id!, result); + for (const { k, v } of value.o) + result[k] = innerParseSerializedValue(v, handles, refs, [...accessChain, k]); + return result; + } + if (value.h !== undefined) { + if (handles === undefined) + throw new Error('Unexpected handle'); + return handles[value.h]; + } + throw new Error(`Attempting to deserialize unexpected value${accessChainToDisplayString(accessChain)}: ${value}`); +} + +export type HandleOrValue = { h: number } | { fallThrough: any }; +type VisitorInfo = { + visited: Map; + lastId: number; +}; + +export function serializeValue(value: any, handleSerializer: (value: any) => HandleOrValue): SerializedValue { + return innerSerializeValue(value, handleSerializer, { lastId: 0, visited: new Map() }, []); +} + +export function serializePlainValue(arg: any): SerializedValue { + return serializeValue(arg, value => ({ fallThrough: value })); +} + +function innerSerializeValue(value: any, handleSerializer: (value: any) => HandleOrValue, visitorInfo: VisitorInfo, accessChain: Array): SerializedValue { + const handle = handleSerializer(value); + if ('fallThrough' in handle) + value = handle.fallThrough; + else + return handle; + + if (typeof value === 'symbol') + return { v: 'undefined' }; + if (Object.is(value, undefined)) + return { v: 'undefined' }; + if (Object.is(value, null)) + return { v: 'null' }; + if (Object.is(value, NaN)) + return { v: 'NaN' }; + if (Object.is(value, Infinity)) + return { v: 'Infinity' }; + if (Object.is(value, -Infinity)) + return { v: '-Infinity' }; + if (Object.is(value, -0)) + return { v: '-0' }; + if (typeof value === 'boolean') + return { b: value }; + if (typeof value === 'number') + return { n: value }; + if (typeof value === 'string') + return { s: value }; + if (typeof value === 'bigint') + return { bi: value.toString() }; + if (isError(value)) + return { e: { n: value.name, m: value.message, s: value.stack || '' } }; + if (isDate(value)) + return { d: value.toJSON() }; + if (isURL(value)) + return { u: value.toJSON() }; + if (isRegExp(value)) + return { r: { p: value.source, f: value.flags } }; + + const typedArrayKind = constructorToTypedArrayKind.get(value.constructor); + if (typedArrayKind) + return { ta: { b: Buffer.from(value.buffer, value.byteOffset, value.byteLength), k: typedArrayKind } }; + + const id = visitorInfo.visited.get(value); + if (id) + return { ref: id }; + + if (Array.isArray(value)) { + const a = []; + const id = ++visitorInfo.lastId; + visitorInfo.visited.set(value, id); + for (let i = 0; i < value.length; ++i) + a.push(innerSerializeValue(value[i], handleSerializer, visitorInfo, [...accessChain, i])); + return { a, id }; + } + if (typeof value === 'object') { + const o: { k: string, v: SerializedValue }[] = []; + const id = ++visitorInfo.lastId; + visitorInfo.visited.set(value, id); + for (const name of Object.keys(value)) + o.push({ k: name, v: innerSerializeValue(value[name], handleSerializer, visitorInfo, [...accessChain, name]) }); + return { o, id }; + } + // Likely only functions can reach here. + throw new Error(`Attempting to serialize unexpected value${accessChainToDisplayString(accessChain)}: ${value}`); +} + +function accessChainToDisplayString(accessChain: Array): string { + const chainString = accessChain.map((accessor, i) => { + if (typeof accessor === 'string') + return i ? `.${accessor}` : accessor; + return `[${accessor}]`; + }).join(''); + + return chainString.length > 0 ? ` at position "${chainString}"` : ''; +} + +function isRegExp(obj: any): obj is RegExp { + return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]'; +} + +function isDate(obj: any): obj is Date { + return obj instanceof Date || Object.prototype.toString.call(obj) === '[object Date]'; +} + +function isURL(obj: any): obj is URL { + return obj instanceof URL || Object.prototype.toString.call(obj) === '[object URL]'; +} + +function isError(obj: any): obj is Error { + const proto = obj ? Object.getPrototypeOf(obj) : null; + return obj instanceof Error || proto?.name === 'Error' || (proto && isError(proto)); +} + + +type TypedArrayKind = NonNullable['k']; +const typedArrayKindToConstructor: Record = { + i8: Int8Array, + ui8: Uint8Array, + ui8c: Uint8ClampedArray, + i16: Int16Array, + ui16: Uint16Array, + i32: Int32Array, + ui32: Uint32Array, + f32: Float32Array, + f64: Float64Array, + bi64: BigInt64Array, + bui64: BigUint64Array, +}; +const constructorToTypedArrayKind: Map = new Map(Object.entries(typedArrayKindToConstructor).map(([k, v]) => [v, k as TypedArrayKind])); diff --git a/daemon/src/sandbox/forked-client/src/protocol/validator.ts b/daemon/src/sandbox/forked-client/src/protocol/validator.ts new file mode 100644 index 00000000..c23a3ab0 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/protocol/validator.ts @@ -0,0 +1,3005 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This file is generated by generate_channels.js, do not edit manually. + +import { scheme, tOptional, tObject, tBoolean, tInt, tFloat, tString, tAny, tEnum, tArray, tBinary, tChannel, tType } from './validatorPrimitives'; +export type { Validator, ValidatorContext } from './validatorPrimitives'; +export { ValidationError, findValidator, maybeFindValidator, createMetadataValidator } from './validatorPrimitives'; + +scheme.StackFrame = tObject({ + file: tString, + line: tInt, + column: tInt, + function: tOptional(tString), +}); +scheme.Metadata = tObject({ + location: tOptional(tObject({ + file: tString, + line: tOptional(tInt), + column: tOptional(tInt), + })), + title: tOptional(tString), + internal: tOptional(tBoolean), + stepId: tOptional(tString), +}); +scheme.ClientSideCallMetadata = tObject({ + id: tInt, + stack: tOptional(tArray(tType('StackFrame'))), +}); +scheme.Point = tObject({ + x: tFloat, + y: tFloat, +}); +scheme.Rect = tObject({ + x: tFloat, + y: tFloat, + width: tFloat, + height: tFloat, +}); +scheme.SerializedValue = tObject({ + n: tOptional(tFloat), + b: tOptional(tBoolean), + s: tOptional(tString), + v: tOptional(tEnum(['null', 'undefined', 'NaN', 'Infinity', '-Infinity', '-0'])), + d: tOptional(tString), + u: tOptional(tString), + bi: tOptional(tString), + ta: tOptional(tObject({ + b: tBinary, + k: tEnum(['i8', 'ui8', 'ui8c', 'i16', 'ui16', 'i32', 'ui32', 'f32', 'f64', 'bi64', 'bui64']), + })), + e: tOptional(tObject({ + m: tString, + n: tString, + s: tString, + })), + r: tOptional(tObject({ + p: tString, + f: tString, + })), + a: tOptional(tArray(tType('SerializedValue'))), + o: tOptional(tArray(tObject({ + k: tString, + v: tType('SerializedValue'), + }))), + h: tOptional(tInt), + id: tOptional(tInt), + ref: tOptional(tInt), +}); +scheme.SerializedArgument = tObject({ + value: tType('SerializedValue'), + handles: tArray(tChannel('*')), +}); +scheme.ExpectedTextValue = tObject({ + string: tOptional(tString), + regexSource: tOptional(tString), + regexFlags: tOptional(tString), + matchSubstring: tOptional(tBoolean), + ignoreCase: tOptional(tBoolean), + normalizeWhiteSpace: tOptional(tBoolean), +}); +scheme.SelectorEngine = tObject({ + name: tString, + source: tString, + contentScript: tOptional(tBoolean), +}); +scheme.URLPattern = tObject({ + hash: tString, + hostname: tString, + password: tString, + pathname: tString, + port: tString, + protocol: tString, + search: tString, + username: tString, +}); +scheme.SetNetworkCookie = tObject({ + name: tString, + value: tString, + url: tOptional(tString), + domain: tOptional(tString), + path: tOptional(tString), + expires: tOptional(tFloat), + httpOnly: tOptional(tBoolean), + secure: tOptional(tBoolean), + sameSite: tOptional(tEnum(['Strict', 'Lax', 'None'])), + partitionKey: tOptional(tString), + _crHasCrossSiteAncestor: tOptional(tBoolean), +}); +scheme.NetworkCookie = tObject({ + name: tString, + value: tString, + domain: tString, + path: tString, + expires: tFloat, + httpOnly: tBoolean, + secure: tBoolean, + sameSite: tEnum(['Strict', 'Lax', 'None']), + partitionKey: tOptional(tString), + _crHasCrossSiteAncestor: tOptional(tBoolean), +}); +scheme.NameValue = tObject({ + name: tString, + value: tString, +}); +scheme.IndexedDBDatabase = tObject({ + name: tString, + version: tInt, + stores: tArray(tObject({ + name: tString, + autoIncrement: tBoolean, + keyPath: tOptional(tString), + keyPathArray: tOptional(tArray(tString)), + records: tArray(tObject({ + key: tOptional(tAny), + keyEncoded: tOptional(tAny), + value: tOptional(tAny), + valueEncoded: tOptional(tAny), + })), + indexes: tArray(tObject({ + name: tString, + keyPath: tOptional(tString), + keyPathArray: tOptional(tArray(tString)), + multiEntry: tBoolean, + unique: tBoolean, + })), + })), +}); +scheme.SetOriginStorage = tObject({ + origin: tString, + localStorage: tArray(tType('NameValue')), + indexedDB: tOptional(tArray(tType('IndexedDBDatabase'))), +}); +scheme.OriginStorage = tObject({ + origin: tString, + localStorage: tArray(tType('NameValue')), + indexedDB: tOptional(tArray(tType('IndexedDBDatabase'))), +}); +scheme.SerializedError = tObject({ + error: tOptional(tObject({ + message: tString, + name: tString, + stack: tOptional(tString), + })), + value: tOptional(tType('SerializedValue')), +}); +scheme.RecordHarOptions = tObject({ + zip: tOptional(tBoolean), + content: tOptional(tEnum(['embed', 'attach', 'omit'])), + mode: tOptional(tEnum(['full', 'minimal'])), + urlGlob: tOptional(tString), + urlRegexSource: tOptional(tString), + urlRegexFlags: tOptional(tString), +}); +scheme.FormField = tObject({ + name: tString, + value: tOptional(tString), + file: tOptional(tObject({ + name: tString, + mimeType: tOptional(tString), + buffer: tBinary, + })), +}); +scheme.SDKLanguage = tEnum(['javascript', 'python', 'java', 'csharp']); +scheme.APIRequestContextInitializer = tObject({ + tracing: tChannel(['Tracing']), +}); +scheme.APIRequestContextFetchParams = tObject({ + url: tString, + encodedParams: tOptional(tString), + params: tOptional(tArray(tType('NameValue'))), + method: tOptional(tString), + headers: tOptional(tArray(tType('NameValue'))), + postData: tOptional(tBinary), + jsonData: tOptional(tString), + formData: tOptional(tArray(tType('NameValue'))), + multipartData: tOptional(tArray(tType('FormField'))), + timeout: tFloat, + failOnStatusCode: tOptional(tBoolean), + ignoreHTTPSErrors: tOptional(tBoolean), + maxRedirects: tOptional(tInt), + maxRetries: tOptional(tInt), +}); +scheme.APIRequestContextFetchResult = tObject({ + response: tType('APIResponse'), +}); +scheme.APIRequestContextFetchResponseBodyParams = tObject({ + fetchUid: tString, +}); +scheme.APIRequestContextFetchResponseBodyResult = tObject({ + binary: tOptional(tBinary), +}); +scheme.APIRequestContextFetchLogParams = tObject({ + fetchUid: tString, +}); +scheme.APIRequestContextFetchLogResult = tObject({ + log: tArray(tString), +}); +scheme.APIRequestContextStorageStateParams = tObject({ + indexedDB: tOptional(tBoolean), +}); +scheme.APIRequestContextStorageStateResult = tObject({ + cookies: tArray(tType('NetworkCookie')), + origins: tArray(tType('OriginStorage')), +}); +scheme.APIRequestContextDisposeAPIResponseParams = tObject({ + fetchUid: tString, +}); +scheme.APIRequestContextDisposeAPIResponseResult = tOptional(tObject({})); +scheme.APIRequestContextDisposeParams = tObject({ + reason: tOptional(tString), +}); +scheme.APIRequestContextDisposeResult = tOptional(tObject({})); +scheme.APIResponse = tObject({ + fetchUid: tString, + url: tString, + status: tInt, + statusText: tString, + headers: tArray(tType('NameValue')), +}); +scheme.LifecycleEvent = tEnum(['load', 'domcontentloaded', 'networkidle', 'commit']); +scheme.ConsoleMessagesFilter = tEnum(['all', 'sinceNavigation']); +scheme.LocalUtilsInitializer = tObject({ + deviceDescriptors: tArray(tObject({ + name: tString, + descriptor: tObject({ + userAgent: tString, + viewport: tObject({ + width: tInt, + height: tInt, + }), + screen: tOptional(tObject({ + width: tInt, + height: tInt, + })), + deviceScaleFactor: tFloat, + isMobile: tBoolean, + hasTouch: tBoolean, + defaultBrowserType: tEnum(['chromium', 'firefox', 'webkit']), + }), + })), +}); +scheme.LocalUtilsZipParams = tObject({ + zipFile: tString, + entries: tArray(tType('NameValue')), + stacksId: tOptional(tString), + mode: tEnum(['write', 'append']), + includeSources: tBoolean, + additionalSources: tOptional(tArray(tString)), +}); +scheme.LocalUtilsZipResult = tOptional(tObject({})); +scheme.LocalUtilsHarOpenParams = tObject({ + file: tString, +}); +scheme.LocalUtilsHarOpenResult = tObject({ + harId: tOptional(tString), + error: tOptional(tString), +}); +scheme.LocalUtilsHarLookupParams = tObject({ + harId: tString, + url: tString, + method: tString, + headers: tArray(tType('NameValue')), + postData: tOptional(tBinary), + isNavigationRequest: tBoolean, +}); +scheme.LocalUtilsHarLookupResult = tObject({ + action: tEnum(['error', 'redirect', 'fulfill', 'noentry']), + message: tOptional(tString), + redirectURL: tOptional(tString), + status: tOptional(tInt), + headers: tOptional(tArray(tType('NameValue'))), + body: tOptional(tBinary), +}); +scheme.LocalUtilsHarCloseParams = tObject({ + harId: tString, +}); +scheme.LocalUtilsHarCloseResult = tOptional(tObject({})); +scheme.LocalUtilsHarUnzipParams = tObject({ + zipFile: tString, + harFile: tString, +}); +scheme.LocalUtilsHarUnzipResult = tOptional(tObject({})); +scheme.LocalUtilsConnectParams = tObject({ + endpoint: tString, + headers: tOptional(tAny), + exposeNetwork: tOptional(tString), + slowMo: tOptional(tFloat), + timeout: tFloat, + socksProxyRedirectPortForTest: tOptional(tInt), +}); +scheme.LocalUtilsConnectResult = tObject({ + pipe: tChannel(['JsonPipe']), + headers: tArray(tType('NameValue')), +}); +scheme.LocalUtilsTracingStartedParams = tObject({ + tracesDir: tOptional(tString), + traceName: tString, + live: tOptional(tBoolean), +}); +scheme.LocalUtilsTracingStartedResult = tObject({ + stacksId: tString, +}); +scheme.LocalUtilsAddStackToTracingNoReplyParams = tObject({ + callData: tType('ClientSideCallMetadata'), +}); +scheme.LocalUtilsAddStackToTracingNoReplyResult = tOptional(tObject({})); +scheme.LocalUtilsTraceDiscardedParams = tObject({ + stacksId: tString, +}); +scheme.LocalUtilsTraceDiscardedResult = tOptional(tObject({})); +scheme.LocalUtilsGlobToRegexParams = tObject({ + glob: tString, + baseURL: tOptional(tString), + webSocketUrl: tOptional(tBoolean), +}); +scheme.LocalUtilsGlobToRegexResult = tObject({ + regex: tString, +}); +scheme.RootInitializer = tOptional(tObject({})); +scheme.RootInitializeParams = tObject({ + sdkLanguage: tType('SDKLanguage'), +}); +scheme.RootInitializeResult = tObject({ + playwright: tChannel(['Playwright']), +}); +scheme.PlaywrightInitializer = tObject({ + chromium: tChannel(['BrowserType']), + firefox: tChannel(['BrowserType']), + webkit: tChannel(['BrowserType']), + android: tChannel(['Android']), + electron: tChannel(['Electron']), + utils: tOptional(tChannel(['LocalUtils'])), + preLaunchedBrowser: tOptional(tChannel(['Browser'])), + preConnectedAndroidDevice: tOptional(tChannel(['AndroidDevice'])), + socksSupport: tOptional(tChannel(['SocksSupport'])), +}); +scheme.PlaywrightNewRequestParams = tObject({ + baseURL: tOptional(tString), + userAgent: tOptional(tString), + ignoreHTTPSErrors: tOptional(tBoolean), + extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), + failOnStatusCode: tOptional(tBoolean), + clientCertificates: tOptional(tArray(tObject({ + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), + }))), + maxRedirects: tOptional(tInt), + httpCredentials: tOptional(tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + send: tOptional(tEnum(['always', 'unauthorized'])), + })), + proxy: tOptional(tObject({ + server: tString, + bypass: tOptional(tString), + username: tOptional(tString), + password: tOptional(tString), + })), + storageState: tOptional(tObject({ + cookies: tOptional(tArray(tType('NetworkCookie'))), + origins: tOptional(tArray(tType('SetOriginStorage'))), + })), + tracesDir: tOptional(tString), +}); +scheme.PlaywrightNewRequestResult = tObject({ + request: tChannel(['APIRequestContext']), +}); +scheme.RecorderSource = tObject({ + isRecorded: tBoolean, + id: tString, + label: tString, + text: tString, + language: tString, + highlight: tArray(tObject({ + line: tInt, + type: tString, + })), + revealLine: tOptional(tInt), + group: tOptional(tString), +}); +scheme.DebugControllerInitializer = tOptional(tObject({})); +scheme.DebugControllerInspectRequestedEvent = tObject({ + selector: tString, + locator: tString, + ariaSnapshot: tString, +}); +scheme.DebugControllerSetModeRequestedEvent = tObject({ + mode: tString, +}); +scheme.DebugControllerStateChangedEvent = tObject({ + pageCount: tInt, +}); +scheme.DebugControllerSourceChangedEvent = tObject({ + text: tString, + header: tOptional(tString), + footer: tOptional(tString), + actions: tOptional(tArray(tString)), +}); +scheme.DebugControllerPausedEvent = tObject({ + paused: tBoolean, +}); +scheme.DebugControllerInitializeParams = tObject({ + codegenId: tString, + sdkLanguage: tType('SDKLanguage'), +}); +scheme.DebugControllerInitializeResult = tOptional(tObject({})); +scheme.DebugControllerSetReportStateChangedParams = tObject({ + enabled: tBoolean, +}); +scheme.DebugControllerSetReportStateChangedResult = tOptional(tObject({})); +scheme.DebugControllerSetRecorderModeParams = tObject({ + mode: tEnum(['inspecting', 'recording', 'none']), + testIdAttributeName: tOptional(tString), + generateAutoExpect: tOptional(tBoolean), +}); +scheme.DebugControllerSetRecorderModeResult = tOptional(tObject({})); +scheme.DebugControllerHighlightParams = tObject({ + selector: tOptional(tString), + ariaTemplate: tOptional(tString), +}); +scheme.DebugControllerHighlightResult = tOptional(tObject({})); +scheme.DebugControllerHideHighlightParams = tOptional(tObject({})); +scheme.DebugControllerHideHighlightResult = tOptional(tObject({})); +scheme.DebugControllerResumeParams = tOptional(tObject({})); +scheme.DebugControllerResumeResult = tOptional(tObject({})); +scheme.DebugControllerKillParams = tOptional(tObject({})); +scheme.DebugControllerKillResult = tOptional(tObject({})); +scheme.SocksSupportInitializer = tOptional(tObject({})); +scheme.SocksSupportSocksRequestedEvent = tObject({ + uid: tString, + host: tString, + port: tInt, +}); +scheme.SocksSupportSocksDataEvent = tObject({ + uid: tString, + data: tBinary, +}); +scheme.SocksSupportSocksClosedEvent = tObject({ + uid: tString, +}); +scheme.SocksSupportSocksConnectedParams = tObject({ + uid: tString, + host: tString, + port: tInt, +}); +scheme.SocksSupportSocksConnectedResult = tOptional(tObject({})); +scheme.SocksSupportSocksFailedParams = tObject({ + uid: tString, + errorCode: tString, +}); +scheme.SocksSupportSocksFailedResult = tOptional(tObject({})); +scheme.SocksSupportSocksDataParams = tObject({ + uid: tString, + data: tBinary, +}); +scheme.SocksSupportSocksDataResult = tOptional(tObject({})); +scheme.SocksSupportSocksErrorParams = tObject({ + uid: tString, + error: tString, +}); +scheme.SocksSupportSocksErrorResult = tOptional(tObject({})); +scheme.SocksSupportSocksEndParams = tObject({ + uid: tString, +}); +scheme.SocksSupportSocksEndResult = tOptional(tObject({})); +scheme.BrowserTypeInitializer = tObject({ + executablePath: tString, + name: tString, +}); +scheme.BrowserTypeLaunchParams = tObject({ + channel: tOptional(tString), + executablePath: tOptional(tString), + args: tOptional(tArray(tString)), + ignoreAllDefaultArgs: tOptional(tBoolean), + ignoreDefaultArgs: tOptional(tArray(tString)), + assistantMode: tOptional(tBoolean), + handleSIGINT: tOptional(tBoolean), + handleSIGTERM: tOptional(tBoolean), + handleSIGHUP: tOptional(tBoolean), + timeout: tFloat, + env: tOptional(tArray(tType('NameValue'))), + headless: tOptional(tBoolean), + proxy: tOptional(tObject({ + server: tString, + bypass: tOptional(tString), + username: tOptional(tString), + password: tOptional(tString), + })), + downloadsPath: tOptional(tString), + tracesDir: tOptional(tString), + artifactsDir: tOptional(tString), + chromiumSandbox: tOptional(tBoolean), + firefoxUserPrefs: tOptional(tAny), + cdpPort: tOptional(tInt), + slowMo: tOptional(tFloat), +}); +scheme.BrowserTypeLaunchResult = tObject({ + browser: tChannel(['Browser']), +}); +scheme.BrowserTypeLaunchPersistentContextParams = tObject({ + channel: tOptional(tString), + executablePath: tOptional(tString), + args: tOptional(tArray(tString)), + ignoreAllDefaultArgs: tOptional(tBoolean), + ignoreDefaultArgs: tOptional(tArray(tString)), + assistantMode: tOptional(tBoolean), + handleSIGINT: tOptional(tBoolean), + handleSIGTERM: tOptional(tBoolean), + handleSIGHUP: tOptional(tBoolean), + timeout: tFloat, + env: tOptional(tArray(tType('NameValue'))), + headless: tOptional(tBoolean), + proxy: tOptional(tObject({ + server: tString, + bypass: tOptional(tString), + username: tOptional(tString), + password: tOptional(tString), + })), + downloadsPath: tOptional(tString), + tracesDir: tOptional(tString), + artifactsDir: tOptional(tString), + chromiumSandbox: tOptional(tBoolean), + firefoxUserPrefs: tOptional(tAny), + cdpPort: tOptional(tInt), + noDefaultViewport: tOptional(tBoolean), + viewport: tOptional(tObject({ + width: tInt, + height: tInt, + })), + screen: tOptional(tObject({ + width: tInt, + height: tInt, + })), + ignoreHTTPSErrors: tOptional(tBoolean), + clientCertificates: tOptional(tArray(tObject({ + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), + }))), + javaScriptEnabled: tOptional(tBoolean), + bypassCSP: tOptional(tBoolean), + userAgent: tOptional(tString), + locale: tOptional(tString), + timezoneId: tOptional(tString), + geolocation: tOptional(tObject({ + longitude: tFloat, + latitude: tFloat, + accuracy: tOptional(tFloat), + })), + permissions: tOptional(tArray(tString)), + extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), + offline: tOptional(tBoolean), + httpCredentials: tOptional(tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + send: tOptional(tEnum(['always', 'unauthorized'])), + })), + deviceScaleFactor: tOptional(tFloat), + isMobile: tOptional(tBoolean), + hasTouch: tOptional(tBoolean), + colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference', 'no-override'])), + reducedMotion: tOptional(tEnum(['reduce', 'no-preference', 'no-override'])), + forcedColors: tOptional(tEnum(['active', 'none', 'no-override'])), + acceptDownloads: tOptional(tEnum(['accept', 'deny', 'internal-browser-default'])), + contrast: tOptional(tEnum(['no-preference', 'more', 'no-override'])), + baseURL: tOptional(tString), + recordVideo: tOptional(tObject({ + dir: tString, + size: tOptional(tObject({ + width: tInt, + height: tInt, + })), + })), + strictSelectors: tOptional(tBoolean), + serviceWorkers: tOptional(tEnum(['allow', 'block'])), + selectorEngines: tOptional(tArray(tType('SelectorEngine'))), + testIdAttributeName: tOptional(tString), + userDataDir: tString, + slowMo: tOptional(tFloat), +}); +scheme.BrowserTypeLaunchPersistentContextResult = tObject({ + browser: tChannel(['Browser']), + context: tChannel(['BrowserContext']), +}); +scheme.BrowserTypeConnectOverCDPParams = tObject({ + endpointURL: tString, + headers: tOptional(tArray(tType('NameValue'))), + slowMo: tOptional(tFloat), + timeout: tFloat, + isLocal: tOptional(tBoolean), +}); +scheme.BrowserTypeConnectOverCDPResult = tObject({ + browser: tChannel(['Browser']), + defaultContext: tOptional(tChannel(['BrowserContext'])), +}); +scheme.BrowserTypeConnectOverCDPTransportParams = tObject({ + transport: tBinary, +}); +scheme.BrowserTypeConnectOverCDPTransportResult = tObject({ + browser: tChannel(['Browser']), + defaultContext: tOptional(tChannel(['BrowserContext'])), +}); +scheme.BrowserInitializer = tObject({ + version: tString, + name: tString, +}); +scheme.BrowserContextEvent = tObject({ + context: tChannel(['BrowserContext']), +}); +scheme.BrowserCloseEvent = tOptional(tObject({})); +scheme.BrowserStartServerParams = tObject({ + title: tString, + workspaceDir: tOptional(tString), + metadata: tOptional(tAny), +}); +scheme.BrowserStartServerResult = tObject({ + pipeName: tString, +}); +scheme.BrowserStopServerParams = tOptional(tObject({})); +scheme.BrowserStopServerResult = tOptional(tObject({})); +scheme.BrowserCloseParams = tObject({ + reason: tOptional(tString), +}); +scheme.BrowserCloseResult = tOptional(tObject({})); +scheme.BrowserKillForTestsParams = tOptional(tObject({})); +scheme.BrowserKillForTestsResult = tOptional(tObject({})); +scheme.BrowserDefaultUserAgentForTestParams = tOptional(tObject({})); +scheme.BrowserDefaultUserAgentForTestResult = tObject({ + userAgent: tString, +}); +scheme.BrowserNewContextParams = tObject({ + noDefaultViewport: tOptional(tBoolean), + viewport: tOptional(tObject({ + width: tInt, + height: tInt, + })), + screen: tOptional(tObject({ + width: tInt, + height: tInt, + })), + ignoreHTTPSErrors: tOptional(tBoolean), + clientCertificates: tOptional(tArray(tObject({ + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), + }))), + javaScriptEnabled: tOptional(tBoolean), + bypassCSP: tOptional(tBoolean), + userAgent: tOptional(tString), + locale: tOptional(tString), + timezoneId: tOptional(tString), + geolocation: tOptional(tObject({ + longitude: tFloat, + latitude: tFloat, + accuracy: tOptional(tFloat), + })), + permissions: tOptional(tArray(tString)), + extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), + offline: tOptional(tBoolean), + httpCredentials: tOptional(tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + send: tOptional(tEnum(['always', 'unauthorized'])), + })), + deviceScaleFactor: tOptional(tFloat), + isMobile: tOptional(tBoolean), + hasTouch: tOptional(tBoolean), + colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference', 'no-override'])), + reducedMotion: tOptional(tEnum(['reduce', 'no-preference', 'no-override'])), + forcedColors: tOptional(tEnum(['active', 'none', 'no-override'])), + acceptDownloads: tOptional(tEnum(['accept', 'deny', 'internal-browser-default'])), + contrast: tOptional(tEnum(['no-preference', 'more', 'no-override'])), + baseURL: tOptional(tString), + recordVideo: tOptional(tObject({ + dir: tString, + size: tOptional(tObject({ + width: tInt, + height: tInt, + })), + })), + strictSelectors: tOptional(tBoolean), + serviceWorkers: tOptional(tEnum(['allow', 'block'])), + selectorEngines: tOptional(tArray(tType('SelectorEngine'))), + testIdAttributeName: tOptional(tString), + proxy: tOptional(tObject({ + server: tString, + bypass: tOptional(tString), + username: tOptional(tString), + password: tOptional(tString), + })), + storageState: tOptional(tObject({ + cookies: tOptional(tArray(tType('SetNetworkCookie'))), + origins: tOptional(tArray(tType('SetOriginStorage'))), + })), +}); +scheme.BrowserNewContextResult = tObject({ + context: tChannel(['BrowserContext']), +}); +scheme.BrowserNewContextForReuseParams = tObject({ + noDefaultViewport: tOptional(tBoolean), + viewport: tOptional(tObject({ + width: tInt, + height: tInt, + })), + screen: tOptional(tObject({ + width: tInt, + height: tInt, + })), + ignoreHTTPSErrors: tOptional(tBoolean), + clientCertificates: tOptional(tArray(tObject({ + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), + }))), + javaScriptEnabled: tOptional(tBoolean), + bypassCSP: tOptional(tBoolean), + userAgent: tOptional(tString), + locale: tOptional(tString), + timezoneId: tOptional(tString), + geolocation: tOptional(tObject({ + longitude: tFloat, + latitude: tFloat, + accuracy: tOptional(tFloat), + })), + permissions: tOptional(tArray(tString)), + extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), + offline: tOptional(tBoolean), + httpCredentials: tOptional(tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + send: tOptional(tEnum(['always', 'unauthorized'])), + })), + deviceScaleFactor: tOptional(tFloat), + isMobile: tOptional(tBoolean), + hasTouch: tOptional(tBoolean), + colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference', 'no-override'])), + reducedMotion: tOptional(tEnum(['reduce', 'no-preference', 'no-override'])), + forcedColors: tOptional(tEnum(['active', 'none', 'no-override'])), + acceptDownloads: tOptional(tEnum(['accept', 'deny', 'internal-browser-default'])), + contrast: tOptional(tEnum(['no-preference', 'more', 'no-override'])), + baseURL: tOptional(tString), + recordVideo: tOptional(tObject({ + dir: tString, + size: tOptional(tObject({ + width: tInt, + height: tInt, + })), + })), + strictSelectors: tOptional(tBoolean), + serviceWorkers: tOptional(tEnum(['allow', 'block'])), + selectorEngines: tOptional(tArray(tType('SelectorEngine'))), + testIdAttributeName: tOptional(tString), + proxy: tOptional(tObject({ + server: tString, + bypass: tOptional(tString), + username: tOptional(tString), + password: tOptional(tString), + })), + storageState: tOptional(tObject({ + cookies: tOptional(tArray(tType('SetNetworkCookie'))), + origins: tOptional(tArray(tType('SetOriginStorage'))), + })), +}); +scheme.BrowserNewContextForReuseResult = tObject({ + context: tChannel(['BrowserContext']), +}); +scheme.BrowserDisconnectFromReusedContextParams = tObject({ + reason: tString, +}); +scheme.BrowserDisconnectFromReusedContextResult = tOptional(tObject({})); +scheme.BrowserNewBrowserCDPSessionParams = tOptional(tObject({})); +scheme.BrowserNewBrowserCDPSessionResult = tObject({ + session: tChannel(['CDPSession']), +}); +scheme.BrowserStartTracingParams = tObject({ + page: tOptional(tChannel(['Page'])), + screenshots: tOptional(tBoolean), + categories: tOptional(tArray(tString)), +}); +scheme.BrowserStartTracingResult = tOptional(tObject({})); +scheme.BrowserStopTracingParams = tOptional(tObject({})); +scheme.BrowserStopTracingResult = tObject({ + artifact: tChannel(['Artifact']), +}); +scheme.EventTargetInitializer = tOptional(tObject({})); +scheme.EventTargetWaitForEventInfoParams = tObject({ + info: tObject({ + waitId: tString, + phase: tEnum(['before', 'after', 'log']), + event: tOptional(tString), + message: tOptional(tString), + error: tOptional(tString), + }), +}); +scheme.BrowserContextWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); +scheme.PageWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); +scheme.WorkerWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); +scheme.WebSocketWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); +scheme.DebuggerWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); +scheme.ElectronApplicationWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); +scheme.AndroidDeviceWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); +scheme.EventTargetWaitForEventInfoResult = tOptional(tObject({})); +scheme.BrowserContextWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); +scheme.PageWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); +scheme.WorkerWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); +scheme.WebSocketWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); +scheme.DebuggerWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); +scheme.ElectronApplicationWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); +scheme.AndroidDeviceWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); +scheme.BrowserContextInitializer = tObject({ + requestContext: tChannel(['APIRequestContext']), + tracing: tChannel(['Tracing']), + options: tObject({ + noDefaultViewport: tOptional(tBoolean), + viewport: tOptional(tObject({ + width: tInt, + height: tInt, + })), + screen: tOptional(tObject({ + width: tInt, + height: tInt, + })), + ignoreHTTPSErrors: tOptional(tBoolean), + clientCertificates: tOptional(tArray(tObject({ + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), + }))), + javaScriptEnabled: tOptional(tBoolean), + bypassCSP: tOptional(tBoolean), + userAgent: tOptional(tString), + locale: tOptional(tString), + timezoneId: tOptional(tString), + geolocation: tOptional(tObject({ + longitude: tFloat, + latitude: tFloat, + accuracy: tOptional(tFloat), + })), + permissions: tOptional(tArray(tString)), + extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), + offline: tOptional(tBoolean), + httpCredentials: tOptional(tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + send: tOptional(tEnum(['always', 'unauthorized'])), + })), + deviceScaleFactor: tOptional(tFloat), + isMobile: tOptional(tBoolean), + hasTouch: tOptional(tBoolean), + colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference', 'no-override'])), + reducedMotion: tOptional(tEnum(['reduce', 'no-preference', 'no-override'])), + forcedColors: tOptional(tEnum(['active', 'none', 'no-override'])), + acceptDownloads: tOptional(tEnum(['accept', 'deny', 'internal-browser-default'])), + contrast: tOptional(tEnum(['no-preference', 'more', 'no-override'])), + baseURL: tOptional(tString), + recordVideo: tOptional(tObject({ + dir: tString, + size: tOptional(tObject({ + width: tInt, + height: tInt, + })), + })), + strictSelectors: tOptional(tBoolean), + serviceWorkers: tOptional(tEnum(['allow', 'block'])), + selectorEngines: tOptional(tArray(tType('SelectorEngine'))), + testIdAttributeName: tOptional(tString), + }), +}); +scheme.BrowserContextBindingCallEvent = tObject({ + binding: tChannel(['BindingCall']), +}); +scheme.BrowserContextConsoleEvent = tObject({ + type: tString, + text: tString, + args: tArray(tChannel(['ElementHandle', 'JSHandle'])), + location: tObject({ + url: tString, + lineNumber: tInt, + columnNumber: tInt, + }), + timestamp: tOptional(tFloat), + page: tOptional(tChannel(['Page'])), + worker: tOptional(tChannel(['Worker'])), +}); +scheme.BrowserContextCloseEvent = tOptional(tObject({})); +scheme.BrowserContextDialogEvent = tObject({ + dialog: tChannel(['Dialog']), +}); +scheme.BrowserContextPageEvent = tObject({ + page: tChannel(['Page']), +}); +scheme.BrowserContextPageErrorEvent = tObject({ + error: tType('SerializedError'), + page: tChannel(['Page']), +}); +scheme.BrowserContextRouteEvent = tObject({ + route: tChannel(['Route']), +}); +scheme.BrowserContextWebSocketRouteEvent = tObject({ + webSocketRoute: tChannel(['WebSocketRoute']), +}); +scheme.BrowserContextServiceWorkerEvent = tObject({ + worker: tChannel(['Worker']), +}); +scheme.BrowserContextRequestEvent = tObject({ + request: tChannel(['Request']), + page: tOptional(tChannel(['Page'])), +}); +scheme.BrowserContextRequestFailedEvent = tObject({ + request: tChannel(['Request']), + failureText: tOptional(tString), + responseEndTiming: tFloat, + page: tOptional(tChannel(['Page'])), +}); +scheme.BrowserContextRequestFinishedEvent = tObject({ + request: tChannel(['Request']), + response: tOptional(tChannel(['Response'])), + responseEndTiming: tFloat, + page: tOptional(tChannel(['Page'])), +}); +scheme.BrowserContextResponseEvent = tObject({ + response: tChannel(['Response']), + page: tOptional(tChannel(['Page'])), +}); +scheme.BrowserContextRecorderEventEvent = tObject({ + event: tEnum(['actionAdded', 'actionUpdated', 'signalAdded']), + data: tAny, + page: tChannel(['Page']), + code: tString, +}); +scheme.BrowserContextAddCookiesParams = tObject({ + cookies: tArray(tType('SetNetworkCookie')), +}); +scheme.BrowserContextAddCookiesResult = tOptional(tObject({})); +scheme.BrowserContextAddInitScriptParams = tObject({ + source: tString, +}); +scheme.BrowserContextAddInitScriptResult = tObject({ + disposable: tChannel(['Disposable']), +}); +scheme.BrowserContextClearCookiesParams = tObject({ + name: tOptional(tString), + nameRegexSource: tOptional(tString), + nameRegexFlags: tOptional(tString), + domain: tOptional(tString), + domainRegexSource: tOptional(tString), + domainRegexFlags: tOptional(tString), + path: tOptional(tString), + pathRegexSource: tOptional(tString), + pathRegexFlags: tOptional(tString), +}); +scheme.BrowserContextClearCookiesResult = tOptional(tObject({})); +scheme.BrowserContextClearPermissionsParams = tOptional(tObject({})); +scheme.BrowserContextClearPermissionsResult = tOptional(tObject({})); +scheme.BrowserContextCloseParams = tObject({ + reason: tOptional(tString), +}); +scheme.BrowserContextCloseResult = tOptional(tObject({})); +scheme.BrowserContextCookiesParams = tObject({ + urls: tArray(tString), +}); +scheme.BrowserContextCookiesResult = tObject({ + cookies: tArray(tType('NetworkCookie')), +}); +scheme.BrowserContextExposeBindingParams = tObject({ + name: tString, + needsHandle: tOptional(tBoolean), +}); +scheme.BrowserContextExposeBindingResult = tObject({ + disposable: tChannel(['Disposable']), +}); +scheme.BrowserContextGrantPermissionsParams = tObject({ + permissions: tArray(tString), + origin: tOptional(tString), +}); +scheme.BrowserContextGrantPermissionsResult = tOptional(tObject({})); +scheme.BrowserContextNewPageParams = tOptional(tObject({})); +scheme.BrowserContextNewPageResult = tObject({ + page: tChannel(['Page']), +}); +scheme.BrowserContextRegisterSelectorEngineParams = tObject({ + selectorEngine: tType('SelectorEngine'), +}); +scheme.BrowserContextRegisterSelectorEngineResult = tOptional(tObject({})); +scheme.BrowserContextSetTestIdAttributeNameParams = tObject({ + testIdAttributeName: tString, +}); +scheme.BrowserContextSetTestIdAttributeNameResult = tOptional(tObject({})); +scheme.BrowserContextSetExtraHTTPHeadersParams = tObject({ + headers: tArray(tType('NameValue')), +}); +scheme.BrowserContextSetExtraHTTPHeadersResult = tOptional(tObject({})); +scheme.BrowserContextSetGeolocationParams = tObject({ + geolocation: tOptional(tObject({ + longitude: tFloat, + latitude: tFloat, + accuracy: tOptional(tFloat), + })), +}); +scheme.BrowserContextSetGeolocationResult = tOptional(tObject({})); +scheme.BrowserContextSetHTTPCredentialsParams = tObject({ + httpCredentials: tOptional(tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + })), +}); +scheme.BrowserContextSetHTTPCredentialsResult = tOptional(tObject({})); +scheme.BrowserContextSetNetworkInterceptionPatternsParams = tObject({ + patterns: tArray(tObject({ + glob: tOptional(tString), + regexSource: tOptional(tString), + regexFlags: tOptional(tString), + urlPattern: tOptional(tType('URLPattern')), + })), +}); +scheme.BrowserContextSetNetworkInterceptionPatternsResult = tOptional(tObject({})); +scheme.BrowserContextSetWebSocketInterceptionPatternsParams = tObject({ + patterns: tArray(tObject({ + glob: tOptional(tString), + regexSource: tOptional(tString), + regexFlags: tOptional(tString), + urlPattern: tOptional(tType('URLPattern')), + })), +}); +scheme.BrowserContextSetWebSocketInterceptionPatternsResult = tOptional(tObject({})); +scheme.BrowserContextSetOfflineParams = tObject({ + offline: tBoolean, +}); +scheme.BrowserContextSetOfflineResult = tOptional(tObject({})); +scheme.BrowserContextStorageStateParams = tObject({ + indexedDB: tOptional(tBoolean), +}); +scheme.BrowserContextStorageStateResult = tObject({ + cookies: tArray(tType('NetworkCookie')), + origins: tArray(tType('OriginStorage')), +}); +scheme.BrowserContextSetStorageStateParams = tObject({ + storageState: tOptional(tObject({ + cookies: tOptional(tArray(tType('SetNetworkCookie'))), + origins: tOptional(tArray(tType('SetOriginStorage'))), + })), +}); +scheme.BrowserContextSetStorageStateResult = tOptional(tObject({})); +scheme.BrowserContextPauseParams = tOptional(tObject({})); +scheme.BrowserContextPauseResult = tOptional(tObject({})); +scheme.BrowserContextEnableRecorderParams = tObject({ + language: tOptional(tString), + mode: tOptional(tEnum(['inspecting', 'recording'])), + recorderMode: tOptional(tEnum(['default', 'api'])), + pauseOnNextStatement: tOptional(tBoolean), + testIdAttributeName: tOptional(tString), + launchOptions: tOptional(tAny), + contextOptions: tOptional(tAny), + device: tOptional(tString), + saveStorage: tOptional(tString), + outputFile: tOptional(tString), + handleSIGINT: tOptional(tBoolean), + omitCallTracking: tOptional(tBoolean), +}); +scheme.BrowserContextEnableRecorderResult = tOptional(tObject({})); +scheme.BrowserContextDisableRecorderParams = tOptional(tObject({})); +scheme.BrowserContextDisableRecorderResult = tOptional(tObject({})); +scheme.BrowserContextExposeConsoleApiParams = tOptional(tObject({})); +scheme.BrowserContextExposeConsoleApiResult = tOptional(tObject({})); +scheme.BrowserContextNewCDPSessionParams = tObject({ + page: tOptional(tChannel(['Page'])), + frame: tOptional(tChannel(['Frame'])), +}); +scheme.BrowserContextNewCDPSessionResult = tObject({ + session: tChannel(['CDPSession']), +}); +scheme.BrowserContextHarStartParams = tObject({ + page: tOptional(tChannel(['Page'])), + options: tType('RecordHarOptions'), +}); +scheme.BrowserContextHarStartResult = tObject({ + harId: tString, +}); +scheme.BrowserContextHarExportParams = tObject({ + harId: tOptional(tString), +}); +scheme.BrowserContextHarExportResult = tObject({ + artifact: tChannel(['Artifact']), +}); +scheme.BrowserContextCreateTempFilesParams = tObject({ + rootDirName: tOptional(tString), + items: tArray(tObject({ + name: tString, + lastModifiedMs: tOptional(tFloat), + })), +}); +scheme.BrowserContextCreateTempFilesResult = tObject({ + rootDir: tOptional(tChannel(['WritableStream'])), + writableStreams: tArray(tChannel(['WritableStream'])), +}); +scheme.BrowserContextUpdateSubscriptionParams = tObject({ + event: tEnum(['console', 'dialog', 'request', 'response', 'requestFinished', 'requestFailed']), + enabled: tBoolean, +}); +scheme.BrowserContextUpdateSubscriptionResult = tOptional(tObject({})); +scheme.BrowserContextClockFastForwardParams = tObject({ + ticksNumber: tOptional(tFloat), + ticksString: tOptional(tString), +}); +scheme.BrowserContextClockFastForwardResult = tOptional(tObject({})); +scheme.BrowserContextClockInstallParams = tObject({ + timeNumber: tOptional(tFloat), + timeString: tOptional(tString), +}); +scheme.BrowserContextClockInstallResult = tOptional(tObject({})); +scheme.BrowserContextClockPauseAtParams = tObject({ + timeNumber: tOptional(tFloat), + timeString: tOptional(tString), +}); +scheme.BrowserContextClockPauseAtResult = tOptional(tObject({})); +scheme.BrowserContextClockResumeParams = tOptional(tObject({})); +scheme.BrowserContextClockResumeResult = tOptional(tObject({})); +scheme.BrowserContextClockRunForParams = tObject({ + ticksNumber: tOptional(tFloat), + ticksString: tOptional(tString), +}); +scheme.BrowserContextClockRunForResult = tOptional(tObject({})); +scheme.BrowserContextClockSetFixedTimeParams = tObject({ + timeNumber: tOptional(tFloat), + timeString: tOptional(tString), +}); +scheme.BrowserContextClockSetFixedTimeResult = tOptional(tObject({})); +scheme.BrowserContextClockSetSystemTimeParams = tObject({ + timeNumber: tOptional(tFloat), + timeString: tOptional(tString), +}); +scheme.BrowserContextClockSetSystemTimeResult = tOptional(tObject({})); +scheme.PageInitializer = tObject({ + mainFrame: tChannel(['Frame']), + viewportSize: tOptional(tObject({ + width: tInt, + height: tInt, + })), + isClosed: tBoolean, + opener: tOptional(tChannel(['Page'])), + video: tOptional(tChannel(['Artifact'])), +}); +scheme.PageBindingCallEvent = tObject({ + binding: tChannel(['BindingCall']), +}); +scheme.PageCloseEvent = tOptional(tObject({})); +scheme.PageCrashEvent = tOptional(tObject({})); +scheme.PageDownloadEvent = tObject({ + url: tString, + suggestedFilename: tString, + artifact: tChannel(['Artifact']), +}); +scheme.PageViewportSizeChangedEvent = tObject({ + viewportSize: tOptional(tObject({ + width: tInt, + height: tInt, + })), +}); +scheme.PageFileChooserEvent = tObject({ + element: tChannel(['ElementHandle']), + isMultiple: tBoolean, +}); +scheme.PageFrameAttachedEvent = tObject({ + frame: tChannel(['Frame']), +}); +scheme.PageFrameDetachedEvent = tObject({ + frame: tChannel(['Frame']), +}); +scheme.PageLocatorHandlerTriggeredEvent = tObject({ + uid: tInt, +}); +scheme.PageRouteEvent = tObject({ + route: tChannel(['Route']), +}); +scheme.PageScreencastFrameEvent = tObject({ + data: tBinary, +}); +scheme.PageWebSocketRouteEvent = tObject({ + webSocketRoute: tChannel(['WebSocketRoute']), +}); +scheme.PageWebSocketEvent = tObject({ + webSocket: tChannel(['WebSocket']), +}); +scheme.PageWorkerEvent = tObject({ + worker: tChannel(['Worker']), +}); +scheme.PageAddInitScriptParams = tObject({ + source: tString, +}); +scheme.PageAddInitScriptResult = tObject({ + disposable: tChannel(['Disposable']), +}); +scheme.PageCloseParams = tObject({ + runBeforeUnload: tOptional(tBoolean), + reason: tOptional(tString), +}); +scheme.PageCloseResult = tOptional(tObject({})); +scheme.PageClearConsoleMessagesParams = tOptional(tObject({})); +scheme.PageClearConsoleMessagesResult = tOptional(tObject({})); +scheme.PageConsoleMessagesParams = tObject({ + filter: tOptional(tType('ConsoleMessagesFilter')), +}); +scheme.PageConsoleMessagesResult = tObject({ + messages: tArray(tObject({ + type: tString, + text: tString, + args: tArray(tChannel(['ElementHandle', 'JSHandle'])), + location: tObject({ + url: tString, + lineNumber: tInt, + columnNumber: tInt, + }), + timestamp: tFloat, + })), +}); +scheme.PageEmulateMediaParams = tObject({ + media: tOptional(tEnum(['screen', 'print', 'no-override'])), + colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference', 'no-override'])), + reducedMotion: tOptional(tEnum(['reduce', 'no-preference', 'no-override'])), + forcedColors: tOptional(tEnum(['active', 'none', 'no-override'])), + contrast: tOptional(tEnum(['no-preference', 'more', 'no-override'])), +}); +scheme.PageEmulateMediaResult = tOptional(tObject({})); +scheme.PageExposeBindingParams = tObject({ + name: tString, + needsHandle: tOptional(tBoolean), +}); +scheme.PageExposeBindingResult = tObject({ + disposable: tChannel(['Disposable']), +}); +scheme.PageGoBackParams = tObject({ + timeout: tFloat, + waitUntil: tOptional(tType('LifecycleEvent')), +}); +scheme.PageGoBackResult = tObject({ + response: tOptional(tChannel(['Response'])), +}); +scheme.PageGoForwardParams = tObject({ + timeout: tFloat, + waitUntil: tOptional(tType('LifecycleEvent')), +}); +scheme.PageGoForwardResult = tObject({ + response: tOptional(tChannel(['Response'])), +}); +scheme.PageRequestGCParams = tOptional(tObject({})); +scheme.PageRequestGCResult = tOptional(tObject({})); +scheme.PageRegisterLocatorHandlerParams = tObject({ + selector: tString, + noWaitAfter: tOptional(tBoolean), +}); +scheme.PageRegisterLocatorHandlerResult = tObject({ + uid: tInt, +}); +scheme.PageResolveLocatorHandlerNoReplyParams = tObject({ + uid: tInt, + remove: tOptional(tBoolean), +}); +scheme.PageResolveLocatorHandlerNoReplyResult = tOptional(tObject({})); +scheme.PageUnregisterLocatorHandlerParams = tObject({ + uid: tInt, +}); +scheme.PageUnregisterLocatorHandlerResult = tOptional(tObject({})); +scheme.PageReloadParams = tObject({ + timeout: tFloat, + waitUntil: tOptional(tType('LifecycleEvent')), +}); +scheme.PageReloadResult = tObject({ + response: tOptional(tChannel(['Response'])), +}); +scheme.PageExpectScreenshotParams = tObject({ + expected: tOptional(tBinary), + timeout: tFloat, + isNot: tBoolean, + locator: tOptional(tObject({ + frame: tChannel(['Frame']), + selector: tString, + })), + comparator: tOptional(tString), + maxDiffPixels: tOptional(tInt), + maxDiffPixelRatio: tOptional(tFloat), + threshold: tOptional(tFloat), + fullPage: tOptional(tBoolean), + clip: tOptional(tType('Rect')), + omitBackground: tOptional(tBoolean), + caret: tOptional(tEnum(['hide', 'initial'])), + animations: tOptional(tEnum(['disabled', 'allow'])), + scale: tOptional(tEnum(['css', 'device'])), + mask: tOptional(tArray(tObject({ + frame: tChannel(['Frame']), + selector: tString, + }))), + maskColor: tOptional(tString), + style: tOptional(tString), +}); +scheme.PageExpectScreenshotResult = tObject({ + diff: tOptional(tBinary), + errorMessage: tOptional(tString), + actual: tOptional(tBinary), + previous: tOptional(tBinary), + timedOut: tOptional(tBoolean), + log: tOptional(tArray(tString)), +}); +scheme.PageScreenshotParams = tObject({ + timeout: tFloat, + type: tOptional(tEnum(['png', 'jpeg'])), + quality: tOptional(tInt), + fullPage: tOptional(tBoolean), + clip: tOptional(tType('Rect')), + omitBackground: tOptional(tBoolean), + caret: tOptional(tEnum(['hide', 'initial'])), + animations: tOptional(tEnum(['disabled', 'allow'])), + scale: tOptional(tEnum(['css', 'device'])), + mask: tOptional(tArray(tObject({ + frame: tChannel(['Frame']), + selector: tString, + }))), + maskColor: tOptional(tString), + style: tOptional(tString), +}); +scheme.PageScreenshotResult = tObject({ + binary: tBinary, +}); +scheme.PageSetExtraHTTPHeadersParams = tObject({ + headers: tArray(tType('NameValue')), +}); +scheme.PageSetExtraHTTPHeadersResult = tOptional(tObject({})); +scheme.PageSetNetworkInterceptionPatternsParams = tObject({ + patterns: tArray(tObject({ + glob: tOptional(tString), + regexSource: tOptional(tString), + regexFlags: tOptional(tString), + urlPattern: tOptional(tType('URLPattern')), + })), +}); +scheme.PageSetNetworkInterceptionPatternsResult = tOptional(tObject({})); +scheme.PageSetWebSocketInterceptionPatternsParams = tObject({ + patterns: tArray(tObject({ + glob: tOptional(tString), + regexSource: tOptional(tString), + regexFlags: tOptional(tString), + urlPattern: tOptional(tType('URLPattern')), + })), +}); +scheme.PageSetWebSocketInterceptionPatternsResult = tOptional(tObject({})); +scheme.PageSetViewportSizeParams = tObject({ + viewportSize: tObject({ + width: tInt, + height: tInt, + }), +}); +scheme.PageSetViewportSizeResult = tOptional(tObject({})); +scheme.PageKeyboardDownParams = tObject({ + key: tString, +}); +scheme.PageKeyboardDownResult = tOptional(tObject({})); +scheme.PageKeyboardUpParams = tObject({ + key: tString, +}); +scheme.PageKeyboardUpResult = tOptional(tObject({})); +scheme.PageKeyboardInsertTextParams = tObject({ + text: tString, +}); +scheme.PageKeyboardInsertTextResult = tOptional(tObject({})); +scheme.PageKeyboardTypeParams = tObject({ + text: tString, + delay: tOptional(tFloat), +}); +scheme.PageKeyboardTypeResult = tOptional(tObject({})); +scheme.PageKeyboardPressParams = tObject({ + key: tString, + delay: tOptional(tFloat), +}); +scheme.PageKeyboardPressResult = tOptional(tObject({})); +scheme.PageMouseMoveParams = tObject({ + x: tFloat, + y: tFloat, + steps: tOptional(tInt), +}); +scheme.PageMouseMoveResult = tOptional(tObject({})); +scheme.PageMouseDownParams = tObject({ + button: tOptional(tEnum(['left', 'right', 'middle'])), + clickCount: tOptional(tInt), +}); +scheme.PageMouseDownResult = tOptional(tObject({})); +scheme.PageMouseUpParams = tObject({ + button: tOptional(tEnum(['left', 'right', 'middle'])), + clickCount: tOptional(tInt), +}); +scheme.PageMouseUpResult = tOptional(tObject({})); +scheme.PageMouseClickParams = tObject({ + x: tFloat, + y: tFloat, + delay: tOptional(tFloat), + button: tOptional(tEnum(['left', 'right', 'middle'])), + clickCount: tOptional(tInt), +}); +scheme.PageMouseClickResult = tOptional(tObject({})); +scheme.PageMouseWheelParams = tObject({ + deltaX: tFloat, + deltaY: tFloat, +}); +scheme.PageMouseWheelResult = tOptional(tObject({})); +scheme.PageTouchscreenTapParams = tObject({ + x: tFloat, + y: tFloat, +}); +scheme.PageTouchscreenTapResult = tOptional(tObject({})); +scheme.PageClearPageErrorsParams = tOptional(tObject({})); +scheme.PageClearPageErrorsResult = tOptional(tObject({})); +scheme.PagePageErrorsParams = tObject({ + filter: tOptional(tType('ConsoleMessagesFilter')), +}); +scheme.PagePageErrorsResult = tObject({ + errors: tArray(tType('SerializedError')), +}); +scheme.PagePdfParams = tObject({ + scale: tOptional(tFloat), + displayHeaderFooter: tOptional(tBoolean), + headerTemplate: tOptional(tString), + footerTemplate: tOptional(tString), + printBackground: tOptional(tBoolean), + landscape: tOptional(tBoolean), + pageRanges: tOptional(tString), + format: tOptional(tString), + width: tOptional(tString), + height: tOptional(tString), + preferCSSPageSize: tOptional(tBoolean), + margin: tOptional(tObject({ + top: tOptional(tString), + bottom: tOptional(tString), + left: tOptional(tString), + right: tOptional(tString), + })), + tagged: tOptional(tBoolean), + outline: tOptional(tBoolean), +}); +scheme.PagePdfResult = tObject({ + pdf: tBinary, +}); +scheme.PageRequestsParams = tOptional(tObject({})); +scheme.PageRequestsResult = tObject({ + requests: tArray(tChannel(['Request'])), +}); +scheme.PageSnapshotForAIParams = tObject({ + track: tOptional(tString), + selector: tOptional(tString), + depth: tOptional(tInt), + timeout: tFloat, +}); +scheme.PageSnapshotForAIResult = tObject({ + full: tString, + incremental: tOptional(tString), +}); +scheme.PageStartJSCoverageParams = tObject({ + resetOnNavigation: tOptional(tBoolean), + reportAnonymousScripts: tOptional(tBoolean), +}); +scheme.PageStartJSCoverageResult = tOptional(tObject({})); +scheme.PageStopJSCoverageParams = tOptional(tObject({})); +scheme.PageStopJSCoverageResult = tObject({ + entries: tArray(tObject({ + url: tString, + scriptId: tString, + source: tOptional(tString), + functions: tArray(tObject({ + functionName: tString, + isBlockCoverage: tBoolean, + ranges: tArray(tObject({ + startOffset: tInt, + endOffset: tInt, + count: tInt, + })), + })), + })), +}); +scheme.PageStartCSSCoverageParams = tObject({ + resetOnNavigation: tOptional(tBoolean), +}); +scheme.PageStartCSSCoverageResult = tOptional(tObject({})); +scheme.PageStopCSSCoverageParams = tOptional(tObject({})); +scheme.PageStopCSSCoverageResult = tObject({ + entries: tArray(tObject({ + url: tString, + text: tOptional(tString), + ranges: tArray(tObject({ + start: tInt, + end: tInt, + })), + })), +}); +scheme.PageBringToFrontParams = tOptional(tObject({})); +scheme.PageBringToFrontResult = tOptional(tObject({})); +scheme.PagePickLocatorParams = tOptional(tObject({})); +scheme.PagePickLocatorResult = tObject({ + selector: tString, +}); +scheme.PageCancelPickLocatorParams = tOptional(tObject({})); +scheme.PageCancelPickLocatorResult = tOptional(tObject({})); +scheme.PageStartScreencastParams = tObject({ + maxSize: tOptional(tObject({ + width: tInt, + height: tInt, + })), +}); +scheme.PageStartScreencastResult = tOptional(tObject({})); +scheme.PageStopScreencastParams = tOptional(tObject({})); +scheme.PageStopScreencastResult = tOptional(tObject({})); +scheme.PageVideoStartParams = tObject({ + size: tOptional(tObject({ + width: tInt, + height: tInt, + })), +}); +scheme.PageVideoStartResult = tObject({ + artifact: tChannel(['Artifact']), +}); +scheme.PageVideoStopParams = tOptional(tObject({})); +scheme.PageVideoStopResult = tOptional(tObject({})); +scheme.PageUpdateSubscriptionParams = tObject({ + event: tEnum(['console', 'dialog', 'fileChooser', 'request', 'response', 'requestFinished', 'requestFailed']), + enabled: tBoolean, +}); +scheme.PageUpdateSubscriptionResult = tOptional(tObject({})); +scheme.PageSetDockTileParams = tObject({ + image: tBinary, +}); +scheme.PageSetDockTileResult = tOptional(tObject({})); +scheme.FrameInitializer = tObject({ + url: tString, + name: tString, + parentFrame: tOptional(tChannel(['Frame'])), + loadStates: tArray(tType('LifecycleEvent')), +}); +scheme.FrameLoadstateEvent = tObject({ + add: tOptional(tType('LifecycleEvent')), + remove: tOptional(tType('LifecycleEvent')), +}); +scheme.FrameNavigatedEvent = tObject({ + url: tString, + name: tString, + newDocument: tOptional(tObject({ + request: tOptional(tChannel(['Request'])), + })), + error: tOptional(tString), +}); +scheme.FrameEvalOnSelectorParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType('SerializedArgument'), +}); +scheme.FrameEvalOnSelectorResult = tObject({ + value: tType('SerializedValue'), +}); +scheme.FrameEvalOnSelectorAllParams = tObject({ + selector: tString, + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType('SerializedArgument'), +}); +scheme.FrameEvalOnSelectorAllResult = tObject({ + value: tType('SerializedValue'), +}); +scheme.FrameAddScriptTagParams = tObject({ + url: tOptional(tString), + content: tOptional(tString), + type: tOptional(tString), +}); +scheme.FrameAddScriptTagResult = tObject({ + element: tChannel(['ElementHandle']), +}); +scheme.FrameAddStyleTagParams = tObject({ + url: tOptional(tString), + content: tOptional(tString), +}); +scheme.FrameAddStyleTagResult = tObject({ + element: tChannel(['ElementHandle']), +}); +scheme.FrameAriaSnapshotParams = tObject({ + selector: tString, + timeout: tFloat, +}); +scheme.FrameAriaSnapshotResult = tObject({ + snapshot: tString, +}); +scheme.FrameBlurParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameBlurResult = tOptional(tObject({})); +scheme.FrameCheckParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + force: tOptional(tBoolean), + position: tOptional(tType('Point')), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.FrameCheckResult = tOptional(tObject({})); +scheme.FrameClickParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + force: tOptional(tBoolean), + noWaitAfter: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift']))), + position: tOptional(tType('Point')), + delay: tOptional(tFloat), + button: tOptional(tEnum(['left', 'right', 'middle'])), + clickCount: tOptional(tInt), + timeout: tFloat, + trial: tOptional(tBoolean), + steps: tOptional(tInt), +}); +scheme.FrameClickResult = tOptional(tObject({})); +scheme.FrameContentParams = tOptional(tObject({})); +scheme.FrameContentResult = tObject({ + value: tString, +}); +scheme.FrameDragAndDropParams = tObject({ + source: tString, + target: tString, + force: tOptional(tBoolean), + timeout: tFloat, + trial: tOptional(tBoolean), + sourcePosition: tOptional(tType('Point')), + targetPosition: tOptional(tType('Point')), + strict: tOptional(tBoolean), + steps: tOptional(tInt), +}); +scheme.FrameDragAndDropResult = tOptional(tObject({})); +scheme.FrameDblclickParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + force: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift']))), + position: tOptional(tType('Point')), + delay: tOptional(tFloat), + button: tOptional(tEnum(['left', 'right', 'middle'])), + timeout: tFloat, + trial: tOptional(tBoolean), + steps: tOptional(tInt), +}); +scheme.FrameDblclickResult = tOptional(tObject({})); +scheme.FrameDispatchEventParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + type: tString, + eventInit: tType('SerializedArgument'), + timeout: tFloat, +}); +scheme.FrameDispatchEventResult = tOptional(tObject({})); +scheme.FrameEvaluateExpressionParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType('SerializedArgument'), +}); +scheme.FrameEvaluateExpressionResult = tObject({ + value: tType('SerializedValue'), +}); +scheme.FrameEvaluateExpressionHandleParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType('SerializedArgument'), +}); +scheme.FrameEvaluateExpressionHandleResult = tObject({ + handle: tChannel(['ElementHandle', 'JSHandle']), +}); +scheme.FrameFillParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + value: tString, + force: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameFillResult = tOptional(tObject({})); +scheme.FrameFocusParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameFocusResult = tOptional(tObject({})); +scheme.FrameFrameElementParams = tOptional(tObject({})); +scheme.FrameFrameElementResult = tObject({ + element: tChannel(['ElementHandle']), +}); +scheme.FrameResolveSelectorParams = tObject({ + selector: tString, +}); +scheme.FrameResolveSelectorResult = tObject({ + resolvedSelector: tString, +}); +scheme.FrameHighlightParams = tObject({ + selector: tString, +}); +scheme.FrameHighlightResult = tOptional(tObject({})); +scheme.FrameGetAttributeParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + name: tString, + timeout: tFloat, +}); +scheme.FrameGetAttributeResult = tObject({ + value: tOptional(tString), +}); +scheme.FrameGotoParams = tObject({ + url: tString, + timeout: tFloat, + waitUntil: tOptional(tType('LifecycleEvent')), + referer: tOptional(tString), +}); +scheme.FrameGotoResult = tObject({ + response: tOptional(tChannel(['Response'])), +}); +scheme.FrameHoverParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + force: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift']))), + position: tOptional(tType('Point')), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.FrameHoverResult = tOptional(tObject({})); +scheme.FrameInnerHTMLParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameInnerHTMLResult = tObject({ + value: tString, +}); +scheme.FrameInnerTextParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameInnerTextResult = tObject({ + value: tString, +}); +scheme.FrameInputValueParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameInputValueResult = tObject({ + value: tString, +}); +scheme.FrameIsCheckedParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameIsCheckedResult = tObject({ + value: tBoolean, +}); +scheme.FrameIsDisabledParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameIsDisabledResult = tObject({ + value: tBoolean, +}); +scheme.FrameIsEnabledParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameIsEnabledResult = tObject({ + value: tBoolean, +}); +scheme.FrameIsHiddenParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), +}); +scheme.FrameIsHiddenResult = tObject({ + value: tBoolean, +}); +scheme.FrameIsVisibleParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), +}); +scheme.FrameIsVisibleResult = tObject({ + value: tBoolean, +}); +scheme.FrameIsEditableParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameIsEditableResult = tObject({ + value: tBoolean, +}); +scheme.FramePressParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + key: tString, + delay: tOptional(tFloat), + noWaitAfter: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FramePressResult = tOptional(tObject({})); +scheme.FrameQuerySelectorParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), +}); +scheme.FrameQuerySelectorResult = tObject({ + element: tOptional(tChannel(['ElementHandle'])), +}); +scheme.FrameQuerySelectorAllParams = tObject({ + selector: tString, +}); +scheme.FrameQuerySelectorAllResult = tObject({ + elements: tArray(tChannel(['ElementHandle'])), +}); +scheme.FrameQueryCountParams = tObject({ + selector: tString, +}); +scheme.FrameQueryCountResult = tObject({ + value: tInt, +}); +scheme.FrameSelectOptionParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + elements: tOptional(tArray(tChannel(['ElementHandle']))), + options: tOptional(tArray(tObject({ + valueOrLabel: tOptional(tString), + value: tOptional(tString), + label: tOptional(tString), + index: tOptional(tInt), + }))), + force: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameSelectOptionResult = tObject({ + values: tArray(tString), +}); +scheme.FrameSetContentParams = tObject({ + html: tString, + timeout: tFloat, + waitUntil: tOptional(tType('LifecycleEvent')), +}); +scheme.FrameSetContentResult = tOptional(tObject({})); +scheme.FrameSetInputFilesParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + payloads: tOptional(tArray(tObject({ + name: tString, + mimeType: tOptional(tString), + buffer: tBinary, + }))), + localDirectory: tOptional(tString), + directoryStream: tOptional(tChannel(['WritableStream'])), + localPaths: tOptional(tArray(tString)), + streams: tOptional(tArray(tChannel(['WritableStream']))), + timeout: tFloat, +}); +scheme.FrameSetInputFilesResult = tOptional(tObject({})); +scheme.FrameTapParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + force: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift']))), + position: tOptional(tType('Point')), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.FrameTapResult = tOptional(tObject({})); +scheme.FrameTextContentParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.FrameTextContentResult = tObject({ + value: tOptional(tString), +}); +scheme.FrameTitleParams = tOptional(tObject({})); +scheme.FrameTitleResult = tObject({ + value: tString, +}); +scheme.FrameTypeParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + text: tString, + delay: tOptional(tFloat), + timeout: tFloat, +}); +scheme.FrameTypeResult = tOptional(tObject({})); +scheme.FrameUncheckParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + force: tOptional(tBoolean), + position: tOptional(tType('Point')), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.FrameUncheckResult = tOptional(tObject({})); +scheme.FrameWaitForTimeoutParams = tObject({ + waitTimeout: tFloat, +}); +scheme.FrameWaitForTimeoutResult = tOptional(tObject({})); +scheme.FrameWaitForFunctionParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType('SerializedArgument'), + timeout: tFloat, + pollingInterval: tOptional(tFloat), +}); +scheme.FrameWaitForFunctionResult = tObject({ + handle: tChannel(['ElementHandle', 'JSHandle']), +}); +scheme.FrameWaitForSelectorParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, + state: tOptional(tEnum(['attached', 'detached', 'visible', 'hidden'])), + omitReturnValue: tOptional(tBoolean), +}); +scheme.FrameWaitForSelectorResult = tObject({ + element: tOptional(tChannel(['ElementHandle'])), +}); +scheme.FrameExpectParams = tObject({ + selector: tOptional(tString), + expression: tString, + expressionArg: tOptional(tAny), + expectedText: tOptional(tArray(tType('ExpectedTextValue'))), + expectedNumber: tOptional(tFloat), + expectedValue: tOptional(tType('SerializedArgument')), + useInnerText: tOptional(tBoolean), + isNot: tBoolean, + timeout: tFloat, +}); +scheme.FrameExpectResult = tObject({ + matches: tBoolean, + received: tOptional(tType('SerializedValue')), + timedOut: tOptional(tBoolean), + errorMessage: tOptional(tString), + log: tOptional(tArray(tString)), +}); +scheme.WorkerInitializer = tObject({ + url: tString, +}); +scheme.WorkerCloseEvent = tOptional(tObject({})); +scheme.WorkerEvaluateExpressionParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType('SerializedArgument'), +}); +scheme.WorkerEvaluateExpressionResult = tObject({ + value: tType('SerializedValue'), +}); +scheme.WorkerEvaluateExpressionHandleParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType('SerializedArgument'), +}); +scheme.WorkerEvaluateExpressionHandleResult = tObject({ + handle: tChannel(['ElementHandle', 'JSHandle']), +}); +scheme.WorkerUpdateSubscriptionParams = tObject({ + event: tEnum(['console']), + enabled: tBoolean, +}); +scheme.WorkerUpdateSubscriptionResult = tOptional(tObject({})); +scheme.DisposableInitializer = tOptional(tObject({})); +scheme.DisposableDisposeParams = tOptional(tObject({})); +scheme.DisposableDisposeResult = tOptional(tObject({})); +scheme.JSHandleInitializer = tObject({ + preview: tString, +}); +scheme.JSHandlePreviewUpdatedEvent = tObject({ + preview: tString, +}); +scheme.ElementHandlePreviewUpdatedEvent = tType('JSHandlePreviewUpdatedEvent'); +scheme.JSHandleDisposeParams = tOptional(tObject({})); +scheme.ElementHandleDisposeParams = tType('JSHandleDisposeParams'); +scheme.JSHandleDisposeResult = tOptional(tObject({})); +scheme.ElementHandleDisposeResult = tType('JSHandleDisposeResult'); +scheme.JSHandleEvaluateExpressionParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType('SerializedArgument'), +}); +scheme.ElementHandleEvaluateExpressionParams = tType('JSHandleEvaluateExpressionParams'); +scheme.JSHandleEvaluateExpressionResult = tObject({ + value: tType('SerializedValue'), +}); +scheme.ElementHandleEvaluateExpressionResult = tType('JSHandleEvaluateExpressionResult'); +scheme.JSHandleEvaluateExpressionHandleParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType('SerializedArgument'), +}); +scheme.ElementHandleEvaluateExpressionHandleParams = tType('JSHandleEvaluateExpressionHandleParams'); +scheme.JSHandleEvaluateExpressionHandleResult = tObject({ + handle: tChannel(['ElementHandle', 'JSHandle']), +}); +scheme.ElementHandleEvaluateExpressionHandleResult = tType('JSHandleEvaluateExpressionHandleResult'); +scheme.JSHandleGetPropertyListParams = tOptional(tObject({})); +scheme.ElementHandleGetPropertyListParams = tType('JSHandleGetPropertyListParams'); +scheme.JSHandleGetPropertyListResult = tObject({ + properties: tArray(tObject({ + name: tString, + value: tChannel(['ElementHandle', 'JSHandle']), + })), +}); +scheme.ElementHandleGetPropertyListResult = tType('JSHandleGetPropertyListResult'); +scheme.JSHandleGetPropertyParams = tObject({ + name: tString, +}); +scheme.ElementHandleGetPropertyParams = tType('JSHandleGetPropertyParams'); +scheme.JSHandleGetPropertyResult = tObject({ + handle: tChannel(['ElementHandle', 'JSHandle']), +}); +scheme.ElementHandleGetPropertyResult = tType('JSHandleGetPropertyResult'); +scheme.JSHandleJsonValueParams = tOptional(tObject({})); +scheme.ElementHandleJsonValueParams = tType('JSHandleJsonValueParams'); +scheme.JSHandleJsonValueResult = tObject({ + value: tType('SerializedValue'), +}); +scheme.ElementHandleJsonValueResult = tType('JSHandleJsonValueResult'); +scheme.ElementHandleInitializer = tObject({ + preview: tString, +}); +scheme.ElementHandleEvalOnSelectorParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType('SerializedArgument'), +}); +scheme.ElementHandleEvalOnSelectorResult = tObject({ + value: tType('SerializedValue'), +}); +scheme.ElementHandleEvalOnSelectorAllParams = tObject({ + selector: tString, + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType('SerializedArgument'), +}); +scheme.ElementHandleEvalOnSelectorAllResult = tObject({ + value: tType('SerializedValue'), +}); +scheme.ElementHandleBoundingBoxParams = tOptional(tObject({})); +scheme.ElementHandleBoundingBoxResult = tObject({ + value: tOptional(tType('Rect')), +}); +scheme.ElementHandleCheckParams = tObject({ + force: tOptional(tBoolean), + position: tOptional(tType('Point')), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.ElementHandleCheckResult = tOptional(tObject({})); +scheme.ElementHandleClickParams = tObject({ + force: tOptional(tBoolean), + noWaitAfter: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift']))), + position: tOptional(tType('Point')), + delay: tOptional(tFloat), + button: tOptional(tEnum(['left', 'right', 'middle'])), + clickCount: tOptional(tInt), + timeout: tFloat, + trial: tOptional(tBoolean), + steps: tOptional(tInt), +}); +scheme.ElementHandleClickResult = tOptional(tObject({})); +scheme.ElementHandleContentFrameParams = tOptional(tObject({})); +scheme.ElementHandleContentFrameResult = tObject({ + frame: tOptional(tChannel(['Frame'])), +}); +scheme.ElementHandleDblclickParams = tObject({ + force: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift']))), + position: tOptional(tType('Point')), + delay: tOptional(tFloat), + button: tOptional(tEnum(['left', 'right', 'middle'])), + timeout: tFloat, + trial: tOptional(tBoolean), + steps: tOptional(tInt), +}); +scheme.ElementHandleDblclickResult = tOptional(tObject({})); +scheme.ElementHandleDispatchEventParams = tObject({ + type: tString, + eventInit: tType('SerializedArgument'), +}); +scheme.ElementHandleDispatchEventResult = tOptional(tObject({})); +scheme.ElementHandleFillParams = tObject({ + value: tString, + force: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.ElementHandleFillResult = tOptional(tObject({})); +scheme.ElementHandleFocusParams = tOptional(tObject({})); +scheme.ElementHandleFocusResult = tOptional(tObject({})); +scheme.ElementHandleGetAttributeParams = tObject({ + name: tString, +}); +scheme.ElementHandleGetAttributeResult = tObject({ + value: tOptional(tString), +}); +scheme.ElementHandleHoverParams = tObject({ + force: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift']))), + position: tOptional(tType('Point')), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.ElementHandleHoverResult = tOptional(tObject({})); +scheme.ElementHandleInnerHTMLParams = tOptional(tObject({})); +scheme.ElementHandleInnerHTMLResult = tObject({ + value: tString, +}); +scheme.ElementHandleInnerTextParams = tOptional(tObject({})); +scheme.ElementHandleInnerTextResult = tObject({ + value: tString, +}); +scheme.ElementHandleInputValueParams = tOptional(tObject({})); +scheme.ElementHandleInputValueResult = tObject({ + value: tString, +}); +scheme.ElementHandleIsCheckedParams = tOptional(tObject({})); +scheme.ElementHandleIsCheckedResult = tObject({ + value: tBoolean, +}); +scheme.ElementHandleIsDisabledParams = tOptional(tObject({})); +scheme.ElementHandleIsDisabledResult = tObject({ + value: tBoolean, +}); +scheme.ElementHandleIsEditableParams = tOptional(tObject({})); +scheme.ElementHandleIsEditableResult = tObject({ + value: tBoolean, +}); +scheme.ElementHandleIsEnabledParams = tOptional(tObject({})); +scheme.ElementHandleIsEnabledResult = tObject({ + value: tBoolean, +}); +scheme.ElementHandleIsHiddenParams = tOptional(tObject({})); +scheme.ElementHandleIsHiddenResult = tObject({ + value: tBoolean, +}); +scheme.ElementHandleIsVisibleParams = tOptional(tObject({})); +scheme.ElementHandleIsVisibleResult = tObject({ + value: tBoolean, +}); +scheme.ElementHandleOwnerFrameParams = tOptional(tObject({})); +scheme.ElementHandleOwnerFrameResult = tObject({ + frame: tOptional(tChannel(['Frame'])), +}); +scheme.ElementHandlePressParams = tObject({ + key: tString, + delay: tOptional(tFloat), + timeout: tFloat, + noWaitAfter: tOptional(tBoolean), +}); +scheme.ElementHandlePressResult = tOptional(tObject({})); +scheme.ElementHandleQuerySelectorParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), +}); +scheme.ElementHandleQuerySelectorResult = tObject({ + element: tOptional(tChannel(['ElementHandle'])), +}); +scheme.ElementHandleQuerySelectorAllParams = tObject({ + selector: tString, +}); +scheme.ElementHandleQuerySelectorAllResult = tObject({ + elements: tArray(tChannel(['ElementHandle'])), +}); +scheme.ElementHandleScreenshotParams = tObject({ + timeout: tFloat, + type: tOptional(tEnum(['png', 'jpeg'])), + quality: tOptional(tInt), + omitBackground: tOptional(tBoolean), + caret: tOptional(tEnum(['hide', 'initial'])), + animations: tOptional(tEnum(['disabled', 'allow'])), + scale: tOptional(tEnum(['css', 'device'])), + mask: tOptional(tArray(tObject({ + frame: tChannel(['Frame']), + selector: tString, + }))), + maskColor: tOptional(tString), + style: tOptional(tString), +}); +scheme.ElementHandleScreenshotResult = tObject({ + binary: tBinary, +}); +scheme.ElementHandleScrollIntoViewIfNeededParams = tObject({ + timeout: tFloat, +}); +scheme.ElementHandleScrollIntoViewIfNeededResult = tOptional(tObject({})); +scheme.ElementHandleSelectOptionParams = tObject({ + elements: tOptional(tArray(tChannel(['ElementHandle']))), + options: tOptional(tArray(tObject({ + valueOrLabel: tOptional(tString), + value: tOptional(tString), + label: tOptional(tString), + index: tOptional(tInt), + }))), + force: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.ElementHandleSelectOptionResult = tObject({ + values: tArray(tString), +}); +scheme.ElementHandleSelectTextParams = tObject({ + force: tOptional(tBoolean), + timeout: tFloat, +}); +scheme.ElementHandleSelectTextResult = tOptional(tObject({})); +scheme.ElementHandleSetInputFilesParams = tObject({ + payloads: tOptional(tArray(tObject({ + name: tString, + mimeType: tOptional(tString), + buffer: tBinary, + }))), + localDirectory: tOptional(tString), + directoryStream: tOptional(tChannel(['WritableStream'])), + localPaths: tOptional(tArray(tString)), + streams: tOptional(tArray(tChannel(['WritableStream']))), + timeout: tFloat, +}); +scheme.ElementHandleSetInputFilesResult = tOptional(tObject({})); +scheme.ElementHandleTapParams = tObject({ + force: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift']))), + position: tOptional(tType('Point')), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.ElementHandleTapResult = tOptional(tObject({})); +scheme.ElementHandleTextContentParams = tOptional(tObject({})); +scheme.ElementHandleTextContentResult = tObject({ + value: tOptional(tString), +}); +scheme.ElementHandleTypeParams = tObject({ + text: tString, + delay: tOptional(tFloat), + timeout: tFloat, +}); +scheme.ElementHandleTypeResult = tOptional(tObject({})); +scheme.ElementHandleUncheckParams = tObject({ + force: tOptional(tBoolean), + position: tOptional(tType('Point')), + timeout: tFloat, + trial: tOptional(tBoolean), +}); +scheme.ElementHandleUncheckResult = tOptional(tObject({})); +scheme.ElementHandleWaitForElementStateParams = tObject({ + state: tEnum(['visible', 'hidden', 'stable', 'enabled', 'disabled', 'editable']), + timeout: tFloat, +}); +scheme.ElementHandleWaitForElementStateResult = tOptional(tObject({})); +scheme.ElementHandleWaitForSelectorParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + timeout: tFloat, + state: tOptional(tEnum(['attached', 'detached', 'visible', 'hidden'])), +}); +scheme.ElementHandleWaitForSelectorResult = tObject({ + element: tOptional(tChannel(['ElementHandle'])), +}); +scheme.RequestInitializer = tObject({ + frame: tOptional(tChannel(['Frame'])), + serviceWorker: tOptional(tChannel(['Worker'])), + url: tString, + resourceType: tString, + method: tString, + postData: tOptional(tBinary), + headers: tArray(tType('NameValue')), + isNavigationRequest: tBoolean, + redirectedFrom: tOptional(tChannel(['Request'])), + hasResponse: tBoolean, +}); +scheme.RequestResponseEvent = tOptional(tObject({})); +scheme.RequestResponseParams = tOptional(tObject({})); +scheme.RequestResponseResult = tObject({ + response: tOptional(tChannel(['Response'])), +}); +scheme.RequestRawRequestHeadersParams = tOptional(tObject({})); +scheme.RequestRawRequestHeadersResult = tObject({ + headers: tArray(tType('NameValue')), +}); +scheme.RouteInitializer = tObject({ + request: tChannel(['Request']), +}); +scheme.RouteRedirectNavigationRequestParams = tObject({ + url: tString, +}); +scheme.RouteRedirectNavigationRequestResult = tOptional(tObject({})); +scheme.RouteAbortParams = tObject({ + errorCode: tOptional(tString), +}); +scheme.RouteAbortResult = tOptional(tObject({})); +scheme.RouteContinueParams = tObject({ + url: tOptional(tString), + method: tOptional(tString), + headers: tOptional(tArray(tType('NameValue'))), + postData: tOptional(tBinary), + isFallback: tBoolean, +}); +scheme.RouteContinueResult = tOptional(tObject({})); +scheme.RouteFulfillParams = tObject({ + status: tOptional(tInt), + headers: tOptional(tArray(tType('NameValue'))), + body: tOptional(tString), + isBase64: tOptional(tBoolean), + fetchResponseUid: tOptional(tString), +}); +scheme.RouteFulfillResult = tOptional(tObject({})); +scheme.WebSocketRouteInitializer = tObject({ + url: tString, +}); +scheme.WebSocketRouteMessageFromPageEvent = tObject({ + message: tString, + isBase64: tBoolean, +}); +scheme.WebSocketRouteMessageFromServerEvent = tObject({ + message: tString, + isBase64: tBoolean, +}); +scheme.WebSocketRouteClosePageEvent = tObject({ + code: tOptional(tInt), + reason: tOptional(tString), + wasClean: tBoolean, +}); +scheme.WebSocketRouteCloseServerEvent = tObject({ + code: tOptional(tInt), + reason: tOptional(tString), + wasClean: tBoolean, +}); +scheme.WebSocketRouteConnectParams = tOptional(tObject({})); +scheme.WebSocketRouteConnectResult = tOptional(tObject({})); +scheme.WebSocketRouteEnsureOpenedParams = tOptional(tObject({})); +scheme.WebSocketRouteEnsureOpenedResult = tOptional(tObject({})); +scheme.WebSocketRouteSendToPageParams = tObject({ + message: tString, + isBase64: tBoolean, +}); +scheme.WebSocketRouteSendToPageResult = tOptional(tObject({})); +scheme.WebSocketRouteSendToServerParams = tObject({ + message: tString, + isBase64: tBoolean, +}); +scheme.WebSocketRouteSendToServerResult = tOptional(tObject({})); +scheme.WebSocketRouteClosePageParams = tObject({ + code: tOptional(tInt), + reason: tOptional(tString), + wasClean: tBoolean, +}); +scheme.WebSocketRouteClosePageResult = tOptional(tObject({})); +scheme.WebSocketRouteCloseServerParams = tObject({ + code: tOptional(tInt), + reason: tOptional(tString), + wasClean: tBoolean, +}); +scheme.WebSocketRouteCloseServerResult = tOptional(tObject({})); +scheme.ResourceTiming = tObject({ + startTime: tFloat, + domainLookupStart: tFloat, + domainLookupEnd: tFloat, + connectStart: tFloat, + secureConnectionStart: tFloat, + connectEnd: tFloat, + requestStart: tFloat, + responseStart: tFloat, +}); +scheme.ResponseInitializer = tObject({ + request: tChannel(['Request']), + url: tString, + status: tInt, + statusText: tString, + headers: tArray(tType('NameValue')), + timing: tType('ResourceTiming'), + fromServiceWorker: tBoolean, +}); +scheme.ResponseBodyParams = tOptional(tObject({})); +scheme.ResponseBodyResult = tObject({ + binary: tBinary, +}); +scheme.ResponseSecurityDetailsParams = tOptional(tObject({})); +scheme.ResponseSecurityDetailsResult = tObject({ + value: tOptional(tType('SecurityDetails')), +}); +scheme.ResponseServerAddrParams = tOptional(tObject({})); +scheme.ResponseServerAddrResult = tObject({ + value: tOptional(tType('RemoteAddr')), +}); +scheme.ResponseRawResponseHeadersParams = tOptional(tObject({})); +scheme.ResponseRawResponseHeadersResult = tObject({ + headers: tArray(tType('NameValue')), +}); +scheme.ResponseHttpVersionParams = tOptional(tObject({})); +scheme.ResponseHttpVersionResult = tObject({ + value: tString, +}); +scheme.ResponseSizesParams = tOptional(tObject({})); +scheme.ResponseSizesResult = tObject({ + sizes: tType('RequestSizes'), +}); +scheme.SecurityDetails = tObject({ + issuer: tOptional(tString), + protocol: tOptional(tString), + subjectName: tOptional(tString), + validFrom: tOptional(tFloat), + validTo: tOptional(tFloat), +}); +scheme.RequestSizes = tObject({ + requestBodySize: tInt, + requestHeadersSize: tInt, + responseBodySize: tInt, + responseHeadersSize: tInt, +}); +scheme.RemoteAddr = tObject({ + ipAddress: tString, + port: tInt, +}); +scheme.WebSocketInitializer = tObject({ + url: tString, +}); +scheme.WebSocketOpenEvent = tOptional(tObject({})); +scheme.WebSocketFrameSentEvent = tObject({ + opcode: tInt, + data: tString, +}); +scheme.WebSocketFrameReceivedEvent = tObject({ + opcode: tInt, + data: tString, +}); +scheme.WebSocketSocketErrorEvent = tObject({ + error: tString, +}); +scheme.WebSocketCloseEvent = tOptional(tObject({})); +scheme.BindingCallInitializer = tObject({ + frame: tChannel(['Frame']), + name: tString, + args: tOptional(tArray(tType('SerializedValue'))), + handle: tOptional(tChannel(['ElementHandle', 'JSHandle'])), +}); +scheme.BindingCallRejectParams = tObject({ + error: tType('SerializedError'), +}); +scheme.BindingCallRejectResult = tOptional(tObject({})); +scheme.BindingCallResolveParams = tObject({ + result: tType('SerializedArgument'), +}); +scheme.BindingCallResolveResult = tOptional(tObject({})); +scheme.DebuggerInitializer = tOptional(tObject({})); +scheme.DebuggerPausedStateChangedEvent = tObject({ + pausedDetails: tArray(tObject({ + location: tObject({ + file: tString, + line: tOptional(tInt), + column: tOptional(tInt), + }), + title: tString, + })), +}); +scheme.DebuggerPauseParams = tOptional(tObject({})); +scheme.DebuggerPauseResult = tOptional(tObject({})); +scheme.DebuggerResumeParams = tOptional(tObject({})); +scheme.DebuggerResumeResult = tOptional(tObject({})); +scheme.DebuggerNextParams = tOptional(tObject({})); +scheme.DebuggerNextResult = tOptional(tObject({})); +scheme.DebuggerRunToParams = tObject({ + location: tObject({ + file: tString, + line: tOptional(tInt), + column: tOptional(tInt), + }), +}); +scheme.DebuggerRunToResult = tOptional(tObject({})); +scheme.DialogInitializer = tObject({ + page: tOptional(tChannel(['Page'])), + type: tString, + message: tString, + defaultValue: tString, +}); +scheme.DialogAcceptParams = tObject({ + promptText: tOptional(tString), +}); +scheme.DialogAcceptResult = tOptional(tObject({})); +scheme.DialogDismissParams = tOptional(tObject({})); +scheme.DialogDismissResult = tOptional(tObject({})); +scheme.TracingInitializer = tOptional(tObject({})); +scheme.TracingTracingStartParams = tObject({ + name: tOptional(tString), + snapshots: tOptional(tBoolean), + screenshots: tOptional(tBoolean), + live: tOptional(tBoolean), +}); +scheme.TracingTracingStartResult = tOptional(tObject({})); +scheme.TracingTracingStartChunkParams = tObject({ + name: tOptional(tString), + title: tOptional(tString), +}); +scheme.TracingTracingStartChunkResult = tObject({ + traceName: tString, +}); +scheme.TracingTracingGroupParams = tObject({ + name: tString, + location: tOptional(tObject({ + file: tString, + line: tOptional(tInt), + column: tOptional(tInt), + })), +}); +scheme.TracingTracingGroupResult = tOptional(tObject({})); +scheme.TracingTracingGroupEndParams = tOptional(tObject({})); +scheme.TracingTracingGroupEndResult = tOptional(tObject({})); +scheme.TracingTracingStopChunkParams = tObject({ + mode: tEnum(['archive', 'discard', 'entries']), +}); +scheme.TracingTracingStopChunkResult = tObject({ + artifact: tOptional(tChannel(['Artifact'])), + entries: tOptional(tArray(tType('NameValue'))), +}); +scheme.TracingTracingStopParams = tOptional(tObject({})); +scheme.TracingTracingStopResult = tOptional(tObject({})); +scheme.ArtifactInitializer = tObject({ + absolutePath: tString, +}); +scheme.ArtifactPathAfterFinishedParams = tOptional(tObject({})); +scheme.ArtifactPathAfterFinishedResult = tObject({ + value: tString, +}); +scheme.ArtifactSaveAsParams = tObject({ + path: tString, +}); +scheme.ArtifactSaveAsResult = tOptional(tObject({})); +scheme.ArtifactSaveAsStreamParams = tOptional(tObject({})); +scheme.ArtifactSaveAsStreamResult = tObject({ + stream: tChannel(['Stream']), +}); +scheme.ArtifactFailureParams = tOptional(tObject({})); +scheme.ArtifactFailureResult = tObject({ + error: tOptional(tString), +}); +scheme.ArtifactStreamParams = tOptional(tObject({})); +scheme.ArtifactStreamResult = tObject({ + stream: tChannel(['Stream']), +}); +scheme.ArtifactCancelParams = tOptional(tObject({})); +scheme.ArtifactCancelResult = tOptional(tObject({})); +scheme.ArtifactDeleteParams = tOptional(tObject({})); +scheme.ArtifactDeleteResult = tOptional(tObject({})); +scheme.StreamInitializer = tOptional(tObject({})); +scheme.StreamReadParams = tObject({ + size: tOptional(tInt), +}); +scheme.StreamReadResult = tObject({ + binary: tBinary, +}); +scheme.StreamCloseParams = tOptional(tObject({})); +scheme.StreamCloseResult = tOptional(tObject({})); +scheme.WritableStreamInitializer = tOptional(tObject({})); +scheme.WritableStreamWriteParams = tObject({ + binary: tBinary, +}); +scheme.WritableStreamWriteResult = tOptional(tObject({})); +scheme.WritableStreamCloseParams = tOptional(tObject({})); +scheme.WritableStreamCloseResult = tOptional(tObject({})); +scheme.CDPSessionInitializer = tOptional(tObject({})); +scheme.CDPSessionEventEvent = tObject({ + method: tString, + params: tOptional(tAny), +}); +scheme.CDPSessionCloseEvent = tOptional(tObject({})); +scheme.CDPSessionSendParams = tObject({ + method: tString, + params: tOptional(tAny), +}); +scheme.CDPSessionSendResult = tObject({ + result: tAny, +}); +scheme.CDPSessionDetachParams = tOptional(tObject({})); +scheme.CDPSessionDetachResult = tOptional(tObject({})); +scheme.ElectronInitializer = tOptional(tObject({})); +scheme.ElectronLaunchParams = tObject({ + executablePath: tOptional(tString), + args: tOptional(tArray(tString)), + chromiumSandbox: tOptional(tBoolean), + cwd: tOptional(tString), + env: tOptional(tArray(tType('NameValue'))), + timeout: tFloat, + acceptDownloads: tOptional(tEnum(['accept', 'deny', 'internal-browser-default'])), + bypassCSP: tOptional(tBoolean), + colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference', 'no-override'])), + extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), + geolocation: tOptional(tObject({ + longitude: tFloat, + latitude: tFloat, + accuracy: tOptional(tFloat), + })), + httpCredentials: tOptional(tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + })), + ignoreHTTPSErrors: tOptional(tBoolean), + locale: tOptional(tString), + offline: tOptional(tBoolean), + recordVideo: tOptional(tObject({ + dir: tString, + size: tOptional(tObject({ + width: tInt, + height: tInt, + })), + })), + strictSelectors: tOptional(tBoolean), + timezoneId: tOptional(tString), + tracesDir: tOptional(tString), + selectorEngines: tOptional(tArray(tType('SelectorEngine'))), + testIdAttributeName: tOptional(tString), +}); +scheme.ElectronLaunchResult = tObject({ + electronApplication: tChannel(['ElectronApplication']), +}); +scheme.ElectronApplicationInitializer = tObject({ + context: tChannel(['BrowserContext']), +}); +scheme.ElectronApplicationCloseEvent = tOptional(tObject({})); +scheme.ElectronApplicationConsoleEvent = tObject({ + type: tString, + text: tString, + args: tArray(tChannel(['ElementHandle', 'JSHandle'])), + location: tObject({ + url: tString, + lineNumber: tInt, + columnNumber: tInt, + }), + timestamp: tFloat, +}); +scheme.ElectronApplicationBrowserWindowParams = tObject({ + page: tChannel(['Page']), +}); +scheme.ElectronApplicationBrowserWindowResult = tObject({ + handle: tChannel(['ElementHandle', 'JSHandle']), +}); +scheme.ElectronApplicationEvaluateExpressionParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType('SerializedArgument'), +}); +scheme.ElectronApplicationEvaluateExpressionResult = tObject({ + value: tType('SerializedValue'), +}); +scheme.ElectronApplicationEvaluateExpressionHandleParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType('SerializedArgument'), +}); +scheme.ElectronApplicationEvaluateExpressionHandleResult = tObject({ + handle: tChannel(['ElementHandle', 'JSHandle']), +}); +scheme.ElectronApplicationUpdateSubscriptionParams = tObject({ + event: tEnum(['console']), + enabled: tBoolean, +}); +scheme.ElectronApplicationUpdateSubscriptionResult = tOptional(tObject({})); +scheme.AndroidInitializer = tOptional(tObject({})); +scheme.AndroidDevicesParams = tObject({ + host: tOptional(tString), + port: tOptional(tInt), + omitDriverInstall: tOptional(tBoolean), +}); +scheme.AndroidDevicesResult = tObject({ + devices: tArray(tChannel(['AndroidDevice'])), +}); +scheme.AndroidSocketInitializer = tOptional(tObject({})); +scheme.AndroidSocketDataEvent = tObject({ + data: tBinary, +}); +scheme.AndroidSocketCloseEvent = tOptional(tObject({})); +scheme.AndroidSocketWriteParams = tObject({ + data: tBinary, +}); +scheme.AndroidSocketWriteResult = tOptional(tObject({})); +scheme.AndroidSocketCloseParams = tOptional(tObject({})); +scheme.AndroidSocketCloseResult = tOptional(tObject({})); +scheme.AndroidDeviceInitializer = tObject({ + model: tString, + serial: tString, +}); +scheme.AndroidDeviceCloseEvent = tOptional(tObject({})); +scheme.AndroidDeviceWebViewAddedEvent = tObject({ + webView: tType('AndroidWebView'), +}); +scheme.AndroidDeviceWebViewRemovedEvent = tObject({ + socketName: tString, +}); +scheme.AndroidDeviceWaitParams = tObject({ + androidSelector: tType('AndroidSelector'), + state: tOptional(tEnum(['gone'])), + timeout: tFloat, +}); +scheme.AndroidDeviceWaitResult = tOptional(tObject({})); +scheme.AndroidDeviceFillParams = tObject({ + androidSelector: tType('AndroidSelector'), + text: tString, + timeout: tFloat, +}); +scheme.AndroidDeviceFillResult = tOptional(tObject({})); +scheme.AndroidDeviceTapParams = tObject({ + androidSelector: tType('AndroidSelector'), + duration: tOptional(tFloat), + timeout: tFloat, +}); +scheme.AndroidDeviceTapResult = tOptional(tObject({})); +scheme.AndroidDeviceDragParams = tObject({ + androidSelector: tType('AndroidSelector'), + dest: tType('Point'), + speed: tOptional(tFloat), + timeout: tFloat, +}); +scheme.AndroidDeviceDragResult = tOptional(tObject({})); +scheme.AndroidDeviceFlingParams = tObject({ + androidSelector: tType('AndroidSelector'), + direction: tEnum(['up', 'down', 'left', 'right']), + speed: tOptional(tFloat), + timeout: tFloat, +}); +scheme.AndroidDeviceFlingResult = tOptional(tObject({})); +scheme.AndroidDeviceLongTapParams = tObject({ + androidSelector: tType('AndroidSelector'), + timeout: tFloat, +}); +scheme.AndroidDeviceLongTapResult = tOptional(tObject({})); +scheme.AndroidDevicePinchCloseParams = tObject({ + androidSelector: tType('AndroidSelector'), + percent: tFloat, + speed: tOptional(tFloat), + timeout: tFloat, +}); +scheme.AndroidDevicePinchCloseResult = tOptional(tObject({})); +scheme.AndroidDevicePinchOpenParams = tObject({ + androidSelector: tType('AndroidSelector'), + percent: tFloat, + speed: tOptional(tFloat), + timeout: tFloat, +}); +scheme.AndroidDevicePinchOpenResult = tOptional(tObject({})); +scheme.AndroidDeviceScrollParams = tObject({ + androidSelector: tType('AndroidSelector'), + direction: tEnum(['up', 'down', 'left', 'right']), + percent: tFloat, + speed: tOptional(tFloat), + timeout: tFloat, +}); +scheme.AndroidDeviceScrollResult = tOptional(tObject({})); +scheme.AndroidDeviceSwipeParams = tObject({ + androidSelector: tType('AndroidSelector'), + direction: tEnum(['up', 'down', 'left', 'right']), + percent: tFloat, + speed: tOptional(tFloat), + timeout: tFloat, +}); +scheme.AndroidDeviceSwipeResult = tOptional(tObject({})); +scheme.AndroidDeviceInfoParams = tObject({ + androidSelector: tType('AndroidSelector'), +}); +scheme.AndroidDeviceInfoResult = tObject({ + info: tType('AndroidElementInfo'), +}); +scheme.AndroidDeviceScreenshotParams = tOptional(tObject({})); +scheme.AndroidDeviceScreenshotResult = tObject({ + binary: tBinary, +}); +scheme.AndroidDeviceInputTypeParams = tObject({ + text: tString, +}); +scheme.AndroidDeviceInputTypeResult = tOptional(tObject({})); +scheme.AndroidDeviceInputPressParams = tObject({ + key: tString, +}); +scheme.AndroidDeviceInputPressResult = tOptional(tObject({})); +scheme.AndroidDeviceInputTapParams = tObject({ + point: tType('Point'), +}); +scheme.AndroidDeviceInputTapResult = tOptional(tObject({})); +scheme.AndroidDeviceInputSwipeParams = tObject({ + segments: tArray(tType('Point')), + steps: tInt, +}); +scheme.AndroidDeviceInputSwipeResult = tOptional(tObject({})); +scheme.AndroidDeviceInputDragParams = tObject({ + from: tType('Point'), + to: tType('Point'), + steps: tInt, +}); +scheme.AndroidDeviceInputDragResult = tOptional(tObject({})); +scheme.AndroidDeviceLaunchBrowserParams = tObject({ + noDefaultViewport: tOptional(tBoolean), + viewport: tOptional(tObject({ + width: tInt, + height: tInt, + })), + screen: tOptional(tObject({ + width: tInt, + height: tInt, + })), + ignoreHTTPSErrors: tOptional(tBoolean), + clientCertificates: tOptional(tArray(tObject({ + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), + }))), + javaScriptEnabled: tOptional(tBoolean), + bypassCSP: tOptional(tBoolean), + userAgent: tOptional(tString), + locale: tOptional(tString), + timezoneId: tOptional(tString), + geolocation: tOptional(tObject({ + longitude: tFloat, + latitude: tFloat, + accuracy: tOptional(tFloat), + })), + permissions: tOptional(tArray(tString)), + extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), + offline: tOptional(tBoolean), + httpCredentials: tOptional(tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + send: tOptional(tEnum(['always', 'unauthorized'])), + })), + deviceScaleFactor: tOptional(tFloat), + isMobile: tOptional(tBoolean), + hasTouch: tOptional(tBoolean), + colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference', 'no-override'])), + reducedMotion: tOptional(tEnum(['reduce', 'no-preference', 'no-override'])), + forcedColors: tOptional(tEnum(['active', 'none', 'no-override'])), + acceptDownloads: tOptional(tEnum(['accept', 'deny', 'internal-browser-default'])), + contrast: tOptional(tEnum(['no-preference', 'more', 'no-override'])), + baseURL: tOptional(tString), + recordVideo: tOptional(tObject({ + dir: tString, + size: tOptional(tObject({ + width: tInt, + height: tInt, + })), + })), + strictSelectors: tOptional(tBoolean), + serviceWorkers: tOptional(tEnum(['allow', 'block'])), + selectorEngines: tOptional(tArray(tType('SelectorEngine'))), + testIdAttributeName: tOptional(tString), + pkg: tOptional(tString), + args: tOptional(tArray(tString)), + proxy: tOptional(tObject({ + server: tString, + bypass: tOptional(tString), + username: tOptional(tString), + password: tOptional(tString), + })), +}); +scheme.AndroidDeviceLaunchBrowserResult = tObject({ + context: tChannel(['BrowserContext']), +}); +scheme.AndroidDeviceOpenParams = tObject({ + command: tString, +}); +scheme.AndroidDeviceOpenResult = tObject({ + socket: tChannel(['AndroidSocket']), +}); +scheme.AndroidDeviceShellParams = tObject({ + command: tString, +}); +scheme.AndroidDeviceShellResult = tObject({ + result: tBinary, +}); +scheme.AndroidDeviceInstallApkParams = tObject({ + file: tBinary, + args: tOptional(tArray(tString)), +}); +scheme.AndroidDeviceInstallApkResult = tOptional(tObject({})); +scheme.AndroidDevicePushParams = tObject({ + file: tBinary, + path: tString, + mode: tOptional(tInt), +}); +scheme.AndroidDevicePushResult = tOptional(tObject({})); +scheme.AndroidDeviceConnectToWebViewParams = tObject({ + socketName: tString, +}); +scheme.AndroidDeviceConnectToWebViewResult = tObject({ + context: tChannel(['BrowserContext']), +}); +scheme.AndroidDeviceCloseParams = tOptional(tObject({})); +scheme.AndroidDeviceCloseResult = tOptional(tObject({})); +scheme.AndroidWebView = tObject({ + pid: tInt, + pkg: tString, + socketName: tString, +}); +scheme.AndroidSelector = tObject({ + checkable: tOptional(tBoolean), + checked: tOptional(tBoolean), + clazz: tOptional(tString), + clickable: tOptional(tBoolean), + depth: tOptional(tInt), + desc: tOptional(tString), + enabled: tOptional(tBoolean), + focusable: tOptional(tBoolean), + focused: tOptional(tBoolean), + hasChild: tOptional(tObject({ + androidSelector: tType('AndroidSelector'), + })), + hasDescendant: tOptional(tObject({ + androidSelector: tType('AndroidSelector'), + maxDepth: tOptional(tInt), + })), + longClickable: tOptional(tBoolean), + pkg: tOptional(tString), + res: tOptional(tString), + scrollable: tOptional(tBoolean), + selected: tOptional(tBoolean), + text: tOptional(tString), +}); +scheme.AndroidElementInfo = tObject({ + children: tOptional(tArray(tType('AndroidElementInfo'))), + clazz: tString, + desc: tString, + res: tString, + pkg: tString, + text: tString, + bounds: tType('Rect'), + checkable: tBoolean, + checked: tBoolean, + clickable: tBoolean, + enabled: tBoolean, + focusable: tBoolean, + focused: tBoolean, + longClickable: tBoolean, + scrollable: tBoolean, + selected: tBoolean, +}); +scheme.JsonPipeInitializer = tOptional(tObject({})); +scheme.JsonPipeMessageEvent = tObject({ + message: tAny, +}); +scheme.JsonPipeClosedEvent = tObject({ + reason: tOptional(tString), +}); +scheme.JsonPipeSendParams = tObject({ + message: tAny, +}); +scheme.JsonPipeSendResult = tOptional(tObject({})); +scheme.JsonPipeCloseParams = tOptional(tObject({})); +scheme.JsonPipeCloseResult = tOptional(tObject({})); diff --git a/daemon/src/sandbox/forked-client/src/protocol/validatorPrimitives.ts b/daemon/src/sandbox/forked-client/src/protocol/validatorPrimitives.ts new file mode 100644 index 00000000..376f1aa8 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/protocol/validatorPrimitives.ts @@ -0,0 +1,157 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class ValidationError extends Error {} +export type Validator = (arg: any, path: string, context: ValidatorContext) => any; +export type ValidatorContext = { + tChannelImpl: (names: '*' | string[], arg: any, path: string, context: ValidatorContext) => any; + binary: 'toBase64' | 'fromBase64' | 'buffer'; + isUnderTest: () => boolean; +}; +export const scheme: { [key: string]: Validator } = {}; + +export function findValidator(type: string, method: string, kind: 'Initializer' | 'Event' | 'Params' | 'Result'): Validator { + const validator = maybeFindValidator(type, method, kind); + if (!validator) + throw new ValidationError(`Unknown scheme for ${kind}: ${type}.${method}`); + return validator; +} +export function maybeFindValidator(type: string, method: string, kind: 'Initializer' | 'Event' | 'Params' | 'Result'): Validator | undefined { + const schemeName = type + (kind === 'Initializer' ? '' : method[0].toUpperCase() + method.substring(1)) + kind; + return scheme[schemeName]; +} +export function createMetadataValidator(): Validator { + return tOptional(scheme['Metadata']); +} + +export const tFloat: Validator = (arg: any, path: string, context: ValidatorContext) => { + if (arg instanceof Number) + return arg.valueOf(); + if (typeof arg === 'number') + return arg; + throw new ValidationError(`${path}: expected float, got ${typeof arg}`); +}; +export const tInt: Validator = (arg: any, path: string, context: ValidatorContext) => { + let value: number; + if (arg instanceof Number) + value = arg.valueOf(); + else if (typeof arg === 'number') + value = arg; + else + throw new ValidationError(`${path}: expected integer, got ${typeof arg}`); + if (!Number.isInteger(value)) + throw new ValidationError(`${path}: expected integer, got float ${value}`); + return value; +}; +export const tBoolean: Validator = (arg: any, path: string, context: ValidatorContext) => { + if (arg instanceof Boolean) + return arg.valueOf(); + if (typeof arg === 'boolean') + return arg; + throw new ValidationError(`${path}: expected boolean, got ${typeof arg}`); +}; +export const tString: Validator = (arg: any, path: string, context: ValidatorContext) => { + if (arg instanceof String) + return arg.valueOf(); + if (typeof arg === 'string') + return arg; + throw new ValidationError(`${path}: expected string, got ${typeof arg}`); +}; +export const tBinary: Validator = (arg: any, path: string, context: ValidatorContext) => { + if (context.binary === 'fromBase64') { + if (arg instanceof String) + return Buffer.from(arg.valueOf(), 'base64'); + if (typeof arg === 'string') + return Buffer.from(arg, 'base64'); + throw new ValidationError(`${path}: expected base64-encoded buffer, got ${typeof arg}`); + } + if (context.binary === 'toBase64') { + if (!(arg instanceof Buffer)) + throw new ValidationError(`${path}: expected Buffer, got ${typeof arg}`); + return (arg as Buffer).toString('base64'); + } + if (context.binary === 'buffer') { + // TODO: support custom binary types. + if (!(arg instanceof Buffer) && !(arg instanceof Object)) + throw new ValidationError(`${path}: expected Buffer, got ${typeof arg}`); + return arg; + } + throw new ValidationError(`Unsupported binary behavior "${context.binary}"`); +}; +export const tUndefined: Validator = (arg: any, path: string, context: ValidatorContext) => { + if (Object.is(arg, undefined)) + return arg; + throw new ValidationError(`${path}: expected undefined, got ${typeof arg}`); +}; +export const tAny: Validator = (arg: any, path: string, context: ValidatorContext) => { + return arg; +}; +export const tOptional = (v: Validator): Validator => { + return (arg: any, path: string, context: ValidatorContext) => { + if (Object.is(arg, undefined)) + return arg; + return v(arg, path, context); + }; +}; +export const tArray = (v: Validator): Validator => { + return (arg: any, path: string, context: ValidatorContext) => { + if (!Array.isArray(arg)) + throw new ValidationError(`${path}: expected array, got ${typeof arg}`); + return arg.map((x, index) => v(x, path + '[' + index + ']', context)); + }; +}; +export const tObject = (s: { [key: string]: Validator }): Validator => { + return (arg: any, path: string, context: ValidatorContext) => { + if (Object.is(arg, null)) + throw new ValidationError(`${path}: expected object, got null`); + if (typeof arg !== 'object') + throw new ValidationError(`${path}: expected object, got ${typeof arg}`); + const result: any = {}; + for (const [key, v] of Object.entries(s)) { + const value = v(arg[key], path ? path + '.' + key : key, context); + if (!Object.is(value, undefined)) + result[key] = value; + } + if (context.isUnderTest()) { + for (const [key, value] of Object.entries(arg)) { + if (key.startsWith('__testHook')) + result[key] = value; + } + } + return result; + }; +}; +export const tEnum = (e: string[]): Validator => { + return (arg: any, path: string, context: ValidatorContext) => { + if (!e.includes(arg)) + throw new ValidationError(`${path}: expected one of (${e.join('|')})`); + return arg; + }; +}; +export const tChannel = (names: '*' | string[]): Validator => { + return (arg: any, path: string, context: ValidatorContext) => { + return context.tChannelImpl(names, arg, path, context); + }; +}; +export const tType = (name: string): Validator => { + return (arg: any, path: string, context: ValidatorContext) => { + const v = scheme[name]; + if (!v) + throw new ValidationError(path + ': unknown type "' + name + '"'); + return v(arg, path, context); + }; +}; diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/ariaSnapshot.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/ariaSnapshot.ts new file mode 100644 index 00000000..f9445213 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/ariaSnapshot.ts @@ -0,0 +1,580 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// https://www.w3.org/TR/wai-aria-1.2/#role_definitions + +export type AriaRole = 'alert' | 'alertdialog' | 'application' | 'article' | 'banner' | 'blockquote' | 'button' | 'caption' | 'cell' | 'checkbox' | 'code' | 'columnheader' | 'combobox' | + 'complementary' | 'contentinfo' | 'definition' | 'deletion' | 'dialog' | 'directory' | 'document' | 'emphasis' | 'feed' | 'figure' | 'form' | 'generic' | 'grid' | + 'gridcell' | 'group' | 'heading' | 'img' | 'insertion' | 'link' | 'list' | 'listbox' | 'listitem' | 'log' | 'main' | 'mark' | 'marquee' | 'math' | 'meter' | 'menu' | + 'menubar' | 'menuitem' | 'menuitemcheckbox' | 'menuitemradio' | 'navigation' | 'none' | 'note' | 'option' | 'paragraph' | 'presentation' | 'progressbar' | 'radio' | 'radiogroup' | + 'region' | 'row' | 'rowgroup' | 'rowheader' | 'scrollbar' | 'search' | 'searchbox' | 'separator' | 'slider' | + 'spinbutton' | 'status' | 'strong' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' | + 'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem'; + +// Note: please keep in sync with ariaPropsEqual() below. +export type AriaProps = { + checked?: boolean | 'mixed'; + disabled?: boolean; + expanded?: boolean; + active?: boolean; + level?: number; + pressed?: boolean | 'mixed'; + selected?: boolean; +}; + +export type AriaBox = { + visible: boolean; + inline: boolean; + cursor?: string; +}; + +// Note: please keep in sync with ariaNodesEqual() below. +export type AriaNode = AriaProps & { + role: AriaRole | 'fragment' | 'iframe'; + name: string; + ref?: string; + children: (AriaNode | string)[]; + box: AriaBox; + receivesPointerEvents: boolean; + props: Record; +}; + +export function ariaNodesEqual(a: AriaNode, b: AriaNode): boolean { + if (a.role !== b.role || a.name !== b.name) + return false; + if (!ariaPropsEqual(a, b) || hasPointerCursor(a) !== hasPointerCursor(b)) + return false; + const aKeys = Object.keys(a.props); + const bKeys = Object.keys(b.props); + return aKeys.length === bKeys.length && aKeys.every(k => a.props[k] === b.props[k]); +} + +export function hasPointerCursor(ariaNode: AriaNode): boolean { + return ariaNode.box.cursor === 'pointer'; +} + +function ariaPropsEqual(a: AriaProps, b: AriaProps): boolean { + return a.active === b.active && a.checked === b.checked && a.disabled === b.disabled && a.expanded === b.expanded && a.selected === b.selected && a.level === b.level && a.pressed === b.pressed; +} + +// We pass parsed template between worlds using JSON, make it easy. +export type AriaRegex = { pattern: string }; + +// We can't tell apart pattern and text, so we pass both. +export type AriaTextValue = { + raw: string; + normalized: string; +}; + +export type AriaTemplateTextNode = { + kind: 'text'; + text: AriaTextValue; +}; + +export type AriaTemplateRoleNode = AriaProps & { + kind: 'role'; + role: AriaRole | 'fragment'; + name?: AriaRegex | string; + children?: AriaTemplateNode[]; + props?: Record; + containerMode?: 'contain' | 'equal' | 'deep-equal'; +}; + +export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode; + +import type * as yamlTypes from 'yaml'; + +type YamlLibrary = { + parseDocument: typeof yamlTypes.parseDocument; + Scalar: typeof yamlTypes.Scalar; + YAMLMap: typeof yamlTypes.YAMLMap; + YAMLSeq: typeof yamlTypes.YAMLSeq; + LineCounter: typeof yamlTypes.LineCounter; +}; + +type ParsedYamlPosition = { line: number; col: number; }; +type ParsingOptions = yamlTypes.ParseOptions; + +export type ParsedYamlError = { + message: string; + range: [ParsedYamlPosition, ParsedYamlPosition]; +}; + +export function parseAriaSnapshotUnsafe(yaml: YamlLibrary, text: string, options: ParsingOptions = {}): AriaTemplateNode { + const result = parseAriaSnapshot(yaml, text, options); + if (result.errors.length) + throw new Error(result.errors[0].message); + return result.fragment; +} + +export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: ParsingOptions = {}): { fragment: AriaTemplateNode, errors: ParsedYamlError[] } { + const lineCounter = new yaml.LineCounter(); + const parseOptions: ParsingOptions = { + keepSourceTokens: true, + lineCounter, + ...options, + }; + const yamlDoc = yaml.parseDocument(text, parseOptions); + const errors: ParsedYamlError[] = []; + + const convertRange = (range: [number, number] | yamlTypes.Range): [ParsedYamlPosition, ParsedYamlPosition] => { + return [lineCounter.linePos(range[0]), lineCounter.linePos(range[1])]; + }; + + const addError = (error: yamlTypes.YAMLError) => { + errors.push({ + message: error.message, + range: [lineCounter.linePos(error.pos[0]), lineCounter.linePos(error.pos[1])], + }); + }; + + const convertSeq = (container: AriaTemplateRoleNode, seq: yamlTypes.YAMLSeq) => { + for (const item of seq.items) { + const itemIsString = item instanceof yaml.Scalar && typeof item.value === 'string'; + if (itemIsString) { + const childNode = KeyParser.parse(item, parseOptions, errors); + if (childNode) { + container.children = container.children || []; + container.children.push(childNode); + } + continue; + } + const itemIsMap = item instanceof yaml.YAMLMap; + if (itemIsMap) { + convertMap(container, item); + continue; + } + errors.push({ + message: 'Sequence items should be strings or maps', + range: convertRange((item as any).range || seq.range), + }); + } + }; + + const convertMap = (container: AriaTemplateRoleNode, map: yamlTypes.YAMLMap) => { + for (const entry of map.items) { + container.children = container.children || []; + // Key must by a string + const keyIsString = entry.key instanceof yaml.Scalar && typeof entry.key.value === 'string'; + if (!keyIsString) { + errors.push({ + message: 'Only string keys are supported', + range: convertRange((entry.key as any).range || map.range), + }); + continue; + } + + const key: yamlTypes.Scalar = entry.key as yamlTypes.Scalar; + const value = entry.value; + + // - text: "text" + if (key.value === 'text') { + const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string'; + if (!valueIsString) { + errors.push({ + message: 'Text value should be a string', + range: convertRange(((entry.value as any).range || map.range)), + }); + continue; + } + container.children.push({ + kind: 'text', + text: textValue(value.value) + }); + continue; + } + + // - /children: equal + if (key.value === '/children') { + const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string'; + if (!valueIsString || (value.value !== 'contain' && value.value !== 'equal' && value.value !== 'deep-equal')) { + errors.push({ + message: 'Strict value should be "contain", "equal" or "deep-equal"', + range: convertRange(((entry.value as any).range || map.range)), + }); + continue; + } + container.containerMode = value.value; + continue; + } + + // - /url: "about:blank" + if (key.value.startsWith('/')) { + const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string'; + if (!valueIsString) { + errors.push({ + message: 'Property value should be a string', + range: convertRange(((entry.value as any).range || map.range)), + }); + continue; + } + container.props = container.props ?? {}; + container.props[key.value.slice(1)] = textValue(value.value); + continue; + } + + // role "name": ... + const childNode = KeyParser.parse(key, parseOptions, errors); + if (!childNode) + continue; + + // - role "name": "text" + const valueIsScalar = value instanceof yaml.Scalar; + if (valueIsScalar) { + const type = typeof value.value; + if (type !== 'string' && type !== 'number' && type !== 'boolean') { + errors.push({ + message: 'Node value should be a string or a sequence', + range: convertRange(((entry.value as any).range || map.range)), + }); + continue; + } + + container.children.push({ + ...childNode, + children: [{ + kind: 'text', + text: textValue(String(value.value)) + }] + }); + continue; + } + + // - role "name": + // - child + const valueIsSequence = value instanceof yaml.YAMLSeq; + if (valueIsSequence) { + container.children.push(childNode); + convertSeq(childNode, value as yamlTypes.YAMLSeq); + continue; + } + + errors.push({ + message: 'Map values should be strings or sequences', + range: convertRange((entry.value as any).range || map.range), + }); + } + }; + + const fragment: AriaTemplateNode = { kind: 'role', role: 'fragment' }; + + yamlDoc.errors.forEach(addError); + if (errors.length) + return { errors, fragment }; + + if (!(yamlDoc.contents instanceof yaml.YAMLSeq)) { + errors.push({ + message: 'Aria snapshot must be a YAML sequence, elements starting with " -"', + range: yamlDoc.contents ? convertRange(yamlDoc.contents!.range) : [{ line: 0, col: 0 }, { line: 0, col: 0 }], + }); + } + if (errors.length) + return { errors, fragment }; + + convertSeq(fragment, yamlDoc.contents as yamlTypes.YAMLSeq); + if (errors.length) + return { errors, fragment: emptyFragment }; + // `- button` should target the button, not its parent. + if (fragment.children?.length === 1 && (!fragment.containerMode || fragment.containerMode === 'contain')) + return { fragment: fragment.children[0], errors: [] }; + return { fragment, errors: [] }; +} + +const emptyFragment: AriaTemplateRoleNode = { kind: 'role', role: 'fragment' }; + +function normalizeWhitespace(text: string) { + // TODO: why is this different from normalizeWhitespace in stringUtils.ts? + return text.replace(/[\u200b\u00ad]/g, '').replace(/[\r\n\s\t]+/g, ' ').trim(); +} + +export function textValue(value: string): AriaTextValue { + return { + raw: value, + normalized: normalizeWhitespace(value), + }; +} + +export class KeyParser { + private _input: string; + private _pos: number; + private _length: number; + + static parse(text: yamlTypes.Scalar, options: ParsingOptions, errors: ParsedYamlError[]): AriaTemplateRoleNode | null { + try { + return new KeyParser(text.value)._parse(); + } catch (e) { + if (e instanceof ParserError) { + const message = options.prettyErrors === false ? e.message : e.message + ':\n\n' + text.value + '\n' + ' '.repeat(e.pos) + '^\n'; + errors.push({ + message, + range: [options.lineCounter!.linePos(text.range![0]), options.lineCounter!.linePos(text.range![0] + e.pos)], + }); + return null; + } + throw e; + } + } + + constructor(input: string) { + this._input = input; + this._pos = 0; + this._length = input.length; + } + + private _peek() { + return this._input[this._pos] || ''; + } + + private _next() { + if (this._pos < this._length) + return this._input[this._pos++]; + return null; + } + + private _eof() { + return this._pos >= this._length; + } + + private _isWhitespace() { + return !this._eof() && /\s/.test(this._peek()); + } + + private _skipWhitespace() { + while (this._isWhitespace()) + this._pos++; + } + + private _readIdentifier(type: 'role' | 'attribute'): string { + if (this._eof()) + this._throwError(`Unexpected end of input when expecting ${type}`); + const start = this._pos; + while (!this._eof() && /[a-zA-Z]/.test(this._peek())) + this._pos++; + return this._input.slice(start, this._pos); + } + + private _readString(): string { + let result = ''; + let escaped = false; + while (!this._eof()) { + const ch = this._next(); + if (escaped) { + result += ch; + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === '"') { + return result; + } else { + result += ch; + } + } + this._throwError('Unterminated string'); + } + + private _throwError(message: string, offset: number = 0): never { + throw new ParserError(message, offset || this._pos); + } + + private _readRegex(): AriaRegex { + let result = ''; + let escaped = false; + let insideClass = false; + while (!this._eof()) { + const ch = this._next(); + if (escaped) { + result += ch; + escaped = false; + } else if (ch === '\\') { + escaped = true; + result += ch; + } else if (ch === '/' && !insideClass) { + return { pattern: result }; + } else if (ch === '[') { + insideClass = true; + result += ch; + } else if (ch === ']' && insideClass) { + result += ch; + insideClass = false; + } else { + result += ch; + } + } + this._throwError('Unterminated regex'); + } + + private _readStringOrRegex(): string | AriaRegex | null { + const ch = this._peek(); + if (ch === '"') { + this._next(); + return normalizeWhitespace(this._readString()); + } + + if (ch === '/') { + this._next(); + return this._readRegex(); + } + + return null; + } + + private _readAttributes(result: AriaTemplateRoleNode) { + let errorPos = this._pos; + while (true) { + this._skipWhitespace(); + if (this._peek() === '[') { + this._next(); + this._skipWhitespace(); + errorPos = this._pos; + const flagName = this._readIdentifier('attribute'); + this._skipWhitespace(); + let flagValue = ''; + if (this._peek() === '=') { + this._next(); + this._skipWhitespace(); + errorPos = this._pos; + while (this._peek() !== ']' && !this._isWhitespace() && !this._eof()) + flagValue += this._next(); + } + this._skipWhitespace(); + if (this._peek() !== ']') + this._throwError('Expected ]'); + + this._next(); // Consume ']' + this._applyAttribute(result, flagName, flagValue || 'true', errorPos); + } else { + break; + } + } + } + + _parse(): AriaTemplateRoleNode { + this._skipWhitespace(); + + const role = this._readIdentifier('role') as AriaTemplateRoleNode['role']; + this._skipWhitespace(); + const name = this._readStringOrRegex() || ''; + const result: AriaTemplateRoleNode = { kind: 'role', role, name }; + this._readAttributes(result); + this._skipWhitespace(); + if (!this._eof()) + this._throwError('Unexpected input'); + return result; + } + + private _applyAttribute(node: AriaTemplateRoleNode, key: string, value: string, errorPos: number) { + if (key === 'checked') { + this._assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "checked\" attribute must be a boolean or "mixed"', errorPos); + node.checked = value === 'true' ? true : value === 'false' ? false : 'mixed'; + return; + } + if (key === 'disabled') { + this._assert(value === 'true' || value === 'false', 'Value of "disabled" attribute must be a boolean', errorPos); + node.disabled = value === 'true'; + return; + } + if (key === 'expanded') { + this._assert(value === 'true' || value === 'false', 'Value of "expanded" attribute must be a boolean', errorPos); + node.expanded = value === 'true'; + return; + } + if (key === 'active') { + this._assert(value === 'true' || value === 'false', 'Value of "active" attribute must be a boolean', errorPos); + node.active = value === 'true'; + return; + } + if (key === 'level') { + this._assert(!isNaN(Number(value)), 'Value of "level" attribute must be a number', errorPos); + node.level = Number(value); + return; + } + if (key === 'pressed') { + this._assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "pressed" attribute must be a boolean or "mixed"', errorPos); + node.pressed = value === 'true' ? true : value === 'false' ? false : 'mixed'; + return; + } + if (key === 'selected') { + this._assert(value === 'true' || value === 'false', 'Value of "selected" attribute must be a boolean', errorPos); + node.selected = value === 'true'; + return; + } + this._assert(false, `Unsupported attribute [${key}]`, errorPos); + } + + private _assert(value: any, message: string, valuePos: number): asserts value { + if (!value) + this._throwError(message || 'Assertion error', valuePos); + } +} + +export class ParserError extends Error { + readonly pos: number; + + constructor(message: string, pos: number) { + super(message); + this.pos = pos; + } +} + +export function findNewNode(from: AriaNode | undefined, to: AriaNode): AriaNode | undefined { + type ByRoleAndName = Map>; + + function fillMap(root: AriaNode, map: ByRoleAndName, position: number) { + let size = 1; + let childPosition = position + size; + for (const child of root.children || []) { + if (typeof child === 'string') { + size++; + childPosition++; + } else { + size += fillMap(child, map, childPosition); + childPosition += size; + } + } + if (!['none', 'presentation', 'fragment', 'iframe', 'generic'].includes(root.role) && root.name) { + let byRole = map.get(root.role); + if (!byRole) { + byRole = new Map(); + map.set(root.role, byRole); + } + const existing = byRole.get(root.name); + // This heuristic prioritizes elements at the top of the page, even if somewhat smaller. + const sizeAndPosition = size * 100 - position; + if (!existing || existing.sizeAndPosition < sizeAndPosition) + byRole.set(root.name, { node: root, sizeAndPosition }); + } + return size; + } + + const fromMap: ByRoleAndName = new Map(); + if (from) + fillMap(from, fromMap, 0); + + const toMap: ByRoleAndName = new Map(); + fillMap(to, toMap, 0); + + const result: { node: AriaNode, sizeAndPosition: number }[] = []; + for (const [role, byRole] of toMap) { + for (const [name, byName] of byRole) { + const inFrom = fromMap.get(role)?.get(name); + if (!inFrom) + result.push(byName); + } + } + result.sort((a, b) => b.sizeAndPosition - a.sizeAndPosition); + return result[0]?.node; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/assert.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/assert.ts new file mode 100644 index 00000000..159db264 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/assert.ts @@ -0,0 +1,21 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function assert(value: any, message?: string): asserts value { + if (!value) + throw new Error(message || 'Assertion error'); +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/colors.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/colors.ts new file mode 100644 index 00000000..d8656995 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/colors.ts @@ -0,0 +1,67 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const webColors = { + enabled: true, + reset: (text: string) => applyStyle(0, 0, text), + + bold: (text: string) => applyStyle(1, 22, text), + dim: (text: string) => applyStyle(2, 22, text), + italic: (text: string) => applyStyle(3, 23, text), + underline: (text: string) => applyStyle(4, 24, text), + inverse: (text: string) => applyStyle(7, 27, text), + hidden: (text: string) => applyStyle(8, 28, text), + strikethrough: (text: string) => applyStyle(9, 29, text), + + black: (text: string) => applyStyle(30, 39, text), + red: (text: string) => applyStyle(31, 39, text), + green: (text: string) => applyStyle(32, 39, text), + yellow: (text: string) => applyStyle(33, 39, text), + blue: (text: string) => applyStyle(34, 39, text), + magenta: (text: string) => applyStyle(35, 39, text), + cyan: (text: string) => applyStyle(36, 39, text), + white: (text: string) => applyStyle(37, 39, text), + gray: (text: string) => applyStyle(90, 39, text), + grey: (text: string) => applyStyle(90, 39, text), +}; + +export type Colors = typeof webColors; + +export const noColors: Colors = { + enabled: false, + reset: t => t, + bold: t => t, + dim: t => t, + italic: t => t, + underline: t => t, + inverse: t => t, + hidden: t => t, + strikethrough: t => t, + black: t => t, + red: t => t, + green: t => t, + yellow: t => t, + blue: t => t, + magenta: t => t, + cyan: t => t, + white: t => t, + gray: t => t, + grey: t => t, +}; + + +const applyStyle = (open: number, close: number, text: string) => `\u001b[${open}m${text}\u001b[${close}m`; diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/cssParser.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/cssParser.ts new file mode 100644 index 00000000..6bfb7f70 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/cssParser.ts @@ -0,0 +1,268 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as css from './cssTokenizer'; + +export class InvalidSelectorError extends Error { +} + +export function isInvalidSelectorError(error: Error) { + return error instanceof InvalidSelectorError; +} + +// Note: '>=' is used internally for text engine to preserve backwards compatibility. +type ClauseCombinator = '' | '>' | '+' | '~' | '>='; +// TODO: consider +// - key=value +// - operators like `=`, `|=`, `~=`, `*=`, `/` +// - ~=value +// - argument modes: "parse all", "parse commas", "just a string" +export type CSSFunctionArgument = CSSComplexSelector | number | string; +export type CSSFunction = { name: string, args: CSSFunctionArgument[] }; +export type CSSSimpleSelector = { css?: string, functions: CSSFunction[] }; +export type CSSComplexSelector = { simples: { selector: CSSSimpleSelector, combinator: ClauseCombinator }[] }; +export type CSSComplexSelectorList = CSSComplexSelector[]; + +export function parseCSS(selector: string, customNames: Set): { selector: CSSComplexSelectorList, names: string[] } { + let tokens: css.CSSTokenInterface[]; + try { + tokens = css.tokenize(selector); + if (!(tokens[tokens.length - 1] instanceof css.EOFToken)) + tokens.push(new css.EOFToken()); + } catch (e) { + const newMessage = e.message + ` while parsing css selector "${selector}". Did you mean to CSS.escape it?`; + const index = (e.stack || '').indexOf(e.message); + if (index !== -1) + e.stack = e.stack.substring(0, index) + newMessage + e.stack.substring(index + e.message.length); + e.message = newMessage; + throw e; + } + const unsupportedToken = tokens.find(token => { + return (token instanceof css.AtKeywordToken) || + (token instanceof css.BadStringToken) || + (token instanceof css.BadURLToken) || + (token instanceof css.ColumnToken) || + (token instanceof css.CDOToken) || + (token instanceof css.CDCToken) || + (token instanceof css.SemicolonToken) || + // TODO: Consider using these for something, e.g. to escape complex strings. + // For example :xpath{ (//div/bar[@attr="foo"])[2]/baz } + // Or this way :xpath( {complex-xpath-goes-here("hello")} ) + (token instanceof css.OpenCurlyToken) || + (token instanceof css.CloseCurlyToken) || + // TODO: Consider treating these as strings? + (token instanceof css.URLToken) || + (token instanceof css.PercentageToken); + }); + if (unsupportedToken) + throw new InvalidSelectorError(`Unsupported token "${unsupportedToken.toSource()}" while parsing css selector "${selector}". Did you mean to CSS.escape it?`); + + let pos = 0; + const names = new Set(); + + function unexpected() { + return new InvalidSelectorError(`Unexpected token "${tokens[pos].toSource()}" while parsing css selector "${selector}". Did you mean to CSS.escape it?`); + } + + function skipWhitespace() { + while (tokens[pos] instanceof css.WhitespaceToken) + pos++; + } + + function isIdent(p = pos) { + return tokens[p] instanceof css.IdentToken; + } + + function isString(p = pos) { + return tokens[p] instanceof css.StringToken; + } + + function isNumber(p = pos) { + return tokens[p] instanceof css.NumberToken; + } + + function isComma(p = pos) { + return tokens[p] instanceof css.CommaToken; + } + + function isOpenParen(p = pos) { + return tokens[p] instanceof css.OpenParenToken; + } + + function isCloseParen(p = pos) { + return tokens[p] instanceof css.CloseParenToken; + } + + function isFunction(p = pos) { + return tokens[p] instanceof css.FunctionToken; + } + + function isStar(p = pos) { + return (tokens[p] instanceof css.DelimToken) && tokens[p].value === '*'; + } + + function isEOF(p = pos) { + return tokens[p] instanceof css.EOFToken; + } + + function isClauseCombinator(p = pos) { + return (tokens[p] instanceof css.DelimToken) && (['>', '+', '~'].includes(tokens[p].value as string)); + } + + function isSelectorClauseEnd(p = pos) { + return isComma(p) || isCloseParen(p) || isEOF(p) || isClauseCombinator(p) || (tokens[p] instanceof css.WhitespaceToken); + } + + function consumeFunctionArguments(): CSSFunctionArgument[] { + const result = [consumeArgument()]; + while (true) { + skipWhitespace(); + if (!isComma()) + break; + pos++; + result.push(consumeArgument()); + } + return result; + } + + function consumeArgument(): CSSFunctionArgument { + skipWhitespace(); + if (isNumber()) + return tokens[pos++].value!; + if (isString()) + return tokens[pos++].value!; + return consumeComplexSelector(); + } + + function consumeComplexSelector(): CSSComplexSelector { + const result: CSSComplexSelector = { simples: [] }; + skipWhitespace(); + if (isClauseCombinator()) { + // Put implicit ":scope" at the start. https://drafts.csswg.org/selectors-4/#relative + result.simples.push({ selector: { functions: [{ name: 'scope', args: [] }] }, combinator: '' }); + } else { + result.simples.push({ selector: consumeSimpleSelector(), combinator: '' }); + } + while (true) { + skipWhitespace(); + if (isClauseCombinator()) { + result.simples[result.simples.length - 1].combinator = tokens[pos++].value as ClauseCombinator; + skipWhitespace(); + } else if (isSelectorClauseEnd()) { + break; + } + result.simples.push({ combinator: '', selector: consumeSimpleSelector() }); + } + return result; + } + + function consumeSimpleSelector(): CSSSimpleSelector { + let rawCSSString = ''; + const functions: CSSFunction[] = []; + + while (!isSelectorClauseEnd()) { + if (isIdent() || isStar()) { + rawCSSString += tokens[pos++].toSource(); + } else if (tokens[pos] instanceof css.HashToken) { + rawCSSString += tokens[pos++].toSource(); + } else if ((tokens[pos] instanceof css.DelimToken) && tokens[pos].value === '.') { + pos++; + if (isIdent()) + rawCSSString += '.' + tokens[pos++].toSource(); + else + throw unexpected(); + } else if (tokens[pos] instanceof css.ColonToken) { + pos++; + if (isIdent()) { + if (!customNames.has((tokens[pos].value as string).toLowerCase())) { + rawCSSString += ':' + tokens[pos++].toSource(); + } else { + const name = (tokens[pos++].value as string).toLowerCase(); + functions.push({ name, args: [] }); + names.add(name); + } + } else if (isFunction()) { + const name = (tokens[pos++].value as string).toLowerCase(); + if (!customNames.has(name)) { + rawCSSString += `:${name}(${consumeBuiltinFunctionArguments()})`; + } else { + functions.push({ name, args: consumeFunctionArguments() }); + names.add(name); + } + skipWhitespace(); + if (!isCloseParen()) + throw unexpected(); + pos++; + } else { + throw unexpected(); + } + } else if (tokens[pos] instanceof css.OpenSquareToken) { + rawCSSString += '['; + pos++; + while (!(tokens[pos] instanceof css.CloseSquareToken) && !isEOF()) + rawCSSString += tokens[pos++].toSource(); + if (!(tokens[pos] instanceof css.CloseSquareToken)) + throw unexpected(); + rawCSSString += ']'; + pos++; + } else { + throw unexpected(); + } + } + if (!rawCSSString && !functions.length) + throw unexpected(); + return { css: rawCSSString || undefined, functions }; + } + + function consumeBuiltinFunctionArguments(): string { + let s = ''; + let balance = 1; // First open paren is a part of a function token. + while (!isEOF()) { + if (isOpenParen() || isFunction()) + balance++; + if (isCloseParen()) + balance--; + if (!balance) + break; + s += tokens[pos++].toSource(); + } + return s; + } + + const result = consumeFunctionArguments(); + if (!isEOF()) + throw unexpected(); + if (result.some(arg => typeof arg !== 'object' || !('simples' in arg))) + throw new InvalidSelectorError(`Error while parsing css selector "${selector}". Did you mean to CSS.escape it?`); + return { selector: result as CSSComplexSelector[], names: Array.from(names) }; +} + +export function serializeSelector(args: CSSFunctionArgument[]) { + return args.map(arg => { + if (typeof arg === 'string') + return `"${arg}"`; + if (typeof arg === 'number') + return String(arg); + return arg.simples.map(({ selector, combinator }) => { + let s = selector.css || ''; + s = s + selector.functions.map(func => `:${func.name}(${serializeSelector(func.args)})`).join(''); + if (combinator) + s += ' ' + combinator; + return s; + }).join(' '); + }).join(', '); +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/cssTokenizer.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/cssTokenizer.ts new file mode 100644 index 00000000..9b5ce510 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/cssTokenizer.ts @@ -0,0 +1,967 @@ +// @ts-nocheck +/* eslint-disable notice/notice */ + +/* + * The code in this file is licensed under the CC0 license. + * http://creativecommons.org/publicdomain/zero/1.0/ + * It is free to use for any purpose. No attribution, permission, or reproduction of this license is required. + */ + +// Original at https://github.com/tabatkins/parse-css +// Changes: +// - JS is replaced with TS. +// - Universal Module Definition wrapper is removed. +// - Everything not related to tokenizing - below the first exports block - is removed. + +export interface CSSTokenInterface { + toSource(): string; + value: string | number | undefined; +} + +const between = function(num: number, first: number, last: number) { return num >= first && num <= last; }; +function digit(code: number) { return between(code, 0x30, 0x39); } +function hexdigit(code: number) { return digit(code) || between(code, 0x41, 0x46) || between(code, 0x61, 0x66); } +function uppercaseletter(code: number) { return between(code, 0x41, 0x5a); } +function lowercaseletter(code: number) { return between(code, 0x61, 0x7a); } +function letter(code: number) { return uppercaseletter(code) || lowercaseletter(code); } +function nonascii(code: number) { return code >= 0x80; } +function namestartchar(code: number) { return letter(code) || nonascii(code) || code === 0x5f; } +function namechar(code: number) { return namestartchar(code) || digit(code) || code === 0x2d; } +function nonprintable(code: number) { return between(code, 0, 8) || code === 0xb || between(code, 0xe, 0x1f) || code === 0x7f; } +function newline(code: number) { return code === 0xa; } +function whitespace(code: number) { return newline(code) || code === 9 || code === 0x20; } + +const maximumallowedcodepoint = 0x10ffff; + +export class InvalidCharacterError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidCharacterError'; + } +} + +function preprocess(str: string): number[] { + // Turn a string into an array of code points, + // following the preprocessing cleanup rules. + const codepoints = []; + for (let i = 0; i < str.length; i++) { + let code = str.charCodeAt(i); + if (code === 0xd && str.charCodeAt(i + 1) === 0xa) { + code = 0xa; i++; + } + if (code === 0xd || code === 0xc) + code = 0xa; + if (code === 0x0) + code = 0xfffd; + if (between(code, 0xd800, 0xdbff) && between(str.charCodeAt(i + 1), 0xdc00, 0xdfff)) { + // Decode a surrogate pair into an astral codepoint. + const lead = code - 0xd800; + const trail = str.charCodeAt(i + 1) - 0xdc00; + code = Math.pow(2, 16) + lead * Math.pow(2, 10) + trail; + i++; + } + codepoints.push(code); + } + return codepoints; +} + +function stringFromCode(code: number) { + if (code <= 0xffff) + return String.fromCharCode(code); + // Otherwise, encode astral char as surrogate pair. + code -= Math.pow(2, 16); + const lead = Math.floor(code / Math.pow(2, 10)) + 0xd800; + const trail = code % Math.pow(2, 10) + 0xdc00; + return String.fromCharCode(lead) + String.fromCharCode(trail); +} + +export function tokenize(str1: string): CSSTokenInterface[] { + const str = preprocess(str1); + let i = -1; + const tokens: CSSTokenInterface[] = []; + let code: number; + + // Line number information. + let line = 0; + let column = 0; + // The only use of lastLineLength is in reconsume(). + let lastLineLength = 0; + const incrLineno = function() { + line += 1; + lastLineLength = column; + column = 0; + }; + const locStart = { line: line, column: column }; + + const codepoint = function(i: number): number { + if (i >= str.length) + return -1; + + return str[i]; + }; + const next = function(num?: number) { + if (num === undefined) + num = 1; + if (num > 3) + throw 'Spec Error: no more than three codepoints of lookahead.'; + return codepoint(i + num); + }; + const consume = function(num?: number): boolean { + if (num === undefined) + num = 1; + i += num; + code = codepoint(i); + if (newline(code)) + incrLineno(); + else + column += num; + // console.log('Consume '+i+' '+String.fromCharCode(code) + ' 0x' + code.toString(16)); + return true; + }; + const reconsume = function() { + i -= 1; + if (newline(code)) { + line -= 1; + column = lastLineLength; + } else { + column -= 1; + } + locStart.line = line; + locStart.column = column; + return true; + }; + const eof = function(codepoint?: number): boolean { + if (codepoint === undefined) + codepoint = code; + return codepoint === -1; + }; + const donothing = function() { }; + const parseerror = function() { + // Language bindings don't like writing to stdout! + // console.log('Parse error at index ' + i + ', processing codepoint 0x' + code.toString(16) + '.'); return true; + }; + + const consumeAToken = function(): CSSTokenInterface { + consumeComments(); + consume(); + if (whitespace(code)) { + while (whitespace(next())) + consume(); + return new WhitespaceToken(); + } else if (code === 0x22) {return consumeAStringToken();} else if (code === 0x23) { + if (namechar(next()) || areAValidEscape(next(1), next(2))) { + const token = new HashToken(''); + if (wouldStartAnIdentifier(next(1), next(2), next(3))) + token.type = 'id'; + token.value = consumeAName(); + return token; + } else { + return new DelimToken(code); + } + } else if (code === 0x24) { + if (next() === 0x3d) { + consume(); + return new SuffixMatchToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x27) {return consumeAStringToken();} else if (code === 0x28) {return new OpenParenToken();} else if (code === 0x29) {return new CloseParenToken();} else if (code === 0x2a) { + if (next() === 0x3d) { + consume(); + return new SubstringMatchToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x2b) { + if (startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x2c) {return new CommaToken();} else if (code === 0x2d) { + if (startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else if (next(1) === 0x2d && next(2) === 0x3e) { + consume(2); + return new CDCToken(); + } else if (startsWithAnIdentifier()) { + reconsume(); + return consumeAnIdentlikeToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x2e) { + if (startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x3a) {return new ColonToken();} else if (code === 0x3b) {return new SemicolonToken();} else if (code === 0x3c) { + if (next(1) === 0x21 && next(2) === 0x2d && next(3) === 0x2d) { + consume(3); + return new CDOToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x40) { + if (wouldStartAnIdentifier(next(1), next(2), next(3))) + return new AtKeywordToken(consumeAName()); + else + return new DelimToken(code); + + } else if (code === 0x5b) {return new OpenSquareToken();} else if (code === 0x5c) { + if (startsWithAValidEscape()) { + reconsume(); + return consumeAnIdentlikeToken(); + } else { + parseerror(); + return new DelimToken(code); + } + } else if (code === 0x5d) {return new CloseSquareToken();} else if (code === 0x5e) { + if (next() === 0x3d) { + consume(); + return new PrefixMatchToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x7b) {return new OpenCurlyToken();} else if (code === 0x7c) { + if (next() === 0x3d) { + consume(); + return new DashMatchToken(); + } else if (next() === 0x7c) { + consume(); + return new ColumnToken(); + } else { + return new DelimToken(code); + } + } else if (code === 0x7d) {return new CloseCurlyToken();} else if (code === 0x7e) { + if (next() === 0x3d) { + consume(); + return new IncludeMatchToken(); + } else { + return new DelimToken(code); + } + } else if (digit(code)) { + reconsume(); + return consumeANumericToken(); + } else if (namestartchar(code)) { + reconsume(); + return consumeAnIdentlikeToken(); + } else if (eof()) {return new EOFToken();} else {return new DelimToken(code);} + }; + + const consumeComments = function() { + while (next(1) === 0x2f && next(2) === 0x2a) { + consume(2); + while (true) { + consume(); + if (code === 0x2a && next() === 0x2f) { + consume(); + break; + } else if (eof()) { + parseerror(); + return; + } + } + } + }; + + const consumeANumericToken = function() { + const num = consumeANumber(); + if (wouldStartAnIdentifier(next(1), next(2), next(3))) { + const token = new DimensionToken(); + token.value = num.value; + token.repr = num.repr; + token.type = num.type; + token.unit = consumeAName(); + return token; + } else if (next() === 0x25) { + consume(); + const token = new PercentageToken(); + token.value = num.value; + token.repr = num.repr; + return token; + } else { + const token = new NumberToken(); + token.value = num.value; + token.repr = num.repr; + token.type = num.type; + return token; + } + }; + + const consumeAnIdentlikeToken = function(): CSSTokenInterface { + const str = consumeAName(); + if (str.toLowerCase() === 'url' && next() === 0x28) { + consume(); + while (whitespace(next(1)) && whitespace(next(2))) + consume(); + if (next() === 0x22 || next() === 0x27) + return new FunctionToken(str); + else if (whitespace(next()) && (next(2) === 0x22 || next(2) === 0x27)) + return new FunctionToken(str); + else + return consumeAURLToken(); + + } else if (next() === 0x28) { + consume(); + return new FunctionToken(str); + } else { + return new IdentToken(str); + } + }; + + const consumeAStringToken = function(endingCodePoint?: number): CSSParserToken { + if (endingCodePoint === undefined) + endingCodePoint = code; + let string = ''; + while (consume()) { + if (code === endingCodePoint || eof()) { + return new StringToken(string); + } else if (newline(code)) { + parseerror(); + reconsume(); + return new BadStringToken(); + } else if (code === 0x5c) { + if (eof(next())) + donothing(); + else if (newline(next())) + consume(); + else + string += stringFromCode(consumeEscape()); + + } else { + string += stringFromCode(code); + } + } + throw new Error('Internal error'); + }; + + const consumeAURLToken = function(): CSSTokenInterface { + const token = new URLToken(''); + while (whitespace(next())) + consume(); + if (eof(next())) + return token; + while (consume()) { + if (code === 0x29 || eof()) { + return token; + } else if (whitespace(code)) { + while (whitespace(next())) + consume(); + if (next() === 0x29 || eof(next())) { + consume(); + return token; + } else { + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } + } else if (code === 0x22 || code === 0x27 || code === 0x28 || nonprintable(code)) { + parseerror(); + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } else if (code === 0x5c) { + if (startsWithAValidEscape()) { + token.value += stringFromCode(consumeEscape()); + } else { + parseerror(); + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } + } else { + token.value += stringFromCode(code); + } + } + throw new Error('Internal error'); + }; + + const consumeEscape = function() { + // Assume the current character is the \ + // and the next code point is not a newline. + consume(); + if (hexdigit(code)) { + // Consume 1-6 hex digits + const digits = [code]; + for (let total = 0; total < 5; total++) { + if (hexdigit(next())) { + consume(); + digits.push(code); + } else { + break; + } + } + if (whitespace(next())) + consume(); + let value = parseInt(digits.map(function(x) { return String.fromCharCode(x); }).join(''), 16); + if (value > maximumallowedcodepoint) + value = 0xfffd; + return value; + } else if (eof()) { + return 0xfffd; + } else { + return code; + } + }; + + const areAValidEscape = function(c1: number, c2: number) { + if (c1 !== 0x5c) + return false; + if (newline(c2)) + return false; + return true; + }; + const startsWithAValidEscape = function() { + return areAValidEscape(code, next()); + }; + + const wouldStartAnIdentifier = function(c1: number, c2: number, c3: number) { + if (c1 === 0x2d) + return namestartchar(c2) || c2 === 0x2d || areAValidEscape(c2, c3); + else if (namestartchar(c1)) + return true; + else if (c1 === 0x5c) + return areAValidEscape(c1, c2); + else + return false; + + }; + const startsWithAnIdentifier = function() { + return wouldStartAnIdentifier(code, next(1), next(2)); + }; + + const wouldStartANumber = function(c1: number, c2: number, c3: number) { + if (c1 === 0x2b || c1 === 0x2d) { + if (digit(c2)) + return true; + if (c2 === 0x2e && digit(c3)) + return true; + return false; + } else if (c1 === 0x2e) { + if (digit(c2)) + return true; + return false; + } else if (digit(c1)) { + return true; + } else { + return false; + } + }; + const startsWithANumber = function() { + return wouldStartANumber(code, next(1), next(2)); + }; + + const consumeAName = function(): string { + let result = ''; + while (consume()) { + if (namechar(code)) { + result += stringFromCode(code); + } else if (startsWithAValidEscape()) { + result += stringFromCode(consumeEscape()); + } else { + reconsume(); + return result; + } + } + throw new Error('Internal parse error'); + }; + + const consumeANumber = function() { + let repr = ''; + let type = 'integer'; + if (next() === 0x2b || next() === 0x2d) { + consume(); + repr += stringFromCode(code); + } + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + if (next(1) === 0x2e && digit(next(2))) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = 'number'; + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + } + const c1 = next(1), c2 = next(2), c3 = next(3); + if ((c1 === 0x45 || c1 === 0x65) && digit(c2)) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = 'number'; + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + } else if ((c1 === 0x45 || c1 === 0x65) && (c2 === 0x2b || c2 === 0x2d) && digit(c3)) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = 'number'; + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + } + const value = convertAStringToANumber(repr); + return { type: type, value: value, repr: repr }; + }; + + const convertAStringToANumber = function(string: string): number { + // CSS's number rules are identical to JS, afaik. + return +string; + }; + + const consumeTheRemnantsOfABadURL = function() { + while (consume()) { + if (code === 0x29 || eof()) { + return; + } else if (startsWithAValidEscape()) { + consumeEscape(); + donothing(); + } else { + donothing(); + } + } + }; + + let iterationCount = 0; + while (!eof(next())) { + tokens.push(consumeAToken()); + iterationCount++; + if (iterationCount > str.length * 2) + throw new Error("I'm infinite-looping!"); + } + return tokens; +} + +export class CSSParserToken implements CSSTokenInterface { + tokenType = ''; + value: string | number | undefined; + toJSON(): any { + return { token: this.tokenType }; + } + toString() { return this.tokenType; } + toSource() { return '' + this; } +} + +export class BadStringToken extends CSSParserToken { + override tokenType = 'BADSTRING'; +} + +export class BadURLToken extends CSSParserToken { + override tokenType = 'BADURL'; +} + +export class WhitespaceToken extends CSSParserToken { + override tokenType = 'WHITESPACE'; + override toString() { return 'WS'; } + override toSource() { return ' '; } +} + +export class CDOToken extends CSSParserToken { + override tokenType = 'CDO'; + override toSource() { return ''; } +} + +export class ColonToken extends CSSParserToken { + override tokenType = ':'; +} + +export class SemicolonToken extends CSSParserToken { + override tokenType = ';'; +} + +export class CommaToken extends CSSParserToken { + override tokenType = ','; +} + +export class GroupingToken extends CSSParserToken { + override value = ''; + mirror = ''; +} + +export class OpenCurlyToken extends GroupingToken { + override tokenType = '{'; + constructor() { + super(); + this.value = '{'; + this.mirror = '}'; + } +} + +export class CloseCurlyToken extends GroupingToken { + override tokenType = '}'; + constructor() { + super(); + this.value = '}'; + this.mirror = '{'; + } +} + +export class OpenSquareToken extends GroupingToken { + override tokenType = '['; + constructor() { + super(); + this.value = '['; + this.mirror = ']'; + } +} + +export class CloseSquareToken extends GroupingToken { + override tokenType = ']'; + constructor() { + super(); + this.value = ']'; + this.mirror = '['; + } +} + +export class OpenParenToken extends GroupingToken { + override tokenType = '('; + constructor() { + super(); + this.value = '('; + this.mirror = ')'; + } +} + +export class CloseParenToken extends GroupingToken { + override tokenType = ')'; + constructor() { + super(); + this.value = ')'; + this.mirror = '('; + } +} + +export class IncludeMatchToken extends CSSParserToken { + override tokenType = '~='; +} + +export class DashMatchToken extends CSSParserToken { + override tokenType = '|='; +} + +export class PrefixMatchToken extends CSSParserToken { + override tokenType = '^='; +} + +export class SuffixMatchToken extends CSSParserToken { + override tokenType = '$='; +} + +export class SubstringMatchToken extends CSSParserToken { + override tokenType = '*='; +} + +export class ColumnToken extends CSSParserToken { + override tokenType = '||'; +} + +export class EOFToken extends CSSParserToken { + override tokenType = 'EOF'; + override toSource() { return ''; } +} + +export class DelimToken extends CSSParserToken { + override tokenType = 'DELIM'; + override value: string = ''; + + constructor(code: number) { + super(); + this.value = stringFromCode(code); + } + + override toString() { return 'DELIM(' + this.value + ')'; } + + override toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + return json; + } + + override toSource() { + if (this.value === '\\') + return '\\\n'; + else + return this.value; + } +} + +export abstract class StringValuedToken extends CSSParserToken { + override value: string = ''; + ASCIIMatch(str: string) { + return this.value.toLowerCase() === str.toLowerCase(); + } + + override toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + return json; + } +} + +export class IdentToken extends StringValuedToken { + constructor(val: string) { + super(); + this.value = val; + } + + override tokenType = 'IDENT'; + override toString() { return 'IDENT(' + this.value + ')'; } + override toSource() { + return escapeIdent(this.value); + } +} + +export class FunctionToken extends StringValuedToken { + override tokenType = 'FUNCTION'; + mirror: string; + constructor(val: string) { + super(); + this.value = val; + this.mirror = ')'; + } + + override toString() { return 'FUNCTION(' + this.value + ')'; } + + override toSource() { + return escapeIdent(this.value) + '('; + } +} + +export class AtKeywordToken extends StringValuedToken { + override tokenType = 'AT-KEYWORD'; + constructor(val: string) { + super(); + this.value = val; + } + override toString() { return 'AT(' + this.value + ')'; } + override toSource() { + return '@' + escapeIdent(this.value); + } +} + +export class HashToken extends StringValuedToken { + override tokenType = 'HASH'; + type: string; + constructor(val: string) { + super(); + this.value = val; + this.type = 'unrestricted'; + } + + override toString() { return 'HASH(' + this.value + ')'; } + + override toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.type = this.type; + return json; + } + + override toSource() { + if (this.type === 'id') + return '#' + escapeIdent(this.value); + else + return '#' + escapeHash(this.value); + + } +} + +export class StringToken extends StringValuedToken { + override tokenType = 'STRING'; + constructor(val: string) { + super(); + this.value = val; + } + + override toString() { + return '"' + escapeString(this.value) + '"'; + } +} + +export class URLToken extends StringValuedToken { + override tokenType = 'URL'; + constructor(val: string) { + super(); + this.value = val; + } + override toString() { return 'URL(' + this.value + ')'; } + override toSource() { + return 'url("' + escapeString(this.value) + '")'; + } +} + +export class NumberToken extends CSSParserToken { + override tokenType = 'NUMBER'; + type: string; + repr: string; + + constructor() { + super(); + this.type = 'integer'; + this.repr = ''; + } + + override toString() { + if (this.type === 'integer') + return 'INT(' + this.value + ')'; + return 'NUMBER(' + this.value + ')'; + } + override toJSON() { + const json = super.toJSON(); + json.value = this.value; + json.type = this.type; + json.repr = this.repr; + return json; + } + override toSource() { return this.repr; } +} + + +export class PercentageToken extends CSSParserToken { + override tokenType = 'PERCENTAGE'; + repr: string; + constructor() { + super(); + this.repr = ''; + } + override toString() { return 'PERCENTAGE(' + this.value + ')'; } + override toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.repr = this.repr; + return json; + } + override toSource() { return this.repr + '%'; } +} + +export class DimensionToken extends CSSParserToken { + override tokenType = 'DIMENSION'; + type: string; + repr: string; + unit: string; + + constructor() { + super(); + this.type = 'integer'; + this.repr = ''; + this.unit = ''; + } + + override toString() { return 'DIM(' + this.value + ',' + this.unit + ')'; } + override toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.type = this.type; + json.repr = this.repr; + json.unit = this.unit; + return json; + } + override toSource() { + const source = this.repr; + let unit = escapeIdent(this.unit); + if (unit[0].toLowerCase() === 'e' && (unit[1] === '-' || between(unit.charCodeAt(1), 0x30, 0x39))) { + // Unit is ambiguous with scinot + // Remove the leading "e", replace with escape. + unit = '\\65 ' + unit.slice(1, unit.length); + } + return source + unit; + } +} + +function escapeIdent(string: string) { + string = '' + string; + let result = ''; + const firstcode = string.charCodeAt(0); + for (let i = 0; i < string.length; i++) { + const code = string.charCodeAt(i); + if (code === 0x0) + throw new InvalidCharacterError('Invalid character: the input contains U+0000.'); + + if ( + between(code, 0x1, 0x1f) || code === 0x7f || + (i === 0 && between(code, 0x30, 0x39)) || + (i === 1 && between(code, 0x30, 0x39) && firstcode === 0x2d) + ) + result += '\\' + code.toString(16) + ' '; + else if ( + code >= 0x80 || + code === 0x2d || + code === 0x5f || + between(code, 0x30, 0x39) || + between(code, 0x41, 0x5a) || + between(code, 0x61, 0x7a) + ) + result += string[i]; + else + result += '\\' + string[i]; + + } + return result; +} + +function escapeHash(string: string) { + // Escapes the contents of "unrestricted"-type hash tokens. + // Won't preserve the ID-ness of "id"-type hash tokens; + // use escapeIdent() for that. + string = '' + string; + let result = ''; + for (let i = 0; i < string.length; i++) { + const code = string.charCodeAt(i); + if (code === 0x0) + throw new InvalidCharacterError('Invalid character: the input contains U+0000.'); + + if ( + code >= 0x80 || + code === 0x2d || + code === 0x5f || + between(code, 0x30, 0x39) || + between(code, 0x41, 0x5a) || + between(code, 0x61, 0x7a) + ) + result += string[i]; + else + result += '\\' + code.toString(16) + ' '; + + } + return result; +} + +function escapeString(string: string) { + string = '' + string; + let result = ''; + for (let i = 0; i < string.length; i++) { + const code = string.charCodeAt(i); + + if (code === 0x0) + throw new InvalidCharacterError('Invalid character: the input contains U+0000.'); + + if (between(code, 0x1, 0x1f) || code === 0x7f) + result += '\\' + code.toString(16) + ' '; + else if (code === 0x22 || code === 0x5c) + result += '\\' + string[i]; + else + result += string[i]; + + } + return result; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/headers.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/headers.ts new file mode 100644 index 00000000..a5504b1e --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/headers.ts @@ -0,0 +1,45 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +type HeadersArray = { name: string, value: string }[]; +type HeadersObject = { [key: string]: string }; + +export function headersObjectToArray(headers: HeadersObject, separator?: string, setCookieSeparator?: string): HeadersArray { + if (!setCookieSeparator) + setCookieSeparator = separator; + const result: HeadersArray = []; + for (const name in headers) { + const values = headers[name]; + if (values === undefined) + continue; + if (separator) { + const sep = name.toLowerCase() === 'set-cookie' ? setCookieSeparator : separator; + for (const value of values.split(sep!)) + result.push({ name, value: value.trim() }); + } else { + result.push({ name, value: values }); + } + } + return result; +} + +export function headersArrayToObject(headers: HeadersArray, lowerCase: boolean): HeadersObject { + const result: HeadersObject = {}; + for (const { name, value } of headers) + result[lowerCase ? name.toLowerCase() : name] = value; + return result; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/imageUtils.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/imageUtils.ts new file mode 100644 index 00000000..7ac68eae --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/imageUtils.ts @@ -0,0 +1,139 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type ImageData = { width: number, height: number, data: Buffer }; + +export function padImageToSize(image: ImageData, size: { width: number, height: number }): ImageData { + if (image.width === size.width && image.height === size.height) + return image; + const buffer = new Uint8Array(size.width * size.height * 4); + for (let y = 0; y < size.height; y++) { + for (let x = 0; x < size.width; x++) { + const to = (y * size.width + x) * 4; + if (y < image.height && x < image.width) { + const from = (y * image.width + x) * 4; + buffer[to] = image.data[from]; + buffer[to + 1] = image.data[from + 1]; + buffer[to + 2] = image.data[from + 2]; + buffer[to + 3] = image.data[from + 3]; + } else { + buffer[to] = 0; + buffer[to + 1] = 0; + buffer[to + 2] = 0; + buffer[to + 3] = 0; + } + } + } + return { data: Buffer.from(buffer), width: size.width, height: size.height }; +} + +export function scaleImageToSize(image: ImageData, size: { width: number; height: number }): ImageData { + const { data: src, width: w1, height: h1 } = image; + const w2 = Math.max(1, Math.floor(size.width)); + const h2 = Math.max(1, Math.floor(size.height)); + + if (w1 === w2 && h1 === h2) + return image; + + if (w1 <= 0 || h1 <= 0) + throw new Error('Invalid input image'); + if (size.width <= 0 || size.height <= 0 || !isFinite(size.width) || !isFinite(size.height)) + throw new Error('Invalid output dimensions'); + + const clamp = (v: number, lo: number, hi: number) => (v < lo ? lo : v > hi ? hi : v); + + // Catmull–Rom weights + const weights = (t: number, o: Float32Array) => { + const t2 = t * t, t3 = t2 * t; + o[0] = -0.5 * t + 1.0 * t2 - 0.5 * t3; + o[1] = 1.0 - 2.5 * t2 + 1.5 * t3; + o[2] = 0.5 * t + 2.0 * t2 - 1.5 * t3; + o[3] = -0.5 * t2 + 0.5 * t3; + }; + + const srcRowStride = w1 * 4; + const dstRowStride = w2 * 4; + + // Precompute X: indices, weights, and byte offsets (idx*4) + const xOff = new Int32Array(w2 * 4); // byte offsets = xIdx*4 + const xW = new Float32Array(w2 * 4); + const wx = new Float32Array(4); + const xScale = w1 / w2; + for (let x = 0; x < w2; x++) { + const sx = (x + 0.5) * xScale - 0.5; + const sxi = Math.floor(sx); + const t = sx - sxi; + weights(t, wx); + const b = x * 4; + const i0 = clamp(sxi - 1, 0, w1 - 1); + const i1 = clamp(sxi + 0, 0, w1 - 1); + const i2 = clamp(sxi + 1, 0, w1 - 1); + const i3 = clamp(sxi + 2, 0, w1 - 1); + xOff[b + 0] = i0 << 2; xOff[b + 1] = i1 << 2; xOff[b + 2] = i2 << 2; xOff[b + 3] = i3 << 2; + xW[b + 0] = wx[0]; xW[b + 1] = wx[1]; xW[b + 2] = wx[2]; xW[b + 3] = wx[3]; + } + + // Precompute Y: indices, weights, and row-base byte offsets (y*rowStride) + const yRow = new Int32Array(h2 * 4); // row base in bytes + const yW = new Float32Array(h2 * 4); + const wy = new Float32Array(4); + const yScale = h1 / h2; + for (let y = 0; y < h2; y++) { + const sy = (y + 0.5) * yScale - 0.5; + const syi = Math.floor(sy); + const t = sy - syi; + weights(t, wy); + const b = y * 4; + const j0 = clamp(syi - 1, 0, h1 - 1); + const j1 = clamp(syi + 0, 0, h1 - 1); + const j2 = clamp(syi + 1, 0, h1 - 1); + const j3 = clamp(syi + 2, 0, h1 - 1); + yRow[b + 0] = j0 * srcRowStride; + yRow[b + 1] = j1 * srcRowStride; + yRow[b + 2] = j2 * srcRowStride; + yRow[b + 3] = j3 * srcRowStride; + yW[b + 0] = wy[0]; yW[b + 1] = wy[1]; yW[b + 2] = wy[2]; yW[b + 3] = wy[3]; + } + + const dst = new Uint8Array(w2 * h2 * 4); + + for (let y = 0; y < h2; y++) { + const yb = y * 4; + const rb0 = yRow[yb + 0], rb1 = yRow[yb + 1], rb2 = yRow[yb + 2], rb3 = yRow[yb + 3]; + const wy0 = yW[yb + 0], wy1 = yW[yb + 1], wy2 = yW[yb + 2], wy3 = yW[yb + 3]; + const dstBase = y * dstRowStride; + + for (let x = 0; x < w2; x++) { + const xb = x * 4; + const xo0 = xOff[xb + 0], xo1 = xOff[xb + 1], xo2 = xOff[xb + 2], xo3 = xOff[xb + 3]; + const wx0 = xW[xb + 0], wx1 = xW[xb + 1], wx2 = xW[xb + 2], wx3 = xW[xb + 3]; + const di = dstBase + (x << 2); + + // unrolled RGBA + for (let c = 0; c < 4; c++) { + const r0 = src[rb0 + xo0 + c] * wx0 + src[rb0 + xo1 + c] * wx1 + src[rb0 + xo2 + c] * wx2 + src[rb0 + xo3 + c] * wx3; + const r1 = src[rb1 + xo0 + c] * wx0 + src[rb1 + xo1 + c] * wx1 + src[rb1 + xo2 + c] * wx2 + src[rb1 + xo3 + c] * wx3; + const r2 = src[rb2 + xo0 + c] * wx0 + src[rb2 + xo1 + c] * wx1 + src[rb2 + xo2 + c] * wx2 + src[rb2 + xo3 + c] * wx3; + const r3 = src[rb3 + xo0 + c] * wx0 + src[rb3 + xo1 + c] * wx1 + src[rb3 + xo2 + c] * wx2 + src[rb3 + xo3 + c] * wx3; + const v = r0 * wy0 + r1 * wy1 + r2 * wy2 + r3 * wy3; + dst[di + c] = v < 0 ? 0 : v > 255 ? 255 : v | 0; + } + } + } + + return { data: Buffer.from(dst.buffer), width: w2, height: h2 }; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorGenerators.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorGenerators.ts new file mode 100644 index 00000000..6412bf12 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorGenerators.ts @@ -0,0 +1,734 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { parseAttributeSelector, parseSelector, stringifySelector } from './selectorParser'; +import { escapeWithQuotes, normalizeEscapedRegexQuotes, toSnakeCase, toTitleCase } from './stringUtils'; + +import type { NestedSelectorBody } from './selectorParser'; +import type { ParsedSelector } from './selectorParser'; + +export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl'; +export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'visible' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'frame-locator' | 'and' | 'or' | 'chain'; +export type LocatorBase = 'page' | 'locator' | 'frame-locator'; +export type Quote = '\'' | '"' | '`'; + +type LocatorOptions = { + attrs?: { name: string, value: string | boolean | number }[], + exact?: boolean, + name?: string | RegExp, + hasText?: string | RegExp, + hasNotText?: string | RegExp, +}; +export interface LocatorFactory { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options?: LocatorOptions): string; + chainLocators(locators: string[]): string; +} + +export function asLocatorDescription(lang: Language, selector: string): string { + try { + const parsed = parseSelector(selector); + const customDescription = parseCustomDescription(parsed); + if (customDescription) + return customDescription; + return innerAsLocators(new generators[lang](), parsed, false, 1)[0]; + } catch (e) { + // Tolerate invalid input. + return selector; + } +} + +export function locatorCustomDescription(selector: string): string | undefined { + try { + const parsed = parseSelector(selector); + return parseCustomDescription(parsed); + } catch (e) { + return undefined; + } +} + +function parseCustomDescription(parsed: ParsedSelector): string | undefined { + const lastPart = parsed.parts[parsed.parts.length - 1]; + if (lastPart?.name === 'internal:describe') { + const description = JSON.parse(lastPart.body as string); + if (typeof description === 'string') + return description; + } + return undefined; +} + +export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string { + return asLocators(lang, selector, isFrameLocator, 1)[0]; +} + +export function asLocators(lang: Language, selector: string, isFrameLocator: boolean = false, maxOutputSize = 20, preferredQuote?: Quote): string[] { + try { + return innerAsLocators(new generators[lang](preferredQuote), parseSelector(selector), isFrameLocator, maxOutputSize); + } catch (e) { + // Tolerate invalid input. + return [selector]; + } +} + +function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFrameLocator: boolean = false, maxOutputSize = 20): string[] { + const parts = [...parsed.parts]; + const tokens: string[][] = []; + let nextBase: LocatorBase = isFrameLocator ? 'frame-locator' : 'page'; + for (let index = 0; index < parts.length; index++) { + const part = parts[index]; + const base = nextBase; + nextBase = 'locator'; + + if (part.name === 'internal:describe') + continue; + if (part.name === 'nth') { + if (part.body === '0') + tokens.push([factory.generateLocator(base, 'first', ''), factory.generateLocator(base, 'nth', '0')]); + else if (part.body === '-1') + tokens.push([factory.generateLocator(base, 'last', ''), factory.generateLocator(base, 'nth', '-1')]); + else + tokens.push([factory.generateLocator(base, 'nth', part.body as string)]); + continue; + } + if (part.name === 'visible') { + tokens.push([factory.generateLocator(base, 'visible', part.body as string), factory.generateLocator(base, 'default', `visible=${part.body}`)]); + continue; + } + if (part.name === 'internal:text') { + const { exact, text } = detectExact(part.body as string); + tokens.push([factory.generateLocator(base, 'text', text, { exact })]); + continue; + } + if (part.name === 'internal:has-text') { + const { exact, text } = detectExact(part.body as string); + // There is no locator equivalent for strict has-text, leave it as is. + if (!exact) { + tokens.push([factory.generateLocator(base, 'has-text', text, { exact })]); + continue; + } + } + if (part.name === 'internal:has-not-text') { + const { exact, text } = detectExact(part.body as string); + // There is no locator equivalent for strict has-not-text, leave it as is. + if (!exact) { + tokens.push([factory.generateLocator(base, 'has-not-text', text, { exact })]); + continue; + } + } + if (part.name === 'internal:has') { + const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize); + tokens.push(inners.map(inner => factory.generateLocator(base, 'has', inner))); + continue; + } + if (part.name === 'internal:has-not') { + const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize); + tokens.push(inners.map(inner => factory.generateLocator(base, 'hasNot', inner))); + continue; + } + if (part.name === 'internal:and') { + const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize); + tokens.push(inners.map(inner => factory.generateLocator(base, 'and', inner))); + continue; + } + if (part.name === 'internal:or') { + const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize); + tokens.push(inners.map(inner => factory.generateLocator(base, 'or', inner))); + continue; + } + if (part.name === 'internal:chain') { + const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize); + tokens.push(inners.map(inner => factory.generateLocator(base, 'chain', inner))); + continue; + } + if (part.name === 'internal:label') { + const { exact, text } = detectExact(part.body as string); + tokens.push([factory.generateLocator(base, 'label', text, { exact })]); + continue; + } + if (part.name === 'internal:role') { + const attrSelector = parseAttributeSelector(part.body as string, true); + const options: LocatorOptions = { attrs: [] }; + for (const attr of attrSelector.attributes) { + if (attr.name === 'name') { + options.exact = attr.caseSensitive; + options.name = attr.value; + } else { + if (attr.name === 'level' && typeof attr.value === 'string') + attr.value = +attr.value; + options.attrs!.push({ name: attr.name === 'include-hidden' ? 'includeHidden' : attr.name, value: attr.value }); + } + } + tokens.push([factory.generateLocator(base, 'role', attrSelector.name, options)]); + continue; + } + if (part.name === 'internal:testid') { + const attrSelector = parseAttributeSelector(part.body as string, true); + const { value } = attrSelector.attributes[0]; + tokens.push([factory.generateLocator(base, 'test-id', value)]); + continue; + } + if (part.name === 'internal:attr') { + const attrSelector = parseAttributeSelector(part.body as string, true); + const { name, value, caseSensitive } = attrSelector.attributes[0]; + const text = value as string | RegExp; + const exact = !!caseSensitive; + if (name === 'placeholder') { + tokens.push([factory.generateLocator(base, 'placeholder', text, { exact })]); + continue; + } + if (name === 'alt') { + tokens.push([factory.generateLocator(base, 'alt', text, { exact })]); + continue; + } + if (name === 'title') { + tokens.push([factory.generateLocator(base, 'title', text, { exact })]); + continue; + } + } + if (part.name === 'internal:control' && (part.body as string) === 'enter-frame') { + // transform last tokens from `${selector}` into `${selector}.contentFrame()` and `frameLocator(${selector})` + const lastTokens = tokens[tokens.length - 1]; + const lastPart = parts[index - 1]; + + const transformed = lastTokens.map(token => factory.chainLocators([token, factory.generateLocator(base, 'frame', '')])); + if (['xpath', 'css'].includes(lastPart.name)) { + transformed.push( + factory.generateLocator(base, 'frame-locator', stringifySelector({ parts: [lastPart] })), + factory.generateLocator(base, 'frame-locator', stringifySelector({ parts: [lastPart] }, true)) + ); + } + + lastTokens.splice(0, lastTokens.length, ...transformed); + nextBase = 'frame-locator'; + continue; + } + + const nextPart = parts[index + 1]; + + const selectorPart = stringifySelector({ parts: [part] }); + const locatorPart = factory.generateLocator(base, 'default', selectorPart); + + if (nextPart && ['internal:has-text', 'internal:has-not-text'].includes(nextPart.name)) { + const { exact, text } = detectExact(nextPart.body as string); + // There is no locator equivalent for strict has-text and has-not-text, leave it as is. + if (!exact) { + const nextLocatorPart = factory.generateLocator('locator', nextPart.name === 'internal:has-text' ? 'has-text' : 'has-not-text', text, { exact }); + const options: LocatorOptions = {}; + if (nextPart.name === 'internal:has-text') + options.hasText = text; + else + options.hasNotText = text; + const combinedPart = factory.generateLocator(base, 'default', selectorPart, options); + // Two options: + // - locator('div').filter({ hasText: 'foo' }) + // - locator('div', { hasText: 'foo' }) + tokens.push([factory.chainLocators([locatorPart, nextLocatorPart]), combinedPart]); + index++; + continue; + } + } + + // Selectors can be prefixed with engine name, e.g. xpath=//foo + let locatorPartWithEngine: string | undefined; + if (['xpath', 'css'].includes(part.name)) { + const selectorPart = stringifySelector({ parts: [part] }, /* forceEngineName */ true); + locatorPartWithEngine = factory.generateLocator(base, 'default', selectorPart); + } + + tokens.push([locatorPart, locatorPartWithEngine].filter(Boolean) as string[]); + } + + return combineTokens(factory, tokens, maxOutputSize); +} + +function combineTokens(factory: LocatorFactory, tokens: string[][], maxOutputSize: number): string[] { + const currentTokens = tokens.map(() => ''); + const result: string[] = []; + + const visit = (index: number) => { + if (index === tokens.length) { + result.push(factory.chainLocators(currentTokens)); + return result.length < maxOutputSize; + } + for (const taken of tokens[index]) { + currentTokens[index] = taken; + if (!visit(index + 1)) + return false; + } + return true; + }; + + visit(0); + return result; +} + +function detectExact(text: string): { exact?: boolean, text: string | RegExp } { + let exact = false; + const match = text.match(/^\/(.*)\/([igm]*)$/); + if (match) + return { text: new RegExp(match[1], match[2]) }; + if (text.endsWith('"')) { + text = JSON.parse(text); + exact = true; + } else if (text.endsWith('"s')) { + text = JSON.parse(text.substring(0, text.length - 1)); + exact = true; + } else if (text.endsWith('"i')) { + text = JSON.parse(text.substring(0, text.length - 1)); + exact = false; + } + return { exact, text }; +} + +export class JavaScriptLocatorFactory implements LocatorFactory { + constructor(private preferredQuote?: Quote) {} + + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string { + switch (kind) { + case 'default': + if (options.hasText !== undefined) + return `locator(${this.quote(body as string)}, { hasText: ${this.toHasText(options.hasText)} })`; + if (options.hasNotText !== undefined) + return `locator(${this.quote(body as string)}, { hasNotText: ${this.toHasText(options.hasNotText)} })`; + return `locator(${this.quote(body as string)})`; + case 'frame-locator': + return `frameLocator(${this.quote(body as string)})`; + case 'frame': + return `contentFrame()`; + case 'nth': + return `nth(${body})`; + case 'first': + return `first()`; + case 'last': + return `last()`; + case 'visible': + return `filter({ visible: ${body === 'true' ? 'true' : 'false'} })`; + case 'role': + const attrs: string[] = []; + if (isRegExp(options.name)) { + attrs.push(`name: ${this.regexToSourceString(options.name)}`); + } else if (typeof options.name === 'string') { + attrs.push(`name: ${this.quote(options.name)}`); + if (options.exact) + attrs.push(`exact: true`); + } + for (const { name, value } of options.attrs!) + attrs.push(`${name}: ${typeof value === 'string' ? this.quote(value) : value}`); + const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : ''; + return `getByRole(${this.quote(body as string)}${attrString})`; + case 'has-text': + return `filter({ hasText: ${this.toHasText(body)} })`; + case 'has-not-text': + return `filter({ hasNotText: ${this.toHasText(body)} })`; + case 'has': + return `filter({ has: ${body} })`; + case 'hasNot': + return `filter({ hasNot: ${body} })`; + case 'and': + return `and(${body})`; + case 'or': + return `or(${body})`; + case 'chain': + return `locator(${body})`; + case 'test-id': + return `getByTestId(${this.toTestIdValue(body)})`; + case 'text': + return this.toCallWithExact('getByText', body, !!options.exact); + case 'alt': + return this.toCallWithExact('getByAltText', body, !!options.exact); + case 'placeholder': + return this.toCallWithExact('getByPlaceholder', body, !!options.exact); + case 'label': + return this.toCallWithExact('getByLabel', body, !!options.exact); + case 'title': + return this.toCallWithExact('getByTitle', body, !!options.exact); + default: + throw new Error('Unknown selector kind ' + kind); + } + } + + chainLocators(locators: string[]): string { + return locators.join('.'); + } + + private regexToSourceString(re: RegExp) { + return normalizeEscapedRegexQuotes(String(re)); + } + + private toCallWithExact(method: string, body: string | RegExp, exact?: boolean) { + if (isRegExp(body)) + return `${method}(${this.regexToSourceString(body)})`; + return exact ? `${method}(${this.quote(body)}, { exact: true })` : `${method}(${this.quote(body)})`; + } + + private toHasText(body: string | RegExp) { + if (isRegExp(body)) + return this.regexToSourceString(body); + return this.quote(body); + } + + private toTestIdValue(value: string | RegExp): string { + if (isRegExp(value)) + return this.regexToSourceString(value); + return this.quote(value); + } + + private quote(text: string) { + return escapeWithQuotes(text, this.preferredQuote ?? '\''); + } +} + +export class PythonLocatorFactory implements LocatorFactory { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string { + switch (kind) { + case 'default': + if (options.hasText !== undefined) + return `locator(${this.quote(body as string)}, has_text=${this.toHasText(options.hasText)})`; + if (options.hasNotText !== undefined) + return `locator(${this.quote(body as string)}, has_not_text=${this.toHasText(options.hasNotText)})`; + return `locator(${this.quote(body as string)})`; + case 'frame-locator': + return `frame_locator(${this.quote(body as string)})`; + case 'frame': + return `content_frame`; + case 'nth': + return `nth(${body})`; + case 'first': + return `first`; + case 'last': + return `last`; + case 'visible': + return `filter(visible=${body === 'true' ? 'True' : 'False'})`; + case 'role': + const attrs: string[] = []; + if (isRegExp(options.name)) { + attrs.push(`name=${this.regexToString(options.name)}`); + } else if (typeof options.name === 'string') { + attrs.push(`name=${this.quote(options.name)}`); + if (options.exact) + attrs.push(`exact=True`); + } + for (const { name, value } of options.attrs!) { + let valueString = typeof value === 'string' ? this.quote(value) : value; + if (typeof value === 'boolean') + valueString = value ? 'True' : 'False'; + attrs.push(`${toSnakeCase(name)}=${valueString}`); + } + const attrString = attrs.length ? `, ${attrs.join(', ')}` : ''; + return `get_by_role(${this.quote(body as string)}${attrString})`; + case 'has-text': + return `filter(has_text=${this.toHasText(body)})`; + case 'has-not-text': + return `filter(has_not_text=${this.toHasText(body)})`; + case 'has': + return `filter(has=${body})`; + case 'hasNot': + return `filter(has_not=${body})`; + case 'and': + return `and_(${body})`; + case 'or': + return `or_(${body})`; + case 'chain': + return `locator(${body})`; + case 'test-id': + return `get_by_test_id(${this.toTestIdValue(body)})`; + case 'text': + return this.toCallWithExact('get_by_text', body, !!options.exact); + case 'alt': + return this.toCallWithExact('get_by_alt_text', body, !!options.exact); + case 'placeholder': + return this.toCallWithExact('get_by_placeholder', body, !!options.exact); + case 'label': + return this.toCallWithExact('get_by_label', body, !!options.exact); + case 'title': + return this.toCallWithExact('get_by_title', body, !!options.exact); + default: + throw new Error('Unknown selector kind ' + kind); + } + } + + chainLocators(locators: string[]): string { + return locators.join('.'); + } + + private regexToString(body: RegExp) { + const suffix = body.flags.includes('i') ? ', re.IGNORECASE' : ''; + return `re.compile(r"${normalizeEscapedRegexQuotes(body.source).replace(/\\\//, '/').replace(/"/g, '\\"')}"${suffix})`; + } + + private toCallWithExact(method: string, body: string | RegExp, exact: boolean) { + if (isRegExp(body)) + return `${method}(${this.regexToString(body)})`; + if (exact) + return `${method}(${this.quote(body)}, exact=True)`; + return `${method}(${this.quote(body)})`; + } + + private toHasText(body: string | RegExp) { + if (isRegExp(body)) + return this.regexToString(body); + return `${this.quote(body)}`; + } + + private toTestIdValue(value: string | RegExp) { + if (isRegExp(value)) + return this.regexToString(value); + return this.quote(value); + } + + private quote(text: string) { + return escapeWithQuotes(text, '\"'); + } +} + +export class JavaLocatorFactory implements LocatorFactory { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string { + let clazz: string; + switch (base) { + case 'page': clazz = 'Page'; break; + case 'frame-locator': clazz = 'FrameLocator'; break; + case 'locator': clazz = 'Locator'; break; + } + switch (kind) { + case 'default': + if (options.hasText !== undefined) + return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasText(${this.toHasText(options.hasText)}))`; + if (options.hasNotText !== undefined) + return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasNotText(${this.toHasText(options.hasNotText)}))`; + return `locator(${this.quote(body as string)})`; + case 'frame-locator': + return `frameLocator(${this.quote(body as string)})`; + case 'frame': + return `contentFrame()`; + case 'nth': + return `nth(${body})`; + case 'first': + return `first()`; + case 'last': + return `last()`; + case 'visible': + return `filter(new ${clazz}.FilterOptions().setVisible(${body === 'true' ? 'true' : 'false'}))`; + case 'role': + const attrs: string[] = []; + if (isRegExp(options.name)) { + attrs.push(`.setName(${this.regexToString(options.name)})`); + } else if (typeof options.name === 'string') { + attrs.push(`.setName(${this.quote(options.name)})`); + if (options.exact) + attrs.push(`.setExact(true)`); + } + for (const { name, value } of options.attrs!) + attrs.push(`.set${toTitleCase(name)}(${typeof value === 'string' ? this.quote(value) : value})`); + const attrString = attrs.length ? `, new ${clazz}.GetByRoleOptions()${attrs.join('')}` : ''; + return `getByRole(AriaRole.${toSnakeCase(body as string).toUpperCase()}${attrString})`; + case 'has-text': + return `filter(new ${clazz}.FilterOptions().setHasText(${this.toHasText(body)}))`; + case 'has-not-text': + return `filter(new ${clazz}.FilterOptions().setHasNotText(${this.toHasText(body)}))`; + case 'has': + return `filter(new ${clazz}.FilterOptions().setHas(${body}))`; + case 'hasNot': + return `filter(new ${clazz}.FilterOptions().setHasNot(${body}))`; + case 'and': + return `and(${body})`; + case 'or': + return `or(${body})`; + case 'chain': + return `locator(${body})`; + case 'test-id': + return `getByTestId(${this.toTestIdValue(body)})`; + case 'text': + return this.toCallWithExact(clazz, 'getByText', body, !!options.exact); + case 'alt': + return this.toCallWithExact(clazz, 'getByAltText', body, !!options.exact); + case 'placeholder': + return this.toCallWithExact(clazz, 'getByPlaceholder', body, !!options.exact); + case 'label': + return this.toCallWithExact(clazz, 'getByLabel', body, !!options.exact); + case 'title': + return this.toCallWithExact(clazz, 'getByTitle', body, !!options.exact); + default: + throw new Error('Unknown selector kind ' + kind); + } + } + + chainLocators(locators: string[]): string { + return locators.join('.'); + } + + private regexToString(body: RegExp) { + const suffix = body.flags.includes('i') ? ', Pattern.CASE_INSENSITIVE' : ''; + return `Pattern.compile(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})`; + } + + private toCallWithExact(clazz: string, method: string, body: string | RegExp, exact: boolean) { + if (isRegExp(body)) + return `${method}(${this.regexToString(body)})`; + if (exact) + return `${method}(${this.quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(true))`; + return `${method}(${this.quote(body)})`; + } + + private toHasText(body: string | RegExp) { + if (isRegExp(body)) + return this.regexToString(body); + return this.quote(body); + } + + private toTestIdValue(value: string | RegExp) { + if (isRegExp(value)) + return this.regexToString(value); + return this.quote(value); + } + + private quote(text: string) { + return escapeWithQuotes(text, '\"'); + } +} + +export class CSharpLocatorFactory implements LocatorFactory { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string { + switch (kind) { + case 'default': + if (options.hasText !== undefined) + return `Locator(${this.quote(body as string)}, new() { ${this.toHasText(options.hasText)} })`; + if (options.hasNotText !== undefined) + return `Locator(${this.quote(body as string)}, new() { ${this.toHasNotText(options.hasNotText)} })`; + return `Locator(${this.quote(body as string)})`; + case 'frame-locator': + return `FrameLocator(${this.quote(body as string)})`; + case 'frame': + return `ContentFrame`; + case 'nth': + return `Nth(${body})`; + case 'first': + return `First`; + case 'last': + return `Last`; + case 'visible': + return `Filter(new() { Visible = ${body === 'true' ? 'true' : 'false'} })`; + case 'role': + const attrs: string[] = []; + if (isRegExp(options.name)) { + attrs.push(`NameRegex = ${this.regexToString(options.name)}`); + } else if (typeof options.name === 'string') { + attrs.push(`Name = ${this.quote(options.name)}`); + if (options.exact) + attrs.push(`Exact = true`); + } + for (const { name, value } of options.attrs!) + attrs.push(`${toTitleCase(name)} = ${typeof value === 'string' ? this.quote(value) : value}`); + const attrString = attrs.length ? `, new() { ${attrs.join(', ')} }` : ''; + return `GetByRole(AriaRole.${toTitleCase(body as string)}${attrString})`; + case 'has-text': + return `Filter(new() { ${this.toHasText(body)} })`; + case 'has-not-text': + return `Filter(new() { ${this.toHasNotText(body)} })`; + case 'has': + return `Filter(new() { Has = ${body} })`; + case 'hasNot': + return `Filter(new() { HasNot = ${body} })`; + case 'and': + return `And(${body})`; + case 'or': + return `Or(${body})`; + case 'chain': + return `Locator(${body})`; + case 'test-id': + return `GetByTestId(${this.toTestIdValue(body)})`; + case 'text': + return this.toCallWithExact('GetByText', body, !!options.exact); + case 'alt': + return this.toCallWithExact('GetByAltText', body, !!options.exact); + case 'placeholder': + return this.toCallWithExact('GetByPlaceholder', body, !!options.exact); + case 'label': + return this.toCallWithExact('GetByLabel', body, !!options.exact); + case 'title': + return this.toCallWithExact('GetByTitle', body, !!options.exact); + default: + throw new Error('Unknown selector kind ' + kind); + } + } + + chainLocators(locators: string[]): string { + return locators.join('.'); + } + + private regexToString(body: RegExp): string { + const suffix = body.flags.includes('i') ? ', RegexOptions.IgnoreCase' : ''; + return `new Regex(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})`; + } + + private toCallWithExact(method: string, body: string | RegExp, exact: boolean) { + if (isRegExp(body)) + return `${method}(${this.regexToString(body)})`; + if (exact) + return `${method}(${this.quote(body)}, new() { Exact = true })`; + return `${method}(${this.quote(body)})`; + } + + private toHasText(body: string | RegExp) { + if (isRegExp(body)) + return `HasTextRegex = ${this.regexToString(body)}`; + return `HasText = ${this.quote(body)}`; + } + + private toTestIdValue(value: string | RegExp) { + if (isRegExp(value)) + return this.regexToString(value); + return this.quote(value); + } + + private toHasNotText(body: string | RegExp) { + if (isRegExp(body)) + return `HasNotTextRegex = ${this.regexToString(body)}`; + return `HasNotText = ${this.quote(body)}`; + } + + private quote(text: string) { + return escapeWithQuotes(text, '\"'); + } +} + +export class JsonlLocatorFactory implements LocatorFactory { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string { + return JSON.stringify({ + kind, + body, + options, + }); + } + + chainLocators(locators: string[]): string { + const objects = locators.map(l => JSON.parse(l)); + for (let i = 0; i < objects.length - 1; ++i) + objects[i].next = objects[i + 1]; + return JSON.stringify(objects[0]); + } +} + +const generators: Record LocatorFactory> = { + javascript: JavaScriptLocatorFactory, + python: PythonLocatorFactory, + java: JavaLocatorFactory, + csharp: CSharpLocatorFactory, + jsonl: JsonlLocatorFactory, +}; + +function isRegExp(obj: any): obj is RegExp { + return obj instanceof RegExp; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorParser.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorParser.ts new file mode 100644 index 00000000..7546f191 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorParser.ts @@ -0,0 +1,249 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { asLocators } from './locatorGenerators'; +import { parseSelector } from './selectorParser'; +import { escapeForAttributeSelector, escapeForTextSelector } from './stringUtils'; + +import type { Language, Quote } from './locatorGenerators'; + +type TemplateParams = { quote: string, text: string }[]; +function parseLocator(locator: string, testIdAttributeName: string): { selector: string, preferredQuote: Quote | undefined } { + locator = locator + .replace(/AriaRole\s*\.\s*([\w]+)/g, (_, group) => group.toLowerCase()) + .replace(/(get_by_role|getByRole)\s*\(\s*(?:["'`])([^'"`]+)['"`]/g, (_, group1, group2) => `${group1}(${group2.toLowerCase()}`); + const params: TemplateParams = []; + let template = ''; + for (let i = 0; i < locator.length; ++i) { + const quote = locator[i]; + if (quote !== '"' && quote !== '\'' && quote !== '`' && quote !== '/') { + template += quote; + continue; + } + const isRegexEscaping = locator[i - 1] === 'r' || locator[i] === '/'; + ++i; + let text = ''; + while (i < locator.length) { + if (locator[i] === '\\') { + if (isRegexEscaping) { + if (locator[i + 1] !== quote) + text += locator[i]; + ++i; + text += locator[i]; + } else { + ++i; + if (locator[i] === 'n') + text += '\n'; + else if (locator[i] === 'r') + text += '\r'; + else if (locator[i] === 't') + text += '\t'; + else + text += locator[i]; + } + ++i; + continue; + } + if (locator[i] !== quote) { + text += locator[i++]; + continue; + } + break; + } + params.push({ quote, text }); + template += (quote === '/' ? 'r' : '') + '$' + params.length; + } + + // Equalize languages. + template = template.toLowerCase() + .replace(/get_by_alt_text/g, 'getbyalttext') + .replace(/get_by_test_id/g, 'getbytestid') + .replace(/get_by_([\w]+)/g, 'getby$1') + .replace(/has_not_text/g, 'hasnottext') + .replace(/has_text/g, 'hastext') + .replace(/has_not/g, 'hasnot') + .replace(/frame_locator/g, 'framelocator') + .replace(/content_frame/g, 'contentframe') + .replace(/[{}\s]/g, '') + .replace(/new\(\)/g, '') + .replace(/new[\w]+\.[\w]+options\(\)/g, '') + .replace(/\.set/g, ',set') + .replace(/\.or_\(/g, 'or(') // Python has "or_" instead of "or". + .replace(/\.and_\(/g, 'and(') // Python has "and_" instead of "and". + .replace(/:/g, '=') + .replace(/,re\.ignorecase/g, 'i') + .replace(/,pattern.case_insensitive/g, 'i') + .replace(/,regexoptions.ignorecase/g, 'i') + .replace(/re.compile\(([^)]+)\)/g, '$1') // Python has regex strings as r"foo" + .replace(/pattern.compile\(([^)]+)\)/g, 'r$1') + .replace(/newregex\(([^)]+)\)/g, 'r$1') + .replace(/string=/g, '=') + .replace(/regex=/g, '=') + .replace(/,,/g, ',') + .replace(/,\)/g, ')'); + + const preferredQuote = params.map(p => p.quote).filter(quote => '\'"`'.includes(quote))[0] as Quote | undefined; + return { selector: transform(template, params, testIdAttributeName), preferredQuote }; +} + +function countParams(template: string) { + return [...template.matchAll(/\$\d+/g)].length; +} + +function shiftParams(template: string, sub: number) { + return template.replace(/\$(\d+)/g, (_, ordinal) => `$${ordinal - sub}`); +} + +function transform(template: string, params: TemplateParams, testIdAttributeName: string): string { + // Recursively handle filter(has=, hasnot=, sethas(), sethasnot()). + // TODO: handle and(locator), or(locator), locator(locator), locator(has=, hasnot=, sethas(), sethasnot()). + while (true) { + const hasMatch = template.match(/filter\(,?(has=|hasnot=|sethas\(|sethasnot\()/); + if (!hasMatch) + break; + + // Extract inner locator based on balanced parens. + const start = hasMatch.index! + hasMatch[0].length; + let balance = 0; + let end = start; + for (; end < template.length; end++) { + if (template[end] === '(') + balance++; + else if (template[end] === ')') + balance--; + if (balance < 0) + break; + } + + // Replace Java sethas(...) and sethasnot(...) with has=... and hasnot=... + let prefix = template.substring(0, start); + let extraSymbol = 0; + if (['sethas(', 'sethasnot('].includes(hasMatch[1])) { + // Eat extra ) symbol at the end of sethas(...) + extraSymbol = 1; + prefix = prefix.replace(/sethas\($/, 'has=').replace(/sethasnot\($/, 'hasnot='); + } + + const paramsCountBeforeHas = countParams(template.substring(0, start)); + const hasTemplate = shiftParams(template.substring(start, end), paramsCountBeforeHas); + const paramsCountInHas = countParams(hasTemplate); + const hasParams = params.slice(paramsCountBeforeHas, paramsCountBeforeHas + paramsCountInHas); + const hasSelector = JSON.stringify(transform(hasTemplate, hasParams, testIdAttributeName)); + + // Replace filter(has=...) with filter(has2=$5). Use has2 to avoid matching the same filter again. + // Replace filter(hasnot=...) with filter(hasnot2=$5). Use hasnot2 to avoid matching the same filter again. + template = prefix.replace(/=$/, '2=') + `$${paramsCountBeforeHas + 1}` + shiftParams(template.substring(end + extraSymbol), paramsCountInHas - 1); + + // Replace inner params with $5 value. + const paramsBeforeHas = params.slice(0, paramsCountBeforeHas); + const paramsAfterHas = params.slice(paramsCountBeforeHas + paramsCountInHas); + params = paramsBeforeHas.concat([{ quote: '"', text: hasSelector }]).concat(paramsAfterHas); + } + + // Transform to selector engines. + template = template + .replace(/\,set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase()) + .replace(/framelocator\(([^)]+)\)/g, '$1.internal:control=enter-frame') + .replace(/contentframe(\(\))?/g, 'internal:control=enter-frame') + .replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2') + .replace(/locator\(([^)]+),hasnottext=([^),]+)\)/g, 'locator($1).internal:has-not-text=$2') + .replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2') + .replace(/locator\(([^)]+)\)/g, '$1') + .replace(/getbyrole\(([^)]+)\)/g, 'internal:role=$1') + .replace(/getbytext\(([^)]+)\)/g, 'internal:text=$1') + .replace(/getbylabel\(([^)]+)\)/g, 'internal:label=$1') + .replace(/getbytestid\(([^)]+)\)/g, `internal:testid=[${testIdAttributeName}=$1]`) + .replace(/getby(placeholder|alt|title)(?:text)?\(([^)]+)\)/g, 'internal:attr=[$1=$2]') + .replace(/first(\(\))?/g, 'nth=0') + .replace(/last(\(\))?/g, 'nth=-1') + .replace(/nth\(([^)]+)\)/g, 'nth=$1') + .replace(/filter\(,?visible=true\)/g, 'visible=true') + .replace(/filter\(,?visible=false\)/g, 'visible=false') + .replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1') + .replace(/filter\(,?hasnottext=([^)]+)\)/g, 'internal:has-not-text=$1') + .replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1') + .replace(/filter\(,?hasnot2=([^)]+)\)/g, 'internal:has-not=$1') + .replace(/,exact=false/g, '') + .replace(/,exact=true/g, 's') + .replace(/,includehidden=/g, ',include-hidden=') + .replace(/\,/g, ']['); + + const parts = template.split('.'); + // Turn "internal:control=enter-frame >> nth=0" into "nth=0 >> internal:control=enter-frame" + // because these are swapped in locators vs selectors. + for (let index = 0; index < parts.length - 1; index++) { + if (parts[index] === 'internal:control=enter-frame' && parts[index + 1].startsWith('nth=')) { + // Swap nth and enter-frame. + const [nth] = parts.splice(index, 1); + parts.splice(index + 1, 0, nth); + } + } + + // Substitute params. + return parts.map(t => { + if (!t.startsWith('internal:') || t === 'internal:control') + return t.replace(/\$(\d+)/g, (_, ordinal) => { const param = params[+ordinal - 1]; return param.text; }); + t = t.includes('[') ? t.replace(/\]/, '') + ']' : t; + t = t + .replace(/(?:r)\$(\d+)(i)?/g, (_, ordinal, suffix) => { + const param = params[+ordinal - 1]; + if (t.startsWith('internal:attr') || t.startsWith('internal:testid') || t.startsWith('internal:role')) + return escapeForAttributeSelector(new RegExp(param.text), false) + (suffix || ''); + return escapeForTextSelector(new RegExp(param.text, suffix), false); + }) + .replace(/\$(\d+)(i|s)?/g, (_, ordinal, suffix) => { + const param = params[+ordinal - 1]; + if (t.startsWith('internal:has=') || t.startsWith('internal:has-not=')) + return param.text; + if (t.startsWith('internal:testid')) + return escapeForAttributeSelector(param.text, true); + if (t.startsWith('internal:attr') || t.startsWith('internal:role')) + return escapeForAttributeSelector(param.text, suffix === 's'); + return escapeForTextSelector(param.text, suffix === 's'); + }); + return t; + }).join(' >> '); +} + +export function locatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string { + try { + return unsafeLocatorOrSelectorAsSelector(language, locator, testIdAttributeName); + } catch (e) { + return ''; + } +} + +export function unsafeLocatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string { + try { + parseSelector(locator); + return locator; + } catch (e) { + } + const { selector, preferredQuote } = parseLocator(locator, testIdAttributeName); + const locators = asLocators(language, selector, undefined, undefined, preferredQuote); + const digest = digestForComparison(language, locator); + if (locators.some(candidate => digestForComparison(language, candidate) === digest)) + return selector; + return ''; +} + +function digestForComparison(language: Language, locator: string) { + locator = locator.replace(/\s/g, ''); + if (language === 'javascript') + locator = locator.replace(/\\?["`]/g, '\'').replace(/,{}/g, ''); + return locator; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorUtils.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorUtils.ts new file mode 100644 index 00000000..80a7b7be --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/locatorUtils.ts @@ -0,0 +1,79 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { escapeForAttributeSelector, escapeForTextSelector } from './stringUtils'; + +export type ByRoleOptions = { + checked?: boolean; + disabled?: boolean; + exact?: boolean; + expanded?: boolean; + includeHidden?: boolean; + level?: number; + name?: string | RegExp; + pressed?: boolean; + selected?: boolean; +}; + +function getByAttributeTextSelector(attrName: string, text: string | RegExp, options?: { exact?: boolean }): string { + return `internal:attr=[${attrName}=${escapeForAttributeSelector(text, options?.exact || false)}]`; +} + +export function getByTestIdSelector(testIdAttributeName: string, testId: string | RegExp): string { + return `internal:testid=[${testIdAttributeName}=${escapeForAttributeSelector(testId, true)}]`; +} + +export function getByLabelSelector(text: string | RegExp, options?: { exact?: boolean }): string { + return 'internal:label=' + escapeForTextSelector(text, !!options?.exact); +} + +export function getByAltTextSelector(text: string | RegExp, options?: { exact?: boolean }): string { + return getByAttributeTextSelector('alt', text, options); +} + +export function getByTitleSelector(text: string | RegExp, options?: { exact?: boolean }): string { + return getByAttributeTextSelector('title', text, options); +} + +export function getByPlaceholderSelector(text: string | RegExp, options?: { exact?: boolean }): string { + return getByAttributeTextSelector('placeholder', text, options); +} + +export function getByTextSelector(text: string | RegExp, options?: { exact?: boolean }): string { + return 'internal:text=' + escapeForTextSelector(text, !!options?.exact); +} + +export function getByRoleSelector(role: string, options: ByRoleOptions = {}): string { + const props: string[][] = []; + if (options.checked !== undefined) + props.push(['checked', String(options.checked)]); + if (options.disabled !== undefined) + props.push(['disabled', String(options.disabled)]); + if (options.selected !== undefined) + props.push(['selected', String(options.selected)]); + if (options.expanded !== undefined) + props.push(['expanded', String(options.expanded)]); + if (options.includeHidden !== undefined) + props.push(['include-hidden', String(options.includeHidden)]); + if (options.level !== undefined) + props.push(['level', String(options.level)]); + if (options.name !== undefined) + props.push(['name', escapeForAttributeSelector(options.name, !!options.exact)]); + if (options.pressed !== undefined) + props.push(['pressed', String(options.pressed)]); + return `internal:role=${role}${props.map(([n, v]) => `[${n}=${v}]`).join('')}`; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/lruCache.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/lruCache.ts new file mode 100644 index 00000000..2c0a0428 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/lruCache.ts @@ -0,0 +1,50 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class LRUCache { + private _maxSize: number; + private _map: Map; + private _size: number; + + constructor(maxSize: number) { + this._maxSize = maxSize; + this._map = new Map(); + this._size = 0; + } + + getOrCompute(key: K, compute: () => { value: V, size: number }): V { + if (this._map.has(key)) { + const result = this._map.get(key)!; + // reinserting makes this the least recently used entry + this._map.delete(key); + this._map.set(key, result); + return result.value; + } + + const result = compute(); + + while (this._map.size && this._size + result.size > this._maxSize) { + const [firstKey, firstValue] = this._map.entries().next().value!; + this._size -= firstValue.size; + this._map.delete(firstKey); + } + + this._map.set(key, result); + this._size += result.size; + return result.value; + } +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/manualPromise.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/manualPromise.ts new file mode 100644 index 00000000..025a61ec --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/manualPromise.ts @@ -0,0 +1,123 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { captureRawStack } from './stackTrace'; + +export class ManualPromise extends Promise { + private _resolve!: (t: T) => void; + private _reject!: (e: Error) => void; + private _isDone: boolean; + + constructor() { + let resolve: (t: T) => void; + let reject: (e: Error) => void; + super((f, r) => { + resolve = f; + reject = r; + }); + this._isDone = false; + this._resolve = resolve!; + this._reject = reject!; + } + + isDone() { + return this._isDone; + } + + resolve(t: T) { + this._isDone = true; + this._resolve(t); + } + + reject(e: Error) { + this._isDone = true; + this._reject(e); + } + + static override get [Symbol.species]() { + return Promise; + } + + override get [Symbol.toStringTag]() { + return 'ManualPromise'; + } +} + +export class LongStandingScope { + private _terminateError: Error | undefined; + private _closeError: Error | undefined; + private _terminatePromises = new Map, string[]>(); + private _isClosed = false; + + reject(error: Error) { + this._isClosed = true; + this._terminateError = error; + for (const p of this._terminatePromises.keys()) + p.resolve(error); + } + + close(error: Error) { + this._isClosed = true; + this._closeError = error; + for (const [p, frames] of this._terminatePromises) + p.resolve(cloneError(error, frames)); + } + + isClosed() { + return this._isClosed; + } + + static async raceMultiple(scopes: LongStandingScope[], promise: Promise): Promise { + return Promise.race(scopes.map(s => s.race(promise))); + } + + async race(promise: Promise | Promise[]): Promise { + return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise; + } + + async safeRace(promise: Promise, defaultValue: T): Promise; + async safeRace(promise: Promise): Promise; + async safeRace(promise: Promise, defaultValue?: T): Promise { + return this._race([promise], true, defaultValue); + } + + private async _race(promises: Promise[], safe: boolean, defaultValue?: any): Promise { + const terminatePromise = new ManualPromise(); + const frames = captureRawStack(); + if (this._terminateError) + terminatePromise.resolve(this._terminateError); + if (this._closeError) + terminatePromise.resolve(cloneError(this._closeError, frames)); + this._terminatePromises.set(terminatePromise, frames); + try { + return await Promise.race([ + terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)), + ...promises + ]); + } finally { + this._terminatePromises.delete(terminatePromise); + } + } +} + +function cloneError(error: Error, frames: string[]) { + const clone = new Error(); + clone.name = error.name; + clone.message = error.message; + clone.stack = [error.name + ':' + error.message, ...frames].join('\n'); + return clone; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/mimeType.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/mimeType.ts new file mode 100644 index 00000000..85b496cb --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/mimeType.ts @@ -0,0 +1,451 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function isJsonMimeType(mimeType: string) { + return !!mimeType.match(/^(application\/json|application\/.*?\+json|text\/(x-)?json)(;\s*charset=.*)?$/); +} + +export function isXmlMimeType(mimeType: string) { + return !!mimeType.match(/^(application\/xml|application\/.*?\+xml|text\/xml)(;\s*charset=.*)?$/); +} + +export function isTextualMimeType(mimeType: string) { + return !!mimeType.match(/^(text\/.*?|application\/(json|(x-)?javascript|xml.*?|ecmascript|graphql|x-www-form-urlencoded)|image\/svg(\+xml)?|application\/.*?(\+json|\+xml))(;\s*charset=.*)?$/); +} +export function getMimeTypeForPath(path: string): string | null { + const dotIndex = path.lastIndexOf('.'); + if (dotIndex === -1) + return null; + const extension = path.substring(dotIndex + 1); + return types.get(extension) || null; +} + +const types: Map = new Map([ + ['ez', 'application/andrew-inset'], + ['aw', 'application/applixware'], + ['atom', 'application/atom+xml'], + ['atomcat', 'application/atomcat+xml'], + ['atomdeleted', 'application/atomdeleted+xml'], + ['atomsvc', 'application/atomsvc+xml'], + ['dwd', 'application/atsc-dwd+xml'], + ['held', 'application/atsc-held+xml'], + ['rsat', 'application/atsc-rsat+xml'], + ['bdoc', 'application/bdoc'], + ['xcs', 'application/calendar+xml'], + ['ccxml', 'application/ccxml+xml'], + ['cdfx', 'application/cdfx+xml'], + ['cdmia', 'application/cdmi-capability'], + ['cdmic', 'application/cdmi-container'], + ['cdmid', 'application/cdmi-domain'], + ['cdmio', 'application/cdmi-object'], + ['cdmiq', 'application/cdmi-queue'], + ['cu', 'application/cu-seeme'], + ['mpd', 'application/dash+xml'], + ['davmount', 'application/davmount+xml'], + ['dbk', 'application/docbook+xml'], + ['dssc', 'application/dssc+der'], + ['xdssc', 'application/dssc+xml'], + ['ecma', 'application/ecmascript'], + ['es', 'application/ecmascript'], + ['emma', 'application/emma+xml'], + ['emotionml', 'application/emotionml+xml'], + ['epub', 'application/epub+zip'], + ['exi', 'application/exi'], + ['exp', 'application/express'], + ['fdt', 'application/fdt+xml'], + ['pfr', 'application/font-tdpfr'], + ['geojson', 'application/geo+json'], + ['gml', 'application/gml+xml'], + ['gpx', 'application/gpx+xml'], + ['gxf', 'application/gxf'], + ['gz', 'application/gzip'], + ['hjson', 'application/hjson'], + ['stk', 'application/hyperstudio'], + ['ink', 'application/inkml+xml'], + ['inkml', 'application/inkml+xml'], + ['ipfix', 'application/ipfix'], + ['its', 'application/its+xml'], + ['ear', 'application/java-archive'], + ['jar', 'application/java-archive'], + ['war', 'application/java-archive'], + ['ser', 'application/java-serialized-object'], + ['class', 'application/java-vm'], + ['js', 'application/javascript'], + ['mjs', 'application/javascript'], + ['json', 'application/json'], + ['map', 'application/json'], + ['json5', 'application/json5'], + ['jsonml', 'application/jsonml+json'], + ['jsonld', 'application/ld+json'], + ['lgr', 'application/lgr+xml'], + ['lostxml', 'application/lost+xml'], + ['hqx', 'application/mac-binhex40'], + ['cpt', 'application/mac-compactpro'], + ['mads', 'application/mads+xml'], + ['webmanifest', 'application/manifest+json'], + ['mrc', 'application/marc'], + ['mrcx', 'application/marcxml+xml'], + ['ma', 'application/mathematica'], + ['mb', 'application/mathematica'], + ['nb', 'application/mathematica'], + ['mathml', 'application/mathml+xml'], + ['mbox', 'application/mbox'], + ['mscml', 'application/mediaservercontrol+xml'], + ['metalink', 'application/metalink+xml'], + ['meta4', 'application/metalink4+xml'], + ['mets', 'application/mets+xml'], + ['maei', 'application/mmt-aei+xml'], + ['musd', 'application/mmt-usd+xml'], + ['mods', 'application/mods+xml'], + ['m21', 'application/mp21'], + ['mp21', 'application/mp21'], + ['m4p', 'application/mp4'], + ['mp4s', 'application/mp4'], + ['doc', 'application/msword'], + ['dot', 'application/msword'], + ['mxf', 'application/mxf'], + ['nq', 'application/n-quads'], + ['nt', 'application/n-triples'], + ['cjs', 'application/node'], + ['bin', 'application/octet-stream'], + ['bpk', 'application/octet-stream'], + ['buffer', 'application/octet-stream'], + ['deb', 'application/octet-stream'], + ['deploy', 'application/octet-stream'], + ['dist', 'application/octet-stream'], + ['distz', 'application/octet-stream'], + ['dll', 'application/octet-stream'], + ['dmg', 'application/octet-stream'], + ['dms', 'application/octet-stream'], + ['dump', 'application/octet-stream'], + ['elc', 'application/octet-stream'], + ['exe', 'application/octet-stream'], + ['img', 'application/octet-stream'], + ['iso', 'application/octet-stream'], + ['lrf', 'application/octet-stream'], + ['mar', 'application/octet-stream'], + ['msi', 'application/octet-stream'], + ['msm', 'application/octet-stream'], + ['msp', 'application/octet-stream'], + ['pkg', 'application/octet-stream'], + ['so', 'application/octet-stream'], + ['oda', 'application/oda'], + ['opf', 'application/oebps-package+xml'], + ['ogx', 'application/ogg'], + ['omdoc', 'application/omdoc+xml'], + ['onepkg', 'application/onenote'], + ['onetmp', 'application/onenote'], + ['onetoc', 'application/onenote'], + ['onetoc2', 'application/onenote'], + ['oxps', 'application/oxps'], + ['relo', 'application/p2p-overlay+xml'], + ['xer', 'application/patch-ops-error+xml'], + ['pdf', 'application/pdf'], + ['pgp', 'application/pgp-encrypted'], + ['asc', 'application/pgp-signature'], + ['sig', 'application/pgp-signature'], + ['prf', 'application/pics-rules'], + ['p10', 'application/pkcs10'], + ['p7c', 'application/pkcs7-mime'], + ['p7m', 'application/pkcs7-mime'], + ['p7s', 'application/pkcs7-signature'], + ['p8', 'application/pkcs8'], + ['ac', 'application/pkix-attr-cert'], + ['cer', 'application/pkix-cert'], + ['crl', 'application/pkix-crl'], + ['pkipath', 'application/pkix-pkipath'], + ['pki', 'application/pkixcmp'], + ['pls', 'application/pls+xml'], + ['ai', 'application/postscript'], + ['eps', 'application/postscript'], + ['ps', 'application/postscript'], + ['provx', 'application/provenance+xml'], + ['pskcxml', 'application/pskc+xml'], + ['raml', 'application/raml+yaml'], + ['owl', 'application/rdf+xml'], + ['rdf', 'application/rdf+xml'], + ['rif', 'application/reginfo+xml'], + ['rnc', 'application/relax-ng-compact-syntax'], + ['rl', 'application/resource-lists+xml'], + ['rld', 'application/resource-lists-diff+xml'], + ['rs', 'application/rls-services+xml'], + ['rapd', 'application/route-apd+xml'], + ['sls', 'application/route-s-tsid+xml'], + ['rusd', 'application/route-usd+xml'], + ['gbr', 'application/rpki-ghostbusters'], + ['mft', 'application/rpki-manifest'], + ['roa', 'application/rpki-roa'], + ['rsd', 'application/rsd+xml'], + ['rss', 'application/rss+xml'], + ['rtf', 'application/rtf'], + ['sbml', 'application/sbml+xml'], + ['scq', 'application/scvp-cv-request'], + ['scs', 'application/scvp-cv-response'], + ['spq', 'application/scvp-vp-request'], + ['spp', 'application/scvp-vp-response'], + ['sdp', 'application/sdp'], + ['senmlx', 'application/senml+xml'], + ['sensmlx', 'application/sensml+xml'], + ['setpay', 'application/set-payment-initiation'], + ['setreg', 'application/set-registration-initiation'], + ['shf', 'application/shf+xml'], + ['sieve', 'application/sieve'], + ['siv', 'application/sieve'], + ['smi', 'application/smil+xml'], + ['smil', 'application/smil+xml'], + ['rq', 'application/sparql-query'], + ['srx', 'application/sparql-results+xml'], + ['gram', 'application/srgs'], + ['grxml', 'application/srgs+xml'], + ['sru', 'application/sru+xml'], + ['ssdl', 'application/ssdl+xml'], + ['ssml', 'application/ssml+xml'], + ['swidtag', 'application/swid+xml'], + ['tei', 'application/tei+xml'], + ['teicorpus', 'application/tei+xml'], + ['tfi', 'application/thraud+xml'], + ['tsd', 'application/timestamped-data'], + ['toml', 'application/toml'], + ['trig', 'application/trig'], + ['ttml', 'application/ttml+xml'], + ['ubj', 'application/ubjson'], + ['rsheet', 'application/urc-ressheet+xml'], + ['td', 'application/urc-targetdesc+xml'], + ['vxml', 'application/voicexml+xml'], + ['wasm', 'application/wasm'], + ['wgt', 'application/widget'], + ['hlp', 'application/winhlp'], + ['wsdl', 'application/wsdl+xml'], + ['wspolicy', 'application/wspolicy+xml'], + ['xaml', 'application/xaml+xml'], + ['xav', 'application/xcap-att+xml'], + ['xca', 'application/xcap-caps+xml'], + ['xdf', 'application/xcap-diff+xml'], + ['xel', 'application/xcap-el+xml'], + ['xns', 'application/xcap-ns+xml'], + ['xenc', 'application/xenc+xml'], + ['xht', 'application/xhtml+xml'], + ['xhtml', 'application/xhtml+xml'], + ['xlf', 'application/xliff+xml'], + ['rng', 'application/xml'], + ['xml', 'application/xml'], + ['xsd', 'application/xml'], + ['xsl', 'application/xml'], + ['dtd', 'application/xml-dtd'], + ['xop', 'application/xop+xml'], + ['xpl', 'application/xproc+xml'], + ['*xsl', 'application/xslt+xml'], + ['xslt', 'application/xslt+xml'], + ['xspf', 'application/xspf+xml'], + ['mxml', 'application/xv+xml'], + ['xhvml', 'application/xv+xml'], + ['xvm', 'application/xv+xml'], + ['xvml', 'application/xv+xml'], + ['yang', 'application/yang'], + ['yin', 'application/yin+xml'], + ['zip', 'application/zip'], + ['*3gpp', 'audio/3gpp'], + ['adp', 'audio/adpcm'], + ['amr', 'audio/amr'], + ['au', 'audio/basic'], + ['snd', 'audio/basic'], + ['kar', 'audio/midi'], + ['mid', 'audio/midi'], + ['midi', 'audio/midi'], + ['rmi', 'audio/midi'], + ['mxmf', 'audio/mobile-xmf'], + ['*mp3', 'audio/mp3'], + ['m4a', 'audio/mp4'], + ['mp4a', 'audio/mp4'], + ['m2a', 'audio/mpeg'], + ['m3a', 'audio/mpeg'], + ['mp2', 'audio/mpeg'], + ['mp2a', 'audio/mpeg'], + ['mp3', 'audio/mpeg'], + ['mpga', 'audio/mpeg'], + ['oga', 'audio/ogg'], + ['ogg', 'audio/ogg'], + ['opus', 'audio/ogg'], + ['spx', 'audio/ogg'], + ['s3m', 'audio/s3m'], + ['sil', 'audio/silk'], + ['wav', 'audio/wav'], + ['*wav', 'audio/wave'], + ['weba', 'audio/webm'], + ['xm', 'audio/xm'], + ['ttc', 'font/collection'], + ['otf', 'font/otf'], + ['ttf', 'font/ttf'], + ['woff', 'font/woff'], + ['woff2', 'font/woff2'], + ['exr', 'image/aces'], + ['apng', 'image/apng'], + ['avif', 'image/avif'], + ['bmp', 'image/bmp'], + ['cgm', 'image/cgm'], + ['drle', 'image/dicom-rle'], + ['emf', 'image/emf'], + ['fits', 'image/fits'], + ['g3', 'image/g3fax'], + ['gif', 'image/gif'], + ['heic', 'image/heic'], + ['heics', 'image/heic-sequence'], + ['heif', 'image/heif'], + ['heifs', 'image/heif-sequence'], + ['hej2', 'image/hej2k'], + ['hsj2', 'image/hsj2'], + ['ief', 'image/ief'], + ['jls', 'image/jls'], + ['jp2', 'image/jp2'], + ['jpg2', 'image/jp2'], + ['jpe', 'image/jpeg'], + ['jpeg', 'image/jpeg'], + ['jpg', 'image/jpeg'], + ['jph', 'image/jph'], + ['jhc', 'image/jphc'], + ['jpm', 'image/jpm'], + ['jpf', 'image/jpx'], + ['jpx', 'image/jpx'], + ['jxr', 'image/jxr'], + ['jxra', 'image/jxra'], + ['jxrs', 'image/jxrs'], + ['jxs', 'image/jxs'], + ['jxsc', 'image/jxsc'], + ['jxsi', 'image/jxsi'], + ['jxss', 'image/jxss'], + ['ktx', 'image/ktx'], + ['ktx2', 'image/ktx2'], + ['png', 'image/png'], + ['sgi', 'image/sgi'], + ['svg', 'image/svg+xml'], + ['svgz', 'image/svg+xml'], + ['t38', 'image/t38'], + ['tif', 'image/tiff'], + ['tiff', 'image/tiff'], + ['tfx', 'image/tiff-fx'], + ['webp', 'image/webp'], + ['wmf', 'image/wmf'], + ['disposition-notification', 'message/disposition-notification'], + ['u8msg', 'message/global'], + ['u8dsn', 'message/global-delivery-status'], + ['u8mdn', 'message/global-disposition-notification'], + ['u8hdr', 'message/global-headers'], + ['eml', 'message/rfc822'], + ['mime', 'message/rfc822'], + ['3mf', 'model/3mf'], + ['gltf', 'model/gltf+json'], + ['glb', 'model/gltf-binary'], + ['iges', 'model/iges'], + ['igs', 'model/iges'], + ['mesh', 'model/mesh'], + ['msh', 'model/mesh'], + ['silo', 'model/mesh'], + ['mtl', 'model/mtl'], + ['obj', 'model/obj'], + ['stpx', 'model/step+xml'], + ['stpz', 'model/step+zip'], + ['stpxz', 'model/step-xml+zip'], + ['stl', 'model/stl'], + ['vrml', 'model/vrml'], + ['wrl', 'model/vrml'], + ['*x3db', 'model/x3d+binary'], + ['x3dbz', 'model/x3d+binary'], + ['x3db', 'model/x3d+fastinfoset'], + ['*x3dv', 'model/x3d+vrml'], + ['x3dvz', 'model/x3d+vrml'], + ['x3d', 'model/x3d+xml'], + ['x3dz', 'model/x3d+xml'], + ['x3dv', 'model/x3d-vrml'], + ['appcache', 'text/cache-manifest'], + ['manifest', 'text/cache-manifest'], + ['ics', 'text/calendar'], + ['ifb', 'text/calendar'], + ['coffee', 'text/coffeescript'], + ['litcoffee', 'text/coffeescript'], + ['css', 'text/css'], + ['csv', 'text/csv'], + ['htm', 'text/html'], + ['html', 'text/html'], + ['shtml', 'text/html'], + ['jade', 'text/jade'], + ['jsx', 'text/jsx'], + ['less', 'text/less'], + ['markdown', 'text/markdown'], + ['md', 'text/markdown'], + ['mml', 'text/mathml'], + ['mdx', 'text/mdx'], + ['n3', 'text/n3'], + ['conf', 'text/plain'], + ['def', 'text/plain'], + ['in', 'text/plain'], + ['ini', 'text/plain'], + ['list', 'text/plain'], + ['log', 'text/plain'], + ['text', 'text/plain'], + ['txt', 'text/plain'], + ['rtx', 'text/richtext'], + ['*rtf', 'text/rtf'], + ['sgm', 'text/sgml'], + ['sgml', 'text/sgml'], + ['shex', 'text/shex'], + ['slim', 'text/slim'], + ['slm', 'text/slim'], + ['spdx', 'text/spdx'], + ['styl', 'text/stylus'], + ['stylus', 'text/stylus'], + ['tsv', 'text/tab-separated-values'], + ['man', 'text/troff'], + ['me', 'text/troff'], + ['ms', 'text/troff'], + ['roff', 'text/troff'], + ['t', 'text/troff'], + ['tr', 'text/troff'], + ['ttl', 'text/turtle'], + ['uri', 'text/uri-list'], + ['uris', 'text/uri-list'], + ['urls', 'text/uri-list'], + ['vcard', 'text/vcard'], + ['vtt', 'text/vtt'], + ['*xml', 'text/xml'], + ['yaml', 'text/yaml'], + ['yml', 'text/yaml'], + ['3gp', 'video/3gpp'], + ['3gpp', 'video/3gpp'], + ['3g2', 'video/3gpp2'], + ['h261', 'video/h261'], + ['h263', 'video/h263'], + ['h264', 'video/h264'], + ['m4s', 'video/iso.segment'], + ['jpgv', 'video/jpeg'], + ['jpm', 'video/jpm'], + ['jpgm', 'video/jpm'], + ['mj2', 'video/mj2'], + ['mjp2', 'video/mj2'], + ['ts', 'application/typescript'], + ['mp4', 'video/mp4'], + ['mp4v', 'video/mp4'], + ['mpg4', 'video/mp4'], + ['m1v', 'video/mpeg'], + ['m2v', 'video/mpeg'], + ['mpe', 'video/mpeg'], + ['mpeg', 'video/mpeg'], + ['mpg', 'video/mpeg'], + ['ogv', 'video/ogg'], + ['mov', 'video/quicktime'], + ['qt', 'video/quicktime'], + ['webm', 'video/webm'] +]); diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/multimap.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/multimap.ts new file mode 100644 index 00000000..ac50cfeb --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/multimap.ts @@ -0,0 +1,83 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class MultiMap { + private _map: Map; + + constructor() { + this._map = new Map(); + } + + set(key: K, value: V) { + let values = this._map.get(key); + if (!values) { + values = []; + this._map.set(key, values); + } + values.push(value); + } + + get(key: K): V[] { + return this._map.get(key) || []; + } + + has(key: K): boolean { + return this._map.has(key); + } + + delete(key: K, value: V) { + const values = this._map.get(key); + if (!values) + return; + if (values.includes(value)) + this._map.set(key, values.filter(v => value !== v)); + } + + deleteAll(key: K) { + this._map.delete(key); + } + + hasValue(key: K, value: V): boolean { + const values = this._map.get(key); + if (!values) + return false; + return values.includes(value); + } + + get size(): number { + return this._map.size; + } + + [Symbol.iterator](): Iterator<[K, V[]]> { + return this._map[Symbol.iterator](); + } + + keys(): IterableIterator { + return this._map.keys(); + } + + values(): Iterable { + const result: V[] = []; + for (const key of this.keys()) + result.push(...this.get(key)); + return result; + } + + clear() { + this._map.clear(); + } +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/protocolFormatter.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/protocolFormatter.ts new file mode 100644 index 00000000..0cf11535 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/protocolFormatter.ts @@ -0,0 +1,77 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { methodMetainfo } from './protocolMetainfo'; + +export function formatProtocolParam(params: Record | undefined, alternatives: string): string | undefined { + return _formatProtocolParam(params, alternatives)?.replaceAll('\n', '\\n'); +} + +function _formatProtocolParam(params: Record | undefined, alternatives: string): string | undefined { + if (!params) + return undefined; + + for (const name of alternatives.split('|')) { + if (name === 'url') { + try { + const urlObject = new URL(params[name]); + if (urlObject.protocol === 'data:') + return urlObject.protocol; + if (urlObject.protocol === 'about:') + return params[name]; + return urlObject.pathname + urlObject.search; + } catch (error) { + if (params[name] !== undefined) + return params[name]; + } + } + if (name === 'timeNumber' && params[name] !== undefined) { + // eslint-disable-next-line no-restricted-globals + return new Date(params[name]).toString(); + } + + const value = deepParam(params, name); + if (value !== undefined) + return value; + } +} + +function deepParam(params: Record, name: string): string | undefined { + const tokens = name.split('.'); + let current = params; + for (const token of tokens) { + if (typeof current !== 'object' || current === null) + return undefined; + current = current[token]; + } + if (current === undefined) + return undefined; + return String(current); +} + +export function renderTitleForCall(metadata: { title?: string, type: string, method: string, params: Record | undefined }) { + const titleFormat = metadata.title ?? methodMetainfo.get(metadata.type + '.' + metadata.method)?.title ?? metadata.method; + return titleFormat.replace(/\{([^}]+)\}/g, (fullMatch, p1) => { + return formatProtocolParam(metadata.params, p1) ?? fullMatch; + }); +} + +export type ActionGroup = 'configuration' | 'route' | 'getter'; + +export function getActionGroup(metadata: { type: string, method: string }) { + return methodMetainfo.get(metadata.type + '.' + metadata.method)?.group as undefined | ActionGroup; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/protocolMetainfo.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/protocolMetainfo.ts new file mode 100644 index 00000000..7cb52101 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/protocolMetainfo.ts @@ -0,0 +1,335 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This file is generated by generate_channels.js, do not edit manually. + +export const methodMetainfo = new Map([ + ['APIRequestContext.fetch', { title: '{method} "{url}"', }], + ['APIRequestContext.fetchResponseBody', { title: 'Get response body', group: 'getter', }], + ['APIRequestContext.fetchLog', { internal: true, }], + ['APIRequestContext.storageState', { title: 'Get storage state', group: 'configuration', }], + ['APIRequestContext.disposeAPIResponse', { internal: true, }], + ['APIRequestContext.dispose', { internal: true, }], + ['LocalUtils.zip', { internal: true, }], + ['LocalUtils.harOpen', { internal: true, }], + ['LocalUtils.harLookup', { internal: true, }], + ['LocalUtils.harClose', { internal: true, }], + ['LocalUtils.harUnzip', { internal: true, }], + ['LocalUtils.connect', { internal: true, }], + ['LocalUtils.tracingStarted', { internal: true, }], + ['LocalUtils.addStackToTracingNoReply', { internal: true, }], + ['LocalUtils.traceDiscarded', { internal: true, }], + ['LocalUtils.globToRegex', { internal: true, }], + ['Root.initialize', { internal: true, }], + ['Playwright.newRequest', { title: 'Create request context', }], + ['DebugController.initialize', { internal: true, }], + ['DebugController.setReportStateChanged', { internal: true, }], + ['DebugController.setRecorderMode', { internal: true, }], + ['DebugController.highlight', { internal: true, }], + ['DebugController.hideHighlight', { internal: true, }], + ['DebugController.resume', { internal: true, }], + ['DebugController.kill', { internal: true, }], + ['SocksSupport.socksConnected', { internal: true, }], + ['SocksSupport.socksFailed', { internal: true, }], + ['SocksSupport.socksData', { internal: true, }], + ['SocksSupport.socksError', { internal: true, }], + ['SocksSupport.socksEnd', { internal: true, }], + ['BrowserType.launch', { title: 'Launch browser', }], + ['BrowserType.launchPersistentContext', { title: 'Launch persistent context', }], + ['BrowserType.connectOverCDP', { title: 'Connect over CDP', }], + ['BrowserType.connectOverCDPTransport', { title: 'Connect over CDP transport', }], + ['Browser.startServer', { title: 'Start server', }], + ['Browser.stopServer', { title: 'Stop server', }], + ['Browser.close', { title: 'Close browser', pausesBeforeAction: true, }], + ['Browser.killForTests', { internal: true, }], + ['Browser.defaultUserAgentForTest', { internal: true, }], + ['Browser.newContext', { title: 'Create context', }], + ['Browser.newContextForReuse', { internal: true, }], + ['Browser.disconnectFromReusedContext', { internal: true, }], + ['Browser.newBrowserCDPSession', { title: 'Create CDP session', group: 'configuration', }], + ['Browser.startTracing', { title: 'Start browser tracing', group: 'configuration', }], + ['Browser.stopTracing', { title: 'Stop browser tracing', group: 'configuration', }], + ['EventTarget.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], + ['BrowserContext.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], + ['Page.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], + ['Worker.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], + ['WebSocket.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], + ['Debugger.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], + ['ElectronApplication.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], + ['AndroidDevice.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], + ['BrowserContext.addCookies', { title: 'Add cookies', group: 'configuration', }], + ['BrowserContext.addInitScript', { title: 'Add init script', group: 'configuration', }], + ['BrowserContext.clearCookies', { title: 'Clear cookies', group: 'configuration', }], + ['BrowserContext.clearPermissions', { title: 'Clear permissions', group: 'configuration', }], + ['BrowserContext.close', { title: 'Close context', pausesBeforeAction: true, }], + ['BrowserContext.cookies', { title: 'Get cookies', group: 'getter', }], + ['BrowserContext.exposeBinding', { title: 'Expose binding', group: 'configuration', }], + ['BrowserContext.grantPermissions', { title: 'Grant permissions', group: 'configuration', }], + ['BrowserContext.newPage', { title: 'Create page', }], + ['BrowserContext.registerSelectorEngine', { internal: true, }], + ['BrowserContext.setTestIdAttributeName', { internal: true, }], + ['BrowserContext.setExtraHTTPHeaders', { title: 'Set extra HTTP headers', group: 'configuration', }], + ['BrowserContext.setGeolocation', { title: 'Set geolocation', group: 'configuration', }], + ['BrowserContext.setHTTPCredentials', { title: 'Set HTTP credentials', group: 'configuration', }], + ['BrowserContext.setNetworkInterceptionPatterns', { title: 'Route requests', group: 'route', }], + ['BrowserContext.setWebSocketInterceptionPatterns', { title: 'Route WebSockets', group: 'route', }], + ['BrowserContext.setOffline', { title: 'Set offline mode', }], + ['BrowserContext.storageState', { title: 'Get storage state', group: 'configuration', }], + ['BrowserContext.setStorageState', { title: 'Set storage state', group: 'configuration', }], + ['BrowserContext.pause', { title: 'Pause', }], + ['BrowserContext.enableRecorder', { internal: true, }], + ['BrowserContext.disableRecorder', { internal: true, }], + ['BrowserContext.exposeConsoleApi', { internal: true, }], + ['BrowserContext.newCDPSession', { title: 'Create CDP session', group: 'configuration', }], + ['BrowserContext.harStart', { internal: true, }], + ['BrowserContext.harExport', { internal: true, }], + ['BrowserContext.createTempFiles', { internal: true, }], + ['BrowserContext.updateSubscription', { internal: true, }], + ['BrowserContext.clockFastForward', { title: 'Fast forward clock "{ticksNumber|ticksString}"', }], + ['BrowserContext.clockInstall', { title: 'Install clock "{timeNumber|timeString}"', }], + ['BrowserContext.clockPauseAt', { title: 'Pause clock "{timeNumber|timeString}"', }], + ['BrowserContext.clockResume', { title: 'Resume clock', }], + ['BrowserContext.clockRunFor', { title: 'Run clock "{ticksNumber|ticksString}"', }], + ['BrowserContext.clockSetFixedTime', { title: 'Set fixed time "{timeNumber|timeString}"', }], + ['BrowserContext.clockSetSystemTime', { title: 'Set system time "{timeNumber|timeString}"', }], + ['Page.addInitScript', { title: 'Add init script', group: 'configuration', }], + ['Page.close', { title: 'Close page', pausesBeforeAction: true, }], + ['Page.clearConsoleMessages', { title: 'Clear console messages', }], + ['Page.consoleMessages', { title: 'Get console messages', group: 'getter', }], + ['Page.emulateMedia', { title: 'Emulate media', snapshot: true, pausesBeforeAction: true, }], + ['Page.exposeBinding', { title: 'Expose binding', group: 'configuration', }], + ['Page.goBack', { title: 'Go back', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Page.goForward', { title: 'Go forward', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Page.requestGC', { title: 'Request garbage collection', group: 'configuration', }], + ['Page.registerLocatorHandler', { title: 'Register locator handler', }], + ['Page.resolveLocatorHandlerNoReply', { internal: true, }], + ['Page.unregisterLocatorHandler', { title: 'Unregister locator handler', }], + ['Page.reload', { title: 'Reload', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Page.expectScreenshot', { title: 'Expect screenshot', snapshot: true, pausesBeforeAction: true, }], + ['Page.screenshot', { title: 'Screenshot', snapshot: true, pausesBeforeAction: true, }], + ['Page.setExtraHTTPHeaders', { title: 'Set extra HTTP headers', group: 'configuration', }], + ['Page.setNetworkInterceptionPatterns', { title: 'Route requests', group: 'route', }], + ['Page.setWebSocketInterceptionPatterns', { title: 'Route WebSockets', group: 'route', }], + ['Page.setViewportSize', { title: 'Set viewport size', snapshot: true, pausesBeforeAction: true, }], + ['Page.keyboardDown', { title: 'Key down "{key}"', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Page.keyboardUp', { title: 'Key up "{key}"', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Page.keyboardInsertText', { title: 'Insert "{text}"', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Page.keyboardType', { title: 'Type "{text}"', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Page.keyboardPress', { title: 'Press "{key}"', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Page.mouseMove', { title: 'Mouse move', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Page.mouseDown', { title: 'Mouse down', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Page.mouseUp', { title: 'Mouse up', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Page.mouseClick', { title: 'Click', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Page.mouseWheel', { title: 'Mouse wheel', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Page.touchscreenTap', { title: 'Tap', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Page.clearPageErrors', { title: 'Clear page errors', }], + ['Page.pageErrors', { title: 'Get page errors', group: 'getter', }], + ['Page.pdf', { title: 'PDF', }], + ['Page.requests', { title: 'Get network requests', group: 'getter', }], + ['Page.snapshotForAI', { internal: true, }], + ['Page.startJSCoverage', { title: 'Start JS coverage', group: 'configuration', }], + ['Page.stopJSCoverage', { title: 'Stop JS coverage', group: 'configuration', }], + ['Page.startCSSCoverage', { title: 'Start CSS coverage', group: 'configuration', }], + ['Page.stopCSSCoverage', { title: 'Stop CSS coverage', group: 'configuration', }], + ['Page.bringToFront', { title: 'Bring to front', }], + ['Page.pickLocator', { title: 'Pick locator', group: 'configuration', }], + ['Page.cancelPickLocator', { title: 'Cancel pick locator', group: 'configuration', }], + ['Page.startScreencast', { title: 'Start screencast', group: 'configuration', }], + ['Page.stopScreencast', { title: 'Stop screencast', group: 'configuration', }], + ['Page.videoStart', { title: 'Start video recording', group: 'configuration', }], + ['Page.videoStop', { title: 'Stop video recording', group: 'configuration', }], + ['Page.updateSubscription', { internal: true, }], + ['Page.setDockTile', { internal: true, }], + ['Frame.evalOnSelector', { title: 'Evaluate', snapshot: true, pausesBeforeAction: true, }], + ['Frame.evalOnSelectorAll', { title: 'Evaluate', snapshot: true, pausesBeforeAction: true, }], + ['Frame.addScriptTag', { title: 'Add script tag', snapshot: true, pausesBeforeAction: true, }], + ['Frame.addStyleTag', { title: 'Add style tag', snapshot: true, pausesBeforeAction: true, }], + ['Frame.ariaSnapshot', { title: 'Aria snapshot', snapshot: true, pausesBeforeAction: true, }], + ['Frame.blur', { title: 'Blur', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Frame.check', { title: 'Check', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['Frame.click', { title: 'Click', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['Frame.content', { title: 'Get content', snapshot: true, pausesBeforeAction: true, }], + ['Frame.dragAndDrop', { title: 'Drag and drop', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['Frame.dblclick', { title: 'Double click', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['Frame.dispatchEvent', { title: 'Dispatch "{type}"', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Frame.evaluateExpression', { title: 'Evaluate', snapshot: true, pausesBeforeAction: true, }], + ['Frame.evaluateExpressionHandle', { title: 'Evaluate', snapshot: true, pausesBeforeAction: true, }], + ['Frame.fill', { title: 'Fill "{value}"', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['Frame.focus', { title: 'Focus', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Frame.frameElement', { title: 'Get frame element', group: 'getter', }], + ['Frame.resolveSelector', { internal: true, }], + ['Frame.highlight', { title: 'Highlight element', group: 'configuration', }], + ['Frame.getAttribute', { title: 'Get attribute "{name}"', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['Frame.goto', { title: 'Navigate to "{url}"', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['Frame.hover', { title: 'Hover', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['Frame.innerHTML', { title: 'Get HTML', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['Frame.innerText', { title: 'Get inner text', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['Frame.inputValue', { title: 'Get input value', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['Frame.isChecked', { title: 'Is checked', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['Frame.isDisabled', { title: 'Is disabled', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['Frame.isEnabled', { title: 'Is enabled', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['Frame.isHidden', { title: 'Is hidden', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['Frame.isVisible', { title: 'Is visible', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['Frame.isEditable', { title: 'Is editable', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['Frame.press', { title: 'Press "{key}"', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['Frame.querySelector', { title: 'Query selector', snapshot: true, }], + ['Frame.querySelectorAll', { title: 'Query selector all', snapshot: true, }], + ['Frame.queryCount', { title: 'Query count', snapshot: true, pausesBeforeAction: true, }], + ['Frame.selectOption', { title: 'Select option', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['Frame.setContent', { title: 'Set content', snapshot: true, pausesBeforeAction: true, }], + ['Frame.setInputFiles', { title: 'Set input files', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['Frame.tap', { title: 'Tap', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['Frame.textContent', { title: 'Get text content', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['Frame.title', { title: 'Get page title', group: 'getter', }], + ['Frame.type', { title: 'Type "{text}"', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['Frame.uncheck', { title: 'Uncheck', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['Frame.waitForTimeout', { title: 'Wait for timeout', snapshot: true, }], + ['Frame.waitForFunction', { title: 'Wait for function', snapshot: true, pausesBeforeAction: true, }], + ['Frame.waitForSelector', { title: 'Wait for selector', snapshot: true, }], + ['Frame.expect', { title: 'Expect "{expression}"', snapshot: true, pausesBeforeAction: true, }], + ['Worker.evaluateExpression', { title: 'Evaluate', }], + ['Worker.evaluateExpressionHandle', { title: 'Evaluate', }], + ['Worker.updateSubscription', { internal: true, }], + ['Disposable.dispose', { internal: true, }], + ['JSHandle.dispose', { internal: true, }], + ['ElementHandle.dispose', { internal: true, }], + ['JSHandle.evaluateExpression', { title: 'Evaluate', snapshot: true, pausesBeforeAction: true, }], + ['ElementHandle.evaluateExpression', { title: 'Evaluate', snapshot: true, pausesBeforeAction: true, }], + ['JSHandle.evaluateExpressionHandle', { title: 'Evaluate', snapshot: true, pausesBeforeAction: true, }], + ['ElementHandle.evaluateExpressionHandle', { title: 'Evaluate', snapshot: true, pausesBeforeAction: true, }], + ['JSHandle.getPropertyList', { title: 'Get property list', group: 'getter', }], + ['ElementHandle.getPropertyList', { title: 'Get property list', group: 'getter', }], + ['JSHandle.getProperty', { title: 'Get JS property', group: 'getter', }], + ['ElementHandle.getProperty', { title: 'Get JS property', group: 'getter', }], + ['JSHandle.jsonValue', { title: 'Get JSON value', group: 'getter', }], + ['ElementHandle.jsonValue', { title: 'Get JSON value', group: 'getter', }], + ['ElementHandle.evalOnSelector', { title: 'Evaluate', snapshot: true, pausesBeforeAction: true, }], + ['ElementHandle.evalOnSelectorAll', { title: 'Evaluate', snapshot: true, pausesBeforeAction: true, }], + ['ElementHandle.boundingBox', { title: 'Get bounding box', snapshot: true, pausesBeforeAction: true, }], + ['ElementHandle.check', { title: 'Check', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['ElementHandle.click', { title: 'Click', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['ElementHandle.contentFrame', { title: 'Get content frame', group: 'getter', }], + ['ElementHandle.dblclick', { title: 'Double click', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['ElementHandle.dispatchEvent', { title: 'Dispatch event', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['ElementHandle.fill', { title: 'Fill "{value}"', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['ElementHandle.focus', { title: 'Focus', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['ElementHandle.getAttribute', { title: 'Get attribute', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['ElementHandle.hover', { title: 'Hover', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['ElementHandle.innerHTML', { title: 'Get HTML', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['ElementHandle.innerText', { title: 'Get inner text', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['ElementHandle.inputValue', { title: 'Get input value', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['ElementHandle.isChecked', { title: 'Is checked', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['ElementHandle.isDisabled', { title: 'Is disabled', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['ElementHandle.isEditable', { title: 'Is editable', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['ElementHandle.isEnabled', { title: 'Is enabled', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['ElementHandle.isHidden', { title: 'Is hidden', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['ElementHandle.isVisible', { title: 'Is visible', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['ElementHandle.ownerFrame', { title: 'Get owner frame', group: 'getter', }], + ['ElementHandle.press', { title: 'Press "{key}"', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['ElementHandle.querySelector', { title: 'Query selector', snapshot: true, }], + ['ElementHandle.querySelectorAll', { title: 'Query selector all', snapshot: true, }], + ['ElementHandle.screenshot', { title: 'Screenshot', snapshot: true, pausesBeforeAction: true, }], + ['ElementHandle.scrollIntoViewIfNeeded', { title: 'Scroll into view', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['ElementHandle.selectOption', { title: 'Select option', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['ElementHandle.selectText', { title: 'Select text', slowMo: true, snapshot: true, pausesBeforeAction: true, }], + ['ElementHandle.setInputFiles', { title: 'Set input files', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['ElementHandle.tap', { title: 'Tap', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['ElementHandle.textContent', { title: 'Get text content', snapshot: true, pausesBeforeAction: true, group: 'getter', }], + ['ElementHandle.type', { title: 'Type', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['ElementHandle.uncheck', { title: 'Uncheck', slowMo: true, snapshot: true, pausesBeforeInput: true, }], + ['ElementHandle.waitForElementState', { title: 'Wait for state', snapshot: true, pausesBeforeAction: true, }], + ['ElementHandle.waitForSelector', { title: 'Wait for selector', snapshot: true, }], + ['Request.response', { internal: true, }], + ['Request.rawRequestHeaders', { internal: true, }], + ['Route.redirectNavigationRequest', { internal: true, }], + ['Route.abort', { title: 'Abort request', group: 'route', }], + ['Route.continue', { title: 'Continue request', group: 'route', }], + ['Route.fulfill', { title: 'Fulfill request', group: 'route', }], + ['WebSocketRoute.connect', { title: 'Connect WebSocket to server', group: 'route', }], + ['WebSocketRoute.ensureOpened', { internal: true, }], + ['WebSocketRoute.sendToPage', { title: 'Send WebSocket message', group: 'route', }], + ['WebSocketRoute.sendToServer', { title: 'Send WebSocket message', group: 'route', }], + ['WebSocketRoute.closePage', { internal: true, }], + ['WebSocketRoute.closeServer', { internal: true, }], + ['Response.body', { title: 'Get response body', group: 'getter', }], + ['Response.securityDetails', { internal: true, }], + ['Response.serverAddr', { internal: true, }], + ['Response.rawResponseHeaders', { internal: true, }], + ['Response.httpVersion', { internal: true, }], + ['Response.sizes', { internal: true, }], + ['BindingCall.reject', { internal: true, }], + ['BindingCall.resolve', { internal: true, }], + ['Debugger.pause', { title: 'Pause on next call', group: 'configuration', }], + ['Debugger.resume', { title: 'Resume', group: 'configuration', }], + ['Debugger.next', { title: 'Step to next call', group: 'configuration', }], + ['Debugger.runTo', { title: 'Run to location', group: 'configuration', }], + ['Dialog.accept', { title: 'Accept dialog', }], + ['Dialog.dismiss', { title: 'Dismiss dialog', }], + ['Tracing.tracingStart', { title: 'Start tracing', group: 'configuration', }], + ['Tracing.tracingStartChunk', { title: 'Start tracing', group: 'configuration', }], + ['Tracing.tracingGroup', { title: 'Trace "{name}"', }], + ['Tracing.tracingGroupEnd', { title: 'Group end', }], + ['Tracing.tracingStopChunk', { title: 'Stop tracing', group: 'configuration', }], + ['Tracing.tracingStop', { title: 'Stop tracing', group: 'configuration', }], + ['Artifact.pathAfterFinished', { internal: true, }], + ['Artifact.saveAs', { internal: true, }], + ['Artifact.saveAsStream', { internal: true, }], + ['Artifact.failure', { internal: true, }], + ['Artifact.stream', { internal: true, }], + ['Artifact.cancel', { internal: true, }], + ['Artifact.delete', { internal: true, }], + ['Stream.read', { internal: true, }], + ['Stream.close', { internal: true, }], + ['WritableStream.write', { internal: true, }], + ['WritableStream.close', { internal: true, }], + ['CDPSession.send', { title: 'Send CDP command', group: 'configuration', }], + ['CDPSession.detach', { title: 'Detach CDP session', group: 'configuration', }], + ['Electron.launch', { title: 'Launch electron', }], + ['ElectronApplication.browserWindow', { internal: true, }], + ['ElectronApplication.evaluateExpression', { title: 'Evaluate', }], + ['ElectronApplication.evaluateExpressionHandle', { title: 'Evaluate', }], + ['ElectronApplication.updateSubscription', { internal: true, }], + ['Android.devices', { internal: true, }], + ['AndroidSocket.write', { internal: true, }], + ['AndroidSocket.close', { internal: true, }], + ['AndroidDevice.wait', { title: 'Wait', }], + ['AndroidDevice.fill', { title: 'Fill "{text}"', }], + ['AndroidDevice.tap', { title: 'Tap', }], + ['AndroidDevice.drag', { title: 'Drag', }], + ['AndroidDevice.fling', { title: 'Fling', }], + ['AndroidDevice.longTap', { title: 'Long tap', }], + ['AndroidDevice.pinchClose', { title: 'Pinch close', }], + ['AndroidDevice.pinchOpen', { title: 'Pinch open', }], + ['AndroidDevice.scroll', { title: 'Scroll', }], + ['AndroidDevice.swipe', { title: 'Swipe', }], + ['AndroidDevice.info', { internal: true, }], + ['AndroidDevice.screenshot', { title: 'Screenshot', }], + ['AndroidDevice.inputType', { title: 'Type', }], + ['AndroidDevice.inputPress', { title: 'Press', }], + ['AndroidDevice.inputTap', { title: 'Tap', }], + ['AndroidDevice.inputSwipe', { title: 'Swipe', }], + ['AndroidDevice.inputDrag', { title: 'Drag', }], + ['AndroidDevice.launchBrowser', { title: 'Launch browser', }], + ['AndroidDevice.open', { title: 'Open app', }], + ['AndroidDevice.shell', { title: 'Execute shell command', group: 'configuration', }], + ['AndroidDevice.installApk', { title: 'Install apk', }], + ['AndroidDevice.push', { title: 'Push', }], + ['AndroidDevice.connectToWebView', { title: 'Connect to Web View', }], + ['AndroidDevice.close', { internal: true, }], + ['JsonPipe.send', { internal: true, }], + ['JsonPipe.close', { internal: true, }] +]); diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/rtti.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/rtti.ts new file mode 100644 index 00000000..309a0342 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/rtti.ts @@ -0,0 +1,30 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { isString } from './stringUtils'; + +export function isRegExp(obj: any): obj is RegExp { + return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]'; +} + +export function isObject(obj: any): obj is NonNullable { + return typeof obj === 'object' && obj !== null; +} + +export function isError(obj: any): obj is Error { + return obj instanceof Error || (obj && Object.getPrototypeOf(obj)?.name === 'Error'); +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/selectorParser.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/selectorParser.ts new file mode 100644 index 00000000..f3bedc5a --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/selectorParser.ts @@ -0,0 +1,442 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InvalidSelectorError, parseCSS } from './cssParser'; + +import type { CSSComplexSelectorList } from './cssParser'; +export { InvalidSelectorError, isInvalidSelectorError } from './cssParser'; + +export type NestedSelectorBody = { parsed: ParsedSelector, distance?: number }; +const kNestedSelectorNames = new Set(['internal:has', 'internal:has-not', 'internal:and', 'internal:or', 'internal:chain', 'left-of', 'right-of', 'above', 'below', 'near']); +const kNestedSelectorNamesWithDistance = new Set(['left-of', 'right-of', 'above', 'below', 'near']); + +export type ParsedSelectorPart = { + name: string, + body: string | CSSComplexSelectorList | NestedSelectorBody, + source: string, +}; + +export type ParsedSelector = { + parts: ParsedSelectorPart[], + capture?: number, +}; + +type ParsedSelectorStrings = { + parts: { name: string, body: string }[], + capture?: number, +}; + +export const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is', 'has-text', 'above', 'below', 'right-of', 'left-of', 'near', 'nth-match']); + +export function parseSelector(selector: string): ParsedSelector { + const parsedStrings = parseSelectorString(selector); + const parts: ParsedSelectorPart[] = []; + for (const part of parsedStrings.parts) { + if (part.name === 'css' || part.name === 'css:light') { + if (part.name === 'css:light') + part.body = ':light(' + part.body + ')'; + const parsedCSS = parseCSS(part.body, customCSSNames); + parts.push({ + name: 'css', + body: parsedCSS.selector, + source: part.body + }); + continue; + } + if (kNestedSelectorNames.has(part.name)) { + let innerSelector: string; + let distance: number | undefined; + try { + const unescaped = JSON.parse('[' + part.body + ']'); + if (!Array.isArray(unescaped) || unescaped.length < 1 || unescaped.length > 2 || typeof unescaped[0] !== 'string') + throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body); + innerSelector = unescaped[0]; + if (unescaped.length === 2) { + if (typeof unescaped[1] !== 'number' || !kNestedSelectorNamesWithDistance.has(part.name)) + throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body); + distance = unescaped[1]; + } + } catch (e) { + throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body); + } + const nested = { name: part.name, source: part.body, body: { parsed: parseSelector(innerSelector), distance } }; + const lastFrame = [...nested.body.parsed.parts].reverse().find(part => part.name === 'internal:control' && part.body === 'enter-frame'); + const lastFrameIndex = lastFrame ? nested.body.parsed.parts.indexOf(lastFrame) : -1; + // Allow nested selectors to start with the same frame selector. + if (lastFrameIndex !== -1 && selectorPartsEqual(nested.body.parsed.parts.slice(0, lastFrameIndex + 1), parts.slice(0, lastFrameIndex + 1))) + nested.body.parsed.parts.splice(0, lastFrameIndex + 1); + parts.push(nested); + continue; + } + parts.push({ ...part, source: part.body }); + } + if (kNestedSelectorNames.has(parts[0].name)) + throw new InvalidSelectorError(`"${parts[0].name}" selector cannot be first`); + return { + capture: parsedStrings.capture, + parts + }; +} + +export function splitSelectorByFrame(selectorText: string): ParsedSelector[] { + const selector = parseSelector(selectorText); + const result: ParsedSelector[] = []; + let chunk: ParsedSelector = { + parts: [], + }; + let chunkStartIndex = 0; + for (let i = 0; i < selector.parts.length; ++i) { + const part = selector.parts[i]; + if (part.name === 'internal:control' && part.body === 'enter-frame') { + if (!chunk.parts.length) + throw new InvalidSelectorError('Selector cannot start with entering frame, select the iframe first'); + result.push(chunk); + chunk = { parts: [] }; + chunkStartIndex = i + 1; + continue; + } + if (selector.capture === i) + chunk.capture = i - chunkStartIndex; + chunk.parts.push(part); + } + if (!chunk.parts.length) + throw new InvalidSelectorError(`Selector cannot end with entering frame, while parsing selector ${selectorText}`); + result.push(chunk); + if (typeof selector.capture === 'number' && typeof result[result.length - 1].capture !== 'number') + throw new InvalidSelectorError(`Can not capture the selector before diving into the frame. Only use * after the last frame has been selected`); + return result; +} + +function selectorPartsEqual(list1: ParsedSelectorPart[], list2: ParsedSelectorPart[]) { + return stringifySelector({ parts: list1 }) === stringifySelector({ parts: list2 }); +} + +export function stringifySelector(selector: string | ParsedSelector, forceEngineName?: boolean): string { + if (typeof selector === 'string') + return selector; + return selector.parts.map((p, i) => { + let includeEngine = true; + if (!forceEngineName && i !== selector.capture) { + if (p.name === 'css') + includeEngine = false; + else if (p.name === 'xpath' && p.source.startsWith('//') || p.source.startsWith('..')) + includeEngine = false; + } + const prefix = includeEngine ? p.name + '=' : ''; + return `${i === selector.capture ? '*' : ''}${prefix}${p.source}`; + }).join(' >> '); +} + +export function visitAllSelectorParts(selector: ParsedSelector, visitor: (part: ParsedSelectorPart, nested: boolean) => void) { + const visit = (selector: ParsedSelector, nested: boolean) => { + for (const part of selector.parts) { + visitor(part, nested); + if (kNestedSelectorNames.has(part.name)) + visit((part.body as NestedSelectorBody).parsed, true); + } + }; + visit(selector, false); +} + +function parseSelectorString(selector: string): ParsedSelectorStrings { + let index = 0; + let quote: string | undefined; + let start = 0; + const result: ParsedSelectorStrings = { parts: [] }; + const append = () => { + const part = selector.substring(start, index).trim(); + const eqIndex = part.indexOf('='); + let name: string; + let body: string; + if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-+:*]+$/)) { + name = part.substring(0, eqIndex).trim(); + body = part.substring(eqIndex + 1); + } else if (part.length > 1 && part[0] === '"' && part[part.length - 1] === '"') { + name = 'text'; + body = part; + } else if (part.length > 1 && part[0] === "'" && part[part.length - 1] === "'") { + name = 'text'; + body = part; + } else if (/^\(*\/\//.test(part) || part.startsWith('..')) { + // If selector starts with '//' or '//' prefixed with multiple opening + // parenthesis, consider xpath. @see https://github.com/microsoft/playwright/issues/817 + // If selector starts with '..', consider xpath as well. + name = 'xpath'; + body = part; + } else { + name = 'css'; + body = part; + } + let capture = false; + if (name[0] === '*') { + capture = true; + name = name.substring(1); + } + result.parts.push({ name, body }); + if (capture) { + if (result.capture !== undefined) + throw new InvalidSelectorError(`Only one of the selectors can capture using * modifier`); + result.capture = result.parts.length - 1; + } + }; + + if (!selector.includes('>>')) { + index = selector.length; + append(); + return result; + } + + const shouldIgnoreTextSelectorQuote = () => { + const prefix = selector.substring(start, index); + const match = prefix.match(/^\s*text\s*=(.*)$/); + // Must be a text selector with some text before the quote. + return !!match && !!match[1]; + }; + + while (index < selector.length) { + const c = selector[index]; + if (c === '\\' && index + 1 < selector.length) { + index += 2; + } else if (c === quote) { + quote = undefined; + index++; + } else if (!quote && (c === '"' || c === '\'' || c === '`') && !shouldIgnoreTextSelectorQuote()) { + quote = c; + index++; + } else if (!quote && c === '>' && selector[index + 1] === '>') { + append(); + index += 2; + start = index; + } else { + index++; + } + } + append(); + return result; +} + +export type AttributeSelectorOperator = ''|'='|'*='|'|='|'^='|'$='|'~='; +export type AttributeSelectorPart = { + name: string, + jsonPath: string[], + op: AttributeSelectorOperator, + value: any, + caseSensitive: boolean, +}; + +export type AttributeSelector = { + name: string, + attributes: AttributeSelectorPart[], +}; + + +export function parseAttributeSelector(selector: string, allowUnquotedStrings: boolean): AttributeSelector { + let wp = 0; + let EOL = selector.length === 0; + + const next = () => selector[wp] || ''; + const eat1 = () => { + const result = next(); + ++wp; + EOL = wp >= selector.length; + return result; + }; + + const syntaxError = (stage: string|undefined) => { + if (EOL) + throw new InvalidSelectorError(`Unexpected end of selector while parsing selector \`${selector}\``); + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - unexpected symbol "${next()}" at position ${wp}` + (stage ? ' during ' + stage : '')); + }; + + function skipSpaces() { + while (!EOL && /\s/.test(next())) + eat1(); + } + + function isCSSNameChar(char: string) { + // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram + return (char >= '\u0080') // non-ascii + || (char >= '\u0030' && char <= '\u0039') // digit + || (char >= '\u0041' && char <= '\u005a') // uppercase letter + || (char >= '\u0061' && char <= '\u007a') // lowercase letter + || (char >= '\u0030' && char <= '\u0039') // digit + || char === '\u005f' // "_" + || char === '\u002d'; // "-" + } + + function readIdentifier() { + let result = ''; + skipSpaces(); + while (!EOL && isCSSNameChar(next())) + result += eat1(); + return result; + } + + function readQuotedString(quote: string) { + let result = eat1(); + if (result !== quote) + syntaxError('parsing quoted string'); + while (!EOL && next() !== quote) { + if (next() === '\\') + eat1(); + result += eat1(); + } + if (next() !== quote) + syntaxError('parsing quoted string'); + result += eat1(); + return result; + } + + function readRegularExpression() { + if (eat1() !== '/') + syntaxError('parsing regular expression'); + let source = ''; + let inClass = false; + // https://262.ecma-international.org/11.0/#sec-literals-regular-expression-literals + while (!EOL) { + if (next() === '\\') { + source += eat1(); + if (EOL) + syntaxError('parsing regular expression'); + } else if (inClass && next() === ']') { + inClass = false; + } else if (!inClass && next() === '[') { + inClass = true; + } else if (!inClass && next() === '/') { + break; + } + source += eat1(); + } + if (eat1() !== '/') + syntaxError('parsing regular expression'); + let flags = ''; + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions + while (!EOL && next().match(/[dgimsuy]/)) + flags += eat1(); + try { + return new RegExp(source, flags); + } catch (e) { + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\`: ${e.message}`); + } + } + + function readAttributeToken() { + let token = ''; + skipSpaces(); + if (next() === `'` || next() === `"`) + token = readQuotedString(next()).slice(1, -1); + else + token = readIdentifier(); + if (!token) + syntaxError('parsing property path'); + return token; + } + + function readOperator(): AttributeSelectorOperator { + skipSpaces(); + let op = ''; + if (!EOL) + op += eat1(); + if (!EOL && (op !== '=')) + op += eat1(); + if (!['=', '*=', '^=', '$=', '|=', '~='].includes(op)) + syntaxError('parsing operator'); + return (op as AttributeSelectorOperator); + } + + function readAttribute(): AttributeSelectorPart { + // skip leading [ + eat1(); + + // read attribute name: + // foo.bar + // 'foo' . "ba zz" + const jsonPath = []; + jsonPath.push(readAttributeToken()); + skipSpaces(); + while (next() === '.') { + eat1(); + jsonPath.push(readAttributeToken()); + skipSpaces(); + } + // check property is truthy: [enabled] + if (next() === ']') { + eat1(); + return { name: jsonPath.join('.'), jsonPath, op: '', value: null, caseSensitive: false }; + } + + const operator = readOperator(); + + let value = undefined; + let caseSensitive = true; + skipSpaces(); + if (next() === '/') { + if (operator !== '=') + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with regular expression`); + value = readRegularExpression(); + } else if (next() === `'` || next() === `"`) { + value = readQuotedString(next()).slice(1, -1); + skipSpaces(); + if (next() === 'i' || next() === 'I') { + caseSensitive = false; + eat1(); + } else if (next() === 's' || next() === 'S') { + caseSensitive = true; + eat1(); + } + } else { + value = ''; + while (!EOL && (isCSSNameChar(next()) || next() === '+' || next() === '.')) + value += eat1(); + if (value === 'true') { + value = true; + } else if (value === 'false') { + value = false; + } else { + if (!allowUnquotedStrings) { + value = +value; + if (Number.isNaN(value)) + syntaxError('parsing attribute value'); + } + } + } + skipSpaces(); + if (next() !== ']') + syntaxError('parsing attribute value'); + + eat1(); + if (operator !== '=' && typeof value !== 'string') + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with non-string matching value - ${value}`); + return { name: jsonPath.join('.'), jsonPath, op: operator, value, caseSensitive }; + } + + const result: AttributeSelector = { + name: '', + attributes: [], + }; + result.name = readIdentifier(); + skipSpaces(); + while (next() === '[') { + result.attributes.push(readAttribute()); + skipSpaces(); + } + if (!EOL) + syntaxError(undefined); + if (!result.name && !result.attributes.length) + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - selector cannot be empty`); + return result; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/semaphore.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/semaphore.ts new file mode 100644 index 00000000..be160c68 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/semaphore.ts @@ -0,0 +1,51 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ManualPromise } from './manualPromise'; + +export class Semaphore { + private _max: number; + private _acquired = 0; + private _queue: ManualPromise[] = []; + + constructor(max: number) { + this._max = max; + } + + setMax(max: number) { + this._max = max; + } + + acquire(): Promise { + const lock = new ManualPromise(); + this._queue.push(lock); + this._flush(); + return lock; + } + + release() { + --this._acquired; + this._flush(); + } + + private _flush() { + while (this._acquired < this._max && this._queue.length) { + ++this._acquired; + this._queue.shift()!.resolve(); + } + } +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/stackTrace.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/stackTrace.ts new file mode 100644 index 00000000..99affe7d --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/stackTrace.ts @@ -0,0 +1,202 @@ +// @ts-nocheck +/** + * The MIT License (MIT) + * Modifications copyright (c) Microsoft Corporation. + * + * Copyright (c) 2016-2023 Isaac Z. Schlueter i@izs.me, James Talmage james@talmage.io (github.com/jamestalmage), and + * Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export type RawStack = string[]; + +export type StackFrame = { + file: string, + line: number, + column: number, + function?: string, +}; + +export function captureRawStack(): RawStack { + const stackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 50; + const error = new Error(); + const stack = error.stack || ''; + Error.stackTraceLimit = stackTraceLimit; + return stack.split('\n'); +} + +export function parseStackFrame(text: string, pathSeparator: string, showInternalStackFrames: boolean): StackFrame | null { + const match = text && text.match(re); + if (!match) + return null; + + let fname = match[2]; + let file = match[7]; + if (!file) + return null; + if (!showInternalStackFrames && (file.startsWith('internal') || file.startsWith('node:'))) + return null; + + const line = match[8]; + const column = match[9]; + const closeParen = match[11] === ')'; + + const frame: StackFrame = { + file: '', + line: 0, + column: 0, + }; + + if (line) + frame.line = Number(line); + + if (column) + frame.column = Number(column); + + if (closeParen && file) { + // make sure parens are balanced + // if we have a file like "asdf) [as foo] (xyz.js", then odds are + // that the fname should be += " (asdf) [as foo]" and the file + // should be just "xyz.js" + // walk backwards from the end to find the last unbalanced ( + let closes = 0; + for (let i = file.length - 1; i > 0; i--) { + if (file.charAt(i) === ')') { + closes++; + } else if (file.charAt(i) === '(' && file.charAt(i - 1) === ' ') { + closes--; + if (closes === -1 && file.charAt(i - 1) === ' ') { + const before = file.slice(0, i - 1); + const after = file.slice(i + 1); + file = after; + fname += ` (${before}`; + break; + } + } + } + } + + if (fname) { + const methodMatch = fname.match(methodRe); + if (methodMatch) + fname = methodMatch[1]; + } + + if (file) { + if (file.startsWith('file://')) + file = fileURLToPath(file, pathSeparator); + frame.file = file; + } + + if (fname) + frame.function = fname; + + return frame; +} + +export function rewriteErrorMessage(e: E, newMessage: string): E { + const lines: string[] = (e.stack?.split('\n') || []).filter(l => l.startsWith(' at ')); + e.message = newMessage; + const errorTitle = `${e.name}: ${e.message}`; + if (lines.length) + e.stack = `${errorTitle}\n${lines.join('\n')}`; + return e; +} + +export function stringifyStackFrames(frames: StackFrame[]): string[] { + const stackLines: string[] = []; + for (const frame of frames) { + if (frame.function) + stackLines.push(` at ${frame.function} (${frame.file}:${frame.line}:${frame.column})`); + else + stackLines.push(` at ${frame.file}:${frame.line}:${frame.column}`); + } + return stackLines; +} + +export function splitErrorMessage(message: string): { name: string, message: string } { + const separationIdx = message.indexOf(':'); + return { + name: separationIdx !== -1 ? message.slice(0, separationIdx) : '', + message: separationIdx !== -1 && separationIdx + 2 <= message.length ? message.substring(separationIdx + 2) : message, + }; +} + +export function parseErrorStack(stack: string, pathSeparator: string, showInternalStackFrames: boolean = false): { + message: string; + stackLines: string[]; + location?: StackFrame; +} { + const lines = stack.split('\n'); + let firstStackLine = lines.findIndex(line => line.startsWith(' at ')); + if (firstStackLine === -1) + firstStackLine = lines.length; + const message = lines.slice(0, firstStackLine).join('\n'); + const stackLines = lines.slice(firstStackLine); + let location: StackFrame | undefined; + for (const line of stackLines) { + const frame = parseStackFrame(line, pathSeparator, showInternalStackFrames); + if (!frame || !frame.file) + continue; + if (belongsToNodeModules(frame.file, pathSeparator)) + continue; + location = { file: frame.file, column: frame.column || 0, line: frame.line || 0 }; + break; + } + return { message, stackLines, location }; +} + +function belongsToNodeModules(file: string, pathSeparator: string) { + return file.includes(`${pathSeparator}node_modules${pathSeparator}`); +} + +const re = new RegExp('^' + + // Sometimes we strip out the ' at' because it's noisy + '(?:\\s*at )?' + + // $1 = ctor if 'new' + '(?:(new) )?' + + // $2 = function name (can be literally anything) + // May contain method at the end as [as xyz] + '(?:(.*?) \\()?' + + // (eval at (file.js:1:1), + // $3 = eval origin + // $4:$5:$6 are eval file/line/col, but not normally reported + '(?:eval at ([^ ]+) \\((.+?):(\\d+):(\\d+)\\), )?' + + // file:line:col + // $7:$8:$9 + // $10 = 'native' if native + '(?:(.+?):(\\d+):(\\d+)|(native))' + + // maybe close the paren, then end + // if $11 is ), then we only allow balanced parens in the filename + // any imbalance is placed on the fname. This is a heuristic, and + // bound to be incorrect in some edge cases. The bet is that + // having weird characters in method names is more common than + // having weird characters in filenames, which seems reasonable. + '(\\)?)$' +); + +const methodRe = /^(.*?) \[as (.*?)\]$/; + +function fileURLToPath(fileUrl: string, pathSeparator: string): string { + if (!fileUrl.startsWith('file://')) + return fileUrl; + + let path = decodeURIComponent(fileUrl.slice(7)); + if (path.startsWith('/') && /^[a-zA-Z]:/.test(path.slice(1))) + path = path.slice(1); + + return path.replace(/\//g, pathSeparator); +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/stringUtils.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/stringUtils.ts new file mode 100644 index 00000000..b4c13343 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/stringUtils.ts @@ -0,0 +1,196 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// NOTE: this function should not be used to escape any selectors. +export function escapeWithQuotes(text: string, char: string = '\'') { + const stringified = JSON.stringify(text); + const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"'); + if (char === '\'') + return char + escapedText.replace(/[']/g, '\\\'') + char; + if (char === '"') + return char + escapedText.replace(/["]/g, '\\"') + char; + if (char === '`') + return char + escapedText.replace(/[`]/g, '\\`') + char; + throw new Error('Invalid escape char'); +} + +export function escapeTemplateString(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\$\{/g, '\\${'); +} + +export function isString(obj: any): obj is string { + return typeof obj === 'string' || obj instanceof String; +} + +export function toTitleCase(name: string) { + return name.charAt(0).toUpperCase() + name.substring(1); +} + +export function toSnakeCase(name: string): string { + // E.g. ignoreHTTPSErrors => ignore_https_errors. + return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/([A-Z])([A-Z][a-z])/g, '$1_$2').toLowerCase(); +} + +export function formatObject(value: any, indent = ' ', mode: 'multiline' | 'oneline' = 'multiline'): string { + if (typeof value === 'string') + return escapeWithQuotes(value, '\''); + if (Array.isArray(value)) + return `[${value.map(o => formatObject(o)).join(', ')}]`; + if (typeof value === 'object') { + const keys = Object.keys(value).filter(key => key !== 'timeout' && value[key] !== undefined).sort(); + if (!keys.length) + return '{}'; + const tokens: string[] = []; + for (const key of keys) + tokens.push(`${key}: ${formatObject(value[key])}`); + if (mode === 'multiline') + return `{\n${tokens.map(t => indent + t).join(`,\n`)}\n}`; + return `{ ${tokens.join(', ')} }`; + } + return String(value); +} + +export function formatObjectOrVoid(value: any, indent = ' '): string { + const result = formatObject(value, indent); + return result === '{}' ? '' : result; +} + +export function quoteCSSAttributeValue(text: string): string { + return `"${text.replace(/["\\]/g, char => '\\' + char)}"`; +} + +let normalizedWhitespaceCache: Map | undefined; + +export function cacheNormalizedWhitespaces() { + normalizedWhitespaceCache = new Map(); +} + +export function normalizeWhiteSpace(text: string): string { + let result = normalizedWhitespaceCache?.get(text); + if (result === undefined) { + result = text.replace(/[\u200b\u00ad]/g, '').trim().replace(/\s+/g, ' '); + normalizedWhitespaceCache?.set(text, result); + } + return result; +} + +export function normalizeEscapedRegexQuotes(source: string) { + // This function reverses the effect of escapeRegexForSelector below. + // Odd number of backslashes followed by the quote -> remove unneeded backslash. + return source.replace(/(^|[^\\])(\\\\)*\\(['"`])/g, '$1$2$3'); +} + +function escapeRegexForSelector(re: RegExp): string { + // Unicode mode does not allow "identity character escapes", so we do not escape and + // hope that it does not contain quotes and/or >> signs. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Character_escape + // TODO: rework RE usages in internal selectors away from literal representation to json, e.g. {source,flags}. + if (re.unicode || (re as any).unicodeSets) + return String(re); + // Even number of backslashes followed by the quote -> insert a backslash. + return String(re).replace(/(^|[^\\])(\\\\)*(["'`])/g, '$1$2\\$3').replace(/>>/g, '\\>\\>'); +} + +export function escapeForTextSelector(text: string | RegExp, exact: boolean): string { + if (typeof text !== 'string') + return escapeRegexForSelector(text); + return `${JSON.stringify(text)}${exact ? 's' : 'i'}`; +} + +export function escapeForAttributeSelector(value: string | RegExp, exact: boolean): string { + if (typeof value !== 'string') + return escapeRegexForSelector(value); + // TODO: this should actually be + // cssEscape(value).replace(/\\ /g, ' ') + // However, our attribute selectors do not conform to CSS parsing spec, + // so we escape them differently. + return `"${value.replace(/\\/g, '\\\\').replace(/["]/g, '\\"')}"${exact ? 's' : 'i'}`; +} + +export function trimString(input: string, cap: number, suffix: string = ''): string { + if (input.length <= cap) + return input; + const chars = [...input]; + if (chars.length > cap) + return chars.slice(0, cap - suffix.length).join('') + suffix; + return chars.join(''); +} + +export function trimStringWithEllipsis(input: string, cap: number): string { + return trimString(input, cap, '\u2026'); +} + +export function escapeRegExp(s: string) { + // From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }; +export function escapeHTMLAttribute(s: string): string { + return s.replace(/[&<>"']/ug, char => (escaped as any)[char]); +} +export function escapeHTML(s: string): string { + return s.replace(/[&<]/ug, char => (escaped as any)[char]); +} + +export function longestCommonSubstring(s1: string, s2: string): string { + const n = s1.length; + const m = s2.length; + let maxLen = 0; + let endingIndex = 0; + + // Initialize a 2D array with zeros + const dp = Array(n + 1) + .fill(null) + .map(() => Array(m + 1).fill(0)); + + // Build the dp table + for (let i = 1; i <= n; i++) { + for (let j = 1; j <= m; j++) { + if (s1[i - 1] === s2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + + if (dp[i][j] > maxLen) { + maxLen = dp[i][j]; + endingIndex = i; + } + } + } + } + + // Extract the longest common substring + return s1.slice(endingIndex - maxLen, endingIndex); +} + +export function parseRegex(regex: string): RegExp { + if (regex[0] !== '/') + throw new Error(`Invalid regex, must start with '/': ${regex}`); + const lastSlash = regex.lastIndexOf('/'); + if (lastSlash <= 0) + throw new Error(`Invalid regex, must end with '/' followed by optional flags: ${regex}`); + const source = regex.slice(1, lastSlash); + const flags = regex.slice(lastSlash + 1); + return new RegExp(source, flags); +} + +export const ansiRegex = new RegExp('([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))', 'g'); +export function stripAnsiEscapes(str: string): string { + return str.replace(ansiRegex, ''); +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/time.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/time.ts new file mode 100644 index 00000000..14397443 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/time.ts @@ -0,0 +1,47 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Hopefully, this file is never used in injected sources, +// because it does not use `builtins.performance`, +// and can break when clock emulation is engaged. + +/* eslint-disable no-restricted-globals */ + +const fallbackTimeOrigin = Date.now(); +const performanceApi = globalThis.performance ?? { + timeOrigin: fallbackTimeOrigin, + now: () => Date.now() - fallbackTimeOrigin, +}; + +let _timeOrigin = performanceApi.timeOrigin; +let _timeShift = 0; + +export function setTimeOrigin(origin: number) { + _timeOrigin = origin; + _timeShift = performanceApi.timeOrigin - origin; +} + +export function timeOrigin(): number { + return _timeOrigin; +} + +export function monotonicTime(): number { + return Math.floor((performanceApi.now() + _timeShift) * 1000) / 1000; +} + +export const DEFAULT_PLAYWRIGHT_TIMEOUT = 30_000; +export const DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT = 3 * 60 * 1000; // 3 minutes diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/timeoutRunner.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/timeoutRunner.ts new file mode 100644 index 00000000..206098fd --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/timeoutRunner.ts @@ -0,0 +1,62 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Hopefully, this file is never used in injected sources, +// because it does not use `builtins.setTimeout` and similar, +// and can break when clock emulation is engaged. + +/* eslint-disable no-restricted-globals */ + +import { monotonicTime } from './time'; + +export async function raceAgainstDeadline(cb: () => Promise, deadline: number): Promise<{ result: T, timedOut: false } | { timedOut: true }> { + let timer: NodeJS.Timeout | undefined; + return Promise.race([ + cb().then(result => { + return { result, timedOut: false }; + }), + new Promise<{ timedOut: true }>(resolve => { + if (!deadline) + return; + timer = setTimeout(() => resolve({ timedOut: true }), deadline - monotonicTime()); + }), + ]).finally(() => { + clearTimeout(timer); + }); +} + +export async function pollAgainstDeadline(callback: () => Promise<{ continuePolling: boolean, result: T }>, deadline: number, pollIntervals: number[] = [100, 250, 500, 1000]): Promise<{ result?: T, timedOut: boolean }> { + const lastPollInterval = pollIntervals.pop() ?? 1000; + let lastResult: T|undefined; + const wrappedCallback = () => Promise.resolve().then(callback); + while (true) { + const time = monotonicTime(); + if (deadline && time >= deadline) + break; + const received = await raceAgainstDeadline(wrappedCallback, deadline); + if (received.timedOut) + break; + lastResult = (received as any).result.result; + if (!(received as any).result.continuePolling) + return { result: lastResult, timedOut: false }; + const interval = pollIntervals!.shift() ?? lastPollInterval; + if (deadline && deadline <= monotonicTime() + interval) + break; + await new Promise(x => setTimeout(x, interval)); + } + return { timedOut: true, result: lastResult }; +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/types.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/types.ts new file mode 100644 index 00000000..02928681 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/types.ts @@ -0,0 +1,23 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type Size = { width: number, height: number }; +export type Point = { x: number, y: number }; +export type Rect = Size & Point; +export type Quad = [ Point, Point, Point, Point ]; +export type NameValue = { name: string, value: string }; +export type HeadersArray = NameValue[]; diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/urlMatch.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/urlMatch.ts new file mode 100644 index 00000000..ad5ae006 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/urlMatch.ts @@ -0,0 +1,272 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isString } from './stringUtils'; + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping +const escapedChars = new Set(['$', '^', '+', '.', '*', '(', ')', '|', '\\', '?', '{', '}', '[', ']']); + +export function globToRegexPattern(glob: string): string { + const tokens = ['^']; + let inGroup = false; + for (let i = 0; i < glob.length; ++i) { + const c = glob[i]; + if (c === '\\' && i + 1 < glob.length) { + const char = glob[++i]; + tokens.push(escapedChars.has(char) ? '\\' + char : char); + continue; + } + if (c === '*') { + const charBefore = glob[i - 1]; + let starCount = 1; + while (glob[i + 1] === '*') { + starCount++; + i++; + } + if (starCount > 1) { + const charAfter = glob[i + 1]; + // Match either /..something../ or /. + if (charAfter === '/') { + if (charBefore === '/') + tokens.push('((.+/)|)'); + else + tokens.push('(.*/)'); + ++i; + } else { + tokens.push('(.*)'); + } + } else { + tokens.push('([^/]*)'); + } + continue; + } + + switch (c) { + case '{': + inGroup = true; + tokens.push('('); + break; + case '}': + inGroup = false; + tokens.push(')'); + break; + case ',': + if (inGroup) { + tokens.push('|'); + break; + } + tokens.push('\\' + c); + break; + default: + tokens.push(escapedChars.has(c) ? '\\' + c : c); + } + } + tokens.push('$'); + return tokens.join(''); +} + +function isRegExp(obj: any): obj is RegExp { + return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]'; +} + +export type URLMatch = string | RegExp | ((url: URL) => boolean) | URLPattern; +// URLPattern is not in @types/node@18, so we polyfill it ourselves +export type URLPattern = { + test(input: string | URL): boolean; + hash: string; + hostname: string; + password: string; + pathname: string; + port: string; + protocol: string; + search: string; + username: string; +}; + +// @ts-expect-error URLPattern is not in @types/node yet +// eslint-disable-next-line no-restricted-globals +export const isURLPattern = (v: unknown): v is URLPattern => typeof globalThis.URLPattern === 'function' && v instanceof globalThis.URLPattern; + +export function serializeURLPattern(v: URLPattern) { + return { + hash: v.hash, + hostname: v.hostname, + password: v.password, + pathname: v.pathname, + port: v.port, + protocol: v.protocol, + search: v.search, + username: v.username, + }; +} + +export type SerializedURLMatch = { glob?: string, regexSource?: string, regexFlags?: string, urlPattern?: ReturnType }; + +export function serializeURLMatch(match: URLMatch): SerializedURLMatch | undefined { + if (isString(match)) + return { glob: match }; + if (isRegExp(match)) + return { regexSource: match.source, regexFlags: match.flags }; + if (isURLPattern(match)) + return { urlPattern: serializeURLPattern(match) }; + // Functions cannot be serialized + return undefined; +} + +function deserializeURLPattern(v: ReturnType): URLPattern | ((url: URL) => boolean) { + // Client is on Node 24+ and can use URLPattern, Server is not. Let's match all URLs on the server, they'll be filtered again on the client. + // @ts-expect-error URLPattern is not in @types/node yet + // eslint-disable-next-line no-restricted-globals + if (typeof globalThis.URLPattern !== 'function') + return () => true; + + // @ts-expect-error URLPattern is not in @types/node yet + // eslint-disable-next-line no-restricted-globals + return new globalThis.URLPattern({ + hash: v.hash, + hostname: v.hostname, + password: v.password, + pathname: v.pathname, + port: v.port, + protocol: v.protocol, + search: v.search, + username: v.username, + }); +} + +export function deserializeURLMatch(match: { glob?: string, regexSource?: string, regexFlags?: string, urlPattern?: ReturnType }): URLMatch { + if (match.regexSource) + return new RegExp(match.regexSource, match.regexFlags); + if (match.urlPattern) + return deserializeURLPattern(match.urlPattern); + return match.glob!; +} + +export function urlMatchesEqual(match1: URLMatch, match2: URLMatch) { + if (isRegExp(match1) && isRegExp(match2)) + return match1.source === match2.source && match1.flags === match2.flags; + return match1 === match2; +} + +export function urlMatches(baseURL: string | undefined, urlString: string, match: URLMatch | undefined, webSocketUrl?: boolean): boolean { + if (match === undefined || match === '') + return true; + if (isString(match)) + match = new RegExp(resolveGlobToRegexPattern(baseURL, match, webSocketUrl)); + if (isRegExp(match)) { + const r = match.test(urlString); + return r; + } + const url = parseURL(urlString); + if (!url) + return false; + if (isURLPattern(match)) + return match.test(url.href); + if (typeof match !== 'function') + throw new Error('url parameter should be string, RegExp, URLPattern or function'); + return match(url); +} + +export function resolveGlobToRegexPattern(baseURL: string | undefined, glob: string, webSocketUrl?: boolean): string { + if (webSocketUrl) + baseURL = toWebSocketBaseUrl(baseURL); + glob = resolveGlobBase(baseURL, glob); + return globToRegexPattern(glob); +} + +function toWebSocketBaseUrl(baseURL: string | undefined) { + // Allow http(s) baseURL to match ws(s) urls. + if (baseURL && /^https?:\/\//.test(baseURL)) + baseURL = baseURL.replace(/^http/, 'ws'); + return baseURL; +} + +function resolveGlobBase(baseURL: string | undefined, match: string): string { + if (!match.startsWith('*')) { + const tokenMap = new Map(); + function mapToken(original: string, replacement: string) { + if (original.length === 0) + return ''; + tokenMap.set(replacement, original); + return replacement; + } + // Escaped `\\?` behaves the same as `?` in our glob patterns. + match = match.replaceAll(/\\\\\?/g, '?'); + // Special case about: URLs as they are not relative to baseURL + if (match.startsWith('about:') || match.startsWith('data:') + || match.startsWith('chrome:') || match.startsWith('edge:') + || match.startsWith('file:')) + return match; + // Glob symbols may be escaped in the URL and some of them such as ? affect resolution, + // so we replace them with safe components first. + const relativePath = match.split('/').map((token, index) => { + if (token === '.' || token === '..' || token === '') + return token; + // Handle special case of http*://, note that the new schema has to be + // a web schema so that slashes are properly inserted after domain. + if (index === 0 && token.endsWith(':')) { + // Replace any pattern with http: + if (token.indexOf('*') !== -1 || token.indexOf('{') !== -1) + return mapToken(token, 'http:'); + // Preserve explicit schema as is as it may affect trailing slashes after domain. + return token; + } + const questionIndex = token.indexOf('?'); + if (questionIndex === -1) + return mapToken(token, `$_${index}_$`); + const newPrefix = mapToken(token.substring(0, questionIndex), `$_${index}_$`); + const newSuffix = mapToken(token.substring(questionIndex), `?$_${index}_$`); + return newPrefix + newSuffix; + }).join('/'); + const result = resolveBaseURL(baseURL, relativePath); + let resolved = result.resolved; + for (const [token, original] of tokenMap) { + const normalize = result.caseInsensitivePart?.includes(token); + resolved = resolved.replace(token, normalize ? original.toLowerCase() : original); + } + match = resolved; + } + return match; +} + +function parseURL(url: string): URL | null { + try { + return new URL(url); + } catch (e) { + return null; + } +} + +export function constructURLBasedOnBaseURL(baseURL: string | undefined, givenURL: string): string { + try { + return resolveBaseURL(baseURL, givenURL).resolved; + } catch (e) { + return givenURL; + } +} + +function resolveBaseURL(baseURL: string | undefined, givenURL: string) { + try { + const url = new URL(givenURL, baseURL); + const resolved = url.toString(); + // Schema and domain are case-insensitive. + const caseInsensitivePrefix = url.origin; + return { resolved, caseInsensitivePart: caseInsensitivePrefix }; + } catch (e) { + return { resolved: givenURL }; + } +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/utilityScriptSerializers.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/utilityScriptSerializers.ts new file mode 100644 index 00000000..b545aac2 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/utilityScriptSerializers.ts @@ -0,0 +1,304 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +type TypedArrayKind = 'i8' | 'ui8' | 'ui8c' | 'i16' | 'ui16' | 'i32' | 'ui32' | 'f32' | 'f64' | 'bi64' | 'bui64'; + +export type SerializedValue = + undefined | boolean | number | string | + { v: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0' } | + { d: string } | + { u: string } | + { bi: string } | + { e: { n: string, m: string, s: string } } | + { r: { p: string, f: string } } | + { a: SerializedValue[], id: number } | + { o: { k: string, v: SerializedValue }[], id: number } | + { ref: number } | + { h: number } | + { ta: { b: string, k: TypedArrayKind } } | + { ab: { b: string } }; + +type HandleOrValue = { h: number } | { fallThrough: any }; + +type VisitorInfo = { + visited: Map; + lastId: number; +}; + +function isRegExp(obj: any): obj is RegExp { + try { + return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]'; + } catch (error) { + return false; + } +} + +function isDate(obj: any): obj is Date { + try { + // eslint-disable-next-line no-restricted-globals + return obj instanceof Date || Object.prototype.toString.call(obj) === '[object Date]'; + } catch (error) { + return false; + } +} + +function isURL(obj: any): obj is URL { + try { + return obj instanceof URL || Object.prototype.toString.call(obj) === '[object URL]'; + } catch (error) { + return false; + } +} + +function isError(obj: any): obj is Error { + try { + return obj instanceof Error || (obj && Object.getPrototypeOf(obj)?.name === 'Error'); + } catch (error) { + return false; + } +} + +function isTypedArray(obj: any, constructor: Function): boolean { + try { + return obj instanceof constructor || Object.prototype.toString.call(obj) === `[object ${constructor.name}]`; + } catch (error) { + return false; + } +} + +function isArrayBuffer(obj: any): obj is ArrayBuffer { + try { + return obj instanceof ArrayBuffer || Object.prototype.toString.call(obj) === '[object ArrayBuffer]'; + } catch (error) { + return false; + } +} + +const typedArrayConstructors: Record = { + i8: Int8Array, + ui8: Uint8Array, + ui8c: Uint8ClampedArray, + i16: Int16Array, + ui16: Uint16Array, + i32: Int32Array, + ui32: Uint32Array, + // TODO: add Float16Array once it's in baseline + f32: Float32Array, + f64: Float64Array, + bi64: BigInt64Array, + bui64: BigUint64Array, +}; + +function typedArrayToBase64(array: any) { + /** + * Firefox does not support iterating over typed arrays, so we use `.toBase64`. + * Error: 'Accessing TypedArray data over Xrays is slow, and forbidden in order to encourage performant code. To copy TypedArrays across origin boundaries, consider using Components.utils.cloneInto().' + */ + if ('toBase64' in array) + return array.toBase64(); + const binary = Array.from(new Uint8Array(array.buffer, array.byteOffset, array.byteLength)).map(b => String.fromCharCode(b)).join(''); + return btoa(binary); +} + +function base64ToTypedArray(base64: string, TypedArrayConstructor: any) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) + bytes[i] = binary.charCodeAt(i); + return new TypedArrayConstructor(bytes.buffer); +} + +export function parseEvaluationResultValue(value: SerializedValue, handles: any[] = [], refs: Map = new Map()): any { + if (Object.is(value, undefined)) + return undefined; + if (typeof value === 'object' && value) { + if ('ref' in value) + return refs.get(value.ref); + if ('v' in value) { + if (value.v === 'undefined') + return undefined; + if (value.v === 'null') + return null; + if (value.v === 'NaN') + return NaN; + if (value.v === 'Infinity') + return Infinity; + if (value.v === '-Infinity') + return -Infinity; + if (value.v === '-0') + return -0; + return undefined; + } + if ('d' in value) { + // eslint-disable-next-line no-restricted-globals + return new Date(value.d); + } + if ('u' in value) + return new URL(value.u); + if ('bi' in value) + return BigInt(value.bi); + if ('e' in value) { + const error = new Error(value.e.m); + error.name = value.e.n; + error.stack = value.e.s; + return error; + } + if ('r' in value) + return new RegExp(value.r.p, value.r.f); + if ('a' in value) { + const result: any[] = []; + refs.set(value.id, result); + for (const a of value.a) + result.push(parseEvaluationResultValue(a, handles, refs)); + return result; + } + if ('o' in value) { + const result: any = {}; + refs.set(value.id, result); + for (const { k, v } of value.o) { + if (k === '__proto__') + continue; + result[k] = parseEvaluationResultValue(v, handles, refs); + } + return result; + } + if ('h' in value) + return handles[value.h]; + if ('ta' in value) + return base64ToTypedArray(value.ta.b, typedArrayConstructors[value.ta.k]); + if ('ab' in value) + return base64ToTypedArray(value.ab.b, Uint8Array).buffer; + } + return value; +} + +export function serializeAsCallArgument(value: any, handleSerializer: (value: any) => HandleOrValue): SerializedValue { + return serialize(value, handleSerializer, { visited: new Map(), lastId: 0 }); +} + +function serialize(value: any, handleSerializer: (value: any) => HandleOrValue, visitorInfo: VisitorInfo): SerializedValue { + if (value && typeof value === 'object') { + // eslint-disable-next-line no-restricted-globals + if (typeof globalThis.Window === 'function' && value instanceof globalThis.Window) + return 'ref: '; + // eslint-disable-next-line no-restricted-globals + if (typeof globalThis.Document === 'function' && value instanceof globalThis.Document) + return 'ref: '; + // eslint-disable-next-line no-restricted-globals + if (typeof globalThis.Node === 'function' && value instanceof globalThis.Node) + return 'ref: '; + } + return innerSerialize(value, handleSerializer, visitorInfo); +} + +function innerSerialize(value: any, handleSerializer: (value: any) => HandleOrValue, visitorInfo: VisitorInfo): SerializedValue { + const result = handleSerializer(value); + if ('fallThrough' in result) + value = result.fallThrough; + else + return result; + + if (typeof value === 'symbol') + return { v: 'undefined' }; + if (Object.is(value, undefined)) + return { v: 'undefined' }; + if (Object.is(value, null)) + return { v: 'null' }; + if (Object.is(value, NaN)) + return { v: 'NaN' }; + if (Object.is(value, Infinity)) + return { v: 'Infinity' }; + if (Object.is(value, -Infinity)) + return { v: '-Infinity' }; + if (Object.is(value, -0)) + return { v: '-0' }; + + if (typeof value === 'boolean') + return value; + if (typeof value === 'number') + return value; + if (typeof value === 'string') + return value; + if (typeof value === 'bigint') + return { bi: value.toString() }; + + if (isError(value)) { + let stack; + if (value.stack?.startsWith(value.name + ': ' + value.message)) { + // v8 + stack = value.stack; + } else { + stack = `${value.name}: ${value.message}\n${value.stack}`; + } + return { e: { n: value.name, m: value.message, s: stack } }; + } + if (isDate(value)) + return { d: value.toJSON() }; + if (isURL(value)) + return { u: value.toJSON() }; + if (isRegExp(value)) + return { r: { p: value.source, f: value.flags } }; + for (const [k, ctor] of Object.entries(typedArrayConstructors) as [TypedArrayKind, Function][]) { + if (isTypedArray(value, ctor)) + return { ta: { b: typedArrayToBase64(value), k } }; + } + if (isArrayBuffer(value)) + return { ab: { b: typedArrayToBase64(new Uint8Array(value)) } }; + + const id = visitorInfo.visited.get(value); + if (id) + return { ref: id }; + + if (Array.isArray(value)) { + const a = []; + const id = ++visitorInfo.lastId; + visitorInfo.visited.set(value, id); + for (let i = 0; i < value.length; ++i) + a.push(serialize(value[i], handleSerializer, visitorInfo)); + return { a, id }; + } + + if (typeof value === 'object') { + const o: { k: string, v: SerializedValue }[] = []; + const id = ++visitorInfo.lastId; + visitorInfo.visited.set(value, id); + for (const name of Object.keys(value)) { + let item; + try { + item = value[name]; + } catch (e) { + continue; // native bindings will throw sometimes + } + if (name === 'toJSON' && typeof item === 'function') + o.push({ k: name, v: { o: [], id: 0 } }); + else + o.push({ k: name, v: serialize(item, handleSerializer, visitorInfo) }); + } + + let jsonWrapper; + try { + // If Object.keys().length === 0 we fall back to toJSON if it exists + if (o.length === 0 && value.toJSON && typeof value.toJSON === 'function') + jsonWrapper = { value: value.toJSON() }; + } catch (e) { + } + if (jsonWrapper) + return innerSerialize(jsonWrapper.value, handleSerializer, visitorInfo); + + return { o, id }; + } +} diff --git a/daemon/src/sandbox/forked-client/src/utils/isomorphic/yaml.ts b/daemon/src/sandbox/forked-client/src/utils/isomorphic/yaml.ts new file mode 100644 index 00000000..d0e63c29 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/utils/isomorphic/yaml.ts @@ -0,0 +1,95 @@ +// @ts-nocheck +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function yamlEscapeKeyIfNeeded(str: string): string { + if (!yamlStringNeedsQuotes(str)) + return str; + return `'` + str.replace(/'/g, `''`) + `'`; +} + +export function yamlEscapeValueIfNeeded(str: string): string { + if (!yamlStringNeedsQuotes(str)) + return str; + return '"' + str.replace(/[\\"\x00-\x1f\x7f-\x9f]/g, c => { + switch (c) { + case '\\': + return '\\\\'; + case '"': + return '\\"'; + case '\b': + return '\\b'; + case '\f': + return '\\f'; + case '\n': + return '\\n'; + case '\r': + return '\\r'; + case '\t': + return '\\t'; + default: + const code = c.charCodeAt(0); + return '\\x' + code.toString(16).padStart(2, '0'); + } + }) + '"'; +} + +function yamlStringNeedsQuotes(str: string): boolean { + if (str.length === 0) + return true; + + // Strings with leading or trailing whitespace need quotes + if (/^\s|\s$/.test(str)) + return true; + + // Strings containing control characters need quotes + if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str)) + return true; + + // Strings starting with '-' need quotes + if (/^-/.test(str)) + return true; + + // Strings containing ':' or '\n' followed by a space or at the end need quotes + if (/[\n:](\s|$)/.test(str)) + return true; + + // Strings containing '#' preceded by a space need quotes (comment indicator) + if (/\s#/.test(str)) + return true; + + // Strings that contain line breaks need quotes + if (/[\n\r]/.test(str)) + return true; + + // Strings starting with indicator characters or quotes need quotes + if (/^[&*\],?!>|@"'#%]/.test(str)) + return true; + + // Strings containing special characters that could cause ambiguity + if (/[{}`]/.test(str)) + return true; + + // YAML array starts with [ + if (/^\[/.test(str)) + return true; + + // Non-string types recognized by YAML + if (!isNaN(Number(str)) || ['y', 'n', 'yes', 'no', 'true', 'false', 'on', 'off', 'null'].includes(str.toLowerCase())) + return true; + + return false; +} diff --git a/daemon/src/sandbox/forked-client/types/protocol.d.ts b/daemon/src/sandbox/forked-client/types/protocol.d.ts new file mode 100644 index 00000000..265faf3f --- /dev/null +++ b/daemon/src/sandbox/forked-client/types/protocol.d.ts @@ -0,0 +1,23824 @@ +// This is generated from /utils/protocol-types-generator/index.js +type binary = string; +export namespace Protocol { + export namespace Accessibility { + /** + * Unique accessibility node identifier. + */ + export type AXNodeId = string; + /** + * Enum of possible property types. + */ + export type AXValueType = "boolean"|"tristate"|"booleanOrUndefined"|"idref"|"idrefList"|"integer"|"node"|"nodeList"|"number"|"string"|"computedString"|"token"|"tokenList"|"domRelation"|"role"|"internalRole"|"valueUndefined"; + /** + * Enum of possible property sources. + */ + export type AXValueSourceType = "attribute"|"implicit"|"style"|"contents"|"placeholder"|"relatedElement"; + /** + * Enum of possible native property sources (as a subtype of a particular AXValueSourceType). + */ + export type AXValueNativeSourceType = "description"|"figcaption"|"label"|"labelfor"|"labelwrapped"|"legend"|"rubyannotation"|"tablecaption"|"title"|"other"; + /** + * A single source for a computed AX property. + */ + export interface AXValueSource { + /** + * What type of source this is. + */ + type: AXValueSourceType; + /** + * The value of this property source. + */ + value?: AXValue; + /** + * The name of the relevant attribute, if any. + */ + attribute?: string; + /** + * The value of the relevant attribute, if any. + */ + attributeValue?: AXValue; + /** + * Whether this source is superseded by a higher priority source. + */ + superseded?: boolean; + /** + * The native markup source for this value, e.g. a `