diff --git a/samples/cdp-tests/.env.example b/samples/cdp-tests/.env.example index 7b26d57..bb3d9ce 100644 --- a/samples/cdp-tests/.env.example +++ b/samples/cdp-tests/.env.example @@ -9,3 +9,10 @@ PLAYWRIGHT_SERVICE_ACCESS_TOKEN= AZURE_OPENAI_API_KEY= AZURE_OPENAI_ENDPOINT= AZURE_OPENAI_API_VERSION= + +# Authenticated forward proxy (Required only if you switch the entry point in +# connectOverCDPScript.js / puppeteerScript.js / cdpUseScript.py to the +# `mainWithProxy()` / `main_with_proxy()` variant) +PROXY_SERVER= +PROXY_USERNAME= +PROXY_PASSWORD= diff --git a/samples/cdp-tests/README.md b/samples/cdp-tests/README.md index a57d6dd..52725be 100644 --- a/samples/cdp-tests/README.md +++ b/samples/cdp-tests/README.md @@ -8,8 +8,10 @@ Samples for connecting to Microsoft Playwright Service via CDP (Chrome DevTools |------|----------|----------|-------------| | `playwright_service_client.py` | Python | Core Module | Shared Python client for all samples | | `playwrightServiceClient.js` | JavaScript | Core Module | Shared JavaScript client | -| `connectOverCDPScript.py` | Python | **Manual** | Simple connect_over_cdp example | -| `connectOverCDPScript.js` | JavaScript | **Manual** | Simple connectOverCDP example | +| `connectOverCDPScript.py` | Python | **Manual** | Playwright `connect_over_cdp` example | +| `connectOverCDPScript.js` | JavaScript | **Manual** | Playwright `connectOverCDP` example | +| `puppeteerScript.js` | JavaScript | **Manual** | Puppeteer over CDP (proxy variant in same file) | +| `cdpUseScript.py` | Python | **Manual** | Raw CDP via `cdp-use` (proxy variant in same file) | | `test_runner.py` | Python | **Testing** | Test runner with helpers | | `Browser-Use-Remote.py` | Python | **AI Agent** | Browser-Use + Azure OpenAI | @@ -107,8 +109,27 @@ PLAYWRIGHT_SERVICE_ACCESS_TOKEN=your_access_token AZURE_OPENAI_API_KEY=your_api_key AZURE_OPENAI_ENDPOINT=https://.openai.azure.com/ AZURE_OPENAI_API_VERSION=2023-07-01-preview + +# For the opt-in proxy snippets only +PROXY_SERVER=http://:8080 +PROXY_USERNAME= +PROXY_PASSWORD= ``` +## 🌐 Optional: Authenticated HTTP proxy + +Each manual sample includes a separate proxy entry-point function alongside +the normal `main()`. It isn't run by default — to use it, change the call at +the bottom of the file from `main()` to the proxy variant and set +`PROXY_SERVER` / `PROXY_USERNAME` / `PROXY_PASSWORD` in your env. + +- [connectOverCDPScript.js](./connectOverCDPScript.js) — `mainWithProxy()` uses `newContext({ proxy })` +- [puppeteerScript.js](./puppeteerScript.js) — `mainWithProxy()` uses `createBrowserContext({ proxyServer })` + `page.authenticate()` +- [cdpUseScript.py](./cdpUseScript.py) — `main_with_proxy()` uses `Target.createBrowserContext({ proxyServer })` + manual `Fetch.continueWithAuth` + +Playwright and Puppeteer answer the proxy 407 challenge for you; with +`cdp-use` the function shows the full dance. + ## 📚 Resources - [Microsoft Playwright Service](https://learn.microsoft.com/azure/playwright-testing/) diff --git a/samples/cdp-tests/cdpUseScript.py b/samples/cdp-tests/cdpUseScript.py new file mode 100644 index 0000000..5d91486 --- /dev/null +++ b/samples/cdp-tests/cdpUseScript.py @@ -0,0 +1,149 @@ +""" +cdp-use over PWW + +Drives a remote Chromium on Microsoft Playwright Service using the low-level +`cdp-use` Python CDP client. + +---------------------------------------- +Install +---------------------------------------- +pip install cdp-use python-dotenv aiohttp + +---------------------------------------- +Required env vars +---------------------------------------- +PLAYWRIGHT_SERVICE_URL +PLAYWRIGHT_SERVICE_ACCESS_TOKEN + +---------------------------------------- +Run +---------------------------------------- +python cdpUseScript.py +""" + +import asyncio +import os +from typing import Optional + +from cdp_use.client import CDPClient +from dotenv import load_dotenv + +from playwright_service_client import get_cdp_endpoint + +load_dotenv() + + +async def main(): + cdp_url = await get_cdp_endpoint() + + async with CDPClient(cdp_url) as client: + ctx = await client.send.Target.createBrowserContext() + target = await client.send.Target.createTarget( + params={"url": "about:blank", "browserContextId": ctx["browserContextId"]} + ) + session = await client.send.Target.attachToTarget( + params={"targetId": target["targetId"], "flatten": True} + ) + session_id = session["sessionId"] + + load_event = asyncio.Event() + + def on_load(event, sid: Optional[str]) -> None: + load_event.set() + + client.register.Page.loadEventFired(on_load) + + await client.send.Page.enable(session_id=session_id) + await client.send.Runtime.enable(session_id=session_id) + + await client.send.Page.navigate( + params={"url": "https://example.com"}, session_id=session_id + ) + await load_event.wait() + + result = await client.send.Runtime.evaluate( + params={"expression": "document.title"}, session_id=session_id + ) + print("Page title:", result["result"]["value"]) + + +# Opt-in proxy variant. Not invoked by default — change the entry point at +# the bottom of this file to `main_with_proxy()` to use it. Requires +# PROXY_SERVER / PROXY_USERNAME / PROXY_PASSWORD in your env. +# +# cdp-use does not abstract proxy auth, so we enable Fetch interception +# and answer Fetch.authRequired ourselves with Fetch.continueWithAuth. +async def main_with_proxy(): + cdp_url = await get_cdp_endpoint() + + async with CDPClient(cdp_url) as client: + ctx = await client.send.Target.createBrowserContext( + params={"proxyServer": os.environ["PROXY_SERVER"]} + ) + target = await client.send.Target.createTarget( + params={"url": "about:blank", "browserContextId": ctx["browserContextId"]} + ) + session = await client.send.Target.attachToTarget( + params={"targetId": target["targetId"], "flatten": True} + ) + session_id = session["sessionId"] + + load_event = asyncio.Event() + + async def on_auth(event, sid: Optional[str]) -> None: + if event["authChallenge"]["source"] == "Proxy": + await client.send.Fetch.continueWithAuth( + params={ + "requestId": event["requestId"], + "authChallengeResponse": { + "response": "ProvideCredentials", + "username": os.environ["PROXY_USERNAME"], + "password": os.environ["PROXY_PASSWORD"], + }, + }, + session_id=sid, + ) + else: # never leak proxy creds to origin servers + await client.send.Fetch.continueWithAuth( + params={ + "requestId": event["requestId"], + "authChallengeResponse": {"response": "CancelAuth"}, + }, + session_id=sid, + ) + + async def on_paused(event, sid: Optional[str]) -> None: + await client.send.Fetch.continueRequest( + params={"requestId": event["requestId"]}, session_id=sid + ) + + def on_load(event, sid: Optional[str]) -> None: + load_event.set() + + client.register.Fetch.authRequired(on_auth) + client.register.Fetch.requestPaused(on_paused) + client.register.Page.loadEventFired(on_load) + + await client.send.Page.enable(session_id=session_id) + await client.send.Runtime.enable(session_id=session_id) + await client.send.Fetch.enable( + params={ + "handleAuthRequests": True, + "patterns": [{"urlPattern": "*"}], + }, + session_id=session_id, + ) + + await client.send.Page.navigate( + params={"url": "https://example.com"}, session_id=session_id + ) + await load_event.wait() + + result = await client.send.Runtime.evaluate( + params={"expression": "document.title"}, session_id=session_id + ) + print("Page title (via proxy):", result["result"]["value"]) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples/cdp-tests/connectOverCDPScript.js b/samples/cdp-tests/connectOverCDPScript.js index 7e7fbf7..1c3a6eb 100644 --- a/samples/cdp-tests/connectOverCDPScript.js +++ b/samples/cdp-tests/connectOverCDPScript.js @@ -64,6 +64,33 @@ async function main() { console.log('✅ Done!'); } +// Opt-in proxy variant. Not invoked by default — change the entry point at +// the bottom of this file to `mainWithProxy()` to use it. Requires +// PROXY_SERVER / PROXY_USERNAME / PROXY_PASSWORD in your env. Playwright +// answers the 407 challenge for you. +async function mainWithProxy() { + const cdpUrl = await getCdpEndpoint(); + const browser = await chromium.connectOverCDP( + cdpUrl, + { headers: { 'User-Agent': 'Chrome-DevTools-Protocol/1.3' } } + ); + + const context = await browser.newContext({ + proxy: { + server: process.env.PROXY_SERVER, + username: process.env.PROXY_USERNAME, + password: process.env.PROXY_PASSWORD, + }, + }); + const page = await context.newPage(); + + await page.goto('https://example.com'); + console.log(`📌 Page title (via proxy): ${await page.title()}`); + + await context.close(); + await browser.close(); +} + main().catch(error => { console.error('❌ Error:', error.message); process.exit(1); diff --git a/samples/cdp-tests/puppeteerScript.js b/samples/cdp-tests/puppeteerScript.js new file mode 100644 index 0000000..b605de3 --- /dev/null +++ b/samples/cdp-tests/puppeteerScript.js @@ -0,0 +1,68 @@ +/** + * Puppeteer over CDP - Microsoft Playwright Service + * + * Connects puppeteer-core to a remote Chromium on PWW over CDP. + * + * Install: + * npm install puppeteer-core + * + * Environment Variables: + * PLAYWRIGHT_SERVICE_URL=wss://.api.playwright.microsoft.com/playwrightworkspaces//browsers + * PLAYWRIGHT_SERVICE_ACCESS_TOKEN=your_access_token + * + * Usage: + * node puppeteerScript.js + */ + +import puppeteer from 'puppeteer-core'; +import { getCdpEndpoint } from './playwrightServiceClient.js'; + +async function main() { + const cdpUrl = await getCdpEndpoint(); + + const browser = await puppeteer.connect({ + browserWSEndpoint: cdpUrl, + defaultViewport: null, + }); + + const context = await browser.createBrowserContext(); + const page = await context.newPage(); + + await page.goto('https://example.com', { waitUntil: 'domcontentloaded' }); + console.log('Page title:', await page.title()); + + await context.close(); + await browser.disconnect(); +} + +// Opt-in proxy variant. Not invoked by default — change the entry point at +// the bottom of this file to `mainWithProxy()` to use it. Requires +// PROXY_SERVER / PROXY_USERNAME / PROXY_PASSWORD in your env. +async function mainWithProxy() { + const cdpUrl = await getCdpEndpoint(); + + const browser = await puppeteer.connect({ + browserWSEndpoint: cdpUrl, + defaultViewport: null, + }); + + const context = await browser.createBrowserContext({ + proxyServer: process.env.PROXY_SERVER, + }); + const page = await context.newPage(); + await page.authenticate({ + username: process.env.PROXY_USERNAME, + password: process.env.PROXY_PASSWORD, + }); + + await page.goto('https://example.com', { waitUntil: 'domcontentloaded' }); + console.log('Page title (via proxy):', await page.title()); + + await context.close(); + await browser.disconnect(); +} + +main().catch((err) => { + console.error('Error:', err); + process.exit(1); +}); diff --git a/samples/cdp-tests/requirements.txt b/samples/cdp-tests/requirements.txt index cad986f..41c54a2 100644 --- a/samples/cdp-tests/requirements.txt +++ b/samples/cdp-tests/requirements.txt @@ -12,3 +12,6 @@ pytest-asyncio>=0.21.0 # For browser_use_remote.py (AI agent scenario) pydantic>=2.0.0 browser-use>=0.1.0 + +# For cdpUseProxyScript.py (opt-in proxy sample) +cdp-use>=0.3.0 diff --git a/samples/playwright-lib/ReadMe.md b/samples/playwright-lib/ReadMe.md index c0931ef..1ddb84d 100644 --- a/samples/playwright-lib/ReadMe.md +++ b/samples/playwright-lib/ReadMe.md @@ -22,4 +22,21 @@ $env:PLAYWRIGHT_RUN_ID="your_guid" npx ts-node src/example.ts ``` -- Test Runs get updated at 5 min interval, so check current test run details after 5 min of running script. \ No newline at end of file +- Test Runs get updated at 5 min interval, so check current test run details after 5 min of running script. + +## Optional: route the run through an authenticated HTTP proxy + +The default [`src/example.ts`](./src/example.ts) talks to PWW directly. If you +need every BrowserContext to go through an authenticated forward proxy, use +the opt-in [`src/example-proxy.ts`](./src/example-proxy.ts) instead. It adds a +`proxy` option to `browser.newContext()`; Playwright handles the 407 +challenge for you. + +```powershell +$env:PROXY_SERVER = "http://:8080" +$env:PROXY_USERNAME = "" +$env:PROXY_PASSWORD = "" +$env:PROXY_ONLY_URL = "http://intranet.example/healthcheck" + +npx ts-node src/example-proxy.ts +``` \ No newline at end of file diff --git a/samples/playwright-lib/src/example-proxy.ts b/samples/playwright-lib/src/example-proxy.ts new file mode 100644 index 0000000..cbf9b08 --- /dev/null +++ b/samples/playwright-lib/src/example-proxy.ts @@ -0,0 +1,54 @@ +import { chromium, devices } from 'playwright'; +import { randomUUID } from 'crypto'; + +/** + * Opt-in proxy variant of example.ts. + * + * Same `chromium.connect()` flow against PWW, but every BrowserContext is + * created with a `proxy:` option so all traffic from this run is routed + * through your authenticated HTTP forward proxy. Playwright transparently + * answers the 407 challenge with the supplied credentials. + * + * Required env vars (in addition to PLAYWRIGHT_SERVICE_URL + + * PLAYWRIGHT_SERVICE_ACCESS_TOKEN): + * PROXY_SERVER e.g. http://:8080 + * PROXY_USERNAME + * PROXY_PASSWORD + * PROXY_ONLY_URL the URL to fetch through the proxy + * + * Run: + * npx ts-node src/example-proxy.ts + */ +const runId = process.env['PLAYWRIGHT_RUN_ID'] || randomUUID(); +const os = 'linux'; +const apiVersion = '2025-09-01'; + +const wsEndpoint = + `${process.env['PLAYWRIGHT_SERVICE_URL']}` + + `?runId=${encodeURIComponent(runId)}&os=${os}&api-version=${apiVersion}`; + +const connectOptions = { + headers: { Authorization: `Bearer ${process.env['PLAYWRIGHT_SERVICE_ACCESS_TOKEN'] || ''}` }, + timeout: 3 * 60 * 1000, + exposeNetwork: '', +}; + +const proxy = { + server: process.env['PROXY_SERVER']!, + username: process.env['PROXY_USERNAME'], + password: process.env['PROXY_PASSWORD'], +}; + +(async () => { + const browser = await chromium.connect(wsEndpoint, connectOptions); + const context = await browser.newContext({ ...devices['Desktop Chrome'], proxy }); + const page = await context.newPage(); + + const target = process.env['PROXY_ONLY_URL']!; + const response = await page.goto(target); + console.log(`status: ${response?.status()}`); + console.log('title :', await page.title()); + + await context.close(); + await browser.close(); +})(); diff --git a/samples/playwright-tests/Readme.md b/samples/playwright-tests/Readme.md index 02443cf..86484cc 100644 --- a/samples/playwright-tests/Readme.md +++ b/samples/playwright-tests/Readme.md @@ -53,6 +53,31 @@ This sample demonstrates how to run Playwright tests using cloud-hosted browsers npx playwright test tests/example.spec.ts --config=playwright.service.config.ts ``` +## Optional: route tests through an authenticated HTTP proxy + +If your tests need to reach a private origin via an authenticated forward +proxy, use the opt-in [`playwright.service.proxy.config.ts`](./playwright.service.proxy.config.ts). +It extends `playwright.service.config.ts` with `use.proxy` and points `testDir` +at [`./tests-proxy`](./tests-proxy), so the default `npx playwright test` +command and the existing `tests/` specs are unaffected. + +1. Set the proxy env vars (in addition to `PLAYWRIGHT_SERVICE_URL`): + + ```powershell + $env:PROXY_SERVER = "http://:8080" + $env:PROXY_USERNAME = "" + $env:PROXY_PASSWORD = "" + $env:PROXY_ONLY_URL = "http://intranet.example/healthcheck" + ``` + +2. Run only the proxy specs: + + ```bash + npx playwright test --config=playwright.service.proxy.config.ts + ``` + +Playwright handles the proxy 407 challenge with the credentials in `use.proxy`. + ## Need Help? If you run into issues, open an issue in this repository or refer to the [Playwright Workspaces documentation](https://aka.ms/pww/docs). diff --git a/samples/playwright-tests/playwright.service.proxy.config.ts b/samples/playwright-tests/playwright.service.proxy.config.ts new file mode 100644 index 0000000..f041ddc --- /dev/null +++ b/samples/playwright-tests/playwright.service.proxy.config.ts @@ -0,0 +1,39 @@ +import { defineConfig } from '@playwright/test'; +import { createAzurePlaywrightConfig, ServiceOS } from '@azure/playwright'; +import { DefaultAzureCredential } from '@azure/identity'; +import config from './playwright.config'; + +/** + * Opt-in PWW service config that routes every test context through an + * authenticated HTTP forward proxy. + * + * Required env vars (in addition to PLAYWRIGHT_SERVICE_URL): + * PROXY_SERVER e.g. http://:8080 + * PROXY_USERNAME + * PROXY_PASSWORD + * + * Run only the proxy-tagged specs: + * npx playwright test --config=playwright.service.proxy.config.ts + * + * The default `npx playwright test --config=playwright.service.config.ts` + * is unaffected. + */ +export default defineConfig( + config, + createAzurePlaywrightConfig(config, { + exposeNetwork: '', + connectTimeout: 3 * 60 * 1000, + os: ServiceOS.LINUX, + credential: new DefaultAzureCredential(), + }), + { + testDir: './tests-proxy', + use: { + proxy: { + server: process.env.PROXY_SERVER!, + username: process.env.PROXY_USERNAME, + password: process.env.PROXY_PASSWORD, + }, + }, + } +); diff --git a/samples/playwright-tests/tests-proxy/proxy.spec.ts b/samples/playwright-tests/tests-proxy/proxy.spec.ts new file mode 100644 index 0000000..0b7e7f9 --- /dev/null +++ b/samples/playwright-tests/tests-proxy/proxy.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; + +/** + * Opt-in spec — only runs under playwright.service.proxy.config.ts. + * Set PROXY_ONLY_URL to a host reachable through your proxy + * (e.g. a private intranet origin). + */ +test('fetches PROXY_ONLY_URL through the proxied PWW context', async ({ page }) => { + const target = process.env.PROXY_ONLY_URL; + test.skip(!target, 'PROXY_ONLY_URL is not set'); + + const response = await page.goto(target!); + expect(response?.ok()).toBeTruthy(); + + const body = await page.locator('body').innerText(); + console.log(`--- PROXIED -> ${target} ---`); + console.log(body); + expect(body.length).toBeGreaterThan(0); +});