# Virtual browser

Anything that drives Chromium through the DevTools protocol — Playwright from
Node.js or Python, Puppeteer — gets the SandboxedJs virtual browser instead of
a Chromium binary. No browser is downloaded and nothing is patched in the
driver: the protocol is the seam.

```js
// inside a container, after `npm install playwright-core`
import { chromium } from 'playwright-core';

const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.test/');
console.log(await page.evaluate(() => 6 * 7)); // 42
await browser.close();
```

```python
# after `pip install playwright`
import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()
        print(await page.evaluate("() => 6 * 7"))
        await browser.close()

asyncio.run(main())
```

## How a launch is routed

1. **Installing Playwright writes the browser it will look for.** When
   `playwright-core` is installed with npm, or `playwright` with pip, the
   executables named in its `browsers.json` are written under
   `~/.cache/ms-playwright` as stubs for the `chrome` built-in. Only Chromium
   (and its headless shell) is provided; Firefox and WebKit stay missing, so
   asking for them fails with Playwright's own message.
2. **Playwright spawns it with `--remote-debugging-pipe`.** Its `stdio` is
   `['ignore', 'pipe', 'pipe', 'pipe', 'pipe']`: commands arrive on fd 3 and
   replies leave on fd 4, each JSON message terminated by a NUL byte.
   `child_process` supports pipes above fd 2 for this, in-realm and through
   the worker.
3. **`chrome` hands every message to `CdpServer`** (`src/browser/`), which owns
   targets, sessions, frames and execution contexts.

`chrome`, `chromium`, `chromium-browser`, `google-chrome` and
`chrome-headless-shell` are all the same built-in.

## What a page is today

A page has a real, isolated JavaScript realm — `node:vm` in Node.js, a detached
iframe in a browser — and **no DOM yet**. That covers `launch`, contexts,
`newPage`, `goto` (the navigation commits and fires its lifecycle events; the
URL is not fetched), `url`, `evaluate` / `evaluateHandle` with arguments,
promises and thrown errors, and `close`.

Anything that needs a document — locators, `click`, `fill`, `content`,
`screenshot`, network interception — does not work yet. Unimplemented protocol
methods are refused with Chromium's own `'Method' wasn't found` error rather
than answered with an empty success, so a driver fails where the gap is.
`*.enable`, `*.disable` and `set*` configuration calls are accepted, because a
page without the feature can honestly take them.

The DOM, CSS layout and painting engine plugs in behind `CdpServer`; that is
the next stage.

## Python specifics

`playwright` publishes only platform wheels, because each bundles Node.js. The
resolver accepts its Linux x86-64 wheel by name (`src/python/substitutions.ts`)
and the install replaces `driver/node` with a script that runs the container's
`node`.

`greenlet` cannot work in WebAssembly — it switches native stacks. A built-in
stand-in is installed instead: it imports and can be subclassed, and switching
raises `greenlet.error`. Playwright only switches greenlets in its synchronous
API, so **`playwright.async_api` works and `playwright.sync_api` fails** with
that error, which names the asyncio API.

Playwright's Python driver is the container's Node.js speaking a
length-prefixed binary protocol over stdin and stdout, which exercised several
general gaps, now fixed:

- **Binary stdio.** A `Buffer` written to stdout, or read from a live pipe,
  used to be decoded as UTF-8 on the way through. Pods still emit text on
  `output`; bytes as written go on `raw-output`, which `node` forwards into
  pipes.
- **Live stdin.** A pipe inherited from Python was reported as non-interactive,
  so `node` read it to end-of-file before starting the script — and a driver
  whose stdin never closes never started.
- **Pipe backpressure.** A kernel pipe holds 64 KiB and accepts a prefix of a
  larger write; the child-output wrapper ignored the count and dropped the
  rest. Output is now queued and written as the reader makes room.
- **Built-in interop.** A partially supported built-in answered `__esModule`,
  so bundlers' `__toESM` lost its `default` (`net`, read by Playwright at load).
- **ESM detection.** `new.target` in CommonJS was mistaken for `import.meta`.

And two in the Python runtime itself:

- **asyncio subprocesses.** Without `pidfd_open`, CPython waits for children
  on a helper thread, whose blocking host call fails with EIO. A polling child
  watcher (installed through the runtime's `sitecustomize`) reaps with
  `WNOHANG` on the loop's own thread instead.
- **Kernel pipes in selectors.** A pipe's descriptor number inside the
  interpreter differs from the kernel's; `poll` now translates it (sockets
  already use kernel numbers and are tracked so they are never translated),
  a stream's readiness is asked of the kernel with a zero timeout, and
  `O_NONBLOCK` reads return `EAGAIN` instead of waiting.

## Not supported

- Headed Chromium, `--remote-debugging-port`, and connecting over WebSocket
  (`connectOverCDP`): only the pipe transport is served.
- Firefox, WebKit.
- The Python synchronous API.
- Anything needing a DOM, layout or pixels (for now).
